Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### Changed

- **The same switch at the table.** The dice tray's ANIMATION, the choices a roll asks about before
it is rolled (ADVANTAGE, PUSH and the like), and TRY ON THE MAP on a house rule in the builder's
graph now use the one switch the builder has: a pill whose knob slides across and lights up when
on, the words beside it part of it. A choice that can't be paid stays red and can't be switched.
- **One switch across the builder, easy to see.** Switches like LEVELLED and MORE THAN ONCE on
CHOICES were plain words with a small circle in front, easy to miss as something to click. Every
switch in the builder is now the same pill with a knob that slides across and lights up when on:
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -822,7 +822,7 @@ CITY_NET/
│ │ │ ├── TableConditionsPanel.tsx # The GAME tab's CONDITIONS under a built-in game, main admin only: the standard set (four shown, the rest folded) and the table's own, added with a name, icon and description (no modifiers or rounds) or removed after asking; the server's refusal said, and every screen asking again once saved
│ │ │ ├── GameRestsPanel.tsx # The GAME tab's RESTS under a custom game, for the GM and granted editors: one of the game's rests, who rests (every player character, the NPCs on the map being viewed too, or ticked by hand), what each one will get (dice still to roll shown as such, conditions wearing off), and CALL; everyone previewed once so ticking asks nothing more, previewed again after a call; a refusal said
│ │ │ ├── TryItPage.tsx # TRY IT: the draft as a player would meet it, unsaved changes included. A made-up character on its own sheet with formulas worked out as you type, its health on a pretend token in the HEALTH folder's own panels with what others see, an NPC rolled from a tier, and CALL A REST: one of the draft's rests rolled on the character and its token, the game's line in the log. Saves nothing but SAVE AS THE SAMPLE CHARACTER. A "?" beside HEALTH, WHAT OTHERS SEE and the NPC's level. RULES: the draft tried with each house rule switched on or off, starting as each STARTS, the game's choice untouched
│ │ │ ├── ToggleSwitch.tsx # The one on/off switch (switches 1; approved mockup toggle-switch, 2026-10-29): a pill whose knob slides across and lights up when on, the label beside it as part of one button, colors from the theme. Every switch in the builder (CHOICES, PARTS, CONDITIONS, RESTS, TRY IT, ROLLS, TEST ROLL)
│ │ │ ├── ToggleSwitch.tsx # The one on/off switch (switches 1; approved mockup toggle-switch, 2026-10-29): a pill whose knob slides across and lights up when on, the label beside it as part of one button, colors from the theme, a button's own attributes passed through. Every switch in the builder (CHOICES, PARTS, CONDITIONS, RESTS, TRY IT, ROLLS, TEST ROLL) and at the table (switches 2: the dice tray's ANIMATION, a roll's choices before it is rolled, TRY ON THE MAP on the graph)
│ │ │ ├── ControlledNpcSheetWindow.tsx # A friendly NPC's sheet for the player the GM gave it to: read-only, with rolls made for the NPC through the server
│ │ │ ├── UpdateModal.tsx # Draggable update notification modal (shown on admin login when update available; Update Now / Remind Me Later / Skip Version; docker-aware)
│ │ │ ├── DevBuildsBox.tsx # LOOK FOR DEV BUILDS under Check for update in SYSTEM_INFO (primary admin, Docker installs): ticking asks, then the server copies the database and sets IMAGE_TAG=dev in both .env files; unticking sets latest; greyed with the line to set by hand where the app cannot; on a dev build, GO BACK TO STABLE asks (naming the copy, nothing deleted) and waits for the restart
Expand Down Expand Up @@ -923,7 +923,7 @@ CITY_NET/
│ │ │ ├── HelpPage.test.tsx # The topics in their groups opening on GETTING STARTED, the one picked with all its words, GO TO a page (waiting for a system), FORMULAS' table held to the engine's list, EXAMPLES looked at or only described, and START THE TOUR only with a tour
│ │ │ ├── giveFromToken.test.tsx # GIVE_ITEM on a token: the GM's, on a player's character or an NPC with a sheet here, opening GIVE.EXE with that character ticked (added to the ticks if it's already open); the player's notice, newest three, each until closed, in the game's word for the GM
│ │ │ ├── TryItPage.test.tsx # The made-up character and its formulas, SAVE AS THE SAMPLE (absent with nowhere to save), RESET, health on a pretend token under each model, both maximums, and an NPC rolled from a tier; a "?" beside the health, what others see, the level and the rules; RULES switching a formula's alternative and a hidden section, never the draft, SAVE AS THE SAMPLE still writing the draft itself
│ │ │ ├── ToggleSwitch.test.tsx # Named by its label or, with the words elsewhere, by name; the label part of the button; what it becomes when clicked; the knob across and lit when on; the color it is given; nothing while disabled
│ │ │ ├── ToggleSwitch.test.tsx # Named by its label or, with the words elsewhere, by name; the label part of the button; what it becomes when clicked; the knob across and lit when on; the color it is given; nothing while disabled; a button's own attributes passed through but never its role or state; a click seen first (the graph keeps it off the map)
│ │ │ ├── ControlledNpcSheetWindow.test.tsx # A friendly NPC's sheet read-only for the player it was given to, fetched with their own login, rolling as the NPC
│ │ │ ├── SheetAttackPanel.attackAs.test.tsx # ATTACK AS: a player with a friendly NPC given to them, the GM with an NPC near the target, its weapons and LUCK, firing as it
│ │ │ ├── SheetAttackPanel.cyber.test.tsx # Attacking with body weaponry, the only way a player reaches the server's cyberIndex
Expand Down
19 changes: 4 additions & 15 deletions frontend/src/components/DiceTray.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Canvas } from '@react-three/fiber';
import { useThree, useFrame } from '@react-three/fiber';
import * as THREE from 'three';
import { DraggableWindow, NotifyBell } from './DraggableWindow';
import { ToggleSwitch } from './ToggleSwitch';
import { ThemeContext } from '../theme/themes';
import { themedRollColor } from '../theme/rollColor';
import paperFillIcon from '../assets/lets-icons--paper-fill.svg';
Expand Down Expand Up @@ -342,22 +343,10 @@ export function DiceTrayWindow({ pos, setPos, onClose, socketRef, authToken, bel

const titleControls = (
<>
<button
type="button"
role="switch"
onClick={() => setMotion(motion === 'roll' ? 'landed' : 'roll')}
className="win95-title-toggle"
aria-checked={motion === 'roll'}
aria-label="Animation"
<ToggleSwitch on={motion === 'roll'} onChange={(on) => setMotion(on ? 'roll' : 'landed')} label="ANIMATION" name="Animation"
color="var(--green)" className="win95-title-toggle"
title={motion === 'roll' ? 'ANIMATION ON: THE DICE ROLL' : 'ANIMATION OFF: THE DICE LAND AT ONCE'}
style={{ background: 'var(--black)', color: 'var(--green)', padding: '0 6px', height: '22px', width: 'auto', fontFamily: 'monospace', fontSize: '10px', letterSpacing: '1px', marginRight: '4px', display: 'inline-flex', alignItems: 'center', gap: '5px' }}
>
{/* A toggle: the knob to the right and the track lit when on. */}
<span aria-hidden style={{ position: 'relative', width: '20px', height: '10px', borderRadius: '5px', border: '1px solid var(--green)', boxSizing: 'border-box', background: motion === 'roll' ? 'var(--green)' : 'transparent', transition: 'background 0.15s' }}>
<span style={{ position: 'absolute', top: '1px', left: motion === 'roll' ? '11px' : '1px', width: '6px', height: '6px', borderRadius: '50%', background: motion === 'roll' ? 'var(--black)' : 'var(--green)', transition: 'left 0.15s' }} />
</span>
ANIMATION
</button>
style={{ background: 'var(--black)', padding: '0 6px', height: '22px', fontSize: '10px', marginRight: '4px', gap: '6px' }} />
{onToggleBell && <NotifyBell enabled={!!bell} onToggle={onToggleBell} />}
<button
onClick={() => setIsHistoryOpen(!isHistoryOpen)}
Expand Down
12 changes: 4 additions & 8 deletions frontend/src/components/GraphView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { spotBeside } from '../sheets/graphNotes';
import { mapProblems, type MapProblem } from '../sheets/graphProblems';
import { GraphProblems } from './GraphProblems';
import { GraphAddPalette } from './GraphAddPalette';
import { ToggleSwitch } from './ToggleSwitch';

// STATS & RULES with GRAPH on (5a3a; approved mockup docs/mockups/builder-graph.html, 2026-10-17,
// nodes as the builder design's node kit draws them, docs/mockups/builder-design, 2026-09-29): the
Expand Down Expand Up @@ -205,14 +206,9 @@ function TrySwitch({ rule, values }: { rule: string; values: Values }) {
const on = values.trying.has(rule);
return (
<div style={{ ...line, height: SIZE.row, padding: '0 10px' }}>
<button type="button" role="switch" aria-checked={on} aria-label="Try on the map" data-browse className="nodrag"
onClick={(e) => { e.stopPropagation(); values.onTry(rule); }}
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: 0, background: 'none', border: 0, color: 'var(--cyan)', fontFamily: 'monospace', fontSize: 10, letterSpacing: 1, cursor: 'pointer' }}>
<span aria-hidden style={{ position: 'relative', width: 22, height: 10, borderRadius: 5, border: '1px solid var(--cyan)', background: on ? 'var(--cyan)' : 'none', boxSizing: 'border-box' }}>
<span style={{ position: 'absolute', top: 1, left: on ? 13 : 1, width: 6, height: 6, borderRadius: 3, background: on ? GROUND.base : 'var(--cyan)' }} />
</span>
TRY ON THE MAP: <b>{on ? 'ON' : 'OFF'}</b>
</button>
<ToggleSwitch on={on} name="Try on the map" data-browse className="nodrag" style={{ fontSize: 10, gap: 6 }}
onClick={(e) => e.stopPropagation()} onChange={() => values.onTry(rule)}
label={<span>TRY ON THE MAP: <b>{on ? 'ON' : 'OFF'}</b></span>} />
</div>
);
}
Expand Down
11 changes: 5 additions & 6 deletions frontend/src/components/RollChooser.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import type { SheetField, RollPick } from '../sheets/types';
import { ToggleSwitch } from './ToggleSwitch';

// The chooser by a custom system's sheet (6b2b; approved mockup docs/mockups/builder-rolls.html,
// 2026-10-19: "a roll with changes asks first; one without rolls straight away"). Opened by a die
Expand Down Expand Up @@ -57,13 +58,11 @@ export function RollChooser({ field, data, onRoll, onCancel }: Props) {
const cost = c.cost ? costOf(c.cost, data) : null;
const off = !!cost && !cost.can;
return (
<button key={c.id} type="button" role="switch" aria-checked={on} disabled={off}
<ToggleSwitch key={c.id} on={on} disabled={off} color={off ? 'var(--danger)' : 'var(--cyan)'}
title={off ? (c.cost!.adds ? 'That would take it past its highest' : 'There is not enough to pay it') : undefined}
onClick={() => setChanges(on ? changes.filter((x) => x !== c.id) : [...changes, c.id])}
style={{ background: 'none', border: 0, padding: 0, textAlign: 'left', color: off ? 'var(--danger)' : 'var(--cyan)', fontFamily: 'inherit', fontSize: '0.62rem', letterSpacing: 1, cursor: off ? 'default' : 'pointer', opacity: off ? 0.6 : 1, display: 'inline-flex', gap: 6, alignItems: 'center' }}>
<span aria-hidden style={{ width: 18, height: 9, borderRadius: 5, border: '1px solid currentColor', background: on ? 'currentColor' : 'none', flexShrink: 0 }} />
{c.label.toUpperCase()}{cost ? ` · ${cost.words}` : ''}
</button>
onChange={(next) => setChanges(next ? [...changes, c.id] : changes.filter((x) => x !== c.id))}
label={`${c.label.toUpperCase()}${cost ? ` · ${cost.words}` : ''}`}
style={{ fontFamily: 'inherit', fontSize: '0.62rem', opacity: off ? 0.6 : 1 }} />
);
})}
</div>
Expand Down
8 changes: 5 additions & 3 deletions frontend/src/components/ToggleSwitch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import React from 'react';
// one button. Asked for after LEVELLED on the builder's CHOICES page, drawn as plain text with a ○,
// went unseen as a control. Colors come from the theme, cyan unless the caller says otherwise.

