feat: окно инструкций, свёрнутая история и обновление спонсоров

Инструкции вынесены в отдельное окно Electron; токены игроков не пропадают при смене сцены; предпросмотр и плейлисты адаптированы; история свёрнута по умолчанию.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-17 06:21:42 +08:00
parent 712086ddca
commit a29c72b307
24 changed files with 506 additions and 290 deletions
+1 -1
View File
@@ -338,7 +338,7 @@ export function PresentationView({
))}
{activeNpcItems.length > 0 ? <NpcsSceneOverlay embedded items={activeNpcItems} /> : null}
</SceneOverlayHost>
<SessionTimerLayer viewport={contentRect} editable={false} />
<SessionTimerLayer viewport={markerViewport} editable={false} />
<DiceRollLayer />
{showTitle ? (
<div className={styles.titleWrap}>
@@ -81,29 +81,27 @@ export function useScenePlayerTokensSession(): [
visible: false,
byPlayerId: {},
});
const localRevRef = useRef(0);
/** Только revision с main. Оптимистичные move/setSize не должны глушить смену сцены. */
const serverRevRef = useRef(0);
useEffect(() => {
const applyServer = (next: ScenePlayerTokensSessionState) => {
if (next.revision < serverRevRef.current) return;
serverRevRef.current = next.revision;
setState(next);
};
void api.invoke(ipcChannels.scenePlayerTokensSession.getState, {}).then((res) => {
if (res.state.revision < localRevRef.current) return;
localRevRef.current = res.state.revision;
setState(res.state);
applyServer(res.state);
});
return api.on(ipcChannels.scenePlayerTokensSession.stateChanged, ({ state: next }) => {
if (next.revision < localRevRef.current) return;
localRevRef.current = next.revision;
setState(next);
applyServer(next);
});
}, [api]);
const dispatch = useCallback(
(event: ScenePlayerTokensSessionEvent) => {
if (event.kind !== 'seedBottom' && event.kind !== 'show') {
setState((prev) => {
const next = applyEvent(prev, event);
localRevRef.current = Math.max(localRevRef.current, next.revision);
return next;
});
setState((prev) => applyEvent(prev, event));
}
void api.invoke(ipcChannels.scenePlayerTokensSession.dispatch, { event });
},
@@ -10,6 +10,7 @@
.sidebar,
.main {
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
gap: 10px;
@@ -74,6 +75,8 @@
color: var(--text);
text-align: left;
cursor: pointer;
min-width: 0;
overflow: hidden;
}
.playlistRow:hover,
@@ -103,10 +106,10 @@
.playlistName,
.trackTitle,
.playlistNameReadonly,
.trackTitleReadonly {
flex: 1;
flex: 1 1 0;
min-width: 0;
width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -114,6 +117,7 @@
}
.trackTitle {
display: block;
background: transparent;
border: 0;
color: inherit;
@@ -122,18 +126,24 @@
padding: 0;
}
.playlistNameReadonly {
font-weight: 700;
font-size: var(--text-md, 1rem);
.trackTitleReadonly {
display: block;
color: inherit;
}
.trackTitleReadonly {
color: inherit;
.playlistNameReadonly {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 700;
font-size: var(--text-md, 1rem);
}
.playlistCount {
font-size: var(--text-xs);
color: var(--text2);
flex-shrink: 0;
}
.mainHeader {
@@ -153,6 +163,7 @@
color: var(--text2);
cursor: pointer;
padding: 0 4px;
flex-shrink: 0;
}
.empty {
@@ -309,7 +309,7 @@ export function PlaylistsPanel({
}
onClick={() => setSelectedId(pl.id)}
>
<span className={styles.playlistName}>{pl.name || labels.untitled}</span>
<span className={styles.playlistName} title={pl.name || labels.untitled}>{pl.name || labels.untitled}</span>
{playerUi[pl.id]?.playing ? <span className={styles.playlistPlayingMark}>▶</span> : null}
<span className={styles.playlistCount}>{pl.tracks.length}</span>
</button>
@@ -337,7 +337,9 @@ export function PlaylistsPanel({
<>
<div className={styles.mainHeader}>
{readOnly ? (
<div className={styles.playlistNameReadonly}>{selected.name || labels.untitled}</div>
<div className={styles.playlistNameReadonly} title={selected.name || labels.untitled}>
{selected.name || labels.untitled}
</div>
) : (
<Input value={editingName} onChange={setEditingName} onBlur={() => void saveRename()} />
)}
@@ -436,7 +438,9 @@ export function PlaylistsPanel({
▶
</button>
{readOnly ? (
<span className={styles.trackTitleReadonly}>{title}</span>
<span className={styles.trackTitleReadonly} title={title}>
{title}
</span>
) : editingAudioId === tr.audioId ? (
<Input
value={editingAudioTitle}
@@ -453,11 +457,11 @@ export function PlaylistsPanel({
<button
type="button"
className={styles.trackTitle}
title={title}
onClick={() => {
setEditingAudioId(tr.audioId);
setEditingAudioTitle(title);
}}
title={labels.renameAudio}
>
{title}
</button>
@@ -18,6 +18,7 @@ const SECONDARY_WINDOW_MAINS = [
'dice/main.tsx',
'sceneEditor/main.tsx',
'sceneDescription/main.tsx',
'instructions/main.tsx',
'control/main.tsx',
'presentation/main.tsx',
] as const;
@@ -119,6 +120,24 @@ void test('Control and Presentation use SceneNpcTokensOverlay', () => {
assert.ok(control.includes('ScenePlayerTokensOverlay'));
assert.ok(presentation.includes('ScenePlayerTokensOverlay'));
assert.ok(control.includes('toggle-session-players'));
assert.match(presentation, /<SessionTimerLayer viewport=\{markerViewport\} editable=\{false\} \/>/);
});
void test('useScenePlayerTokensSession: scene-switch broadcast is not gated by optimistic local revision', () => {
const src = fs.readFileSync(
path.join(rendererRoot, 'shared/playerToken/useScenePlayerTokensSession.ts'),
'utf8',
);
assert.ok(src.includes('serverRevRef'));
assert.doesNotMatch(src, /localRevRef/);
assert.doesNotMatch(src, /localRevRef\.current = Math\.max/);
});
void test('EditorApp: Instructions opens electron window, not modal', () => {
const src = fs.readFileSync(path.join(rendererRoot, 'editor/EditorApp.tsx'), 'utf8');
assert.ok(src.includes('windows.openInstructions'));
assert.doesNotMatch(src, /InstructionsModal/);
assert.ok(fs.existsSync(path.join(rendererRoot, 'instructions/InstructionsApp.tsx')));
});
void test('EditorApp: split Run button and launch-with-players modal', () => {
@@ -143,11 +162,24 @@ void test('ControlApp: Playlists tool opens window', () => {
assert.ok(src.includes('windows.openPlaylists'));
});
void test('PlaylistsPanel: long audio titles ellipsis via CSS', () => {
const css = fs.readFileSync(
path.join(rendererRoot, 'shared/playlists/PlaylistsPanel.module.css'),
'utf8',
);
const panel = fs.readFileSync(path.join(rendererRoot, 'shared/playlists/PlaylistsPanel.tsx'), 'utf8');
assert.match(css, /\.trackTitleReadonly[\s\S]*?text-overflow:\s*ellipsis/);
assert.match(css, /\.trackTitle[\s\S]*?text-overflow:\s*ellipsis/);
assert.ok(panel.includes('trackTitleReadonly'));
assert.ok(panel.includes('title={title}'));
});
void test('ControlApp: Timer tool opens window', () => {
const src = fs.readFileSync(path.join(rendererRoot, 'control/ControlApp.tsx'), 'utf8');
assert.ok(src.includes('data-testid="control-timer-tool"'));
assert.ok(src.includes('windows.openTimer'));
assert.ok(src.includes('SessionTimerLayer'));
assert.match(src, /<SessionTimerLayer viewport=\{markerViewport\} editable \/>/);
});
void test('ControlApp: Initiative tool opens window', () => {