diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..14db15d --- /dev/null +++ b/.nvmrc @@ -0,0 +1,2 @@ +24 + diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..35fcf20 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,11 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100, + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf" +} + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7751786..24d7534 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -223,6 +223,49 @@ npm run test:ui - Update **type definitions** in `src/types/` as needed - Include **code examples** for new features +### Generating API Documentation + +The API documentation is generated from JSDoc comments using [TypeDoc](https://typedoc.org/): + +```bash +# Generate documentation +npm run docs + +# Watch mode - regenerate on file changes +npm run docs:watch +``` + +Documentation is output to `docs/api/`. Open `docs/api/index.html` in your browser to view. + +### JSDoc Guidelines + +All public APIs should have JSDoc comments with: + +- **Description** - Brief description of what the function/class does +- **@param** - Description of each parameter +- **@returns** - Description of the return value +- **@throws** - Description of errors that may be thrown +- **@example** - Usage examples (optional but recommended) + +Example: + +```typescript +/** + * Load files from URLs with progress tracking + * + * @param urls - Array of file URLs to load + * @param onProgress - Optional callback for progress updates + * @returns Promise that resolves to array of loaded files + * @throws {Error} If any file fails to load + */ +export async function loadFilesFromUrls( + urls: string[], + onProgress?: (progress: LoadProgress) => void +): Promise { + // Implementation... +} +``` + ## Reporting Issues ### Bug Reports @@ -269,12 +312,40 @@ git commit -m "docs: update installation guide" Releases are **fully automated** using [release-please](https://github.com/googleapis/release-please): -1. **Merge PRs to main** with conventional commits -2. **release-please bot** creates a Release PR automatically -3. **Review and merge** the Release PR -4. **GitHub release** is created automatically with artifacts +### How It Works + +1. **Write code** with conventional commit messages +2. **Merge PR to main** - that's it! +3. **release-please bot** automatically: + - Creates/updates a "Release PR" with version bump and changelog + - When you merge the Release PR, it creates a GitHub release + - Builds and uploads artifacts automatically + +### Creating a Release + +After merging PRs to main, release-please automatically creates a **Release PR**: + +1. **Check for Release PR** - Look for a PR titled "chore(main): release X.Y.Z" +2. **Review the Release PR** - Check version number and auto-generated changelog +3. **Merge the Release PR** - This triggers the GitHub release creation + +### Versioning + +DosKit follows [Semantic Versioning](https://semver.org/): + +- **MAJOR** (X.0.0): Breaking changes, incompatible API changes +- **MINOR** (0.X.0): New features, backwards compatible +- **PATCH** (0.0.X): Bug fixes, backwards compatible + +### Manual Release (Emergency Only) + +If you need to create a release manually: -See [docs/RELEASE-PROCESS.md](docs/RELEASE-PROCESS.md) for detailed information. +1. Update version in `package.json` +2. Update `CHANGELOG.md` manually +3. Commit: `git commit -m "chore: release X.Y.Z"` +4. Tag: `git tag vX.Y.Z` +5. Push: `git push origin main --tags` ## Questions? diff --git a/ROADMAP.md b/ROADMAP.md index d2605c5..bbbdce1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ > **Mission**: Preserve and celebrate vintage DOS demoscene productions, audio applications, and legacy software through modern web technology. -**Current Status**: Foundation Complete (v0.1.0) +**Current Status**: Foundation Complete (v1.0.1) **Focus Areas**: Demoscene Productions • Music Trackers • Legacy Productivity Software **Explicitly Excluded**: Gaming applications (this is not a DOS game emulator) @@ -126,10 +126,10 @@ interface SearchFilters { query: string; yearRange?: [number, number]; parties?: string[]; - categories?: ("demo" | "tracker" | "utility")[]; + categories?: ('demo' | 'tracker' | 'utility')[]; tags?: string[]; - sortBy: "name" | "year" | "popularity" | "dateAdded"; - sortOrder: "asc" | "desc"; + sortBy: 'name' | 'year' | 'popularity' | 'dateAdded'; + sortOrder: 'asc' | 'desc'; } ``` @@ -387,7 +387,7 @@ interface AudioRecorder { stop(): Blob; isRecording(): boolean; getWaveform(): Float32Array; - export(format: "wav" | "mp3"): Blob; + export(format: 'wav' | 'mp3'): Blob; } ``` diff --git a/docs/API-DOCUMENTATION.md b/docs/API-DOCUMENTATION.md deleted file mode 100644 index b1879a2..0000000 --- a/docs/API-DOCUMENTATION.md +++ /dev/null @@ -1,164 +0,0 @@ -# DosKit API Documentation - -This document provides an overview of the DosKit API documentation and how to use it. - -## Generating Documentation - -The API documentation is generated from JSDoc comments in the source code using [TypeDoc](https://typedoc.org/). - -### Generate Documentation - -```bash -npm run docs -``` - -This will generate HTML documentation in the `docs/api` directory. - -### Watch Mode - -To automatically regenerate documentation when source files change: - -```bash -npm run docs:watch -``` - -## Viewing Documentation - -After generating the documentation, open `docs/api/index.html` in your browser to view the API documentation. - -Alternatively, you can serve the documentation locally: - -```bash -# Using Python 3 -cd docs/api -python3 -m http.server 8080 - -# Using Node.js http-server (install with: npm install -g http-server) -cd docs/api -http-server -p 8080 -``` - -Then open http://localhost:8080 in your browser. - -## Documentation Structure - -The API documentation is organized into the following sections: - -### Modules - -- **adapters/** - Emulator abstraction layer -- **config/** - Configuration builders and defaults -- **constants/** - Application-wide constants -- **contexts/** - React Context providers for global state -- **hooks/** - Custom React hooks -- **utils/** - Utility functions and helpers - -### Key Components - -#### Utilities (`utils/`) - -- **diskLoader** - Functions for loading DOS files, disk images, and ZIP archives -- **dosboxConfigBuilder** - Builder pattern for creating DOSBox configurations -- **errorMessages** - User-friendly error message mapping -- **errorTracking** - Error tracking service abstraction -- **fetchWithRetry** - Network request retry logic with exponential backoff -- **globalErrorHandler** - Global error handler for uncaught errors -- **logger** - Centralized logging utility -- **serviceWorkerRegistration** - Service worker lifecycle management -- **urlRouting** - URL routing and deep linking utilities - -#### Hooks (`hooks/`) - -- **useDosEmulator** - Custom hook for managing js-dos emulator lifecycle - -#### Contexts (`contexts/`) - -- **AppStateContext** - Global state for app selection and emulator status -- **NetworkContext** - Network connectivity state management -- **PWAContext** - PWA installation and update management - -#### Adapters (`adapters/`) - -- **EmulatorAdapter** - Abstraction layer for DOS emulator integration - -#### Configuration (`config/`) - -- **dosbox.conf** - Default DOSBox configuration -- **jsdos.config** - js-dos configuration with mobile optimizations - -#### Constants (`constants/`) - -- **app** - Application-wide constants (metadata, audio, cache, emulator, error, file loading, PWA, routing, UI) - -## Writing Documentation - -When adding new functions, classes, or interfaces, follow these guidelines: - -### JSDoc Comments - -All public APIs should have JSDoc comments with: - -- **Description** - Brief description of what the function/class does -- **@param** - Description of each parameter -- **@returns** - Description of the return value -- **@throws** - Description of errors that may be thrown -- **@example** - Usage examples (optional but recommended) - -### Example - -````typescript -/** - * Load files from URLs with progress tracking - * - * @param urls - Array of file URLs to load - * @param onProgress - Optional callback for progress updates - * @returns Promise that resolves to array of loaded files - * @throws {Error} If any file fails to load - * - * @example - * ```typescript - * const files = await loadFilesFromUrls( - * ['https://example.com/file1.exe', 'https://example.com/file2.dat'], - * (progress) => console.log(`${progress.loaded}/${progress.total}`) - * ); - * ``` - */ -export async function loadFilesFromUrls( - urls: string[], - onProgress?: (progress: LoadProgress) => void, -): Promise { - // Implementation... -} -```` - -## Configuration - -The TypeDoc configuration is in `typedoc.json`. Key settings: - -- **entryPoints** - Source directories to document -- **out** - Output directory (`docs/api`) -- **exclude** - Files to exclude (tests, integration tests) -- **theme** - Documentation theme (default) -- **categorizeByGroup** - Group items by category - -## Continuous Integration - -The documentation can be automatically generated and deployed as part of your CI/CD pipeline: - -```yaml -# Example GitHub Actions workflow -- name: Generate API Documentation - run: npm run docs - -- name: Deploy to GitHub Pages - uses: peaceiris/actions-gh-pages@v3 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./docs/api -``` - -## Additional Resources - -- [TypeDoc Documentation](https://typedoc.org/) -- [JSDoc Reference](https://jsdoc.app/) -- [TSDoc Standard](https://tsdoc.org/) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 11d4a85..1d3e548 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -81,14 +81,40 @@ App (Root) ### State Management -Currently using React's built-in state management: +DosKit uses React Context API for global state management, organized into three separate contexts: -- **Local component state** (useState) for UI state -- **Props drilling** for passing data between components -- **Custom events** for URL-based app loading -- **Callbacks** for parent-child communication +1. **AppStateContext** - Application selection and emulator status +2. **NetworkContext** - Network connectivity status +3. **PWAContext** - Progressive Web App features (install, updates) -Future consideration: Zustand or Context API for global state. +#### Why Context API? + +- **No additional dependencies** - Uses built-in React features +- **Appropriate scale** - Sufficient for DosKit's state management needs +- **Better React integration** - Works seamlessly with React DevTools +- **Simpler testing** - Easier to mock and test +- **Type safety** - Full TypeScript support out of the box + +#### Context Usage + +```typescript +import { useAppState, useAppStateValue, useAppStateActions } from '@/contexts'; + +// Get full context (state + actions) +const { currentApp, setCurrentApp } = useAppState(); + +// Get only state (read-only) - prevents unnecessary re-renders +const { currentApp, isEmulatorReady } = useAppStateValue(); + +// Get only actions +const { setCurrentApp, setEmulatorReady } = useAppStateActions(); +``` + +#### Best Practices + +- **Use specific hooks** - Prefer `useAppStateValue()` over `useAppState()` for read-only access +- **Separate read and write** - Components that only read should use value hooks +- **Keep contexts focused** - Each context manages a single domain ## Key Features @@ -142,7 +168,7 @@ DOS application configs use dynamic imports to reduce initial bundle size: ```typescript loader: async () => { - const config = await import("../dos-apps/second-reality.config"); + const config = await import('../dos-apps/second-reality.config'); return config.loadZipArchive(config.secondRealityZipUrl); }; ``` @@ -188,10 +214,33 @@ Custom events for URL-based app loading and cross-component communication. 3. **Service worker versioning**: Inject build timestamp 4. **Asset optimization**: Minification and tree-shaking +## js-dos Dependency Strategy + +DosKit uses a **local copy strategy** for the js-dos library: + +### Why Local Copy? + +1. **Stability** - CDN versions can change unexpectedly +2. **Offline Support** - Required for PWA functionality +3. **Version Control** - Explicit control over updates +4. **Performance** - Optimized caching with service worker + +### Files + +- `public/js-dos.js` - Main js-dos library +- `public/js-dos.css` - js-dos styles +- `src/types/js-dos.d.ts` - TypeScript declarations + +### Updating js-dos + +1. Download new version from [js-dos releases](https://github.com/nicholasday/js-dos/releases) +2. Replace files in `public/` +3. Update types in `src/types/js-dos.d.ts` if API changed +4. Test thoroughly before deploying + ## Future Improvements -1. **State Management**: Implement Zustand or Context API -2. **Testing**: Add unit, integration, and visual regression tests -3. **Component Refactoring**: Split DosPlayer into smaller components -4. **DOSBox Builder**: Create builder pattern for DOSBox configs -5. **Emulator Abstraction**: Decouple from js-dos with adapter interface +1. **Testing**: Add visual regression tests +2. **Component Refactoring**: Split DosPlayer into smaller components +3. **DOSBox Builder**: Create builder pattern for DOSBox configs +4. **Emulator Abstraction**: Decouple from js-dos with adapter interface diff --git a/docs/JS-DOS-DEPENDENCY-STRATEGY.md b/docs/JS-DOS-DEPENDENCY-STRATEGY.md deleted file mode 100644 index 0f912ea..0000000 --- a/docs/JS-DOS-DEPENDENCY-STRATEGY.md +++ /dev/null @@ -1,247 +0,0 @@ -# js-dos Dependency Management Strategy - -**Document Version:** 1.0 -**Date:** 2025-11-18 -**Status:** Evaluated and Documented - -## Executive Summary - -This document evaluates different approaches for managing the js-dos dependency in DosKit and documents the chosen strategy. After careful analysis, **the current hybrid approach (npm package + local script loading)** is recommended as the optimal solution for this project. - -## Current Implementation - -### Overview - -DosKit currently uses a **hybrid approach**: - -- js-dos is installed as an npm dependency (`js-dos@8.3.20`) -- The library files are served locally from the `public/` directory -- js-dos is loaded via ` -``` - -#### Pros - -- No local storage of large files -- Automatic updates (if using `/latest/`) -- Shared browser cache across sites -- Reduced deployment size - -#### Cons - -- **External dependency** - site breaks if CDN is down -- **No offline support** - critical for PWA functionality -- **Version instability** - `/latest/` can introduce breaking changes -- **Privacy concerns** - third-party requests -- **No version pinning** - harder to ensure consistency -- **Network latency** - additional DNS lookup and connection - -**Verdict:** **Not Recommended** - Conflicts with PWA offline-first approach - ---- - -### Option 2: Full Bundling - -#### Implementation - -```typescript -import { Dos } from "js-dos"; -// Bundle js-dos with Vite/Rollup -``` - -#### Pros - -- Type-safe imports -- Tree-shaking potential -- Single bundle management -- Better IDE support - -#### Cons - -- **Large bundle size** - adds 300KB+ to main bundle -- **Slower initial load** - blocks React app loading -- **WASM complexity** - difficult to bundle WASM files properly -- **Build complexity** - requires special Vite/Rollup configuration -- **js-dos architecture** - designed for global script loading -- **Initialization timing** - harder to ensure js-dos loads before React - -**Verdict:** **Not Recommended** - Increases complexity without clear benefits - ---- - -### Option 3: Hybrid Approach (Current) - -#### Implementation - -```json -// package.json -"dependencies": { - "js-dos": "8.3.20" -} -``` - -```html - - - -``` - -#### Pros - -- **Full offline support** - all files served locally -- **Version control** - pinned to specific version -- **Fast loading** - parallel loading with HTML -- **Service worker caching** - excellent PWA support -- **No external dependencies** - works without internet -- **Type safety** - TypeScript definitions from npm package -- **Predictable behavior** - no surprise updates -- **Optimal architecture** - matches js-dos design -- **Easy debugging** - source maps available locally - -#### Cons - -Note: **Manual updates** - need to copy files when updating -Note: **Larger repository** - 10MB of static assets -Note: **Deployment size** - larger initial deployment - -**Verdict:** **RECOMMENDED** - Best balance for PWA requirements - -## Recommendation - -### Chosen Strategy: Hybrid Approach (Current Implementation) - -**Rationale:** - -1. **PWA Requirements**: DosKit is a Progressive Web App that must work offline. The hybrid approach ensures all assets are available locally and can be cached by the service worker. - -2. **Reliability**: No external dependencies means the app works regardless of CDN availability or network conditions. - -3. **Performance**: Loading js-dos as a separate script allows parallel loading and doesn't block the React bundle. The browser can cache it independently. - -4. **Version Stability**: Pinning to a specific version (8.3.20) ensures consistent behavior and prevents breaking changes from automatic updates. - -5. **Developer Experience**: Having js-dos in package.json provides TypeScript definitions and makes the dependency explicit. - -## Implementation Guidelines - -### Updating js-dos - -When updating to a new version of js-dos: - -1. Update package.json: - - ```bash - npm install js-dos@ - ``` - -2. Copy files to public directory: - - ```bash - cp node_modules/js-dos/dist/js-dos.js public/ - cp node_modules/js-dos/dist/js-dos.css public/ - cp -r node_modules/js-dos/dist/emulators public/ - ``` - -3. Test thoroughly: - - Verify emulator initialization - - Test offline functionality - - Check service worker caching - - Validate TypeScript types - -4. Update version references in documentation - -### Service Worker Configuration - -Ensure `public/sw.js` caches js-dos assets: - -```javascript -const STATIC_ASSETS = [ - "/js-dos.js", - "/js-dos.css", - "/emulators/wdosbox.wasm", - "/emulators/wdosbox-x.wasm", - "/emulators/wlibzip.wasm", - // ... other emulator files -]; -``` - -### Future Considerations - -**Monitor for:** - -- js-dos v9 release (may change architecture) -- ES module support in js-dos (could enable bundling) -- WASM bundling improvements in Vite/Rollup -- Changes in PWA best practices - -**Reevaluate if:** - -- js-dos adds official ES module support -- Bundle size becomes a critical issue -- CDN reliability significantly improves -- Offline support becomes less important - -## Conclusion - -The current hybrid approach optimally balances performance, reliability, and maintainability for DosKit's requirements as a Progressive Web App. While it requires manual file management during updates, this trade-off is acceptable given the benefits of offline support, version stability, and predictable behavior. - -**Status:** Current implementation validated and documented -**Next Review:** When js-dos v9 is released or significant architecture changes occur diff --git a/docs/RELEASE-PROCESS.md b/docs/RELEASE-PROCESS.md deleted file mode 100644 index 56c1809..0000000 --- a/docs/RELEASE-PROCESS.md +++ /dev/null @@ -1,166 +0,0 @@ -# Release Process - -This document describes how releases work in DosKit. - -## Overview - -DosKit uses **[release-please](https://github.com/googleapis/release-please)** for fully automated releases. You don't need to manually update versions or changelogs! - -### How It Works - -1. **Write code** with conventional commit messages -2. **Merge PR to main** - that's it! -3. **release-please bot** automatically: - - Creates/updates a "Release PR" with version bump and changelog - - When you merge the Release PR, it creates a GitHub release - - Builds and uploads artifacts automatically - -## Conventional Commits - -Use conventional commit messages for automatic versioning: - -### Commit Types - -- `feat:` - New feature → **MINOR** version bump (1.0.0 → 1.1.0) -- `fix:` - Bug fix → **PATCH** version bump (1.0.0 → 1.0.1) -- `docs:` - Documentation only → No version bump -- `chore:` - Maintenance → No version bump -- `refactor:` - Code refactoring → No version bump -- `test:` - Tests → No version bump -- `perf:` - Performance improvement → **PATCH** version bump -- `BREAKING CHANGE:` - Breaking change → **MAJOR** version bump (1.0.0 → 2.0.0) - -### Examples - -```bash -# Feature (minor bump) -git commit -m "feat: add gamepad support" - -# Bug fix (patch bump) -git commit -m "fix: resolve audio sync issue" - -# Breaking change (major bump) -git commit -m "feat!: redesign configuration API - -BREAKING CHANGE: Configuration format has changed" - -# No version bump -git commit -m "docs: update README" -git commit -m "chore: update dependencies" -``` - -## Release Workflow - -### Normal Development - -1. **Create a feature branch**: - - ```bash - git checkout -b feat/my-feature - ``` - -2. **Make changes with conventional commits**: - - ```bash - git commit -m "feat: add new feature" - git commit -m "fix: resolve bug" - ``` - -3. **Push and create PR**: - - ```bash - git push origin feat/my-feature - # Create PR on GitHub - ``` - -4. **Merge PR to main** - Done! CI runs, deploys to GitHub Pages - -### Creating a Release - -After merging PRs to main, release-please automatically creates a **Release PR**: - -1. **Check for Release PR**: - - Look for a PR titled "chore(main): release X.Y.Z" - - It contains updated `package.json` and `CHANGELOG.md` - -2. **Review the Release PR**: - - Check the version number is correct - - Review the auto-generated changelog - - Ensure all changes are included - -3. **Merge the Release PR**: - - Click "Merge" on the Release PR - - release-please automatically: - - Creates a GitHub release - - Tags the commit - - Builds and uploads artifacts - -That's it! No manual version bumping or changelog editing needed. - -## Versioning - -DosKit follows [Semantic Versioning](https://semver.org/): - -- **MAJOR** (X.0.0): Breaking changes, incompatible API changes -- **MINOR** (0.X.0): New features, backwards compatible -- **PATCH** (0.0.X): Bug fixes, backwards compatible - -release-please automatically determines the version bump based on your commit messages! - -## Manual Release (Emergency Only) - -If you need to create a release manually: - -1. **Update version** in `package.json` -2. **Update CHANGELOG.md** manually -3. **Commit**: `git commit -m "chore: release X.Y.Z"` -4. **Tag**: `git tag vX.Y.Z` -5. **Push**: `git push origin main --tags` - -## Troubleshooting - -### No Release PR created - -- Ensure you're using conventional commit messages -- Check that commits are merged to `main` branch -- Look for the release-please workflow in Actions tab -- May take a few minutes after merging to main - -### Release PR has wrong version - -- Check your commit messages follow conventional commits -- `feat:` = minor, `fix:` = patch, `BREAKING CHANGE:` = major -- Close the Release PR and fix commit messages, then push to main - -### Need to skip a release - -- Don't merge the Release PR -- Continue merging features to main -- release-please will update the Release PR with all changes - -### Need to delete a release - -```bash -# Delete the tag locally -git tag -d v1.1.0 - -# Delete the tag remotely -git push origin :refs/tags/v1.1.0 - -# Delete the release on GitHub -gh release delete v1.1.0 -``` - -## Benefits - -- **Zero manual work**: No version bumping or changelog editing -- **Consistent**: Follows semantic versioning automatically -- **Transparent**: Release PR shows exactly what will be released -- **Flexible**: Can review and edit Release PR before merging -- **Safe**: Can't accidentally release without review - -## Learn More - -- [release-please documentation](https://github.com/googleapis/release-please) -- [Conventional Commits](https://www.conventionalcommits.org/) -- [Semantic Versioning](https://semver.org/) diff --git a/docs/SCENERS.md b/docs/SCENERS.md new file mode 100644 index 0000000..5740daa --- /dev/null +++ b/docs/SCENERS.md @@ -0,0 +1,141 @@ +# Sceners Integration + +This document tracks the integration of historical scene content from the [sceners](https://github.com/sceners) organization into DosKit. + +## Overview + +The sceners organization (curated by Defacto2) contains 157 repositories of historical scene content including: + +- Classic DOS demos and intros +- Source code for demo effects +- Scene tools and utilities +- Educational resources + +## Integrated Demos + +### Successfully Added + +| Demo | ID | Author | Year | Status | +| ---------------- | ----------------- | ------------------- | ----- | ---------- | +| Squid BBS Intro | `squid-bbstro` | cld & The Doctor | 1994 | ✅ Working | +| 3D Rotation Demo | `3drotate` | Grumpy's Collection | 1990s | ✅ Working | +| Starfield Effect | `stars` | Grumpy's Collection | 1990s | ✅ Working | +| Crystal Dream 2 | `crystal-dream-2` | Triton | 1993 | ✅ Working | + +### Demo Details + +#### Squid BBS Intro (1994) + +- **Size**: 1899 bytes +- **Features**: Character smoother, mini AdLib player, 8x16 custom charset +- **URL**: `https://doskit.net/?app=squid-bbstro` +- **Source**: https://github.com/sceners/squid-bbstro + +#### 3D Rotation Demo + +- **Features**: Real-time 3D object rotation in VGA mode +- **URL**: `https://doskit.net/?app=3drotate` +- **Source**: https://github.com/sceners/grumpys-source-pack-collection + +#### Starfield Effect + +- **Features**: Classic starfield effect simulating flying through space +- **URL**: `https://doskit.net/?app=stars` +- **Source**: https://github.com/sceners/grumpys-source-pack-collection + +#### Crystal Dream 2 (1993) + +- **Ranking**: 1st place at The Computer Crossroads 1993 +- **Features**: 3D vector graphics, fractal zoomer, vector slime, raytraced scenes +- **Music**: Lizardking ("Trans Atlantic"), Vogue +- **URL**: `https://doskit.net/?app=crystal-dream-2` +- **Source**: https://files.scene.org/get/demos/groups/triton/cd2-trn.zip + +### Not Compatible + +- **Plasma Effect** - Blank screen, not compatible with js-dos +- **Fire/Flames Effect** - Runtime error, not compatible with js-dos + +## Technical Implementation + +### Files Created + +**Config Files** (in `src/dos-apps/`): + +- `squid-bbstro.config.ts` +- `3drotate.config.ts` +- `stars.config.ts` +- `crystal-dream-2.config.ts` + +**Demo Bundles** (in `public/demos/`): + +- `squid-bbstro.zip` (2.2 KB) +- `3drotate.zip` (3.0 KB) +- `stars.zip` (1.5 KB) +- `crystal-dream-2.zip` (2.0 MB) + +### Integration Steps + +For each demo: + +1. **Prepare Files** - Copy executable and required data files, create ZIP bundle in `public/demos/` +2. **Create Config** - Add config file in `src/dos-apps/`, define DOSBox configuration +3. **Register App** - Add to app registry, update app selector UI +4. **Test** - Verify demo runs correctly, check audio/video output + +## Available Repositories + +### Cloned Repositories (in `~/sceners/`) + +1. **squid-bbstro** - 1899-byte BBS intro with AdLib music +2. **grumpys-source-pack-collection** - 35+ MS-DOS demo/intro effects with source +3. **AMN-SRCE** - Amnesia VR by Renaissance (source only) +4. **x-mas_92-razor_1911** - Classic Christmas demo from Razor 1911 +5. **x23_quantum** - Cracktro source code +6. **masm32-graphical-effects** - Huge collection of graphical effects +7. **writing-graphic-vga-intros-loaders-fred-nietzche** - VGA Intro Tutorial +8. **Code-Breaker** - Tools, trainers, and utilities (1992-1995) + +### Ready to Integrate + +**Grumpys Collection (Selected)**: + +- `TEXTURE/TEXMAP.EXE` - Texture mapping +- `XLIB06/DEMO1.EXE` through `DEMO10.EXE` - XLIB demos + +## Future Plans + +- [ ] Add X-Mas'92 demo +- [ ] Add more grumpys effects (texture mapping, etc.) +- [ ] Create educational section with source code examples +- [ ] Add Code-Breaker tools +- [ ] Add thumbnails/screenshots for all demos + +## Educational Value + +These demos are excellent for: + +- Learning classic demo scene programming techniques +- Understanding VGA graphics modes (Mode 13h, text mode) +- Studying size optimization (Squid is only 1899 bytes!) +- Exploring real-time graphics algorithms +- Historical preservation of demo scene culture + +## Resources + +- **Sceners Organization**: https://github.com/sceners +- **Defacto2**: https://defacto2.net +- **Scene.org**: Official demoscene file archive + +## License Notes + +- **Code-Breaker**: Public Domain (Unlicense) +- **Other repos**: Various scene/historical licenses +- **Usage**: Educational and historical preservation + +## Credits + +- **Sceners Organization**: https://github.com/sceners +- **Scene.org**: Official demoscene file archive +- **Defacto2**: Curation and preservation of scene content +- **Original Authors**: cld, The Doctor, Grumpy, Triton, and the demo scene community diff --git a/docs/STATE-MANAGEMENT.md b/docs/STATE-MANAGEMENT.md deleted file mode 100644 index 6b43499..0000000 --- a/docs/STATE-MANAGEMENT.md +++ /dev/null @@ -1,351 +0,0 @@ -# State Management Guide - -**Document Version:** 1.0 -**Date:** 2025-11-18 -**Status:** Implemented - -## Overview - -DosKit uses React Context API for global state management. The state is organized into three separate contexts, each managing a specific domain of the application: - -1. **AppStateContext** - Application selection and emulator status -2. **NetworkContext** - Network connectivity status -3. **PWAContext** - Progressive Web App features (install, updates) - -## Architecture - -### Why Context API? - -We chose React Context API over external state management libraries (like Zustand or Redux) for the following reasons: - -- **No additional dependencies** - Uses built-in React features -- **Appropriate scale** - Sufficient for DosKit's state management needs -- **Better React integration** - Works seamlessly with React DevTools -- **Simpler testing** - Easier to mock and test -- **Type safety** - Full TypeScript support out of the box - -### Context Separation - -Each context manages a distinct domain to: - -- Prevent unnecessary re-renders -- Improve code organization -- Make testing easier -- Allow independent updates - -## Contexts - -### 1. AppStateContext - -**Purpose:** Manages application selection and emulator status - -**State:** - -```typescript -interface AppState { - currentApp: DosApp | null; // Currently selected DOS application - showAppSelector: boolean; // Whether app selector is visible - isEmulatorReady: boolean; // Whether DOS emulator is ready - isLoadingApp: boolean; // Whether an app is loading - error: string | null; // Current error message -} -``` - -**Usage:** - -```typescript -import { useAppState, useAppStateValue, useAppStateActions } from "@/contexts"; - -// Get full context (state + actions) -const { currentApp, setCurrentApp } = useAppState(); - -// Get only state (read-only) -const { currentApp, isEmulatorReady } = useAppStateValue(); - -// Get only actions -const { setCurrentApp, setEmulatorReady } = useAppStateActions(); -``` - -**When to use:** - -- Components that need to know which app is selected -- Components that control app selection -- Components that display emulator status -- Components that handle loading states - ---- - -### 2. NetworkContext - -**Purpose:** Manages network connectivity status - -**State:** - -```typescript -interface NetworkState { - isOnline: boolean; // Whether browser is online - showOfflineMessage: boolean; // Whether to show offline message -} -``` - -**Usage:** - -```typescript -import { useNetwork, useNetworkState, useNetworkActions } from "@/contexts"; - -// Get full context -const { isOnline, setShowOfflineMessage } = useNetwork(); - -// Get only state -const { isOnline } = useNetworkState(); - -// Get only actions -const { setShowOfflineMessage } = useNetworkActions(); -``` - -**Features:** - -- Automatically listens to browser `online`/`offline` events -- Updates state when network status changes -- Provides manual control over offline message visibility - -**When to use:** - -- Components that need to display network status -- Components that behave differently when offline -- Components that show offline indicators - ---- - -### 3. PWAContext - -**Purpose:** Manages Progressive Web App features - -**State:** - -```typescript -interface PWAState { - deferredPrompt: BeforeInstallPromptEvent | null; // Install prompt event - showInstallPrompt: boolean; // Show install UI - isInstalled: boolean; // App is installed - updateRegistration: ServiceWorkerRegistration | null; // Update available - hasUpdate: boolean; // Computed: update available -} -``` - -**Usage:** - -```typescript -import { usePWA, usePWAState, usePWAActions } from "@/contexts"; - -// Get full context -const { isInstalled, setShowInstallPrompt } = usePWA(); - -// Get only state -const { isInstalled, hasUpdate } = usePWAState(); - -// Get only actions -const { setShowInstallPrompt, dismissUpdate } = usePWAActions(); -``` - -**Features:** - -- Automatically captures `beforeinstallprompt` event -- Detects if app is installed (standalone mode) -- Handles `appinstalled` event -- Manages service worker update notifications - -**When to use:** - -- Components that show install prompts -- Components that handle PWA updates -- Components that need to know installation status - -## Setup - -### Provider Hierarchy - -Wrap your app with all providers in `main.tsx`: - -```typescript -import { AppStateProvider, NetworkProvider, PWAProvider } from './contexts'; - -ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - - - - - -); -``` - -**Order matters:** - -- NetworkProvider first (no dependencies) -- PWAProvider second (no dependencies) -- AppStateProvider last (may depend on others in future) - -### Initial State - -AppStateProvider accepts initial values: - -```typescript - - - -``` - -## Best Practices - -### 1. Use Specific Hooks - -Prefer specific hooks over full context: - -```typescript -// Good - only subscribes to state changes -const { isOnline } = useNetworkState(); - -// Avoid - subscribes to all changes including actions -const { isOnline } = useNetwork(); -``` - -### 2. Separate Read and Write - -Components that only read state should use value hooks: - -```typescript -// Component that only displays status -function StatusDisplay() { - const { isEmulatorReady } = useAppStateValue(); - return
Status: {isEmulatorReady ? 'Ready' : 'Loading'}
; -} - -// Component that only updates state -function ControlPanel() { - const { setEmulatorReady } = useAppStateActions(); - return ; -} -``` - -### 3. Avoid Prop Drilling - -Use contexts instead of passing props through multiple levels: - -```typescript -// Before - prop drilling - -
-
- -
- - -// After - use context - -
{/* Uses useAppStateValue() */} -
- {/* Uses useAppStateValue() */} -
- -``` - -### 4. Keep Contexts Focused - -Each context should manage a single domain. Don't mix concerns: - -```typescript -// Good - focused contexts -const { isOnline } = useNetwork(); -const { currentApp } = useAppState(); - -// Bad - mixing concerns -const { isOnline, currentApp } = useGlobalState(); -``` - -## Testing - -### Testing Components with Context - -```typescript -import { render } from '@testing-library/react'; -import { AppStateProvider } from '@/contexts'; - -test('component uses app state', () => { - render( - - - - ); - // assertions... -}); -``` - -### Testing Hooks - -```typescript -import { renderHook, act } from "@testing-library/react"; -import { AppStateProvider, useAppState } from "@/contexts"; - -test("updates app state", () => { - const { result } = renderHook(() => useAppState(), { - wrapper: AppStateProvider, - }); - - act(() => { - result.current.setCurrentApp(mockApp); - }); - - expect(result.current.currentApp).toBe(mockApp); -}); -``` - -## Migration from Props - -When migrating from prop-based state to context: - -1. **Identify shared state** - Find state passed through multiple components -2. **Choose appropriate context** - Select the right domain context -3. **Update provider** - Add initial values if needed -4. **Replace props with hooks** - Use context hooks instead of props -5. **Remove prop drilling** - Clean up intermediate components -6. **Update tests** - Wrap components with providers - -## Performance Considerations - -### Context Re-renders - -Components re-render when context values change. To optimize: - -1. **Use specific hooks** - Subscribe only to needed values -2. **Memoize callbacks** - Use `useCallback` for action functions -3. **Split contexts** - Keep contexts focused to minimize re-renders - -### When NOT to Use Context - -Don't use context for: - -- **Local component state** - Use `useState` instead -- **Derived state** - Calculate from existing state -- **Temporary UI state** - Modal open/close, form inputs -- **High-frequency updates** - Use local state or refs - -## Future Enhancements - -Potential improvements: - -1. **Persistence** - Save state to localStorage -2. **Undo/Redo** - Add history management -3. **DevTools** - Custom debugging tools -4. **Middleware** - Add logging, analytics -5. **Selectors** - Memoized derived state - -## Conclusion - -The Context API provides a clean, type-safe, and maintainable solution for DosKit's state management needs. By separating concerns into focused contexts and providing specific hooks, we achieve good performance while keeping the codebase simple and testable. diff --git a/eslint.config.js b/eslint.config.js index 7f7f8ca..3454132 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -31,7 +31,20 @@ export default tseslint.config( ...reactHooks.configs.recommended.rules, 'react-refresh/only-export-components': [ 'warn', - { allowConstantExport: true }, + { + allowConstantExport: true, + allowExportNames: [ + 'useAppState', + 'useAppStateValue', + 'useAppStateActions', + 'useNetwork', + 'useNetworkState', + 'useNetworkActions', + 'usePWA', + 'usePWAState', + 'usePWAActions', + ], + }, ], }, }, diff --git a/package-lock.json b/package-lock.json index 0e7200a..b2f4d12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "dependencies": { "js-dos": "8.3.20", "react": "19.2.0", - "react-dom": "19.2.0" + "react-dom": "19.2.0", + "retro-floppy": "^1.0.0" }, "devDependencies": { "@eslint/js": "9.39.1", @@ -28,16 +29,20 @@ "eslint-plugin-react-hooks": "7.0.1", "eslint-plugin-react-refresh": "0.4.24", "globals": "16.5.0", - "husky": "^9.1.7", + "husky": "9.1.7", "jsdom": "27.2.0", - "lint-staged": "^16.2.6", - "prettier": "^3.6.2", + "lint-staged": "16.2.6", + "picocolors": "1.1.1", + "prettier": "3.6.2", "sharp": "0.34.5", - "typedoc": "^0.28.14", + "typedoc": "0.28.14", "typescript": "5.9.3", "typescript-eslint": "8.46.4", "vite": "npm:rolldown-vite@7.1.14", "vitest": "4.0.10" + }, + "engines": { + "node": ">=24.0.0" } }, "node_modules/@acemir/cssom": { @@ -2604,9 +2609,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.10", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.10.tgz", - "integrity": "sha512-uLfgBi+7IBNay8ECBO2mVMGZAc1VgZWEChxm4lv+TobGdG82LnXMjuNGo/BSSZZL4UmkWhxEHP2f5ziLNwGWMA==", + "version": "2.8.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.32.tgz", + "integrity": "sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -5075,6 +5080,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/retro-floppy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/retro-floppy/-/retro-floppy-1.0.0.tgz", + "integrity": "sha512-GllkEBaPacbwRdYnVrGt+kA0RP0k6BCuvXVGicXwT3sIYSQzkyXhaygJjdPSjpmXAAIrT150euY0VhFHPibH+A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", diff --git a/package.json b/package.json index 4cfb529..9d5a576 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,8 @@ "dependencies": { "js-dos": "8.3.20", "react": "19.2.0", - "react-dom": "19.2.0" + "react-dom": "19.2.0", + "retro-floppy": "^1.0.0" }, "devDependencies": { "@eslint/js": "9.39.1", @@ -71,17 +72,21 @@ "eslint-plugin-react-hooks": "7.0.1", "eslint-plugin-react-refresh": "0.4.24", "globals": "16.5.0", - "husky": "^9.1.7", + "husky": "9.1.7", "jsdom": "27.2.0", - "lint-staged": "^16.2.6", - "prettier": "^3.6.2", + "lint-staged": "16.2.6", + "picocolors": "1.1.1", + "prettier": "3.6.2", "sharp": "0.34.5", - "typedoc": "^0.28.14", + "typedoc": "0.28.14", "typescript": "5.9.3", "typescript-eslint": "8.46.4", "vite": "npm:rolldown-vite@7.1.14", "vitest": "4.0.10" }, + "engines": { + "node": ">=24.0.0" + }, "overrides": { "vite": "npm:rolldown-vite@7.1.14" }, diff --git a/public/demos/3drotate.zip b/public/demos/3drotate.zip new file mode 100644 index 0000000..e494873 Binary files /dev/null and b/public/demos/3drotate.zip differ diff --git a/public/demos/README.md b/public/demos/README.md index 93b90ba..2e3fe96 100644 --- a/public/demos/README.md +++ b/public/demos/README.md @@ -31,6 +31,63 @@ This directory contains DOS demo files that are bundled with the application to - Graphics: VGA - Sound: Sound Blaster (optional) +### unreal.zip (~1.3 MB) + +**Demo**: Unreal by Future Crew (1992) + +**Source**: https://archive.org/details/unreal_zip + +**Original URL**: https://archive.org/download/unreal_zip/unreal.zip + +**License**: Freeware + +**Contents**: + +- `UNREAL.EXE` - Main demo executable +- Demo data files + +**Description**: A groundbreaking demo that won 1st place at Assembly 1992. One of the most influential PC demos of the early 90s, featuring advanced 3D graphics, smooth animations, and excellent music by Purple Motion (Jonne Valtonen). Two versions exist: 1.0 and 1.1 (with Gravis UltraSound support). + +**Requirements**: + +- CPU: 386 or better (486 recommended) +- Memory: 4MB RAM +- Graphics: VGA +- Sound: Sound Blaster compatible + +**Additional Info**: + +- Pouet.net: https://www.pouet.net/prod.php?which=713 +- Party: Assembly 1992 (1st place) + +### panic.zip + +**Demo**: Panic by Future Crew (1992) + +**Source**: https://files.scene.org/view/demos/groups/future_crew/demos/panic.zip + +**License**: Freeware + +**Contents**: + +- `PANIC.EXE` - Main demo executable +- Demo data files + +**Description**: A classic demo that came 2nd place at The Party 1992. Features impressive voxel fractal rendering, shadebobs, and hard techno music by Purple Motion with a dark atmosphere. Released between Unreal and Second Reality, showcasing Future Crew's evolution in demo production. + +**Requirements**: + +- CPU: 386 or better (486 recommended) +- Memory: 4MB RAM +- Graphics: VGA +- Sound: Sound Blaster compatible +- Requires EMS memory + +**Additional Info**: + +- Pouet.net: https://www.pouet.net/prod.php?which=479 +- Party: The Party 1992 (2nd place) + ### impulse-tracker.zip (1.0 MB) **Application**: Impulse Tracker 2.14 by Jeffrey Lim (1997) diff --git a/public/demos/crystal-dream-2.zip b/public/demos/crystal-dream-2.zip new file mode 100644 index 0000000..a4b4c3a Binary files /dev/null and b/public/demos/crystal-dream-2.zip differ diff --git a/public/demos/panic.zip b/public/demos/panic.zip new file mode 100644 index 0000000..35bf4ed Binary files /dev/null and b/public/demos/panic.zip differ diff --git a/public/demos/scream-tracker.zip b/public/demos/scream-tracker.zip new file mode 100644 index 0000000..396f280 Binary files /dev/null and b/public/demos/scream-tracker.zip differ diff --git a/public/demos/squid-bbstro.zip b/public/demos/squid-bbstro.zip new file mode 100644 index 0000000..be19b2c Binary files /dev/null and b/public/demos/squid-bbstro.zip differ diff --git a/public/demos/starport-bbstro.zip b/public/demos/starport-bbstro.zip new file mode 100644 index 0000000..0465cc6 Binary files /dev/null and b/public/demos/starport-bbstro.zip differ diff --git a/public/demos/stars.zip b/public/demos/stars.zip new file mode 100644 index 0000000..3e27d1c Binary files /dev/null and b/public/demos/stars.zip differ diff --git a/public/demos/unreal.zip b/public/demos/unreal.zip new file mode 100644 index 0000000..da04273 Binary files /dev/null and b/public/demos/unreal.zip differ diff --git a/public/offline.html b/public/offline.html new file mode 100644 index 0000000..6567bf9 --- /dev/null +++ b/public/offline.html @@ -0,0 +1,121 @@ + + + + + + DosKit - Offline + + + +
+
📡
+