interface Props {
/** A button's own attributes pass through (a title, a class, a click seen first), all but its role and state. */
interface Props extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onChange' | 'role' | 'type' | 'children' | 'name'> {
on: boolean;
/** Called with what the switch becomes. */
onChange: (on: boolean) => void;
Expand All @@ -19,9 +20,10 @@ interface Props {
style?: React.CSSProperties;
}

export function ToggleSwitch({ on, onChange, label, name, disabled, color = 'var(--cyan)', style }: Props) {
export function ToggleSwitch({ on, onChange, label, name, disabled, color = 'var(--cyan)', style, onClick, ...rest }: Props) {
return (
<button type="button" role="switch" aria-checked={on} aria-label={name} disabled={disabled} onClick={() => onChange(!on)}
<button {...rest} type="button" role="switch" aria-checked={on} aria-label={name} disabled={disabled}
onClick={(e) => { onClick?.(e); onChange(!on); }}
style={{
display: 'inline-flex', alignItems: 'center', gap: 8, justifySelf: 'start', alignSelf: 'center', background: 'none', border: 0, padding: 0,
color, fontFamily: 'monospace', fontSize: 12, letterSpacing: 1, textAlign: 'left', cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.4 : 1, ...style,
Expand Down
20 changes: 20 additions & 0 deletions frontend/src/components/__tests__/ToggleSwitch.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,26 @@ describe('ToggleSwitch', () => {
expect(knobOf(sw).style.background).toBe('var(--warning)');
});

it('passes a button\'s own attributes through, but never its role or state', () => {
render(<ToggleSwitch label="ANIMATION" on title="ANIMATION ON" className="win95-title-toggle" data-browse
{...({ role: 'checkbox', 'aria-checked': false } as object)} onChange={() => {}} />);
const sw = screen.getByRole('switch', { name: 'ANIMATION' });
expect(sw.title).toBe('ANIMATION ON');
expect(sw.className).toBe('win95-title-toggle');
expect(sw.hasAttribute('data-browse')).toBe(true);
expect(sw.getAttribute('aria-checked')).toBe('true');
});

it('lets a click be seen first, then says what it becomes (the graph keeps it off the map)', async () => {
const seen: string[] = [];
const outside = vi.fn();
render(<div onClick={outside}><ToggleSwitch label="TRY" on={false} onClick={(e) => { e.stopPropagation(); seen.push('click'); }}
onChange={(next) => seen.push(`change ${next}`)} /></div>);
await userEvent.click(screen.getByRole('switch'));
expect(seen).toEqual(['click', 'change true']);
expect(outside).not.toHaveBeenCalled();
});

it('does nothing while disabled, and looks it', async () => {
const onChange = vi.fn();
render(<ToggleSwitch label="MORE THAN ONCE" on={false} disabled onChange={onChange} />);
Expand Down
Loading