Redesign launcher UI with 98.css

Replace the sparse start/settings cards with a Win95-styled launcher:

- Start screen is now a 'Welcome to Windows 95' dialog with a gradient
  side stripe, a rotating tip box, and a button column.
- Settings is a tabbed Properties sheet (Floppy / Network / State) with
  group boxes, sunken read-only path fields, and OK/Cancel.
- Vendored 98.css and its Pixelated MS Sans Serif fonts; dropped the
  old 95css-based .btn/.card/.nav classes.
- Removed the bottom taskbar nav (start-menu.tsx) — navigation now goes
  through the dialog buttons so the launcher isn't mistaken for the
  running OS.
This commit is contained in:
Felix Rieseberg
2026-04-11 08:48:11 -07:00
parent 55c4fbb27e
commit 74fc2d291e
13 changed files with 414 additions and 334 deletions

View File

@@ -1,117 +1,77 @@
@import "./status.less";
@import "./emulator.less";
@import "./info.less";
@import "./settings.less";
@import "./start.less";
@import "./settings.less";
/* GENERAL RESETS */
// 98.css uses the actual MS Sans Serif bitmap font and pixel-exact bevels.
// Everything below is layout — the chrome comes from 98.css.
html, body {
@win-teal: #008080;
@win-silver: silver;
@win-font: "Pixelated MS Sans Serif", Arial, sans-serif;
* {
user-select: none;
cursor: default;
}
html,
body {
margin: 0;
padding: 0;
overflow: hidden;
}
body {
background: #000;
}
body.paused > #emulator {
display: none;
font-family: @win-font;
-webkit-font-smoothing: none;
image-rendering: pixelated;
}
body.paused {
background: #008080;
font-family: Courier;
background: @win-teal;
> #emulator {
display: none;
}
}
#buttons {
user-select: none;
button:not(:disabled),
li[role="tab"],
.title-bar-controls button:not(:disabled) {
cursor: pointer;
}
button:focus {
outline: none;
}
// 98.css renders button text via text-shadow (color: transparent) so the
// bitmap font stays crisp; <img> children need their own alignment.
button img {
height: 16px;
width: 16px;
margin-right: 4px;
vertical-align: -3px;
}
p {
font-family: @win-font;
font-size: 11px;
line-height: 1.5;
}
code {
font-family: "Courier New", monospace;
font-size: 11px;
}
section {
display: flex;
position: absolute;
width: 100vw;
height: 100vh;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.card {
width: 75%;
max-width: 700px;
min-width: 400px;
.card-title {
img {
margin-right: 5px;
}
}
}
.nav-link > img,
.btn > img {
height: 24px;
margin-right: 4px;
}
.windows95 {
* {
user-select: none;
}
*:focus {
outline: none;
}
nav .nav-link,
nav .nav-logo {
height: 37px;
display: flex;
}
nav .nav-logo img {
margin-left: 2px;
max-height: 20px;
}
nav .nav-logo > span {
position: absolute;
top: 9px;
left: 37px;
font-weight: bold;
}
.btn {
height: 40px;
padding-top: 3px;
}
.btn:focus {
border-color: #fff #000 #000 #fff;
outline: 5px auto -webkit-focus-ring-color;
}
.btn.active:before,
.btn:focus:before,
button.active:before,
button:focus:before,
input[type=submit].active:before,
input[type=submit]:focus:before {
border-color: #dedede grey grey #dedede;
}
.card {
// Fix link colors
.link, .link:active, .link:link, .link:visited, a, a:active, a:link, a:visited {
color: #008080;
text-decoration: underline;
cursor: pointer;
}
// Ensure a-elements in fieldsets receive click events
fieldset:before {
pointer-events: none;
}
}
}

View File

@@ -1,21 +1,71 @@
#floppy-path {
font-size: .6rem;
width: 100%;
height: 30px;
padding-left: 8px;
border-color: #000 #fff #fff #000;
border-style: solid;
border-width: 2px;
background-color: #c3c3c3;
line-height: 27px;
}
.settings-window {
width: 460px;
#file-input {
display: none;
}
.settings {
legend > img {
margin-right: 5px;
> .window-body {
margin: 8px;
}
}
.settings-panel {
padding: 3px;
> .window-body {
margin: 12px;
min-height: 220px;
}
fieldset {
margin: 0;
}
}
.settings-row {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 12px;
p {
margin: 0;
}
}
.settings-icon {
width: 32px;
height: 32px;
flex-shrink: 0;
}
.settings-window .field-row-stacked {
margin-bottom: 12px;
input[type="text"] {
width: 100%;
font-family: "Pixelated MS Sans Serif", Arial;
}
input[type="text"]:read-only {
background-color: #fff;
color: #222;
}
}
.settings-buttons {
display: flex;
gap: 6px;
button {
min-width: 110px;
}
}
.settings-footer {
display: flex;
justify-content: flex-end;
gap: 6px;
margin-top: 10px;
button {
min-width: 75px;
}
}

View File

@@ -1,9 +1,99 @@
#section-start {
display: flex;
flex-direction: column;
// "Welcome to Windows" splash — modelled on the real first-boot dialog.
> small {
margin-top: 25px;
font-size: .8rem;
.welcome {
width: 540px;
}
.welcome-body {
display: flex;
align-items: stretch;
gap: 12px;
margin: 4px;
min-height: 250px;
}
.welcome-stripe {
width: 26px;
background: linear-gradient(180deg, #000 0%, navy 60%, #1084d0 100%);
position: relative;
flex-shrink: 0;
span {
position: absolute;
bottom: 8px;
left: 50%;
transform: translateX(-50%) rotate(180deg);
writing-mode: vertical-rl;
color: #fff;
font-weight: 700;
font-size: 14px;
letter-spacing: 1px;
white-space: nowrap;
}
}
.welcome-main {
flex: 1;
display: flex;
flex-direction: column;
padding: 8px 4px;
}
.welcome-title {
font-family: "Times New Roman", serif;
-webkit-font-smoothing: antialiased;
font-weight: 400;
font-size: 24px;
margin: 0 0 14px;
color: #000;
span {
font-weight: 700;
}
small {
color: #fff;
font-size: 24px;
font-weight: 700;
vertical-align: baseline;
margin-left: 1px;
}
}
.welcome-tip {
flex: 1;
background: #ffffe1;
box-shadow: inset -1px -1px #fff, inset 1px 1px grey, inset -2px -2px #dfdfdf,
inset 2px 2px #0a0a0a;
padding: 12px 14px;
.welcome-tip-header {
border-bottom: 1px solid grey;
box-shadow: 0 1px 0 #fff;
padding-bottom: 6px;
margin-bottom: 8px;
font-size: 11px;
}
p {
margin: 0;
}
}
.welcome-actions {
width: 130px;
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px 4px;
flex-shrink: 0;
button {
width: 100%;
height: 24px;
}
.welcome-spacer {
flex: 1;
}
}

2
src/less/vendor/98.css vendored Normal file

File diff suppressed because one or more lines are too long

BIN
src/less/vendor/ms_sans_serif.woff vendored Normal file

Binary file not shown.

BIN
src/less/vendor/ms_sans_serif.woff2 vendored Normal file

Binary file not shown.

BIN
src/less/vendor/ms_sans_serif_bold.woff vendored Normal file

Binary file not shown.

BIN
src/less/vendor/ms_sans_serif_bold.woff2 vendored Normal file

Binary file not shown.

View File

@@ -8,12 +8,16 @@ interface CardSettingsProps {
setCdrom: (cdrom: File) => void;
setSmbSharePath: (path: string) => void;
pickFolder: () => Promise<string | null>;
navigate: (to: string) => void;
floppy?: File;
cdrom?: File;
smbSharePath: string;
}
type Tab = "floppy" | "network" | "state";
interface CardSettingsState {
tab: Tab;
isStateReset: boolean;
}
@@ -29,187 +33,166 @@ export class CardSettings extends React.Component<
this.onResetState = this.onResetState.bind(this);
this.state = {
tab: "floppy",
isStateReset: false,
};
}
public render() {
const { tab } = this.state;
return (
<section>
<div className="card settings">
<div className="card-header">
<h2 className="card-title">
<img src="../../static/settings.png" />
Settings
</h2>
</div>
<div className="card-body">
{this.renderCdrom()}
<hr />
{this.renderFloppy()}
<hr />
{this.renderSmbShare()}
<hr />
{this.renderState()}
<div className="window settings-window">
<div className="title-bar">
<div className="title-bar-text">windows95 Properties</div>
<div className="title-bar-controls">
<button aria-label="Help" disabled />
<button
aria-label="Close"
onClick={() => this.props.navigate("start")}
/>
</div>
</div>
</section>
<div className="window-body">
<menu role="tablist">
{this.renderTab("floppy", "Floppy Drive")}
{this.renderTab("network", "Network Share")}
{this.renderTab("state", "Machine State")}
</menu>
<div className="window settings-panel" role="tabpanel">
<div className="window-body">
{tab === "floppy" && this.renderFloppy()}
{tab === "network" && this.renderSmbShare()}
{tab === "state" && this.renderState()}
</div>
</div>
<div className="settings-footer">
<button
className="default"
onClick={() => this.props.navigate("start")}
>
OK
</button>
<button onClick={() => this.props.navigate("start")}>Cancel</button>
</div>
</div>
</div>
);
}
public renderCdrom() {
// CD is currently not working, so.. let's return nothing.
return null;
const { cdrom } = this.props;
private renderTab(id: Tab, label: string) {
return (
<fieldset>
<legend>
<img src="../../static/cdrom.png" />
CD-ROM
</legend>
<input
id="cdrom-input"
type="file"
onChange={this.onChangeCdrom}
style={{ display: "none" }}
/>
<p>
windows95 comes with a virtual CD drive. It can mount images in the
"iso" format.
</p>
<p id="floppy-path">
{cdrom ? `Inserted CD: ${cdrom?.name}` : `No CD mounted`}
</p>
<button
className="btn"
onClick={() =>
(document.querySelector("#cdrom-input") as any).click()
}
>
<img src="../../static/select-cdrom.png" />
<span>Mount CD</span>
</button>
</fieldset>
<li
role="tab"
aria-selected={this.state.tab === id}
onClick={() => this.setState({ tab: id })}
>
<a href="#">{label}</a>
</li>
);
}
public renderSmbShare() {
const { smbSharePath } = this.props;
return (
<fieldset>
<legend>Network Share</legend>
<p>
A folder on your computer is exposed inside Windows 95 as a
network drive. From inside Windows, open Start Run and type{" "}
<code>\\HOST\HOST</code> to browse it, or use Map Network Drive to
give it a drive letter.
</p>
<p>
Shared folder: <code>{smbSharePath}</code>
</p>
<button
className="btn"
onClick={async () => {
const picked = await this.props.pickFolder();
if (picked) this.props.setSmbSharePath(picked);
}}
>
<span>Choose folder</span>
</button>
</fieldset>
);
}
public renderFloppy() {
private renderFloppy() {
const { floppy } = this.props;
return (
<fieldset>
<legend>
<img src="../../static/floppy.png" />
Floppy
</legend>
<legend>Drive A:</legend>
<input
id="floppy-input"
type="file"
onChange={this.onChangeFloppy}
style={{ display: "none" }}
/>
<p>
windows95 comes with a virtual floppy drive. It can mount floppy disk
images in the "img" format.
</p>
<p>
Back in the 90s and before CD-ROMs became a popular, software was
typically distributed on floppy disks. Some developers have since
released their apps or games for free, usually on virtual floppy disks
using the "img" format.
</p>
<p>
Once you've mounted a disk image, you might have to boot your virtual
windows95 machine from scratch.
</p>
<p id="floppy-path">
{floppy
? `Inserted Floppy Disk: ${floppy.name}`
: `No floppy mounted`}
</p>
<button
className="btn"
onClick={() =>
(document.querySelector("#floppy-input") as any).click()
}
>
<img src="../../static/select-floppy.png" />
<span>Mount floppy disk</span>
</button>
</fieldset>
);
}
public renderState() {
const { isStateReset } = this.state;
const { bootFromScratch } = this.props;
return (
<fieldset>
<legend>
<img src="../../static/reset.png" />
Reset machine state
</legend>
<div>
<div className="settings-row">
<img className="settings-icon" src="../../static/floppy.png" />
<p>
windows95 stores changes to your machine (like saved files) in a
state file. If you encounter any trouble, you can reset your state
or boot Windows 95 from scratch.{" "}
<strong>All your changes will be lost.</strong>
windows95 ships with a virtual 3½" floppy drive. Mount an{" "}
<code>.img</code> disk image here, then boot the machine to read it
from inside Windows.
</p>
</div>
<div className="field-row-stacked">
<label htmlFor="floppy-path">Mounted image</label>
<input
id="floppy-path"
type="text"
readOnly
value={floppy ? floppy.name : "(No disk in drive)"}
/>
</div>
<div className="settings-buttons">
<button
className="btn"
onClick={this.onResetState}
disabled={isStateReset}
style={{ marginRight: "5px" }}
onClick={() =>
(document.querySelector("#floppy-input") as any).click()
}
>
<img src="../../static/reset-state.png" />
{isStateReset ? "State reset" : "Reset state"}
</button>
<button className="btn" onClick={bootFromScratch}>
<img src="../../static/boot-fresh.png" />
Boot from scratch
Mount image...
</button>
</div>
</fieldset>
);
}
/**
* Handle a change in the floppy input
*
* @param event
*/
private renderSmbShare() {
const { smbSharePath } = this.props;
return (
<fieldset>
<legend>\\HOST\HOST</legend>
<div className="settings-row">
<img className="settings-icon" src="../../static/show-disk-image.png" />
<p>
A folder on your computer is exposed inside Windows 95 as a network
drive. From inside Windows, open Start → Run and type{" "}
<code>\\HOST\HOST</code> — or use Map Network Drive to give it a
letter.
</p>
</div>
<div className="field-row-stacked">
<label htmlFor="smb-path">Shared folder</label>
<input id="smb-path" type="text" readOnly value={smbSharePath} />
</div>
<div className="settings-buttons">
<button
onClick={async () => {
const picked = await this.props.pickFolder();
if (picked) this.props.setSmbSharePath(picked);
}}
>
Choose folder...
</button>
</div>
</fieldset>
);
}
private renderState() {
const { isStateReset } = this.state;
const { bootFromScratch } = this.props;
return (
<fieldset>
<legend>Reset</legend>
<div className="settings-row">
<img className="settings-icon" src="../../static/reset.png" />
<p>
Changes to your machine (saved files, installed programs) are stored
in a state file. If something breaks, you can either discard that
state or boot a fresh copy of Windows from scratch.{" "}
<strong>All your changes will be lost.</strong>
</p>
</div>
<div className="settings-buttons">
<button onClick={this.onResetState} disabled={isStateReset}>
{isStateReset ? "State has been reset" : "Reset state"}
</button>
<button onClick={bootFromScratch}>Boot from scratch</button>
</div>
</fieldset>
);
}
private onChangeFloppy(event: React.ChangeEvent<HTMLInputElement>) {
const floppyFile =
event.target.files && event.target.files.length > 0
@@ -223,27 +206,19 @@ export class CardSettings extends React.Component<
}
}
/**
* Handle a change in the cdrom input
*
* @param event
*/
private onChangeCdrom(event: React.ChangeEvent<HTMLInputElement>) {
const CdromFile =
const cdromFile =
event.target.files && event.target.files.length > 0
? event.target.files[0]
: null;
if (CdromFile) {
this.props.setCdrom(CdromFile);
if (cdromFile) {
this.props.setCdrom(cdromFile);
} else {
console.log(`Cdrom: Input changed but no file selected`);
}
}
/**
* Handle the state reset
*/
private async onResetState() {
await resetState();
this.setState({ isStateReset: true });

View File

@@ -2,18 +2,64 @@ import * as React from "react";
export interface CardStartProps {
startEmulator: () => void;
navigate: (to: string) => void;
}
export class CardStart extends React.Component<CardStartProps, {}> {
const TIPS = [
"Press the Escape key at any time to release or recapture your mouse cursor.",
"You can mount a floppy image from Settings before booting to install vintage software.",
"Map a host folder as a network drive: open Start → Run inside Windows and type \\\\HOST\\HOST.",
"Your machine state is saved automatically when you quit. Reset it from Settings if things get weird.",
"Use the Machine menu in the menubar to send Ctrl+Alt+Del and other special key combos.",
];
export class CardStart extends React.Component<CardStartProps> {
private tip = TIPS[Math.floor(Math.random() * TIPS.length)];
public render() {
return (
<section id="section-start">
<button className="btn" id="win95" onClick={this.props.startEmulator}>
<img src="../../static/run.png" />
<span>Start Windows 95</span>
</button>
<small>Hit ESC to lock or unlock your mouse</small>
</section>
<div className="window welcome" id="welcome-window">
<div className="title-bar">
<div className="title-bar-text">Welcome</div>
<div className="title-bar-controls">
<button aria-label="Minimize" disabled />
<button aria-label="Maximize" disabled />
<button aria-label="Close" disabled />
</div>
</div>
<div className="window-body welcome-body">
<aside className="welcome-stripe">
<span>Windows&nbsp;95</span>
</aside>
<div className="welcome-main">
<h1 className="welcome-title">
Welcome to <span>Windows</span>
<small>95</small>
</h1>
<div className="welcome-tip">
<div className="welcome-tip-header">
<strong>Did you know...</strong>
</div>
<p>{this.tip}</p>
</div>
</div>
<div className="welcome-actions">
<button
id="win95"
className="default"
onClick={this.props.startEmulator}
>
<u>S</u>tart Windows 95
</button>
<button onClick={() => this.props.navigate("settings")}>
S<u>e</u>ttings...
</button>
<div className="welcome-spacer" />
<button disabled>What's New</button>
</div>
</div>
</div>
);
}
}

View File

@@ -6,7 +6,6 @@ import { ipcRenderer, shell, webUtils } from "electron";
import { CONSTANTS, IPC_COMMANDS } from "../constants";
import { getDiskImageSize } from "../utils/disk-image-size";
import { CardStart } from "./card-start";
import { StartMenu } from "./start-menu";
import { CardSettings } from "./card-settings";
import { EmulatorInfo } from "./emulator-info";
import { getStatePath } from "./utils/get-state-path";
@@ -217,6 +216,9 @@ export class Emulator extends React.Component<{}, EmulatorState> {
return null;
}
const navigate = (target: string) =>
this.setState({ currentUiCard: target as "start" | "settings" });
let card;
if (currentUiCard === "settings") {
@@ -233,22 +235,16 @@ export class Emulator extends React.Component<{}, EmulatorState> {
floppy={floppyFile}
cdrom={cdromFile}
smbSharePath={this.state.smbSharePath}
navigate={navigate}
/>
);
} else {
card = <CardStart startEmulator={this.startEmulator} />;
card = (
<CardStart startEmulator={this.startEmulator} navigate={navigate} />
);
}
return (
<>
{card}
<StartMenu
navigate={(target) =>
this.setState({ currentUiCard: target as "start" | "settings" })
}
/>
</>
);
return <section>{card}</section>;
}
/**

View File

@@ -1,39 +0,0 @@
import * as React from "react";
export interface StartMenuProps {
navigate: (to: string) => void;
}
export class StartMenu extends React.Component<StartMenuProps, {}> {
constructor(props: StartMenuProps) {
super(props);
this.navigate = this.navigate.bind(this);
}
public render() {
return (
<nav className="nav nav-bottom">
<a onClick={this.navigate} href="#" id="start" className="nav-link">
<img src="../../static/start.png" alt="Start" />
<span>Start</span>
</a>
<div className="nav-menu">
<a
onClick={this.navigate}
href="#"
id="settings"
className="nav-link"
>
<img src="../../static/settings.png" />
<span>Settings</span>
</a>
</div>
</nav>
);
}
private navigate(event: React.SyntheticEvent<HTMLAnchorElement>) {
this.props.navigate(event.currentTarget.id);
}
}

View File

@@ -5,7 +5,7 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>windows95</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="../src/less/vendor/95css.css">
<link rel="stylesheet" href="../src/less/vendor/98.css">
<link rel="stylesheet" href="../src/less/root.less">
<!-- libv86 -->
</head>