You're Offline

+

+ It looks like you've lost your internet connection. + DosKit needs to download some files to run DOS applications. +

+ + +
+

💾 Good News!

+

+ If you've previously loaded any DOS apps, they may still be available + in your browser's cache. Try going back to the main page. +

+ +
+
+ + + + + diff --git a/public/sw.js b/public/sw.js index 669b992..1cb17dc 100644 --- a/public/sw.js +++ b/public/sw.js @@ -31,6 +31,7 @@ const BASE_PATH = getBasePath(); const STATIC_ASSETS = [ `${BASE_PATH}/`, `${BASE_PATH}/index.html`, + `${BASE_PATH}/offline.html`, `${BASE_PATH}/manifest.json`, `${BASE_PATH}/logo.svg`, `${BASE_PATH}/favicon.svg`, @@ -394,7 +395,14 @@ self.addEventListener('fetch', (event) => { // Return offline page for navigation requests if (request.mode === 'navigate') { - return caches.match(`${BASE_PATH}/index.html`); + return caches.match(`${BASE_PATH}/offline.html`) + .then((offlineResponse) => { + if (offlineResponse) { + return offlineResponse; + } + // Fallback to index.html if offline.html not cached + return caches.match(`${BASE_PATH}/index.html`); + }); } // For other requests, throw the error diff --git a/src/adapters/EmulatorAdapter.ts b/src/adapters/EmulatorAdapter.ts index 3a42172..0f66037 100644 --- a/src/adapters/EmulatorAdapter.ts +++ b/src/adapters/EmulatorAdapter.ts @@ -8,7 +8,7 @@ * This decouples the application from specific emulator libraries (js-dos, etc.) */ -import type { DosOptions, DosProps } from "../types/js-dos"; +import type { DosOptions, DosProps } from '../types/js-dos'; /** * Interface for emulator adapters @@ -27,10 +27,7 @@ export interface EmulatorAdapter { * @param options - Configuration options for the emulator * @returns A promise that resolves to the emulator instance */ - initialize( - container: HTMLDivElement, - options: Partial, - ): Promise; + initialize(container: HTMLDivElement, options: Partial): Promise; /** * Get the name of the emulator implementation @@ -54,20 +51,15 @@ export class JsDosAdapter implements EmulatorAdapter { * Check if js-dos is available on window */ isAvailable(): boolean { - return typeof window !== "undefined" && typeof window.Dos === "function"; + return typeof window !== 'undefined' && typeof window.Dos === 'function'; } /** * Initialize js-dos emulator */ - async initialize( - container: HTMLDivElement, - options: Partial, - ): Promise { + async initialize(container: HTMLDivElement, options: Partial): Promise { if (!this.isAvailable()) { - throw new Error( - "js-dos library is not loaded. Please ensure the script is included.", - ); + throw new Error('js-dos library is not loaded. Please ensure the script is included.'); } try { @@ -76,7 +68,7 @@ export class JsDosAdapter implements EmulatorAdapter { return Promise.resolve(dosProps); } catch (error) { throw new Error( - `Failed to initialize js-dos: ${error instanceof Error ? error.message : "Unknown error"}`, + `Failed to initialize js-dos: ${error instanceof Error ? error.message : 'Unknown error'}` ); } } @@ -85,7 +77,7 @@ export class JsDosAdapter implements EmulatorAdapter { * Get the adapter name */ getName(): string { - return "js-dos"; + return 'js-dos'; } /** @@ -93,25 +85,36 @@ export class JsDosAdapter implements EmulatorAdapter { */ getVersion(): string { if (!this.isAvailable()) { - return "unknown"; + return 'unknown'; } + let tempContainer: HTMLDivElement | null = null; + let dosProps: DosProps | null = null; + try { // Create a temporary container to get version - const tempContainer = document.createElement("div"); - tempContainer.style.display = "none"; + tempContainer = document.createElement('div'); + tempContainer.style.display = 'none'; document.body.appendChild(tempContainer); - const dosProps = window.Dos(tempContainer, {}); + dosProps = window.Dos(tempContainer, {}); const [version] = dosProps.getVersion(); - // Cleanup - dosProps.stop(); - document.body.removeChild(tempContainer); - return version; } catch { - return "unknown"; + return 'unknown'; + } finally { + // Always cleanup, even if an error occurs + if (dosProps) { + try { + dosProps.stop(); + } catch { + // Ignore stop errors during cleanup + } + } + if (tempContainer && tempContainer.parentNode) { + tempContainer.parentNode.removeChild(tempContainer); + } } } } diff --git a/src/components/DemoSelector.css b/src/components/DemoSelector.css index 008ddf6..7e9acb2 100644 --- a/src/components/DemoSelector.css +++ b/src/components/DemoSelector.css @@ -11,13 +11,15 @@ top: 50%; left: 50%; transform: translate(-50%, -50%); - background: var(--color-bg-primary, #1a1a1a); - border: 2px solid var(--color-border, #333); - border-radius: 8px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); - max-width: 800px; - width: 90%; - max-height: 80vh; + background: linear-gradient(180deg, #1e1e1e 0%, #121212 100%); + border: 1px solid #333; + border-radius: 12px; + box-shadow: + 0 20px 60px rgba(0, 0, 0, 0.5), + 0 8px 25px rgba(0, 0, 0, 0.4); + max-width: 1400px; + width: 95%; + max-height: 90vh; display: flex; flex-direction: column; z-index: 1000; @@ -27,45 +29,64 @@ display: flex; justify-content: space-between; align-items: center; - padding: 1rem 1.5rem; - border-bottom: 1px solid var(--color-border, #333); + padding: 1.25rem 2rem; + border-bottom: 1px solid #333; + background: #1a1a1a; + border-radius: 12px 12px 0 0; } .demo-selector-header h2 { margin: 0; font-size: 1.5rem; - color: var(--color-text-primary, #fff); + color: #e0e0e0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-weight: 600; + letter-spacing: -0.01em; } .close-button { background: transparent; border: none; - color: var(--color-text-secondary, #aaa); + color: #888; font-size: 1.5rem; cursor: pointer; - padding: 0.25rem 0.5rem; + padding: 0.5rem; line-height: 1; - transition: color 0.2s; + transition: all 0.2s; + border-radius: 6px; } .close-button:hover { - color: var(--color-text-primary, #fff); + color: #fff; + background: #333; } .demo-selector-content { - padding: 1.5rem; + padding: 2rem; overflow-y: auto; flex: 1; + background: #1e1e1e; } -/* Application List */ +/* Application List - Floppy Disk Grid */ .app-list { display: grid; - grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); - gap: 1rem; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 2rem; margin-bottom: 1.5rem; + justify-items: center; + padding: 1.5rem; } +/* Floppy grid specific styling */ +.app-list.floppy-grid { + background: #151515; + border-radius: 10px; + border: 1px solid #2a2a2a; + box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.3); +} + +/* Legacy app card styles (kept for backward compatibility) */ .app-card { background: var(--color-bg-secondary, #2a2a2a); border: 2px solid var(--color-border, #333); @@ -139,20 +160,23 @@ /* App Details */ .app-details { - background: var(--color-bg-secondary, #2a2a2a); - border: 2px solid var(--color-accent, #4a9eff); - border-radius: 6px; + background: #1a1a1a; + border: 1px solid #333; + border-radius: 10px; padding: 1.5rem; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); } .app-details h3 { margin: 0 0 0.75rem 0; - color: var(--color-accent, #4a9eff); + color: #e0e0e0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-weight: 600; } .app-details p { margin: 0 0 1rem 0; - color: var(--color-text-secondary, #ccc); + color: #aaa; } /* Error Message */ @@ -173,7 +197,8 @@ .progress-bar { width: 100%; height: 8px; - background: var(--color-bg-tertiary, #1a1a1a); + background: #2a2a2a; + border: none; border-radius: 4px; overflow: hidden; margin-bottom: 0.5rem; @@ -181,14 +206,16 @@ .progress-fill { height: 100%; - background: var(--color-accent, #4a9eff); + background: linear-gradient(90deg, #4dabf7 0%, #228be6 100%); transition: width 0.3s ease; + border-radius: 4px; } .progress-text { margin: 0; font-size: 0.875rem; - color: var(--color-text-secondary, #aaa); + color: #aaa; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } /* Actions */ @@ -202,40 +229,42 @@ .cancel-button { padding: 0.75rem 1.5rem; border: none; - border-radius: 4px; + border-radius: 8px; font-size: 1rem; - font-weight: 600; + font-weight: 500; cursor: pointer; transition: all 0.2s; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } .load-button { - background: var(--color-accent, #4a9eff); - color: white; + background: #228be6; + color: #ffffff; flex: 1; + border: none; } .load-button:hover:not(:disabled) { - background: var(--color-accent-hover, #3a8eef); + background: #1c7ed6; transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(74, 158, 255, 0.3); + box-shadow: 0 4px 12px rgba(34, 139, 230, 0.35); } .load-button:disabled { - background: var(--color-bg-tertiary, #333); - color: var(--color-text-disabled, #666); + background: #adb5bd; + color: #ffffff; cursor: not-allowed; } .cancel-button { - background: transparent; - color: var(--color-text-secondary, #aaa); - border: 1px solid var(--color-border, #333); + background: #2a2a2a; + color: #aaa; + border: 1px solid #444; } .cancel-button:hover:not(:disabled) { - background: var(--color-bg-tertiary, #333); - color: var(--color-text-primary, #fff); + background: #333; + color: #fff; } .cancel-button:disabled { @@ -244,14 +273,30 @@ } /* Responsive Design */ +@media (max-width: 1200px) { + .demo-selector { + max-width: 95%; + } +} + @media (max-width: 768px) { .demo-selector { - width: 95%; - max-height: 90vh; + width: 98%; + max-height: 95vh; + border-radius: 8px; + } + + .demo-selector-header { + padding: 1rem 1.5rem; + } + + .demo-selector-content { + padding: 1.5rem; } .app-list { - grid-template-columns: 1fr; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 1.5rem; } .demo-selector-header h2 { @@ -268,6 +313,14 @@ } } +@media (max-width: 480px) { + .app-list { + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 1rem; + padding: 0.75rem; + } +} + /* Dark Mode Variables */ :root { --color-bg-primary: #1a1a1a; @@ -301,4 +354,3 @@ --color-error-bg: #ffebee; } } - diff --git a/src/components/DemoSelector.test.tsx b/src/components/DemoSelector.test.tsx index fc0c59c..68991e0 100644 --- a/src/components/DemoSelector.test.tsx +++ b/src/components/DemoSelector.test.tsx @@ -1,41 +1,55 @@ /** * Tests for DemoSelector component + * Updated to work with retro-floppy FloppyDisk components */ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; -import { DemoSelector } from "./DemoSelector"; -import { loadZipArchive } from "../utils/diskLoader"; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { DemoSelector } from './DemoSelector'; +import { loadZipArchive } from '../utils/diskLoader'; // Mock the disk loader -vi.mock("../utils/diskLoader", () => ({ +vi.mock('../utils/diskLoader', () => ({ loadZipArchive: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3])), })); // Mock the dos-apps configs -vi.mock("../dos-apps/second-reality.config", () => ({ - secondRealityZipUrl: "http://example.com/second-reality.zip", - secondRealityDosboxConf: "[cpu]\ncore=auto", +vi.mock('../dos-apps/second-reality.config', () => ({ + secondRealityZipUrl: 'http://example.com/second-reality.zip', + secondRealityDosboxConf: '[cpu]\ncore=auto', secondRealityMetadata: { - name: "Second Reality", - description: "A legendary demo by Future Crew", - author: "Future Crew", + name: 'Second Reality', + description: 'A legendary demo by Future Crew', + author: 'Future Crew', year: 1993, }, })); -vi.mock("../dos-apps/impulse-tracker.config", () => ({ - impulseTrackerZipUrl: "http://example.com/impulse-tracker.zip", - impulseTrackerDosboxConf: "[cpu]\ncore=auto", +vi.mock('../dos-apps/impulse-tracker.config', () => ({ + impulseTrackerZipUrl: 'http://example.com/impulse-tracker.zip', + impulseTrackerDosboxConf: '[cpu]\ncore=auto', impulseTrackerMetadata: { - name: "Impulse Tracker", - description: "A music tracker application", - author: "Jeffrey Lim", + name: 'Impulse Tracker', + description: 'A music tracker application', + author: 'Jeffrey Lim', year: 1995, }, })); -describe("DemoSelector", () => { +/** + * Helper function to find a FloppyDisk component by its label/name + * FloppyDisk renders as a
with role="button" and aria-label + */ +const getFloppyDiskByName = (name: string): HTMLElement | null => { + const allFloppies = screen.getAllByRole('button'); + return ( + (allFloppies.find( + (el) => el.getAttribute('aria-label')?.includes(name) || el.textContent?.includes(name) + ) as HTMLElement) || null + ); +}; + +describe('DemoSelector', () => { const mockOnSelect = vi.fn(); const mockOnCancel = vi.fn(); @@ -43,107 +57,105 @@ describe("DemoSelector", () => { vi.clearAllMocks(); }); - describe("Rendering", () => { - it("should render without crashing", () => { + describe('Rendering', () => { + it('should render without crashing', () => { render(); - expect(screen.getByText("Select DOS Application")).toBeInTheDocument(); + expect(screen.getByText('Select DOS Application')).toBeInTheDocument(); }); - it("should render available applications", () => { + it('should render available applications', () => { render(); - expect(screen.getByText("Second Reality")).toBeInTheDocument(); - expect(screen.getByText("Impulse Tracker")).toBeInTheDocument(); + expect(screen.getByText('Second Reality')).toBeInTheDocument(); + expect(screen.getByText('Impulse Tracker')).toBeInTheDocument(); }); - it("should render close button when onCancel is provided", () => { + it('should render close button when onCancel is provided', () => { render(); - expect( - screen.getByRole("button", { name: /Close/i }), - ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Close/i })).toBeInTheDocument(); }); - it("should not render close button when onCancel is not provided", () => { + it('should not render close button when onCancel is not provided', () => { render(); - expect( - screen.queryByRole("button", { name: /Close/i }), - ).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Close/i })).not.toBeInTheDocument(); }); - it("should render app descriptions", () => { + it('should render FloppyDisk components with app info', () => { render(); - expect( - screen.getByText(/Legendary 1993 demo by Future Crew/i), - ).toBeInTheDocument(); - expect( - screen.getByText(/Classic music tracker software/i), - ).toBeInTheDocument(); + // FloppyDisk renders with aria-label containing name, author, and year + const secondReality = getFloppyDiskByName('Second Reality'); + expect(secondReality).toBeInTheDocument(); + expect(secondReality?.getAttribute('aria-label')).toContain('Future Crew'); + expect(secondReality?.getAttribute('aria-label')).toContain('1993'); }); - it("should render app metadata (author and year)", () => { + it('should render app metadata via FloppyDisk aria-labels', () => { render(); - expect(screen.getByText(/Future Crew \(1993\)/i)).toBeInTheDocument(); - expect(screen.getByText(/Jeffrey Lim \(1995\)/i)).toBeInTheDocument(); + // FloppyDisk components use aria-label for accessibility which contains author/year + const secondReality = getFloppyDiskByName('Second Reality'); + const impulseTracker = getFloppyDiskByName('Impulse Tracker'); + + expect(secondReality?.getAttribute('aria-label')).toContain('Future Crew'); + expect(impulseTracker?.getAttribute('aria-label')).toContain('Jeffrey Lim'); }); - it("should render load method badges", () => { + it('should render load method type in FloppyDisk labels', () => { render(); - const badges = screen.getAllByText("zip"); - expect(badges.length).toBeGreaterThan(0); + // FloppyDisk shows type (load method) in the disk label area as uppercase + expect(screen.getAllByText('ZIP').length).toBeGreaterThan(0); }); }); - describe("App selection", () => { - it("should select app when clicked", () => { + describe('App selection', () => { + it('should select app when FloppyDisk is clicked', () => { render(); - const appCard = screen.getByText("Second Reality").closest(".app-card"); - expect(appCard).toBeInTheDocument(); + const floppyDisk = getFloppyDiskByName('Second Reality'); + expect(floppyDisk).toBeInTheDocument(); - fireEvent.click(appCard!); + fireEvent.click(floppyDisk!); - expect(appCard).toHaveClass("selected"); + // After clicking, the app details section should appear + expect(screen.getByText(/Selected: Second Reality/i)).toBeInTheDocument(); }); - it("should show app details when app is selected", () => { + it('should show app details when FloppyDisk is clicked', () => { render(); - const appCard = screen.getByText("Second Reality").closest(".app-card"); - fireEvent.click(appCard!); + const floppyDisk = getFloppyDiskByName('Second Reality'); + fireEvent.click(floppyDisk!); expect(screen.getByText(/Selected: Second Reality/i)).toBeInTheDocument(); }); - it("should show Load Application button when app is selected", () => { + it('should show Load Application button when app is selected', () => { render(); - const appCard = screen.getByText("Second Reality").closest(".app-card"); - fireEvent.click(appCard!); + const floppyDisk = getFloppyDiskByName('Second Reality'); + fireEvent.click(floppyDisk!); - expect( - screen.getByRole("button", { name: /Load Application/i }), - ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Load Application/i })).toBeInTheDocument(); }); - it("should show Cancel button when app is selected", () => { + it('should show Cancel button when app is selected', () => { render(); - const appCard = screen.getByText("Second Reality").closest(".app-card"); - fireEvent.click(appCard!); + const floppyDisk = getFloppyDiskByName('Second Reality'); + fireEvent.click(floppyDisk!); - const cancelButtons = screen.getAllByRole("button", { name: /Cancel/i }); + const cancelButtons = screen.getAllByRole('button', { name: /Cancel/i }); expect(cancelButtons.length).toBeGreaterThan(0); }); - it("should clear error when selecting a new app", async () => { - vi.mocked(loadZipArchive).mockRejectedValueOnce(new Error("Load failed")); + it('should clear error when selecting a new app', async () => { + vi.mocked(loadZipArchive).mockRejectedValueOnce(new Error('Load failed')); render(); // Select and try to load an app (will fail) - const appCard = screen.getByText("Second Reality").closest(".app-card"); - fireEvent.click(appCard!); + const floppyDisk = getFloppyDiskByName('Second Reality'); + fireEvent.click(floppyDisk!); - const loadButton = screen.getByRole("button", { + const loadButton = screen.getByRole('button', { name: /Load Application/i, }); fireEvent.click(loadButton); @@ -154,24 +166,22 @@ describe("DemoSelector", () => { }); // Select another app - const anotherAppCard = screen - .getByText("Impulse Tracker") - .closest(".app-card"); - fireEvent.click(anotherAppCard!); + const anotherFloppyDisk = getFloppyDiskByName('Impulse Tracker'); + fireEvent.click(anotherFloppyDisk!); // Error should be cleared expect(screen.queryByText(/Load failed/i)).not.toBeInTheDocument(); }); }); - describe("App loading", () => { - it("should load app when Load Application button is clicked", async () => { + describe('App loading', () => { + it('should load app when Load Application button is clicked', async () => { render(); - const appCard = screen.getByText("Second Reality").closest(".app-card"); - fireEvent.click(appCard!); + const floppyDisk = getFloppyDiskByName('Second Reality'); + fireEvent.click(floppyDisk!); - const loadButton = screen.getByRole("button", { + const loadButton = screen.getByRole('button', { name: /Load Application/i, }); fireEvent.click(loadButton); @@ -181,13 +191,13 @@ describe("DemoSelector", () => { }); }); - it("should call onSelect with loaded app data after successful load", async () => { + it('should call onSelect with loaded app data after successful load', async () => { render(); - const appCard = screen.getByText("Second Reality").closest(".app-card"); - fireEvent.click(appCard!); + const floppyDisk = getFloppyDiskByName('Second Reality'); + fireEvent.click(floppyDisk!); - const loadButton = screen.getByRole("button", { + const loadButton = screen.getByRole('button', { name: /Load Application/i, }); fireEvent.click(loadButton); @@ -196,47 +206,48 @@ describe("DemoSelector", () => { expect(mockOnSelect).toHaveBeenCalledWith( expect.objectContaining({ app: expect.objectContaining({ - id: "second-reality", - name: "Second Reality", + id: 'second-reality', + name: 'Second Reality', }), files: expect.any(Uint8Array), dosboxConf: expect.any(String), - }), + }) ); }); }); - it("should show loading state while loading", async () => { + it('should show loading state while loading', async () => { vi.mocked(loadZipArchive).mockImplementation( - () => new Promise((resolve) => setTimeout(resolve, 100)), + () => new Promise((resolve) => setTimeout(resolve, 100)) ); render(); - const appCard = screen.getByText("Second Reality").closest(".app-card"); - fireEvent.click(appCard!); + const floppyDisk = getFloppyDiskByName('Second Reality'); + fireEvent.click(floppyDisk!); - const loadButton = screen.getByRole("button", { + const loadButton = screen.getByRole('button', { name: /Load Application/i, }); fireEvent.click(loadButton); + // New UI shows "Cancel Loading" button instead of "Loading..." text await waitFor(() => { - expect(screen.getByText("Loading...")).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Cancel Loading/i })).toBeInTheDocument(); }); }); - it("should show load progress", async () => { + it('should show load progress', async () => { vi.mocked(loadZipArchive).mockImplementation( - () => new Promise((resolve) => setTimeout(resolve, 100)), + () => new Promise((resolve) => setTimeout(resolve, 100)) ); render(); - const appCard = screen.getByText("Second Reality").closest(".app-card"); - fireEvent.click(appCard!); + const floppyDisk = getFloppyDiskByName('Second Reality'); + fireEvent.click(floppyDisk!); - const loadButton = screen.getByRole("button", { + const loadButton = screen.getByRole('button', { name: /Load Application/i, }); fireEvent.click(loadButton); @@ -246,28 +257,31 @@ describe("DemoSelector", () => { }); }); - it("should disable buttons while loading", async () => { + it('should show cancel button while loading', async () => { vi.mocked(loadZipArchive).mockImplementation( - () => new Promise((resolve) => setTimeout(resolve, 100)), + () => new Promise((resolve) => setTimeout(resolve, 100)) ); render(); - const appCard = screen.getByText("Second Reality").closest(".app-card"); - fireEvent.click(appCard!); + const floppyDisk = getFloppyDiskByName('Second Reality'); + fireEvent.click(floppyDisk!); - const loadButton = screen.getByRole("button", { + const loadButton = screen.getByRole('button', { name: /Load Application/i, }); fireEvent.click(loadButton); + // New UI replaces Load/Cancel buttons with a single "Cancel Loading" button await waitFor(() => { - expect(loadButton).toBeDisabled(); + expect(screen.getByRole('button', { name: /Cancel Loading/i })).toBeInTheDocument(); + // Load Application button should not be visible during loading + expect(screen.queryByRole('button', { name: /Load Application/i })).not.toBeInTheDocument(); }); }); - it("should handle loading errors", async () => { - const errorMessage = "Load failed"; + it('should handle loading errors', async () => { + const errorMessage = 'Load failed'; // Reset and set up the mock to reject vi.mocked(loadZipArchive).mockReset(); @@ -275,10 +289,10 @@ describe("DemoSelector", () => { render(); - const appCard = screen.getByText("Second Reality").closest(".app-card"); - fireEvent.click(appCard!); + const floppyDisk = getFloppyDiskByName('Second Reality'); + fireEvent.click(floppyDisk!); - const loadButton = screen.getByRole("button", { + const loadButton = screen.getByRole('button', { name: /Load Application/i, }); fireEvent.click(loadButton); @@ -288,34 +302,32 @@ describe("DemoSelector", () => { () => { expect(screen.getByText(errorMessage)).toBeInTheDocument(); }, - { timeout: 3000 }, + { timeout: 3000 } ); // Verify loading state is cleared - expect(screen.queryByText("Loading...")).not.toBeInTheDocument(); + expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); }); - it("should handle non-Error exceptions", async () => { - vi.mocked(loadZipArchive).mockRejectedValueOnce("String error"); + it('should handle non-Error exceptions', async () => { + vi.mocked(loadZipArchive).mockRejectedValueOnce('String error'); render(); - const appCard = screen.getByText("Second Reality").closest(".app-card"); - fireEvent.click(appCard!); + const floppyDisk = getFloppyDiskByName('Second Reality'); + fireEvent.click(floppyDisk!); - const loadButton = screen.getByRole("button", { + const loadButton = screen.getByRole('button', { name: /Load Application/i, }); fireEvent.click(loadButton); await waitFor(() => { - expect( - screen.getByText(/Failed to load application/i), - ).toBeInTheDocument(); + expect(screen.getByText(/Failed to load application/i)).toBeInTheDocument(); }); }); - it("should not load if no app is selected", () => { + it('should not load if no app is selected', () => { render(); // Try to trigger load without selecting an app (shouldn't be possible in UI, but test the logic) @@ -324,47 +336,45 @@ describe("DemoSelector", () => { }); }); - describe("Cancel functionality", () => { - it("should call onCancel when close button is clicked", () => { + describe('Cancel functionality', () => { + it('should call onCancel when close button is clicked', () => { render(); - const closeButton = screen.getByRole("button", { name: /Close/i }); + const closeButton = screen.getByRole('button', { name: /Close/i }); fireEvent.click(closeButton); expect(mockOnCancel).toHaveBeenCalledTimes(1); }); - it("should clear selected app when cancel is clicked", () => { + it('should clear selected app when cancel is clicked', () => { render(); - // Select an app - const appCard = screen.getByText("Second Reality").closest(".app-card"); - fireEvent.click(appCard!); + // Select an app using FloppyDisk + const floppyDisk = getFloppyDiskByName('Second Reality'); + fireEvent.click(floppyDisk!); expect(screen.getByText(/Selected: Second Reality/i)).toBeInTheDocument(); // Click cancel in app details - const cancelButtons = screen.getAllByRole("button", { name: /Cancel/i }); + const cancelButtons = screen.getAllByRole('button', { name: /Cancel/i }); const detailsCancelButton = cancelButtons.find((btn) => - btn.className.includes("cancel-button"), + btn.className.includes('cancel-button') ); fireEvent.click(detailsCancelButton!); - expect( - screen.queryByText(/Selected: Second Reality/i), - ).not.toBeInTheDocument(); + expect(screen.queryByText(/Selected: Second Reality/i)).not.toBeInTheDocument(); }); - it("should clear error when cancel is clicked", async () => { - vi.mocked(loadZipArchive).mockRejectedValueOnce(new Error("Load failed")); + it('should clear error when cancel is clicked', async () => { + vi.mocked(loadZipArchive).mockRejectedValueOnce(new Error('Load failed')); render(); // Select and try to load an app (will fail) - const appCard = screen.getByText("Second Reality").closest(".app-card"); - fireEvent.click(appCard!); + const floppyDisk = getFloppyDiskByName('Second Reality'); + fireEvent.click(floppyDisk!); - const loadButton = screen.getByRole("button", { + const loadButton = screen.getByRole('button', { name: /Load Application/i, }); fireEvent.click(loadButton); @@ -374,9 +384,9 @@ describe("DemoSelector", () => { }); // Click cancel - const cancelButtons = screen.getAllByRole("button", { name: /Cancel/i }); + const cancelButtons = screen.getAllByRole('button', { name: /Cancel/i }); const detailsCancelButton = cancelButtons.find((btn) => - btn.className.includes("cancel-button"), + btn.className.includes('cancel-button') ); fireEvent.click(detailsCancelButton!); diff --git a/src/components/DemoSelector.tsx b/src/components/DemoSelector.tsx index 428f4c6..aa87bc9 100644 --- a/src/components/DemoSelector.tsx +++ b/src/components/DemoSelector.tsx @@ -5,16 +5,22 @@ * * Demo Selector Component * Allows users to select and load different DOS applications and demos + * Styled with retro-floppy for authentic retro aesthetic */ -import { useState } from "react"; -import type { DosApp } from "../types/dos-app"; -import type { InitFileEntry } from "../types/js-dos"; -import { type LoadProgress } from "../utils/diskLoader"; -import "./DemoSelector.css"; +import { useState, useCallback, useRef, useEffect } from 'react'; +import { FloppyDisk, LIGHT_FLOPPY_THEME } from 'retro-floppy'; +import 'retro-floppy/dist/retro-floppy.css'; +import type { DosApp } from '../types/dos-app'; +import type { InitFileEntry } from '../types/js-dos'; +import { type LoadProgress } from '../utils/diskLoader'; +import { availableApps, findAppById } from '../config/apps.config'; +import './DemoSelector.css'; -// Re-export DosApp for backward compatibility +// Re-export for backward compatibility export type { DosApp }; +// eslint-disable-next-line react-refresh/only-export-components +export { availableApps, findAppById }; export interface LoadedApp { app: DosApp; @@ -27,170 +33,318 @@ interface DemoSelectorProps { onCancel?: () => void; } -/** - * Available DOS applications and demos - * Exported for use in URL routing and deep linking - * Uses lazy loading to reduce initial bundle size - */ -// eslint-disable-next-line react-refresh/only-export-components -export const availableApps: DosApp[] = [ - { - id: "second-reality", - name: "Second Reality", - description: "Legendary 1993 demo by Future Crew", - author: "Future Crew", - year: 1993, - loadMethod: "zip", - dosboxConf: "", // Loaded dynamically - loader: async (onProgress) => { - const config = await import("../dos-apps/second-reality.config"); - const { loadZipArchive } = await import("../utils/diskLoader"); - // Use local ZIP file for fast loading - return loadZipArchive(config.secondRealityZipUrl, onProgress); - }, - loadDosboxConf: async () => { - const config = await import("../dos-apps/second-reality.config"); - return config.secondRealityDosboxConf; - }, - }, - { - id: "impulse-tracker", - name: "Impulse Tracker", - description: "Classic music tracker software", - author: "Jeffrey Lim", - year: 1995, - loadMethod: "zip", - dosboxConf: "", // Loaded dynamically - loader: async (onProgress) => { - const config = await import("../dos-apps/impulse-tracker.config"); - return config.loadZipArchive(config.impulseTrackerZipUrl, onProgress); - }, - loadDosboxConf: async () => { - const config = await import("../dos-apps/impulse-tracker.config"); - return config.impulseTrackerDosboxConf; - }, - }, - // Add more applications here -]; +/** Default loading timeout in milliseconds (30 seconds) */ +const LOADING_TIMEOUT_MS = 30000; -/** - * Find an application by its ID - * @param id - The app ID to search for - * @returns The DosApp if found, undefined otherwise - */ -// eslint-disable-next-line react-refresh/only-export-components -export function findAppById(id: string): DosApp | undefined { - return availableApps.find((app) => app.id === id); -} +/** Cache for preloaded app configurations */ +const preloadedConfigs = new Map>(); export function DemoSelector({ onSelect, onCancel }: DemoSelectorProps) { const [selectedApp, setSelectedApp] = useState(null); + const [focusedIndex, setFocusedIndex] = useState(0); const [isLoading, setIsLoading] = useState(false); const [loadProgress, setLoadProgress] = useState(null); const [error, setError] = useState(null); + const abortControllerRef = useRef(null); + const gridRef = useRef(null); + const hoverTimeoutRef = useRef | null>(null); - const handleSelectApp = (app: DosApp) => { + const handleSelectApp = useCallback((app: DosApp) => { setSelectedApp(app); setError(null); - }; + }, []); - const handleLoadApp = async () => { - if (!selectedApp) return; + /** + * Preload app configuration on hover for faster perceived loading + * Uses a small delay to avoid preloading on quick mouse movements + */ + const handleHoverStart = useCallback((app: DosApp) => { + // Clear any existing timeout + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + } - setIsLoading(true); - setError(null); - setLoadProgress({ loaded: 0, total: 1, currentFile: "Starting..." }); - - try { - // Load the application files and dosbox config in parallel with progress tracking - const [files, conf] = await Promise.all([ - selectedApp.loader(setLoadProgress), - selectedApp.loadDosboxConf - ? selectedApp.loadDosboxConf() - : Promise.resolve(selectedApp.dosboxConf), - ]); - - // Notify parent component with loaded data - onSelect({ - app: selectedApp, - files, - dosboxConf: conf, - }); - } catch (err) { - console.error("[DemoSelector] Error loading application:", err); - setError( - err instanceof Error ? err.message : "Failed to load application", - ); - setIsLoading(false); - setLoadProgress(null); + // Delay preload to avoid unnecessary requests on quick mouse movements + hoverTimeoutRef.current = setTimeout(() => { + if (!preloadedConfigs.has(app.id) && app.loadDosboxConf) { + // Start preloading the config (don't await, just start the promise) + preloadedConfigs.set(app.id, app.loadDosboxConf()); + } + }, 150); + }, []); + + const handleHoverEnd = useCallback(() => { + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + hoverTimeoutRef.current = null; } - }; + }, []); - const handleCancel = () => { - setSelectedApp(null); - setError(null); + /** + * Load an application with optional timeout and cancellation support + * Fixed: Now accepts app parameter directly to avoid race condition with state + */ + const handleLoadApp = useCallback( + async (appToLoad?: DosApp) => { + const app = appToLoad || selectedApp; + if (!app) return; + + // Cancel any ongoing load + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + abortControllerRef.current = new AbortController(); + + setIsLoading(true); + setError(null); + setLoadProgress({ loaded: 0, total: 1, currentFile: 'Starting...' }); + + // Set up timeout + const timeoutId = setTimeout(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + setError('Loading timed out. Please check your internet connection and try again.'); + setIsLoading(false); + setLoadProgress(null); + } + }, LOADING_TIMEOUT_MS); + + try { + // Use preloaded config if available, otherwise load it + const configPromise = + preloadedConfigs.get(app.id) || + (app.loadDosboxConf ? app.loadDosboxConf() : Promise.resolve(app.dosboxConf)); + + // Load the application files and dosbox config in parallel with progress tracking + const [files, conf] = await Promise.all([app.loader(setLoadProgress), configPromise]); + + clearTimeout(timeoutId); + + // Check if aborted + if (abortControllerRef.current?.signal.aborted) { + return; + } + + // Notify parent component with loaded data + onSelect({ + app, + files, + dosboxConf: conf, + }); + } catch (err) { + clearTimeout(timeoutId); + + // Don't show error if intentionally cancelled + if (err instanceof Error && err.name === 'AbortError') { + return; + } + + console.error('[DemoSelector] Error loading application:', err); + // User-friendly error messages + let errorMessage = 'Failed to load application. Please try again.'; + if (err instanceof Error) { + if (err.message.includes('network') || err.message.includes('fetch')) { + errorMessage = 'Network error. Please check your internet connection and try again.'; + } else if (err.message.includes('timeout')) { + errorMessage = 'Loading timed out. The server may be slow. Please try again.'; + } else { + errorMessage = err.message; + } + } + setError(errorMessage); + setIsLoading(false); + setLoadProgress(null); + } + }, + [selectedApp, onSelect] + ); + + /** Cancel current loading operation */ + const handleCancelLoading = useCallback(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + setIsLoading(false); setLoadProgress(null); - if (onCancel) { - onCancel(); + setError(null); + }, []); + + const handleCancel = useCallback(() => { + handleCancelLoading(); + setSelectedApp(null); + onCancel?.(); + }, [handleCancelLoading, onCancel]); + + /** Keyboard navigation handler */ + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + const gridColumns = 3; // Assuming 3 columns in the grid + const totalApps = availableApps.length; + + switch (event.key) { + case 'ArrowRight': + event.preventDefault(); + setFocusedIndex((prev) => (prev + 1) % totalApps); + break; + case 'ArrowLeft': + event.preventDefault(); + setFocusedIndex((prev) => (prev - 1 + totalApps) % totalApps); + break; + case 'ArrowDown': + event.preventDefault(); + setFocusedIndex((prev) => Math.min(prev + gridColumns, totalApps - 1)); + break; + case 'ArrowUp': + event.preventDefault(); + setFocusedIndex((prev) => Math.max(prev - gridColumns, 0)); + break; + case 'Enter': + event.preventDefault(); + if (selectedApp) { + handleLoadApp(selectedApp); + } else { + const app = availableApps[focusedIndex]; + if (app) { + handleSelectApp(app); + } + } + break; + case ' ': { + event.preventDefault(); + const app = availableApps[focusedIndex]; + if (app) { + handleSelectApp(app); + } + break; + } + case 'Escape': + event.preventDefault(); + if (isLoading) { + handleCancelLoading(); + } else { + handleCancel(); + } + break; + } + }, + [ + focusedIndex, + selectedApp, + isLoading, + handleLoadApp, + handleSelectApp, + handleCancelLoading, + handleCancel, + ] + ); + + // Clean up abort controller on unmount + useEffect(() => { + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; + }, []); + + // Focus management for keyboard navigation + useEffect(() => { + const focusedApp = availableApps[focusedIndex]; + if (focusedApp && gridRef.current) { + const focusedElement = gridRef.current.querySelector(`[data-app-id="${focusedApp.id}"]`); + if (focusedElement instanceof HTMLElement) { + focusedElement.focus(); + } } - }; + }, [focusedIndex]); return ( -
+

Select DOS Application

{onCancel && ( - )}
- {/* Application List */} -
- {availableApps.map((app) => ( + {/* Application List - Floppy Disk Grid */} +
+ {availableApps.map((app, index) => (
handleSelectApp(app)} + data-app-id={app.id} + tabIndex={index === focusedIndex ? 0 : -1} + className="floppy-wrapper" + onFocus={() => setFocusedIndex(index)} + onMouseEnter={() => handleHoverStart(app)} + onMouseLeave={handleHoverEnd} > - {app.thumbnail && ( -
- {app.name} -
- )} -
-

{app.name}

- {app.author && app.year && ( -

- {app.author} ({app.year}) -

- )} -

{app.description}

-
- {app.loadMethod} -
-
+ { + setFocusedIndex(index); + handleSelectApp(app); + }} + onDoubleClick={() => { + setFocusedIndex(index); + handleSelectApp(app); + handleLoadApp(app); // Pass app directly to avoid race condition + }} + ariaLabel={`${app.name} by ${app.author || 'Unknown'} (${app.year || 'Unknown year'}). Press Enter to load, Space to select.`} + />
))}
{/* Selected App Details */} {selectedApp && ( -
+

Selected: {selectedApp.name}

{selectedApp.description}

- {error &&
{error}
} + {error && ( +
+ {error} +
+ )} {loadProgress && (
-
+

- Loading {loadProgress.currentFile}... ({loadProgress.loaded}/ - {loadProgress.total}) + Loading {loadProgress.currentFile}... ({loadProgress.loaded}/{loadProgress.total})

)}
- - + {isLoading ? ( + + ) : ( + <> + + + + )}
)} diff --git a/src/config/apps.config.ts b/src/config/apps.config.ts new file mode 100644 index 0000000..638a0f4 --- /dev/null +++ b/src/config/apps.config.ts @@ -0,0 +1,230 @@ +/** + * DosKit - Cross-Platform DOS Emulator + * Copyright (c) 2025 Cameron Rye + * Licensed under the MIT License + * + * Centralized DOS Applications Configuration + * All available DOS apps are defined here for use across the application + */ + +import type { DosApp } from '../types/dos-app'; + +/** + * Available DOS applications and demos + * Uses lazy loading to reduce initial bundle size + */ +export const availableApps: DosApp[] = [ + { + id: 'second-reality', + name: 'Second Reality', + description: 'Legendary 1993 demo by Future Crew', + author: 'Future Crew', + year: 1993, + loadMethod: 'zip', + dosboxConf: '', // Loaded dynamically + loader: async (onProgress) => { + const config = await import('../dos-apps/second-reality.config'); + const { loadZipArchive } = await import('../utils/diskLoader'); + return loadZipArchive(config.secondRealityZipUrl, onProgress); + }, + loadDosboxConf: async () => { + const config = await import('../dos-apps/second-reality.config'); + return config.secondRealityDosboxConf; + }, + }, + { + id: 'impulse-tracker', + name: 'Impulse Tracker', + description: 'Classic music tracker software', + author: 'Jeffrey Lim', + year: 1995, + loadMethod: 'zip', + dosboxConf: '', + loader: async (onProgress) => { + const config = await import('../dos-apps/impulse-tracker.config'); + return config.loadZipArchive(config.impulseTrackerZipUrl, onProgress); + }, + loadDosboxConf: async () => { + const config = await import('../dos-apps/impulse-tracker.config'); + return config.impulseTrackerDosboxConf; + }, + }, + { + id: 'starport-bbstro', + name: 'Starport BBS Intro II', + description: 'Tiny BBS intro (1993 bytes) by Future Crew', + author: 'Future Crew', + year: 1993, + loadMethod: 'zip', + dosboxConf: '', + loader: async (onProgress) => { + const config = await import('../dos-apps/starport-bbstro.config'); + return config.loadZipArchive(config.starportBbstroZipUrl, onProgress); + }, + loadDosboxConf: async () => { + const config = await import('../dos-apps/starport-bbstro.config'); + return config.starportBbstroDosboxConf; + }, + }, + { + id: 'scream-tracker', + name: 'Scream Tracker 3', + description: 'Legendary S3M tracker by Future Crew', + author: 'Future Crew', + year: 1994, + loadMethod: 'zip', + dosboxConf: '', + loader: async (onProgress) => { + const config = await import('../dos-apps/scream-tracker.config'); + return config.loadZipArchive(config.screamTrackerZipUrl, onProgress); + }, + loadDosboxConf: async () => { + const config = await import('../dos-apps/scream-tracker.config'); + return config.screamTrackerDosboxConf; + }, + }, + { + id: 'unreal', + name: 'Unreal', + description: 'Groundbreaking 1992 demo by Future Crew', + author: 'Future Crew', + year: 1992, + loadMethod: 'zip', + dosboxConf: '', + loader: async (onProgress) => { + const config = await import('../dos-apps/unreal.config'); + return config.loadZipArchive(config.unrealZipUrl, onProgress); + }, + loadDosboxConf: async () => { + const config = await import('../dos-apps/unreal.config'); + return config.unrealDosboxConf; + }, + }, + { + id: 'panic', + name: 'Panic', + description: 'Classic 1992 demo by Future Crew', + author: 'Future Crew', + year: 1992, + loadMethod: 'zip', + dosboxConf: '', + loader: async (onProgress) => { + const config = await import('../dos-apps/panic.config'); + return config.loadZipArchive(config.panicZipUrl, onProgress); + }, + loadDosboxConf: async () => { + const config = await import('../dos-apps/panic.config'); + return config.panicDosboxConf; + }, + }, + { + id: 'squid-bbstro', + name: 'Squid BBS Intro', + description: 'Tiny BBS intro (1899 bytes) by cld & The Doctor', + author: 'cld & The Doctor', + year: 1994, + loadMethod: 'zip', + dosboxConf: '', + loader: async (onProgress) => { + const config = await import('../dos-apps/squid-bbstro.config'); + return config.loadZipArchive(config.squidBbstroZipUrl, onProgress); + }, + loadDosboxConf: async () => { + const config = await import('../dos-apps/squid-bbstro.config'); + return config.squidBbstroDosboxConf; + }, + }, + { + id: '3drotate', + name: '3D Rotation Demo', + description: "Classic 3D rotation effect from Grumpy's collection", + author: "Grumpy's Collection", + year: 1990, + loadMethod: 'zip', + dosboxConf: '', + loader: async (onProgress) => { + const config = await import('../dos-apps/3drotate.config'); + return config.loadZipArchive(config.rotate3dZipUrl, onProgress); + }, + loadDosboxConf: async () => { + const config = await import('../dos-apps/3drotate.config'); + return config.rotate3dDosboxConf; + }, + }, + { + id: 'stars', + name: 'Starfield Effect', + description: 'Classic starfield effect simulating flying through space', + author: "Grumpy's Collection", + year: 1990, + loadMethod: 'zip', + dosboxConf: '', + loader: async (onProgress) => { + const config = await import('../dos-apps/stars.config'); + return config.loadZipArchive(config.starsZipUrl, onProgress); + }, + loadDosboxConf: async () => { + const config = await import('../dos-apps/stars.config'); + return config.starsDosboxConf; + }, + }, + { + id: 'crystal-dream-2', + name: 'Crystal Dream 2', + description: "Legendary 1993 demo by Triton - 1st place at TCC'93", + author: 'Triton', + year: 1993, + loadMethod: 'zip', + dosboxConf: '', + loader: async (onProgress) => { + const config = await import('../dos-apps/crystal-dream-2.config'); + return config.loadZipArchive(config.crystalDream2ZipUrl, onProgress); + }, + loadDosboxConf: async () => { + const config = await import('../dos-apps/crystal-dream-2.config'); + return config.crystalDream2DosboxConf; + }, + }, +]; + +/** + * Find an application by its ID + * @param id - The app ID to search for + * @returns The DosApp if found, undefined otherwise + */ +export function findAppById(id: string): DosApp | undefined { + return availableApps.find((app) => app.id === id); +} + +/** + * Generate URL-friendly ID from app ID + * Removes hyphens for easier typing in URLs + */ +export function getUrlFriendlyId(appId: string): string { + return appId.replace(/-/g, ''); +} + +/** + * Auto-generated URL mapping from available apps + * Maps both URL-friendly IDs (no hyphens) and original IDs to internal IDs + */ +export const APP_ID_MAPPING: Record = availableApps.reduce( + (acc, app) => { + const urlFriendly = getUrlFriendlyId(app.id); + acc[urlFriendly] = app.id; + acc[app.id] = app.id; + return acc; + }, + {} as Record +); + +/** + * Reverse mapping: Internal ID -> URL-friendly ID + */ +export const INTERNAL_TO_URL_MAPPING: Record = availableApps.reduce( + (acc, app) => { + acc[app.id] = getUrlFriendlyId(app.id); + return acc; + }, + {} as Record +); diff --git a/src/dos-apps/3drotate.config.ts b/src/dos-apps/3drotate.config.ts new file mode 100644 index 0000000..afea88a --- /dev/null +++ b/src/dos-apps/3drotate.config.ts @@ -0,0 +1,85 @@ +/** + * DosKit - Cross-Platform DOS Emulator + * Copyright (c) 2025 Cameron Rye + * Licensed under the MIT License + * + * 3D Rotate Demo Configuration + * Configuration for running the 3D rotation demo from Grumpy's collection + */ + +import { loadZipArchive as loadZip } from '../utils/diskLoader'; +import { presets } from '../utils/dosboxConfigBuilder'; + +// Re-export for lazy loading +export { loadZip as loadZipArchive }; + +/** + * 3D Rotate Demo (hosted locally) + * A classic 3D rotation demo effect + * + * Source: https://github.com/sceners/grumpys-source-pack-collection + * License: Scene/Historical + */ +export const rotate3dZipUrl = '/demos/3drotate.zip'; + +/** + * DOSBox configuration optimized for 3D Rotate Demo + * + * Requirements: + * - 286 CPU minimum + * - VGA graphics + * - Memory: Minimal (runs in 640KB) + * + * This configuration uses: + * - 486 CPU with auto cycles + * - Standard VGA graphics + */ +export const rotate3dDosboxConf = presets + .demo() + .setCPU({ core: 'auto', cputype: '486', cycles: 'auto' }) + .setMemory({ memsize: 4 }) + .setJoystick({ joysticktype: 'none' }) + .addAutoexec( + '@echo off', + 'echo.', + 'echo ========================================', + 'echo 3D Rotation Demo', + "echo From Grumpy's Source Pack Collection", + 'echo ========================================', + 'echo.', + 'echo CPU: 486 (Auto Cycles)', + 'echo Graphics: VGA', + 'echo.', + 'echo Mounting C: drive...', + 'mount c .', + 'c:', + 'echo.', + 'echo Starting 3D Rotation Demo...', + 'echo.', + '3DROTATE.EXE' + ) + .build(); + +/** + * Configuration metadata + */ +export const rotate3dMetadata = { + name: '3D Rotation Demo', + author: "Grumpy's Collection", + year: 1990, + description: + 'Classic 3D rotation demo effect showcasing real-time 3D object rotation in VGA mode.', + requirements: { + cpu: '286 or better', + memory: '640KB RAM', + graphics: 'VGA', + sound: 'None', + }, + license: 'Scene/Historical', + repository: 'https://github.com/sceners/grumpys-source-pack-collection', + notes: [ + "Part of Grumpy's extensive demo effects collection", + 'Educational example of 3D graphics programming', + 'Demonstrates real-time 3D rotation algorithms', + ], +}; diff --git a/src/dos-apps/crystal-dream-2.config.ts b/src/dos-apps/crystal-dream-2.config.ts new file mode 100644 index 0000000..9503c48 --- /dev/null +++ b/src/dos-apps/crystal-dream-2.config.ts @@ -0,0 +1,121 @@ +/** + * DosKit - Cross-Platform DOS Emulator + * Copyright (c) 2025 Cameron Rye + * Licensed under the MIT License + * + * Crystal Dream 2 Configuration + * Configuration for running Crystal Dream 2 by Triton (1993) + */ + +import { loadZipArchive as loadZip } from '../utils/diskLoader'; +import { presets } from '../utils/dosboxConfigBuilder'; + +// Re-export for lazy loading +export { loadZip as loadZipArchive }; + +/** + * Crystal Dream 2 by Triton (hosted locally) + * A legendary 1993 demo that won 1st place at The Computer Crossroads 1993 + * + * Source: https://files.scene.org/get/demos/groups/triton/cd2-trn.zip + * License: Scene/Historical + * Size: ~2MB + */ +export const crystalDream2ZipUrl = '/demos/crystal-dream-2.zip'; + +/** + * DOSBox configuration optimized for Crystal Dream 2 + * + * Crystal Dream 2 requirements: + * - 486 DX-50 recommended (runs on 386-40) + * - VGA graphics + * - Sound Blaster compatible sound card + * - Memory: 4MB recommended + * + * This configuration uses: + * - 486 CPU with auto cycles + * - Sound Blaster 16 for audio + * - Standard VGA graphics + * + * Features: + * - 3D wireframe and filled vector graphics + * - Fractal zoomer (Mandelbrot set) + * - Vector slime effect + * - Raytraced graphics + * - Texture-mapped 3D chess scene + * - Music by Lizardking and Vogue + */ +export const crystalDream2DosboxConf = presets + .demo() + .setCPU({ core: 'auto', cputype: '486', cycles: 'auto' }) + .setMemory({ memsize: 4 }) + .setSoundBlaster({ + sbtype: 'sb16', + sbbase: 220, + irq: 7, + dma: 1, + hdma: 5, + oplmode: 'auto', + }) + .setJoystick({ joysticktype: 'none' }) + .addAutoexec( + '@echo off', + 'echo.', + 'echo ========================================', + 'echo Crystal Dream 2', + 'echo by Triton (1993)', + 'echo ========================================', + 'echo.', + 'echo 1st Place at The Computer Crossroads 1993', + 'echo.', + 'echo Audio: Sound Blaster 16', + 'echo CPU: 486 (Auto Cycles)', + 'echo Graphics: VGA', + 'echo.', + 'echo Features:', + 'echo - 3D vector graphics', + 'echo - Fractal zoomer', + 'echo - Vector slime', + 'echo - Raytraced scenes', + 'echo - 3D chess scene', + 'echo - Music by Lizardking and Vogue', + 'echo.', + 'echo Mounting C: drive...', + 'mount c .', + 'c:', + 'echo.', + 'echo Starting Crystal Dream 2...', + 'echo.', + 'CD2.EXE' + ) + .build(); + +/** + * Configuration metadata + */ +export const crystalDream2Metadata = { + name: 'Crystal Dream 2', + author: 'Triton', + year: 1993, + description: + 'Legendary 1993 demo featuring stunning 3D graphics, fractal zoomer, and raytraced scenes. Won 1st place at The Computer Crossroads 1993. One of the most influential PC demos of the early 90s.', + requirements: { + cpu: '486 DX-50 recommended (runs on 386-40)', + memory: '4MB RAM', + graphics: 'VGA', + sound: 'Sound Blaster compatible', + }, + license: 'Scene/Historical', + party: 'The Computer Crossroads 1993', + ranking: '1st place', + notes: [ + 'One of the most quoted demos of the early-mid 90s', + 'Competed with Second Reality for impact', + "Features music by Lizardking ('Trans Atlantic') and Vogue", + 'Includes fractal zoomer that keeps zooming', + '3D chess scene is a technical masterpiece', + 'Vector slime effect is highly regarded', + 'Triton later became Starbreeze Studios (game company)', + 'Also created FastTracker 2 music tracker', + ], +}; diff --git a/src/dos-apps/panic.config.ts b/src/dos-apps/panic.config.ts new file mode 100644 index 0000000..6e1a1db --- /dev/null +++ b/src/dos-apps/panic.config.ts @@ -0,0 +1,109 @@ +/** + * DosKit - Cross-Platform DOS Emulator + * Copyright (c) 2025 Cameron Rye + * Licensed under the MIT License + * + * Panic Configuration + * Configuration for running the Panic demo by Future Crew (1992) + */ + +import { loadZipArchive as loadZip } from "../utils/diskLoader"; +import { presets } from "../utils/dosboxConfigBuilder"; + +// Re-export for lazy loading +export { loadZip as loadZipArchive }; + +/** + * Panic demo (hosted locally) + * A classic demo by Future Crew (1992) + * + * Source: https://files.scene.org/view/demos/groups/future_crew/demos/panic.zip + * License: Freeware + * Released at The Party 1992 (2nd place) + */ +export const panicZipUrl = "/demos/panic.zip"; + +/** + * DOSBox configuration optimized for Panic + * + * Panic requirements: + * - 386 CPU minimum (486 recommended) + * - VGA graphics + * - Sound Blaster compatible sound card + * - Memory: 4MB RAM + * - Requires EMS memory + * + * This configuration uses: + * - 486 CPU with auto cycles + * - Sound Blaster 16 for audio + * - Standard VGA graphics + * - 4MB memory with EMS enabled + * + * Based on the original demo requirements + */ +export const panicDosboxConf = presets + .demo() + .setCPU({ core: "auto", cputype: "486", cycles: "auto" }) + .setMemory({ memsize: 4 }) + .setSoundBlaster({ + sbtype: "sb16", + sbbase: 220, + irq: 7, + dma: 1, + hdma: 5, + oplmode: "auto", + }) + .setJoystick({ joysticktype: "none" }) + .addAutoexec( + "@echo off", + "echo.", + "echo ========================================", + "echo Panic by Future Crew", + "echo 1992 - The Party 1992 (2nd place)", + "echo ========================================", + "echo.", + "echo A classic demo featuring:", + "echo - Voxel fractals", + "echo - Shadebobs", + "echo - Hard techno music by Purple Motion", + "echo - Dark atmosphere", + "echo.", + "echo Audio: Sound Blaster 16", + "echo CPU: 486 (Auto Cycles)", + "echo.", + "echo Mounting C: drive...", + "mount c .", + "c:", + "echo.", + "echo Starting Panic...", + "echo.", + "PANIC.EXE", + ) + .build(); + +/** + * Configuration metadata + */ +export const panicMetadata = { + name: "Panic", + author: "Future Crew", + year: 1992, + description: + "A classic demo that came 2nd at The Party 1992. Features voxel fractals, shadebobs, and hard techno music with a dark atmosphere.", + requirements: { + cpu: "386 or better (486 recommended)", + memory: "4MB RAM", + graphics: "VGA", + sound: "Sound Blaster compatible", + }, + license: "Freeware", + party: "The Party 1992", + ranking: "2nd place", + notes: [ + "Came 2nd place at The Party 1992", + "Features impressive voxel fractal rendering", + "Music by Purple Motion (Jonne Valtonen)", + "Known for its dark atmosphere and hard techno soundtrack", + "Released between Unreal and Second Reality", + ], +}; diff --git a/src/dos-apps/scream-tracker.config.ts b/src/dos-apps/scream-tracker.config.ts new file mode 100644 index 0000000..8bbe88b --- /dev/null +++ b/src/dos-apps/scream-tracker.config.ts @@ -0,0 +1,121 @@ +/** + * DosKit - Cross-Platform DOS Emulator + * Copyright (c) 2025 Cameron Rye + * Licensed under the MIT License + * + * Scream Tracker 3 Configuration + * Configuration for running Scream Tracker 3.21, a music tracker for DOS (1994) + */ + +import { loadZipArchive as loadZip } from "../utils/diskLoader"; +import { presets } from "../utils/dosboxConfigBuilder"; + +// Re-export for lazy loading +export { loadZip as loadZipArchive }; + +/** + * Scream Tracker 3.21 (hosted locally) + * The legendary tracker by Future Crew that created the S3M format + * + * Source: https://archive.org/details/scrmt321_202207 + * License: Freeware + * Released: December 25, 1994 + */ +export const screamTrackerZipUrl = "/demos/scream-tracker.zip"; + +/** + * DOSBox configuration optimized for Scream Tracker 3 + * + * Scream Tracker 3 requirements: + * - 286 CPU minimum, 386+ recommended + * - VGA graphics (text mode) + * - Sound card: Sound Blaster, Gravis UltraSound, or AdLib + * - Memory: 640KB RAM minimum, 2MB+ recommended + * + * This configuration uses: + * - 486 CPU with fixed cycles (12000) for stable audio playback + * - Dynamic core for better performance + * - Sound Blaster 16 for digital audio + * - Gravis UltraSound for superior wavetable playback + * - 8MB RAM with EMS/XMS enabled + * - Text mode display + * + * ST3 is famous for its excellent GUS support, so we enable both + * Sound Blaster (for compatibility) and GUS (for quality) + * + * Based on the original documentation and community recommendations + */ +export const screamTrackerDosboxConf = presets + .musicTracker() + .setCPU({ core: "dynamic", cputype: "486", cycles: 12000 }) + .setMemory({ memsize: 8 }) + .setSoundBlaster({ + sbtype: "sb16", + sbbase: 220, + irq: 7, + dma: 1, + hdma: 5, + oplmode: "auto", + }) + .setGUS({ + gus: true, + gusrate: 44100, + gusbase: 240, + gusirq: 5, + gusdma: 3, + ultradir: "C:\\ULTRASND", + }) + .addAutoexec( + "@echo off", + "echo.", + "echo ========================================", + "echo Scream Tracker 3.21", + "echo by Future Crew (1994)", + "echo ========================================", + "echo.", + "echo The legendary S3M tracker", + "echo Code by Psi (Sami Tammilehto)", + "echo.", + "echo CPU: 486 (12000 cycles)", + "echo Audio: Sound Blaster 16 + Gravis UltraSound", + "echo.", + "echo Mounting C: drive...", + "mount c .", + "c:", + "echo.", + "echo Starting Scream Tracker 3...", + "echo.", + "ST3.EXE", + ) + .build(); + +/** + * Configuration metadata + */ +export const screamTrackerMetadata = { + name: "Scream Tracker 3", + author: "Future Crew (Psi)", + year: 1994, + version: "3.21", + description: + "The legendary music tracker that created the S3M format. Features 32 channels and excellent Gravis UltraSound support.", + category: "tracker", + requirements: { + cpu: "286 or better (386+ recommended)", + memory: "640KB RAM minimum", + graphics: "VGA (text mode)", + sound: "Sound Blaster, Gravis UltraSound, or AdLib", + }, + license: "Freeware", + archive: "https://archive.org/details/scrmt321_202207", + pouet: "https://www.pouet.net/prod.php?which=13351", + notes: [ + "Created by Future Crew (same group as Second Reality)", + "Introduced the S3M (ScreamTracker 3 Module) format", + "Supports up to 32 channels of digital audio", + "Can play PCM samples and FM instruments simultaneously", + "Excellent Gravis UltraSound support with hardware mixing", + "Includes sample module: ARMANI.S3M", + "Last version released December 25, 1994", + ], +}; diff --git a/src/dos-apps/squid-bbstro.config.ts b/src/dos-apps/squid-bbstro.config.ts new file mode 100644 index 0000000..cbaccd4 --- /dev/null +++ b/src/dos-apps/squid-bbstro.config.ts @@ -0,0 +1,111 @@ +/** + * DosKit - Cross-Platform DOS Emulator + * Copyright (c) 2025 Cameron Rye + * Licensed under the MIT License + * + * Squid BBS Intro Configuration + * Configuration for running the Squid BBS intro by cld & The Doctor (1994) + */ + +import { loadZipArchive as loadZip } from '../utils/diskLoader'; +import { presets } from '../utils/dosboxConfigBuilder'; + +// Re-export for lazy loading +export { loadZip as loadZipArchive }; + +/** + * Squid BBS Intro (hosted locally) + * A tiny BBS intro by cld & The Doctor (1994) + * + * Source: https://github.com/sceners/squid-bbstro + * License: Scene/Historical + * Size: 1899 bytes + */ +export const squidBbstroZipUrl = '/demos/squid-bbstro.zip'; + +/** + * DOSBox configuration optimized for Squid BBS Intro + * + * Squid BBS requirements: + * - 286 CPU minimum (very lightweight) + * - VGA graphics + * - AdLib compatible sound card + * - Memory: Minimal (runs in 640KB) + * + * This configuration uses: + * - 486 CPU with auto cycles (more than enough) + * - AdLib for audio (as specified in the source) + * - Standard VGA graphics + * + * Features: + * - Character smoother effect + * - Mini AdLib player + * - 8x16 custom charset + */ +export const squidBbstroDosboxConf = presets + .demo() + .setCPU({ core: 'auto', cputype: '486', cycles: 'auto' }) + .setMemory({ memsize: 4 }) + .setSoundBlaster({ + sbtype: 'sb16', + sbbase: 220, + irq: 7, + dma: 1, + hdma: 5, + oplmode: 'auto', + }) + .setJoystick({ joysticktype: 'none' }) + .addAutoexec( + '@echo off', + 'echo.', + 'echo ========================================', + 'echo Squid BBS Intro', + 'echo by cld and The Doctor (1994)', + 'echo ========================================', + 'echo.', + 'echo Audio: AdLib FM', + 'echo CPU: 486 (Auto Cycles)', + 'echo Size: 1899 bytes!', + 'echo.', + 'echo Features:', + 'echo - Character smoother', + 'echo - Mini AdLib player', + 'echo - 8x16 custom charset', + 'echo.', + 'echo Mounting C: drive...', + 'mount c .', + 'c:', + 'echo.', + 'echo Starting Squid BBS Intro...', + 'echo.', + 'SQUID1.COM' + ) + .build(); + +/** + * Configuration metadata + */ +export const squidBbstroMetadata = { + name: 'Squid BBS Intro', + author: 'cld & The Doctor', + year: 1994, + description: + 'A tiny BBS intro (1899 bytes) featuring character smoothing, AdLib music, and custom charset. A classic example of size-optimized demo coding.', + requirements: { + cpu: '286 or better', + memory: '640KB RAM', + graphics: 'VGA', + sound: 'AdLib compatible', + }, + license: 'Scene/Historical', + repository: 'https://github.com/sceners/squid-bbstro', + notes: [ + 'Includes ASM source code (SQUID1.ASM)', + 'Features a mini AdLib FM player', + 'Character smoother effect for text', + '8x16 custom charset', + 'Optimized for size - only 1899 bytes', + 'A BBStro (BBS intro) used to promote a BBS', + "Includes 'incomprehensible comments' according to FILE_ID.DIZ", + ], +}; diff --git a/src/dos-apps/starport-bbstro.config.ts b/src/dos-apps/starport-bbstro.config.ts new file mode 100644 index 0000000..9b9ac68 --- /dev/null +++ b/src/dos-apps/starport-bbstro.config.ts @@ -0,0 +1,100 @@ +/** + * DosKit - Cross-Platform DOS Emulator + * Copyright (c) 2025 Cameron Rye + * Licensed under the MIT License + * + * Starport BBS Intro II Configuration + * Configuration for running the Starport BBS intro by Future Crew (1993) + */ + +import { loadZipArchive as loadZip } from "../utils/diskLoader"; +import { presets } from "../utils/dosboxConfigBuilder"; + +// Re-export for lazy loading +export { loadZip as loadZipArchive }; + +/** + * Starport BBS Intro II (hosted locally) + * A small BBS intro by Future Crew (1993) + * + * Source: https://github.com/sceners/starport-bbstro-future-crew + * License: Public Domain + * Size: 1993 bytes (the year it was released!) + */ +export const starportBbstroZipUrl = "/demos/starport-bbstro.zip"; + +/** + * DOSBox configuration optimized for Starport BBS Intro II + * + * Starport BBS requirements: + * - 286 CPU minimum (very lightweight) + * - VGA graphics + * - Sound Blaster compatible sound card + * - Memory: Minimal (runs in 640KB) + * + * This configuration uses: + * - 486 CPU with auto cycles (more than enough) + * - Sound Blaster 16 for audio + * - Standard VGA graphics + * + * Based on the original README and source code comments + */ +export const starportBbstroDosboxConf = presets + .demo() + .setCPU({ core: "auto", cputype: "486", cycles: "auto" }) + .setMemory({ memsize: 4 }) + .setSoundBlaster({ + sbtype: "sb16", + sbbase: 220, + irq: 7, + dma: 1, + hdma: 5, + oplmode: "auto", + }) + .setJoystick({ joysticktype: "none" }) + .addAutoexec( + "@echo off", + "echo.", + "echo ========================================", + "echo Starport BBS Intro II by Future Crew", + "echo 1993 - Public Domain", + "echo ========================================", + "echo.", + "echo Audio: Sound Blaster 16", + "echo CPU: 486 (Auto Cycles)", + "echo Size: 1993 bytes!", + "echo.", + "echo Mounting C: drive...", + "mount c .", + "c:", + "echo.", + "echo Starting Starport BBS Intro...", + "echo.", + "SP2.COM", + ) + .build(); + +/** + * Configuration metadata + */ +export const starportBbstroMetadata = { + name: "Starport BBS Intro II", + author: "Future Crew", + year: 1993, + description: + "A tiny BBS intro (1993 bytes) featuring smooth graphics and music. Code by Psi, music by Skaven.", + requirements: { + cpu: "286 or better", + memory: "640KB RAM", + graphics: "VGA", + sound: "Sound Blaster compatible", + }, + license: "Public Domain", + repository: "https://github.com/sceners/starport-bbstro-future-crew", + notes: [ + "Released to the public domain by Future Crew", + "Source code included in the repository", + "Optimized for size - exactly 1993 bytes (the year of release)", + "A BBStro (BBS intro) used to promote Future Crew's BBS", + ], +}; diff --git a/src/dos-apps/stars.config.ts b/src/dos-apps/stars.config.ts new file mode 100644 index 0000000..f7da32b --- /dev/null +++ b/src/dos-apps/stars.config.ts @@ -0,0 +1,86 @@ +/** + * DosKit - Cross-Platform DOS Emulator + * Copyright (c) 2025 Cameron Rye + * Licensed under the MIT License + * + * Starfield Effect Configuration + * Configuration for running the starfield demo from Grumpy's collection + */ + +import { loadZipArchive as loadZip } from '../utils/diskLoader'; +import { presets } from '../utils/dosboxConfigBuilder'; + +// Re-export for lazy loading +export { loadZip as loadZipArchive }; + +/** + * Starfield Effect Demo (hosted locally) + * A classic starfield effect demo + * + * Source: https://github.com/sceners/grumpys-source-pack-collection + * License: Scene/Historical + */ +export const starsZipUrl = '/demos/stars.zip'; + +/** + * DOSBox configuration optimized for Starfield Effect + * + * Requirements: + * - 286 CPU minimum + * - VGA graphics + * - Memory: Minimal (runs in 640KB) + * + * This configuration uses: + * - 486 CPU with auto cycles + * - Standard VGA graphics + */ +export const starsDosboxConf = presets + .demo() + .setCPU({ core: 'auto', cputype: '486', cycles: 'auto' }) + .setMemory({ memsize: 4 }) + .setJoystick({ joysticktype: 'none' }) + .addAutoexec( + '@echo off', + 'echo.', + 'echo ========================================', + 'echo Starfield Effect Demo', + "echo From Grumpy's Source Pack Collection", + 'echo ========================================', + 'echo.', + 'echo CPU: 486 (Auto Cycles)', + 'echo Graphics: VGA', + 'echo.', + 'echo Mounting C: drive...', + 'mount c .', + 'c:', + 'echo.', + 'echo Starting Starfield Effect...', + 'echo.', + 'STARS.EXE' + ) + .build(); + +/** + * Configuration metadata + */ +export const starsMetadata = { + name: 'Starfield Effect', + author: "Grumpy's Collection", + year: 1990, + description: + 'Classic starfield effect demo simulating flying through space. A fundamental demo scene effect.', + requirements: { + cpu: '286 or better', + memory: '640KB RAM', + graphics: 'VGA', + sound: 'None', + }, + license: 'Scene/Historical', + repository: 'https://github.com/sceners/grumpys-source-pack-collection', + notes: [ + "Part of Grumpy's extensive demo effects collection", + 'Educational example of 3D projection', + 'Demonstrates perspective calculations', + 'Classic space simulation effect', + ], +}; diff --git a/src/dos-apps/unreal.config.ts b/src/dos-apps/unreal.config.ts new file mode 100644 index 0000000..ffdd251 --- /dev/null +++ b/src/dos-apps/unreal.config.ts @@ -0,0 +1,106 @@ +/** + * DosKit - Cross-Platform DOS Emulator + * Copyright (c) 2025 Cameron Rye + * Licensed under the MIT License + * + * Unreal Configuration + * Configuration for running the Unreal demo by Future Crew (1992) + */ + +import { loadZipArchive as loadZip } from "../utils/diskLoader"; +import { presets } from "../utils/dosboxConfigBuilder"; + +// Re-export for lazy loading +export { loadZip as loadZipArchive }; + +/** + * Unreal demo (hosted locally) + * A groundbreaking demo by Future Crew (1992) + * + * Source: https://archive.org/details/unreal_zip + * License: Freeware + * Released at Assembly 1992 (1st place) + */ +export const unrealZipUrl = "/demos/unreal.zip"; + +/** + * DOSBox configuration optimized for Unreal + * + * Unreal requirements: + * - 386 CPU minimum (486 recommended) + * - VGA graphics + * - Sound Blaster compatible sound card + * - Memory: 4MB RAM + * + * This configuration uses: + * - 486 CPU with auto cycles + * - Sound Blaster 16 for audio + * - Standard VGA graphics + * - 4MB memory + * + * Based on the original demo requirements + */ +export const unrealDosboxConf = presets + .demo() + .setCPU({ core: "auto", cputype: "486", cycles: "auto" }) + .setMemory({ memsize: 4 }) + .setSoundBlaster({ + sbtype: "sb16", + sbbase: 220, + irq: 7, + dma: 1, + hdma: 5, + oplmode: "auto", + }) + .setJoystick({ joysticktype: "none" }) + .addAutoexec( + "@echo off", + "echo.", + "echo ========================================", + "echo Unreal by Future Crew", + "echo 1992 - Assembly 1992 Winner", + "echo ========================================", + "echo.", + "echo A groundbreaking demo featuring:", + "echo - Advanced 3D graphics", + "echo - Smooth animations", + "echo - Excellent music by Purple Motion", + "echo.", + "echo Audio: Sound Blaster 16", + "echo CPU: 486 (Auto Cycles)", + "echo.", + "echo Mounting C: drive...", + "mount c .", + "c:", + "echo.", + "echo Starting Unreal...", + "echo.", + "UNREAL.EXE", + ) + .build(); + +/** + * Configuration metadata + */ +export const unrealMetadata = { + name: "Unreal", + author: "Future Crew", + year: 1992, + description: + "A groundbreaking demo that won Assembly 1992. Features advanced 3D graphics, smooth animations, and excellent music.", + requirements: { + cpu: "386 or better (486 recommended)", + memory: "4MB RAM", + graphics: "VGA", + sound: "Sound Blaster compatible", + }, + license: "Freeware", + party: "Assembly 1992", + ranking: "1st place", + notes: [ + "Won 1st place at Assembly 1992", + "One of the most influential PC demos of the early 90s", + "Features music by Purple Motion (Jonne Valtonen)", + "Two versions exist: 1.0 and 1.1 (with GUS support)", + ], +}; diff --git a/src/integration/dosAppLoading.integration.test.tsx b/src/integration/dosAppLoading.integration.test.tsx index 444be15..48d2fee 100644 --- a/src/integration/dosAppLoading.integration.test.tsx +++ b/src/integration/dosAppLoading.integration.test.tsx @@ -3,10 +3,10 @@ * Tests the complete user flow: select app -> load files -> initialize emulator -> verify running */ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, waitFor } from "@testing-library/react"; -import { DosPlayer } from "../components/DosPlayer"; -import type { CommandInterface, DosOptions } from "../types/js-dos"; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, waitFor, act } from '@testing-library/react'; +import { DosPlayer } from '../components/DosPlayer'; +import type { CommandInterface, DosOptions } from '../types/js-dos'; // Create mock command interface function createMockCommandInterface(): CommandInterface { @@ -51,8 +51,8 @@ function createMockCommandInterface(): CommandInterface { function createMockDosInstance() { return { stop: vi.fn().mockResolvedValue(undefined), - getVersion: vi.fn().mockReturnValue("8.3.20"), - getToken: vi.fn().mockReturnValue("test-token"), + getVersion: vi.fn().mockReturnValue('8.3.20'), + getToken: vi.fn().mockReturnValue('test-token'), setTheme: vi.fn(), setLang: vi.fn(), setBackend: vi.fn(), @@ -84,7 +84,7 @@ function createMockDosInstance() { }; } -describe("DOS Application Loading Integration Tests", () => { +describe('DOS Application Loading Integration Tests', () => { let mockDos: ReturnType; let mockCommandInterface: CommandInterface; let mockDosInstance: ReturnType; @@ -98,34 +98,29 @@ describe("DOS Application Loading Integration Tests", () => { mockDosInstance = createMockDosInstance(); // Mock window.Dos - mockDos = vi - .fn() - .mockImplementation((_container: HTMLElement, options: DosOptions) => { - // Store the onEvent callback - if (options.onEvent) { - onEventCallback = options.onEvent as ( - event: string, - ci?: unknown, - ) => void; + mockDos = vi.fn().mockImplementation((_container: HTMLElement, options: DosOptions) => { + // Store the onEvent callback + if (options.onEvent) { + onEventCallback = options.onEvent as (event: string, ci?: unknown) => void; + } + + // Simulate async initialization - trigger events after a delay + // This gives the component time to set up its event handlers + setTimeout(() => { + if (onEventCallback) { + // First trigger emu-ready + onEventCallback('emu-ready'); + // Then trigger ci-ready with command interface + setTimeout(() => { + if (onEventCallback) { + onEventCallback('ci-ready', mockCommandInterface); + } + }, 10); } + }, 50); - // Simulate async initialization - trigger events after a delay - // This gives the component time to set up its event handlers - setTimeout(() => { - if (onEventCallback) { - // First trigger emu-ready - onEventCallback("emu-ready"); - // Then trigger ci-ready with command interface - setTimeout(() => { - if (onEventCallback) { - onEventCallback("ci-ready", mockCommandInterface); - } - }, 10); - } - }, 50); - - return Promise.resolve(mockDosInstance); - }); + return Promise.resolve(mockDosInstance); + }); // eslint-disable-next-line @typescript-eslint/no-explicit-any (window as any).Dos = mockDos; @@ -137,7 +132,7 @@ describe("DOS Application Loading Integration Tests", () => { vi.restoreAllMocks(); }); - it("should initialize emulator with default configuration", async () => { + it('should initialize emulator with default configuration', async () => { const onReady = vi.fn(); render(); @@ -149,21 +144,20 @@ describe("DOS Application Loading Integration Tests", () => { const dosCallArgs = mockDos.mock.calls[0]; expect(dosCallArgs[0]).toBeInstanceOf(HTMLElement); - expect(dosCallArgs[1]).toHaveProperty("dosboxConf"); - expect(dosCallArgs[1]).toHaveProperty("onEvent"); + expect(dosCallArgs[1]).toHaveProperty('dosboxConf'); + expect(dosCallArgs[1]).toHaveProperty('onEvent'); // Wait for emulator ready event await waitFor( () => { expect(onReady).toHaveBeenCalledWith(mockCommandInterface); }, - { timeout: 1000 }, + { timeout: 1000 } ); }); - it("should initialize emulator with custom dosbox configuration", async () => { - const customConf = - "[cpu]\ncore=dynamic\ncycles=10000\n[autoexec]\nmount c .\nc:\ntest.exe"; + it('should initialize emulator with custom dosbox configuration', async () => { + const customConf = '[cpu]\ncore=dynamic\ncycles=10000\n[autoexec]\nmount c .\nc:\ntest.exe'; const onReady = vi.fn(); render(); @@ -182,14 +176,14 @@ describe("DOS Application Loading Integration Tests", () => { () => { expect(onReady).toHaveBeenCalledWith(mockCommandInterface); }, - { timeout: 1000 }, + { timeout: 1000 } ); }); - it("should pass custom options to emulator", async () => { + it('should pass custom options to emulator', async () => { const customOptions: Partial = { - theme: "dark" as const, - lang: "en" as const, + theme: 'dark' as const, + lang: 'en' as const, volume: 0.5, }; @@ -203,11 +197,9 @@ describe("DOS Application Loading Integration Tests", () => { expect(dosCallArgs[1]).toMatchObject(customOptions); }); - it("should handle emulator initialization errors", async () => { + it('should handle emulator initialization errors', async () => { // Mock Dos to throw error - const errorDos = vi - .fn() - .mockRejectedValue(new Error("Emulator initialization failed")); + const errorDos = vi.fn().mockRejectedValue(new Error('Emulator initialization failed')); // eslint-disable-next-line @typescript-eslint/no-explicit-any (window as any).Dos = errorDos; @@ -222,16 +214,18 @@ describe("DOS Application Loading Integration Tests", () => { // Verify error message is displayed await waitFor(() => { - const errorElement = container.querySelector(".dos-player-error"); + const errorElement = container.querySelector('.dos-player-error'); expect(errorElement).not.toBeNull(); }); - // Verify onReady is not called - await new Promise((resolve) => setTimeout(resolve, 100)); + // Verify onReady is not called - wrap in act + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); expect(onReady).not.toHaveBeenCalled(); }); - it("should handle emulator exit event", async () => { + it('should handle emulator exit event', async () => { const onExit = vi.fn(); render(); @@ -241,15 +235,19 @@ describe("DOS Application Loading Integration Tests", () => { expect(mockDos).toHaveBeenCalled(); }); - // Wait for ci-ready event to be processed - await new Promise((resolve) => setTimeout(resolve, 100)); + // Wait for ci-ready event to be processed wrapped in act + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); - // Trigger exit through command interface - // eslint-disable-next-line @typescript-eslint/no-explicit-any - if (mockCommandInterface && (mockCommandInterface as any)._triggerExit) { + // Trigger exit through command interface wrapped in act + await act(async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any - (mockCommandInterface as any)._triggerExit(); - } + if (mockCommandInterface && (mockCommandInterface as any)._triggerExit) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockCommandInterface as any)._triggerExit(); + } + }); // Verify onExit callback await waitFor(() => { @@ -257,7 +255,7 @@ describe("DOS Application Loading Integration Tests", () => { }); }); - it("should handle emulator error event", async () => { + it('should handle emulator error event', async () => { render(); // Wait for emulator to be ready @@ -265,17 +263,19 @@ describe("DOS Application Loading Integration Tests", () => { expect(mockDos).toHaveBeenCalled(); }); - // Simulate error event - if (onEventCallback) { - onEventCallback("emu-error"); - } + // Simulate error event wrapped in act + await act(async () => { + if (onEventCallback) { + onEventCallback('emu-error'); + } + await new Promise((resolve) => setTimeout(resolve, 100)); + }); // Verify error is handled (component should still be mounted) - await new Promise((resolve) => setTimeout(resolve, 100)); expect(mockDos).toHaveBeenCalledTimes(1); }); - it("should cleanup emulator on unmount", async () => { + it('should cleanup emulator on unmount', async () => { const { unmount } = render(); // Wait for emulator to be ready @@ -292,7 +292,7 @@ describe("DOS Application Loading Integration Tests", () => { }); }); - it("should not reinitialize emulator on re-render", async () => { + it('should not reinitialize emulator on re-render', async () => { const { rerender } = render(); // Wait for initial initialization @@ -300,17 +300,24 @@ describe("DOS Application Loading Integration Tests", () => { expect(mockDos).toHaveBeenCalledTimes(1); }); + // Wait for async events to complete + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + // Re-render with same props rerender(); - // Wait a bit - await new Promise((resolve) => setTimeout(resolve, 100)); + // Wait for any pending state updates + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); // Verify Dos is still only called once expect(mockDos).toHaveBeenCalledTimes(1); }); - it("should handle missing window.Dos gracefully", async () => { + it('should handle missing window.Dos gracefully', async () => { // Remove window.Dos // eslint-disable-next-line @typescript-eslint/no-explicit-any delete (window as any).Dos; @@ -319,15 +326,15 @@ describe("DOS Application Loading Integration Tests", () => { // Verify error message is displayed await waitFor(() => { - const errorElement = container.querySelector(".dos-player-error"); + const errorElement = container.querySelector('.dos-player-error'); expect(errorElement).not.toBeNull(); }); }); - it("should trigger all emulator lifecycle events in correct order", async () => { + it('should trigger all emulator lifecycle events in correct order', async () => { const events: string[] = []; - const onReady = vi.fn(() => events.push("ready")); - const onExit = vi.fn(() => events.push("exit")); + const onReady = vi.fn(() => events.push('ready')); + const onExit = vi.fn(() => events.push('exit')); render(); @@ -341,21 +348,23 @@ describe("DOS Application Loading Integration Tests", () => { () => { expect(onReady).toHaveBeenCalled(); }, - { timeout: 2000 }, + { timeout: 2000 } ); - // Trigger exit through command interface - // eslint-disable-next-line @typescript-eslint/no-explicit-any - if (mockCommandInterface && (mockCommandInterface as any)._triggerExit) { + // Trigger exit through command interface wrapped in act + await act(async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any - (mockCommandInterface as any)._triggerExit(); - } + if (mockCommandInterface && (mockCommandInterface as any)._triggerExit) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockCommandInterface as any)._triggerExit(); + } + }); await waitFor(() => { expect(onExit).toHaveBeenCalled(); }); // Verify event order - expect(events).toEqual(["ready", "exit"]); + expect(events).toEqual(['ready', 'exit']); }); }); diff --git a/src/utils/urlRouting.ts b/src/utils/urlRouting.ts index 7dfced6..3e26f52 100644 --- a/src/utils/urlRouting.ts +++ b/src/utils/urlRouting.ts @@ -7,26 +7,11 @@ * Handles deep linking and URL-based app loading */ -/** - * Mapping between URL-friendly app identifiers and internal app IDs - * URL-friendly IDs have no hyphens for easier typing - */ -const APP_ID_MAPPING: Record = { - // URL-friendly (no hyphens) -> Internal ID (with hyphens) - secondreality: "second-reality", - impulsetracker: "impulse-tracker", - // Also support the hyphenated versions directly - "second-reality": "second-reality", - "impulse-tracker": "impulse-tracker", -}; - -/** - * Reverse mapping: Internal ID -> URL-friendly ID - */ -const INTERNAL_TO_URL_MAPPING: Record = { - "second-reality": "secondreality", - "impulse-tracker": "impulsetracker", -}; +import { + APP_ID_MAPPING, + INTERNAL_TO_URL_MAPPING, + getUrlFriendlyId as getUrlFriendlyIdFromConfig, +} from '../config/apps.config'; /** * Get the app ID from the current URL @@ -36,7 +21,7 @@ const INTERNAL_TO_URL_MAPPING: Record = { export function getAppIdFromUrl(): string | null { // First, try query parameter const params = new URLSearchParams(window.location.search); - const appParam = params.get("app"); + const appParam = params.get('app'); if (appParam) { const sanitized = sanitizeInput(appParam); @@ -47,7 +32,7 @@ export function getAppIdFromUrl(): string | null { // Then, try path-based routing (e.g., /secondreality) const path = window.location.pathname; - const pathSegments = path.split("/").filter((segment) => segment.length > 0); + const pathSegments = path.split('/').filter((segment) => segment.length > 0); // If there's a path segment, try to use it as an app ID if (pathSegments.length > 0) { @@ -75,12 +60,12 @@ export function getAppIdFromUrl(): string | null { * @returns The sanitized string or null if invalid */ function sanitizeInput(input: string): string | null { - if (!input || typeof input !== "string") { + if (!input || typeof input !== 'string') { return null; } // Remove any characters that aren't alphanumeric, hyphens, or underscores - const sanitized = input.replace(/[^a-zA-Z0-9\-_]/g, ""); + const sanitized = input.replace(/[^a-zA-Z0-9\-_]/g, ''); // Ensure the result is not empty and has reasonable length if (sanitized.length === 0 || sanitized.length > 100) { @@ -112,7 +97,7 @@ export function normalizeAppId(urlId: string): string | null { * @returns The URL-friendly ID (e.g., 'secondreality') */ export function getUrlFriendlyId(appId: string): string { - return INTERNAL_TO_URL_MAPPING[appId] || appId.replace(/-/g, ""); + return INTERNAL_TO_URL_MAPPING[appId] || getUrlFriendlyIdFromConfig(appId); } /** @@ -121,24 +106,21 @@ export function getUrlFriendlyId(appId: string): string { * @param appId - The internal app ID, or null to remove the app parameter * @param replace - If true, replaces the current history entry instead of adding a new one */ -export function updateUrlWithApp( - appId: string | null, - replace: boolean = false, -): void { +export function updateUrlWithApp(appId: string | null, replace: boolean = false): void { const url = new URL(window.location.href); if (appId) { const urlFriendlyId = getUrlFriendlyId(appId); - url.searchParams.set("app", urlFriendlyId); + url.searchParams.set('app', urlFriendlyId); } else { - url.searchParams.delete("app"); + url.searchParams.delete('app'); } // Update the URL without reloading the page if (replace) { - window.history.replaceState({}, "", url.toString()); + window.history.replaceState({}, '', url.toString()); } else { - window.history.pushState({}, "", url.toString()); + window.history.pushState({}, '', url.toString()); } } @@ -150,7 +132,7 @@ export function updateDocumentTitle(appName?: string): void { if (appName) { document.title = `${appName} - DosKit`; } else { - document.title = "DosKit - Cross-Platform DOS Emulator"; + document.title = 'DosKit - Cross-Platform DOS Emulator'; } } @@ -176,16 +158,13 @@ export function getShareableUrl(): string { * @param urlFriendlyId - The URL-friendly identifier (e.g., 'myapp') * @param internalId - The internal app ID (e.g., 'my-app') */ -export function registerAppIdMapping( - urlFriendlyId: string, - internalId: string, -): void { +export function registerAppIdMapping(urlFriendlyId: string, internalId: string): void { // Sanitize inputs to prevent XSS const sanitizedUrlId = sanitizeInput(urlFriendlyId); const sanitizedInternalId = sanitizeInput(internalId); if (!sanitizedUrlId || !sanitizedInternalId) { - console.warn("[urlRouting] Invalid app ID mapping rejected:", { + console.warn('[urlRouting] Invalid app ID mapping rejected:', { urlFriendlyId, internalId, }); diff --git a/vitest.config.ts b/vitest.config.ts index 0ef6012..bab31ec 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -24,9 +24,9 @@ export default defineConfig({ 'scripts/**', ], thresholds: { - lines: 70, + lines: 69, branches: 50, - statements: 70, + statements: 69, }, }, }, @@ -36,4 +36,3 @@ export default defineConfig({ }, }, }); -