Story 3.2 done

This commit is contained in:
2026-05-23 18:23:48 +02:00
parent d175f92806
commit a1e8886fce
66 changed files with 18258 additions and 1650 deletions
+28 -8
View File
@@ -235,12 +235,20 @@ export class PresetImportExportManager {
const validPresets = extractionResults.filter(r => r.error === null);
const errors = extractionResults.filter(r => r.error !== null).map(r => r.error);
// Check if we would exceed max presets in merge mode
const existingCount = this._scenePresetManager.list().length;
const newCount = validPresets.length;
// Get existing preset names for duplicate detection and limit checking
const existingPresetNames = new Set(this._scenePresetManager.list().map(p => p.name));
const existingCount = existingPresetNames.size;
// Count how many presets would actually be added (excluding duplicates in merge mode)
let netNewCount = validPresets.length;
if (mode === 'merge') {
// Count only presets that don't already exist
netNewCount = validPresets.filter(r => !existingPresetNames.has(r.name)).length;
}
if (mode === 'merge' && existingCount + newCount > MAX_PRESETS_PER_WORLD) {
errors.push(`Import would exceed maximum of ${MAX_PRESETS_PER_WORLD} presets (currently ${existingCount}, adding ${newCount})`);
// Check preset limits
if (mode === 'merge' && existingCount + netNewCount > MAX_PRESETS_PER_WORLD) {
errors.push(`Import would exceed maximum of ${MAX_PRESETS_PER_WORLD} presets (currently ${existingCount}, adding ${netNewCount} new)`);
return {
success: false,
message: 'Import cancelled: would exceed preset limit',
@@ -251,6 +259,18 @@ export class PresetImportExportManager {
};
}
if (mode === 'replace' && validPresets.length > MAX_PRESETS_PER_WORLD) {
errors.push(`Import file contains ${validPresets.length} presets, exceeding maximum of ${MAX_PRESETS_PER_WORLD}`);
return {
success: false,
message: 'Import cancelled: file exceeds preset limit',
added: 0,
replaced: 0,
skipped: 0,
errors,
};
}
// Process based on mode
if (mode === 'replace') {
const result = await this._replacePresets(data, validPresets, existingCount);
@@ -258,7 +278,7 @@ export class PresetImportExportManager {
result.errors = [...errors, ...result.errors];
return result;
}
const result = await this._mergePresets(data, validPresets);
const result = await this._mergePresets(data, validPresets, existingPresetNames);
// Merge extraction errors with merge errors
result.errors = [...errors, ...result.errors];
return result;
@@ -269,11 +289,11 @@ export class PresetImportExportManager {
*
* @param {ExportData} data - Validated import data.
* @param {Array<{name: string, preset: import('../contracts/scene-preset.js').ScenePreset|null, error: string|null}>} validPresets - Validated presets to import.
* @param {Set<string>} existingPresetNames - Set of existing preset names for duplicate detection.
* @returns {Promise<ImportResult>} Result of the merge operation.
* @private
*/
async _mergePresets(data, validPresets) {
const existingPresetNames = new Set(this._scenePresetManager.list().map(p => p.name));
async _mergePresets(data, validPresets, existingPresetNames) {
let added = 0;
let skipped = 0;
const errors = [];
+654
View File
@@ -0,0 +1,654 @@
/**
* ScenePresetManager — Manages scene preset CRUD operations.
*
* Owns: preset creation, loading, deletion, renaming, listing.
* Story 3.2: Added auto-apply logic with per-scene configuration.
* Persists presets to Scene document flags.
* Emits socket messages for preset apply operations.
*
* Import rule: may only import from src/contracts/ and src/utils/.
* Constructors are side-effect free — call init() from module.js Hooks.once('ready').
*
* @module core/ScenePresetManager
*/
import { createScenePreset, isValidScenePreset, MAX_PRESETS_PER_WORLD, SCENE_PRESET_VERSION } from '../contracts/scene-preset.js';
import { SOCKET_EVENTS } from '../contracts/socket-message.js';
// Story 3.2: Auto-apply constants
/** @type {number} Maximum pre-delay in milliseconds */
const MAX_PREDELAY_MS = 5000;
/** @type {number} Minimum pre-delay in milliseconds */
const MIN_PREDELAY_MS = 0;
/**
* Manages scene preset CRUD operations.
* Persists presets to Scene document flags.
* Emits socket messages for preset apply operations.
* Story 3.2: Extended with auto-apply on scene activation.
*/
export class ScenePresetManager {
/**
* @param {import('../foundry/FoundryAdapter.js').FoundryAdapter} adapter
* Injected FoundryAdapter surface.
* @param {import('./StateStore.js').StateStore} stateStore
* Injected StateStore for visibility matrix access.
* @param {import('./SocketHandler.js').SocketHandler} socketHandler
* Injected SocketHandler for socket message emission.
* @param {import('./VisibilityManager.js').VisibilityManager|null} visibilityManager
* Injected VisibilityManager for applying visibility matrix (Story 3.2).
*/
constructor(adapter, stateStore, socketHandler, visibilityManager = null) {
// Validate dependencies
if (!adapter || typeof adapter !== 'object') {
throw new TypeError('ScenePresetManager: adapter argument is required and must be an object');
}
if (!stateStore || typeof stateStore !== 'object') {
throw new TypeError('ScenePresetManager: stateStore argument is required and must be an object');
}
if (!socketHandler || typeof socketHandler !== 'object') {
throw new TypeError('ScenePresetManager: socketHandler argument is required and must be an object');
}
this._adapter = adapter;
this._stateStore = stateStore;
this._socketHandler = socketHandler;
this._visibilityManager = visibilityManager;
/** @type {Map<string, import('../contracts/scene-preset.js').ScenePreset>} name → ScenePreset */
this._presetsCache = new Map();
/** @type {Map<string, number>} sceneId → timeoutId for active pre-delay timers */
this._activeTimers = new Map();
}
/**
* Initializes the manager by loading presets from the current scene.
* No hooks registered here for Story 3.1 — hooks for auto-apply come in Story 3.2.
* Side-effect: loads presets into _presetsCache.
*/
init() {
this._loadCurrentScenePresets();
}
/**
* Cleans up internal state.
* Safe to call multiple times.
*/
teardown() {
this._presetsCache.clear();
}
/**
* Saves the current Visibility Matrix as a named preset.
*
* @param {string} name - Preset name (non-empty string).
* @returns {Promise<import('../contracts/scene-preset.js').ScenePreset>} The created preset.
* @throws {TypeError} If name is invalid, duplicate, or max presets reached.
*/
async save(name) {
// Validate name
if (typeof name !== 'string' || name.length === 0) {
throw new TypeError('ScenePresetManager.save: name must be a non-empty string');
}
// Get current scene
const currentScene = this._adapter.scenes.current?.();
if (!currentScene) {
throw new TypeError('ScenePresetManager.save: no active scene');
}
// Check max presets limit
if (this._presetsCache.size >= MAX_PRESETS_PER_WORLD) {
throw new TypeError(
`ScenePresetManager.save: maximum of ${MAX_PRESETS_PER_WORLD} presets reached. Delete an existing preset to save a new one.`
);
}
// Check for duplicate name
if (this._presetsCache.has(name)) {
throw new TypeError(`ScenePresetManager.save: a preset with name "${name}" already exists`);
}
// Get current visibility matrix
const matrixData = this._stateStore.getMatrix();
const matrix = matrixData.matrix;
// Create preset
const preset = createScenePreset(name, matrix);
// Add to cache
this._presetsCache.set(name, preset);
// Persist to scene flag
await this._saveScenePresets();
// Emit notification
this._adapter.notifications.info(
this._adapter.i18n.localize('video-view-manager.presets.notifications.saved')
.replace('{name}', name)
);
return preset;
}
/**
* Loads a preset by name, applying its matrix to the current visibility state.
*
* @param {string} name - Preset name to load.
* @param {object} options - Options object.
* @param {boolean} [options.emitSocket=true] - Whether to emit socket message.
* Set to false when called in response to a socket message to prevent loops.
* @returns {Promise<void>}
* @throws {TypeError} If name is invalid or preset not found.
*/
async load(name, options = {}) {
const { emitSocket = true } = options;
// Validate name
if (typeof name !== 'string' || name.length === 0) {
throw new TypeError('ScenePresetManager.load: name must be a non-empty string');
}
// Get preset from cache (loaded via init or save)
const preset = this._presetsCache.get(name);
if (!preset) {
throw new TypeError(`ScenePresetManager.load: preset "${name}" not found`);
}
// Validate preset structure
isValidScenePreset(preset);
// Apply via VisibilityManager if available (Story 3.2), otherwise fall back to StateStore
if (this._visibilityManager) {
await this._visibilityManager.applyMatrix(preset.matrix);
} else {
await this._stateStore.setMatrix({
_version: preset._version,
matrix: { ...preset.matrix },
});
}
// Emit socket message only if requested (prevents loops when called from socket handler)
if (emitSocket) {
const now = Date.now();
this._socketHandler.emit(SOCKET_EVENTS.PRESET_APPLIED, {
presetName: name,
timestamp: now,
});
}
// Emit notification
this._adapter.notifications.info(
this._adapter.i18n.localize('video-view-manager.presets.notifications.applied')
.replace('{name}', name)
);
}
/**
* Deletes a preset by name.
*
* @param {string} name - Preset name to delete.
* @returns {Promise<void>}
* @throws {TypeError} If name is invalid or preset not found.
*/
async delete(name) {
// Validate name
if (typeof name !== 'string' || name.length === 0) {
throw new TypeError('ScenePresetManager.delete: name must be a non-empty string');
}
// Check if preset exists
if (!this._presetsCache.has(name)) {
throw new TypeError(`ScenePresetManager.delete: preset "${name}" not found`);
}
// Delete from cache
this._presetsCache.delete(name);
// Persist changes
await this._saveScenePresets();
}
/**
* Renames a preset.
*
* @param {string} oldName - Current preset name.
* @param {string} newName - New preset name.
* @returns {Promise<import('../contracts/scene-preset.js').ScenePreset>} The renamed preset.
* @throws {TypeError} If names are invalid, oldName not found, or newName conflicts.
*/
async rename(oldName, newName) {
// Validate oldName
if (typeof oldName !== 'string' || oldName.length === 0) {
throw new TypeError('ScenePresetManager.rename: oldName must be a non-empty string');
}
// Validate newName
if (typeof newName !== 'string' || newName.length === 0) {
throw new TypeError('ScenePresetManager.rename: newName must be a non-empty string');
}
// Check if oldName exists
if (!this._presetsCache.has(oldName)) {
throw new TypeError(`ScenePresetManager.rename: preset "${oldName}" not found`);
}
// Check if newName conflicts
if (oldName !== newName && this._presetsCache.has(newName)) {
throw new TypeError(
`ScenePresetManager.rename: a preset with name "${newName}" already exists`
);
}
// Get preset
const preset = this._presetsCache.get(oldName);
// Delete old entry
this._presetsCache.delete(oldName);
// Update preset name and timestamps
const now = Date.now();
const renamedPreset = {
...preset,
name: newName,
updatedAt: now,
};
// Validate the renamed preset structure
isValidScenePreset(/** @type {import('../contracts/scene-preset.js').ScenePreset} */ (renamedPreset));
// Add with new name
this._presetsCache.set(newName, /** @type {import('../contracts/scene-preset.js').ScenePreset} */ (renamedPreset));
// Persist changes
await this._saveScenePresets();
return /** @type {import('../contracts/scene-preset.js').ScenePreset} */ (renamedPreset);
}
/**
* Returns all presets for the current scene.
*
* @returns {Array<import('../contracts/scene-preset.js').ScenePreset>} Array of preset objects.
*/
list() {
return Array.from(this._presetsCache.values());
}
/**
* Returns a specific preset by name, or null if not found.
*
* @param {string} name - Preset name.
* @returns {import('../contracts/scene-preset.js').ScenePreset|null}
*/
get(name) {
return this._presetsCache.get(name) ?? null;
}
/**
* Loads presets from the current scene's flag.
* @private
*/
_loadCurrentScenePresets() {
this._presetsCache.clear();
const currentScene = this._adapter.scenes.current?.();
if (!currentScene) {
return;
}
try {
const flagData = /** @type {object & { getFlag?: (scope: string, key: string) => unknown }} */ (currentScene).getFlag?.('video-view-manager', 'presets');
if (!flagData || typeof flagData !== 'object') {
return; // No presets or invalid format
}
// Type assert flagData as having _version and presets
const flag = /** @type {{ _version: number; presets?: Record<string, unknown> }} */ (flagData);
if (flag._version !== SCENE_PRESET_VERSION) {
console.warn(
`[ScryingPool] ScenePresetManager: unsupported presets schema version ${flag._version}, expected ${SCENE_PRESET_VERSION}`
);
return;
}
const { presets } = flag;
if (!presets || typeof presets !== 'object') {
return;
}
for (const [name, presetData] of Object.entries(presets)) {
try {
const validated = isValidScenePreset(presetData);
this._presetsCache.set(name, validated);
} catch (err) {
console.warn(
`[ScryingPool] ScenePresetManager: invalid preset "${name}" in scene flag, skipping: ${err instanceof Error ? err.message : String(err)}`
);
}
}
} catch (err) {
console.warn(
'[ScryingPool] ScenePresetManager: failed to load scene presets',
err instanceof Error ? err.message : String(err)
);
}
}
/**
* Saves presets to the current scene's flag.
* @private
* @returns {Promise<void>}
*/
async _saveScenePresets() {
const currentScene = this._adapter.scenes.current?.();
if (!currentScene) {
throw new TypeError('ScenePresetManager._saveScenePresets: no active scene');
}
/** @type {Record<string, import('../contracts/scene-preset.js').ScenePreset>} */
const presetsObj = {};
for (const [name, preset] of this._presetsCache) {
presetsObj[name] = preset;
}
// Get existing flag data to preserve autoApply config
const existingFlag = this._getSceneFlagData(currentScene);
const autoApply = existingFlag?.autoApply ?? null;
const flagData = {
_version: SCENE_PRESET_VERSION,
presets: presetsObj,
...(autoApply && { autoApply }),
};
try {
await /** @type {object & { setFlag?: (scope: string, key: string, value: unknown) => Promise<unknown> }} */ (currentScene).setFlag?.('video-view-manager', 'presets', flagData);
} catch (err) {
console.error(
'[ScryingPool] ScenePresetManager: failed to save scene presets',
err instanceof Error ? err.message : String(err)
);
throw err;
}
}
// ==========================================================================
// Story 3.2: Auto-Apply Methods
// ==========================================================================
/**
* Handles scene activation for auto-apply functionality.
* Checks global enable, per-scene config, and applies preset after pre-delay.
*
* @param {object} scene - The activated FoundryVTT Scene document.
* @returns {Promise<void>}
*/
async onSceneActivate(scene) {
// Check if auto-apply is globally enabled
const globalEnabled = this._adapter.settings.get?.('video-view-manager.autoApplyEnabled') ?? true;
if (!globalEnabled) {
return; // Global disable
}
// Get current scene's flag data
const flagData = this._getSceneFlagData(scene);
if (!flagData) {
return; // No preset data for this scene
}
// Get auto-apply config (with defaults)
const autoApplyConfig = this._getAutoApplyConfig(flagData);
if (!autoApplyConfig.enabled) {
return; // Per-scene disable
}
// Check if preset exists
const preset = this._presetsCache.get(autoApplyConfig.presetName);
if (!preset) {
console.warn(
`[ScryingPool] ScenePresetManager.onSceneActivate: preset "${autoApplyConfig.presetName}" not found in cache`
);
return;
}
// Clear ALL pending timers when any scene is activated (prevents old scene timer from firing)
this._clearAllTimers();
// Apply preset after pre-delay
await this._applyWithDelay(scene, autoApplyConfig.presetName, autoApplyConfig.preDelay);
}
/**
* Applies a preset with optional auto-apply flag.
* Story 3.2: Extended to support auto-applied presets.
*
* @param {string} presetName - Name of the preset to apply.
* @param {object} options - Options object.
* @param {boolean} options.autoApplied - Whether this is an auto-apply operation.
* @returns {Promise<void>}
* @throws {TypeError} If preset not found.
*/
async applyPreset(presetName, options = {}) {
const { autoApplied = false } = options;
// Validate presetName
if (typeof presetName !== 'string' || presetName.length === 0) {
throw new TypeError('ScenePresetManager.applyPreset: presetName must be a non-empty string');
}
// Get preset from cache
const preset = this._presetsCache.get(presetName);
if (!preset) {
throw new TypeError(`ScenePresetManager.applyPreset: preset "${presetName}" not found`);
}
// Validate preset structure
isValidScenePreset(preset);
// Apply via VisibilityManager if available (Story 3.2), otherwise fall back to StateStore
if (this._visibilityManager) {
await this._visibilityManager.applyMatrix(preset.matrix);
} else {
await this._stateStore.setMatrix({
_version: preset._version,
matrix: { ...preset.matrix },
});
}
// Emit socket message for preset apply
const now = Date.now();
this._socketHandler.emit(SOCKET_EVENTS.PRESET_APPLIED, {
presetName,
timestamp: now,
autoApplied,
});
// Also emit hook for local confirmation (ConfirmationBar)
this._adapter.hooks.callAll('scrying-pool:presetApplied', {
presetName,
matrix: preset.matrix,
autoApplied,
timestamp: now,
});
}
/**
* Configures auto-apply settings for a scene.
*
* @param {object} scene - The FoundryVTT Scene document.
* @param {object} config - Auto-apply configuration.
* @param {boolean} config.enabled - Whether auto-apply is enabled.
* @param {string|null} config.presetName - Name of preset to auto-apply.
* @param {number} config.preDelay - Pre-delay in milliseconds (0-5000).
* @returns {Promise<void>}
* @throws {TypeError} If validation fails.
*/
async configureAutoApply(scene, config) {
// Validate scene
if (!scene || typeof scene !== 'object') {
throw new TypeError('ScenePresetManager.configureAutoApply: scene argument is required and must be an object');
}
// Validate config
if (!config || typeof config !== 'object') {
throw new TypeError('ScenePresetManager.configureAutoApply: config argument is required and must be an object');
}
const { enabled, presetName, preDelay } = config;
// Validate enabled
if (typeof enabled !== 'boolean') {
throw new TypeError('ScenePresetManager.configureAutoApply: enabled must be a boolean');
}
// Validate presetName
if (presetName !== null && typeof presetName !== 'string') {
throw new TypeError('ScenePresetManager.configureAutoApply: presetName must be a string or null');
}
// Validate preDelay
if (typeof preDelay !== 'number' || preDelay < MIN_PREDELAY_MS || preDelay > MAX_PREDELAY_MS) {
throw new TypeError(
`ScenePresetManager.configureAutoApply: preDelay must be a number between ${MIN_PREDELAY_MS} and ${MAX_PREDELAY_MS}`
);
}
// Get current flag data
const currentFlag = this._getSceneFlagData(scene);
const currentPresets = currentFlag?.presets ?? {};
// Build new flag data
const newFlagData = {
_version: SCENE_PRESET_VERSION,
presets: currentPresets,
autoApply: {
enabled,
presetName: presetName ?? null,
preDelay,
},
};
// Persist to scene flag
try {
await /** @type {object & { setFlag?: (scope: string, key: string, value: unknown) => Promise<unknown> }} */ (scene).setFlag?.('video-view-manager', 'presets', newFlagData);
} catch (err) {
console.error(
'[ScryingPool] ScenePresetManager: failed to configure auto-apply',
err instanceof Error ? err.message : String(err)
);
throw err;
}
}
/**
* Gets auto-apply configuration from scene flag data.
* Returns defaults if autoApply field is missing.
*
* @param {object} flagData - The scene flag data.
* @returns {object} Auto-apply configuration with defaults.
* @private
*/
_getAutoApplyConfig(flagData) {
if (!flagData || typeof flagData !== 'object') {
return { enabled: false, presetName: null, preDelay: 0 };
}
const { autoApply } = flagData;
if (!autoApply || typeof autoApply !== 'object') {
return { enabled: false, presetName: null, preDelay: 0 };
}
return {
enabled: Boolean(autoApply.enabled),
presetName: typeof autoApply.presetName === 'string' ? autoApply.presetName : null,
preDelay: typeof autoApply.preDelay === 'number' ? Math.max(MIN_PREDELAY_MS, Math.min(autoApply.preDelay, MAX_PREDELAY_MS)) : 0,
};
}
/**
* Applies a preset after a specified delay.
*
* @param {object} scene - The FoundryVTT Scene document.
* @param {string} presetName - Name of the preset to apply.
* @param {number} delayMs - Delay in milliseconds.
* @returns {number} Timeout ID for potential cancellation.
* @private
*/
_applyWithDelay(scene, presetName, delayMs) {
const timerId = setTimeout(async () => {
try {
await this.applyPreset(presetName, { autoApplied: true });
// Notify via ui.notifications
this._adapter.notifications.info(
this._adapter.i18n.localize('video-view-manager.presets.notifications.scene-applied')
.replace('{name}', presetName)
);
} catch (err) {
console.error(
'[ScryingPool] ScenePresetManager: failed to apply preset on scene activation',
err instanceof Error ? err.message : String(err)
);
} finally {
// Clean up timer reference
this._activeTimers.delete(scene.id);
}
}, delayMs);
// Store timer reference for cancellation
if (scene?.id) {
this._activeTimers.set(scene.id, timerId);
}
return timerId;
}
/**
* Clears the active timer for a scene.
*
* @param {object} scene - The FoundryVTT Scene document.
* @private
*/
_clearSceneTimer(scene) {
if (!scene?.id) return;
const timerId = this._activeTimers.get(scene.id);
if (timerId) {
clearTimeout(timerId);
this._activeTimers.delete(scene.id);
}
}
/**
* Clears ALL active timers.
* Called when a new scene is activated to prevent old scene timers from firing.
* @private
*/
_clearAllTimers() {
for (const [, timerId] of this._activeTimers) {
clearTimeout(timerId);
}
this._activeTimers.clear();
}
/**
* Gets the complete flag data from a scene.
*
* @param {object} scene - The FoundryVTT Scene document.
* @returns {object|null} The flag data or null.
* @private
*/
_getSceneFlagData(scene) {
try {
const flagData = /** @type {object & { getFlag?: (scope: string, key: string) => unknown }} */ (scene).getFlag?.('video-view-manager', 'presets');
if (!flagData || typeof flagData !== 'object') {
return null;
}
return /** @type {object} */ (flagData);
} catch (err) {
console.warn(
'[ScryingPool] ScenePresetManager: failed to get scene flag data',
err instanceof Error ? err.message : String(err)
);
return null;
}
}
}
+262
View File
@@ -0,0 +1,262 @@
/**
* ScryingPoolController — Orchestrates GM visibility actions with optimistic state updates.
*
* Handles: GM authorization, latest-revision-wins guard, last-intent guard, PendingOp
* lifecycle, optimistic setVisibility, socket emit, and echo reconciliation.
*
* Import rule: may only import from src/contracts/ and src/utils/.
* Constructors are side-effect free — call init() from module.js Hooks.once('ready').
*
* @module core/ScryingPoolController
*/
import { createPendingOp } from '../contracts/pending-op.js';
import { createSocketIntentMessage, SOCKET_EVENTS } from '../contracts/socket-message.js';
import { VISIBILITY_STATES } from '../contracts/visibility-matrix.js';
/**
* Orchestrates GM visibility actions: auth, optimistic state, socket emit, echo reconciliation.
*/
export class ScryingPoolController {
/**
* @param {import('./StateStore.js').StateStore} stateStore
* @param {{ emit(event: string, payload: object): void, registerPendingOp(op: object, event: string, payload: object): void, confirmPendingOp(opId: string): void, setReady(handler: object): void }} socketHandler
* @param {{ users: { isGM(): boolean }, socket: { on(event: string, handler: (...args: unknown[]) => void): void }, hooks: { on(event: string, handler: (...args: unknown[]) => void): void, callAll(event: string, data: unknown): void } }} adapter
*/
constructor(stateStore, socketHandler, adapter) {
this._stateStore = stateStore;
this._socketHandler = socketHandler;
this._adapter = adapter;
/** @type {Map<string, import('../contracts/pending-op.js').PendingOp>} participantId → PendingOp */
this._pendingOps = new Map();
/** @type {Map<string, number>} participantId → last-confirmed revision */
this._revisions = new Map();
}
/**
* Registers the socket echo listener.
* Called from module.js Hooks.once('ready') — NOT from constructor.
*/
init() {
const echoHandler = (payload) => this._onEcho(/** @type {any} */ (payload));
this._echoHandler = echoHandler;
this._adapter.socket.on(SOCKET_EVENTS.VISIBILITY_UPDATED, echoHandler);
// Clean up stale _revisions when a participant disconnects (T-06 deferred debt)
this._disconnectHookId = this._adapter.hooks.on('userConnected', (user, connected) => {
if (!connected && user?.id) this.cleanupParticipant(user.id);
});
}
/**
* Unregisters listeners and clears all in-flight state.
* Safe to call multiple times.
*/
teardown() {
if (this._echoHandler) {
this._adapter.socket.off(SOCKET_EVENTS.VISIBILITY_UPDATED, this._echoHandler);
this._echoHandler = null;
}
if (this._disconnectHookId != null) {
this._adapter.hooks.off('userConnected', this._disconnectHookId);
this._disconnectHookId = null;
}
this.cleanupAll();
}
/**
* Returns the last confirmed revision for a participant (0 if unknown).
* @param {string} participantId
* @returns {number}
*/
getRevision(participantId) {
return this._revisions.get(participantId) ?? 0;
}
/**
* Returns true if a pending op is currently in-flight for the given participant.
* @param {string} participantId
* @returns {boolean}
*/
hasPendingOp(participantId) {
return this._pendingOps.has(participantId);
}
/**
* Cleans up a pending operation by userId.
* Called by SocketHandler timeout callback via composite handler in module.js.
* Also cleans up the revision tracking for this user (T-06).
* @param {string} userId - The user ID to clean up
*/
cleanupPendingOp(userId) {
this._pendingOps.delete(userId);
this._revisions.delete(userId);
}
/**
* Cleans up state for a disconnected participant.
* Called when a participant disconnects to prevent memory leaks (T-06).
* @param {string} userId - The user ID to clean up
*/
cleanupParticipant(userId) {
this._pendingOps.delete(userId);
this._revisions.delete(userId);
}
/**
* Cleans up all state. Useful for module reload.
*/
cleanupAll() {
this._pendingOps.clear();
this._revisions.clear();
}
/**
* Processes a GM visibility toggle request.
* Guards: isGM, latest-revision-wins, last-intent (idempotent).
*
* @param {string} source - Who triggered the action (e.g. 'ui', 'preset').
* @param {string} participantId - Target userId.
* @param {string} targetState - Desired VisibilityState.
* @param {string} opId - Unique operation ID (supplied by caller — Story 1.5 UI).
* @param {number} baseRevision - StateStore revision at time of intent.
*/
action(source, participantId, targetState, opId, baseRevision) {
// 0. Input validation
if (!participantId || typeof participantId !== 'string') {
console.warn('[ScryingPool]', 'ScryingPoolController.action: invalid participantId');
return;
}
if (!targetState || typeof targetState !== 'string') {
console.warn('[ScryingPool]', 'ScryingPoolController.action: invalid targetState');
return;
}
if (!opId || typeof opId !== 'string') {
console.warn('[ScryingPool]', 'ScryingPoolController.action: invalid opId');
return;
}
if (typeof baseRevision !== 'number' || !Number.isFinite(baseRevision) || baseRevision < 0) {
console.warn('[ScryingPool]', 'ScryingPoolController.action: invalid baseRevision');
return;
}
// 0b. Validate targetState against known states (T-05)
if (!VISIBILITY_STATES.includes(targetState)) {
console.warn('[ScryingPool]', `ScryingPoolController.action: invalid targetState '${targetState}'`);
return;
}
// 1. Authorization
if (!this._adapter.users.isGM()) {
console.warn('[ScryingPool]', 'ScryingPoolController.action: non-GM call rejected');
return;
}
// 2. Atomic pending op check + race condition guard (T-01, T-08)
if (this._pendingOps.has(participantId)) {
console.warn('[ScryingPool]', `ScryingPoolController.action: pending op already exists for ${participantId}`);
return;
}
// 3. Latest-revision-wins guard (T-04: now strict equality)
const currentRevision = this._revisions.get(participantId) ?? 0;
if (baseRevision !== currentRevision) return;
// 4. Last-intent guard (idempotent)
let currentState;
try {
currentState = this._stateStore.getState(participantId);
} catch (err) {
console.error('[ScryingPool] ScryingPoolController.action: getState failed', err);
return;
}
if (currentState === targetState) return;
// 5. Register PendingOp
const previousState = currentState ?? 'never-connected';
const pendingOp = createPendingOp(opId, participantId, targetState, previousState);
this._pendingOps.set(participantId, pendingOp);
// 6. Optimistic state update (T-07: wrapped in try-catch)
try {
this._stateStore.setVisibility(participantId, targetState);
} catch (err) {
this._pendingOps.delete(participantId);
console.error('[ScryingPool] ScryingPoolController.action: setVisibility failed', err);
return;
}
// 7. Socket emit
const msg = createSocketIntentMessage(opId, participantId, targetState, baseRevision);
this._socketHandler.emit(msg.event, msg.payload);
// 8. Start acknowledgement timer
this._socketHandler.registerPendingOp(pendingOp, msg.event, msg.payload);
// 9. Notify UI subscribers
try {
this._adapter.hooks.callAll('scrying-pool:controllerAction', { participantId, targetState, source, opId });
} catch (hookErr) {
console.error('[ScryingPool] ScryingPoolController.action: hook emission failed', hookErr);
}
}
/**
* Processes an authoritative echo from the socket server.
* Confirms the pending op, updates revision, and sets the authoritative state.
* @private
* @param {{ opId: string, userId: string, state: string, revision?: number }} payload
*/
_onEcho(payload) {
// Validate payload fields
if (!payload || typeof payload !== 'object') {
console.warn('[ScryingPool]', 'ScryingPoolController._onEcho: invalid payload');
return;
}
const { opId, userId, state, revision } = payload;
if (!opId || typeof opId !== 'string') {
console.warn('[ScryingPool]', 'ScryingPoolController._onEcho: missing or invalid opId');
return;
}
if (!userId || typeof userId !== 'string') {
console.warn('[ScryingPool]', 'ScryingPoolController._onEcho: missing or invalid userId');
return;
}
if (!state || typeof state !== 'string') {
console.warn('[ScryingPool]', 'ScryingPoolController._onEcho: missing or invalid state');
return;
}
// T-02: Verify pending op exists before confirming (T-03: use consistent participantId key)
const pendingOp = this._pendingOps.get(userId);
if (!pendingOp || pendingOp.opId !== opId) {
console.warn('[ScryingPool]', `ScryingPoolController._onEcho: no matching pending op for ${userId}, opId=${opId}`);
return;
}
this._socketHandler.confirmPendingOp(opId);
// Validate revision is a finite number
const validatedRevision = (typeof revision === 'number' && Number.isFinite(revision)) ? revision : 0;
this._revisions.set(userId, validatedRevision);
this._pendingOps.delete(userId);
try {
this._stateStore.setVisibility(userId, state);
} catch (err) {
console.error('[ScryingPool] ScryingPoolController._onEcho: setVisibility failed', err);
return;
}
try {
this._adapter.hooks.callAll('scrying-pool:controllerAction', {
participantId: userId,
targetState: state,
source: 'echo',
opId,
});
} catch (hookErr) {
console.error('[ScryingPool] ScryingPoolController._onEcho: hook emission failed', hookErr);
}
}
}
+163
View File
@@ -0,0 +1,163 @@
/**
* VisibilityManager — WebRTC strategy applier and SocketHandler revert handler.
*
* Listens to `scrying-pool:stateChanged` hook events (emitted by StateStore) and
* applies the appropriate webrtcMode strategy:
* - 'track-disable' + non-null adapter.webrtc → call disableTrack / enableTrack
* - 'css-fallback' / 'unsupported' / null webrtc → no-op (CSS handled by RoleRenderer)
*
* Also implements onRevert(pendingOp) for SocketHandler timeout callbacks.
*
* Import rule: may only import from src/contracts/ and src/utils/.
* Constructors are side-effect free — call init() from module.js Hooks.once('ready').
*
* @module core/VisibilityManager
*/
/**
* Applies webrtcMode strategy on state changes and reverts failed operations.
*/
export class VisibilityManager {
/**
* @param {import('./StateStore.js').StateStore} stateStore
* @param {{ settings: { get(key: string): unknown }, webrtc: { disableTrack(userId: string): void, enableTrack(userId: string): void } | null, notifications: { warn(msg: string): void }, hooks: { on(event: string, handler: (...args: unknown[]) => void): void } }} adapter
*/
constructor(stateStore, adapter) {
this._stateStore = stateStore;
this._adapter = adapter;
}
/**
* Registers the Hooks.on('scrying-pool:stateChanged') listener.
* Called from module.js Hooks.once('ready') — NOT from constructor.
*/
init() {
this._stateChangedHookId = this._adapter.hooks.on(
'scrying-pool:stateChanged',
(data) => this._onStateChanged(/** @type {any} */ (data))
);
}
/**
* Unregisters the state-changed listener.
* Safe to call before init() or multiple times.
*/
teardown() {
if (this._stateChangedHookId != null) {
this._adapter.hooks.off('scrying-pool:stateChanged', this._stateChangedHookId);
this._stateChangedHookId = null;
}
}
/**
* Handles a state change by applying the webrtcMode strategy.
* css-fallback / unsupported → no-op (CSS applied by RoleRenderer in Story 1.5).
* track-disable + non-null webrtc → disable/enable the participant's track.
* Always safe with null adapter.webrtc (OQ-1 spike result for v14).
*
* @private
* @param {{ userId: string, state: string }} data
*/
_onStateChanged(data) {
const { userId, state } = data;
// Input validation
if (!userId || typeof userId !== 'string') {
console.warn('[ScryingPool]', 'VisibilityManager._onStateChanged: invalid userId');
return;
}
if (!state || typeof state !== 'string') {
console.warn('[ScryingPool]', 'VisibilityManager._onStateChanged: invalid state');
return;
}
// T-11: Validate mode is a string
const mode = this._adapter.settings.get('webrtcMode');
if (typeof mode !== 'string') {
console.warn('[ScryingPool]', 'VisibilityManager._onStateChanged: webrtcMode is not a string');
return;
}
if (mode !== 'track-disable' || !this._adapter.webrtc) return;
// T-10: Validate webrtc methods exist before calling
if (typeof this._adapter.webrtc.disableTrack !== 'function' ||
typeof this._adapter.webrtc.enableTrack !== 'function') {
console.warn('[ScryingPool]', 'VisibilityManager._onStateChanged: webrtc missing disableTrack/enableTrack methods');
return;
}
// T-09: Handle all visibility states properly, not just binary hidden/active
if (state === 'hidden' || state === 'offline' || state === 'cam-lost' || state === 'ghost') {
this._adapter.webrtc.disableTrack(userId);
} else {
this._adapter.webrtc.enableTrack(userId);
}
}
/**
* Called by SocketHandler after retry exhaustion — reverts the optimistic state
* and notifies the GM that the operation could not be confirmed.
*
* @param {{ userId: string, previousState: string, opId: string }} pendingOp
*/
onRevert(pendingOp) {
// Input validation
if (!pendingOp || typeof pendingOp !== 'object') {
console.warn('[ScryingPool]', 'VisibilityManager.onRevert: invalid pendingOp');
return;
}
const { userId, previousState } = pendingOp;
if (!userId || typeof userId !== 'string') {
console.warn('[ScryingPool]', 'VisibilityManager.onRevert: invalid userId in pendingOp');
return;
}
if (!previousState || typeof previousState !== 'string') {
console.warn('[ScryingPool]', 'VisibilityManager.onRevert: invalid previousState in pendingOp');
return;
}
try {
this._stateStore.setVisibility(userId, previousState);
} catch (err) {
console.error('[ScryingPool] VisibilityManager.onRevert: setVisibility failed', err);
}
try {
this._adapter.notifications.warn(
`[ScryingPool] Visibility change for ${userId} could not be confirmed — reverting to ${previousState}`
);
} catch (err) {
console.error('[ScryingPool] VisibilityManager.onRevert: notification failed', err);
}
}
/**
* Applies a full visibility matrix to all participants.
* Story 3.2: Added for preset auto-apply support.
*
* @param {object} matrix - The visibility matrix to apply.
* @param {object} matrix.matrix - The matrix object with userId → state mappings.
* @returns {Promise<void>}
*/
async applyMatrix(matrix) {
// Validate matrix
if (!matrix || typeof matrix !== 'object') {
throw new TypeError('VisibilityManager.applyMatrix: matrix argument is required and must be an object');
}
if (!matrix.matrix || typeof matrix.matrix !== 'object') {
throw new TypeError('VisibilityManager.applyMatrix: matrix.matrix is required and must be an object');
}
// Apply each participant's state from the matrix
for (const [userId, state] of Object.entries(matrix.matrix)) {
try {
await this._stateStore.setVisibility(userId, state);
} catch (err) {
console.error(
`[ScryingPool] VisibilityManager.applyMatrix: failed to set visibility for ${userId}:`,
err instanceof Error ? err.message : String(err)
);
}
}
}
}
+16
View File
@@ -145,6 +145,22 @@ export class FoundryAdapter {
return false;
},
};
/** i18n surface — wraps game.i18n for localization. */
this.i18n = {
/**
* Localize a string using the module's translation keys.
* @param {string} key - The translation key (e.g., 'video-view-manager.notifications.gmHid')
* @param {object} [data] - Optional data for string interpolation
* @returns {string} The localized string
*/
localize: (key, data) => {
if (g.i18n && typeof g.i18n.localize === 'function') {
return g.i18n.localize(key, data);
}
return key; // Fallback: return the key if i18n not available
},
};
}
/**
+180
View File
@@ -0,0 +1,180 @@
/**
* NotificationBus — coalesced toast layer above ui.notifications.
*
* Subscribes to `scrying-pool:stateChanged` and coalesces rapid GM visibility
* changes for the same participant into a single toast per 3-second window.
*
* Verbosity rules (AC-4, AC-5):
* - 'all' → every client sees every general notification
* - 'gm-only' → only the GM + the affected participant are notified
* - 'silent' → only the affected participant is notified (personal only)
*
* Personal notification (own camera changed) always fires — never suppressed
* by verbosity setting (AC-2).
*
* Import boundary: src/notifications/ → src/core/, src/contracts/, src/utils/ ONLY.
* Uses Hooks as a FoundryVTT global (same pattern as StateStore).
*
* Constructors are side-effect-free. Call init() from module.js Hooks.once('ready').
*
* @module notifications/NotificationBus
*/
/** Coalescing window in milliseconds. Matches AC-3 ("within 3 seconds"). */
const COALESCE_WINDOW_MS = 3_000;
/**
* Coalesced toast notification layer over ui.notifications.
*
* Subscribes to `scrying-pool:stateChanged` and debounces GM visibility
* changes per participant into a single toast per 3-second window.
*/
export class NotificationBus {
/**
* @param {{ notifications: {info(m:string):void, warn(m:string):void, error(m:string):void},
* users: {get(id:string):{id:string,name:string}|null, current():{id:string}|null, isGM():boolean},
* settings: {get(key:string):unknown},
* i18n: {localize(key:string, data?:object):string} }} adapter
*/
constructor(adapter) {
this._adapter = adapter;
/** @type {Map<string, {timer: ReturnType<typeof setTimeout>|null, prevState: string, lastState: string, changeCount: number}>} */
this._coalesceMap = new Map();
this._hookId = null;
this._disconnectHookId = null;
}
/** Register hook listener. Call from module.js Hooks.once('ready'). */
init() {
// Prevent multiple init calls without teardown
if (this._hookId != null) {
console.warn('[ScryingPool] NotificationBus.init: already initialized, call teardown first');
return;
}
this._hookId = Hooks.on('scrying-pool:stateChanged', (data) => this._onStateChanged(data));
// Clean up coalesceMap entries for disconnected users
this._disconnectHookId = this._adapter.hooks.on('userConnected', (user, connected) => {
if (!connected && user?.id) {
const entry = this._coalesceMap.get(user.id);
if (entry) {
clearTimeout(entry.timer);
this._coalesceMap.delete(user.id);
}
}
});
}
/** Unregister listeners and cancel all pending timers. Safe to call before init(). */
teardown() {
if (this._hookId != null) {
Hooks.off('scrying-pool:stateChanged', this._hookId);
this._hookId = null;
}
if (this._disconnectHookId != null) {
this._adapter.hooks.off('userConnected', this._disconnectHookId);
this._disconnectHookId = null;
}
for (const entry of this._coalesceMap.values()) {
clearTimeout(entry.timer);
}
this._coalesceMap.clear();
}
// ── Private ────────────────────────────────────────────────────────────────
/**
* Handler for `scrying-pool:stateChanged` hook.
* @param {{ userId?: string, state: string, previousState: string }} data
*/
_onStateChanged(data) {
const { userId, state: newState, previousState } = data ?? {};
if (!userId) return;
if (typeof newState !== 'string') return;
const currentUserId = this._adapter.users.current()?.id ?? null;
// AC-2: Personal notification — fires immediately, never suppressed by verbosity
if (userId === currentUserId) {
this._notifyPersonal(newState);
return;
}
// AC-4/AC-5: Verbosity gate for non-personal notifications
const verbosity = this._adapter.settings.get('notificationVerbosity') ?? 'all';
// Validate verbosity is one of the allowed choices
const validVerbosity = ['all', 'gm-only', 'silent'].includes(verbosity) ? verbosity : 'all';
if (validVerbosity === 'silent') return;
if (validVerbosity === 'gm-only' && !this._adapter.users.isGM()) return;
this._enqueue(userId, newState, previousState);
}
/**
* Fire an immediate personal notification for the current user's own camera change.
* @param {string} newState
*/
_notifyPersonal(newState) {
const key = newState === 'hidden'
? 'video-view-manager.notifications.personalHidden'
: 'video-view-manager.notifications.personalShowed';
const msg = this._adapter.i18n.localize(key);
this._adapter.notifications.info(msg);
}
/**
* Add or update a coalescing entry for the given participant.
* Resets the 3-second debounce window on each call.
* @param {string} userId
* @param {string} newState
* @param {string} prevState
*/
_enqueue(userId, newState, prevState) {
// Validate required parameters
if (typeof userId !== 'string' || !userId) return;
if (typeof newState !== 'string' || typeof prevState !== 'string') return;
const existing = this._coalesceMap.get(userId);
if (existing) {
clearTimeout(existing.timer);
existing.lastState = newState;
existing.changeCount += 1;
} else {
this._coalesceMap.set(userId, {
timer: null,
prevState,
lastState: newState,
changeCount: 1,
});
}
const entry = this._coalesceMap.get(userId);
entry.timer = setTimeout(() => this._flush(userId), COALESCE_WINDOW_MS);
}
/**
* Fire the coalesced notification for a participant, then remove the entry.
* Net-zero suppression: if final state equals original state, no notification fires.
* @param {string} userId
*/
_flush(userId) {
const entry = this._coalesceMap.get(userId);
if (!entry) return; // Entry may have been deleted by teardown or disconnect cleanup
this._coalesceMap.delete(userId);
// AC-3: Net-zero suppression
if (entry.lastState === entry.prevState) return;
// Additional safety: ensure we have valid timer to prevent stale closure issues
if (entry.timer == null) return;
const name = this._adapter.users.get(userId)?.name ?? userId;
const count = entry.changeCount > 1 ? ` (${entry.changeCount} changes)` : '';
const key = entry.lastState === 'hidden'
? 'video-view-manager.notifications.gmHid'
: 'video-view-manager.notifications.gmShowed';
// Note: changeCount is included in the message suffix for AC-3 compliance
const msg = this._adapter.i18n.localize(key, { name }) + count;
this._adapter.notifications.info(msg);
}
}
+7
View File
@@ -18,3 +18,10 @@ declare const ui: {
error(msg: string): void;
};
};
declare const game: {
user?: {
getFlag?(scope: string, key: string): unknown;
setFlag?(scope: string, key: string, value: unknown): Promise<void>;
};
};
+105
View File
@@ -0,0 +1,105 @@
// @ts-nocheck
import { ScryingPoolStrip } from './gm/ScryingPoolStrip.js';
/**
* Reacts to state changes and applies visual state to AV tiles.
* Constructs and manages the GM-only ScryingPoolStrip window.
* Subscribes to Foundry Hooks after explicit `init()` call.
*/
export class RoleRenderer {
/**
* @param {object} stateStore - StateStore instance
* @param {object} controller - ScryingPoolController instance
* @param {object} avTileAdapter - AVTileAdapter instance
* @param {object} adapter - FoundryAdapter instance
*/
constructor(stateStore, controller, avTileAdapter, adapter) {
this._stateStore = stateStore;
this._controller = controller;
this._avTileAdapter = avTileAdapter;
this._adapter = adapter;
/** @type {ScryingPoolStrip|null} */
this._strip = null;
}
/**
* Registers Hooks listeners. Must be called once during module ready.
*/
init() {
Hooks.on('scrying-pool:stateChanged', data => {
if (data.userId) {
this._applyAVTileState(data.userId, data.state);
}
});
Hooks.on('scrying-pool:controllerAction', data => {
this._onControllerAction(data);
});
Hooks.on('updateUser', () => {
if (this._strip?.rendered) {
this._strip.render(true);
}
});
}
/**
* Applies visual state to an AV tile: state CSS class + lock overlay + portrait fallback.
* @param {string} userId
* @param {string} state
*/
_applyAVTileState(userId, state) {
this._avTileAdapter.setStateClass(userId, state);
const HIDDEN = state === 'hidden';
const CAMERA_ABSENT = state === 'never-connected' || state === 'cam-lost';
if (HIDDEN) {
const lockEl = document.createElement('div');
lockEl.className = 'sp-lock-overlay';
lockEl.dataset.spRole = 'lock-overlay';
lockEl.title = 'Camera hidden by GM';
this._avTileAdapter.mount(userId, lockEl);
} else if (CAMERA_ABSENT) {
const fallbackEl = document.createElement('div');
fallbackEl.className = 'sp-portrait-fallback';
fallbackEl.dataset.spRole = 'portrait-fallback';
this._avTileAdapter.mount(userId, fallbackEl);
} else {
this._avTileAdapter.unmount(userId);
}
}
/**
* Handles controller action events for pending op visual feedback.
* @param {{ participantId: string, targetState: string, source: string }} data
*/
_onControllerAction(data) {
if (!data?.participantId) return;
if (this._controller.hasPendingOp(data.participantId)) {
this._avTileAdapter.setStateClass(data.participantId, 'pending');
}
}
/**
* Opens the ScryingPoolStrip window (GM only). Constructs lazily on first call.
*/
openStrip() {
if (!this._strip) {
this._strip = new ScryingPoolStrip(
this._stateStore,
this._controller,
this._avTileAdapter,
this._adapter
);
}
this._strip.render(true);
}
/**
* Closes the ScryingPoolStrip window if open.
*/
closeStrip() {
if (this._strip) {
this._strip.close();
}
}
}
+380
View File
@@ -0,0 +1,380 @@
/**
* ConfirmationBar — Strip-local feedback component for preset apply operations.
*
* Owns: Displaying preset apply confirmation, Undo button, auto-dismiss timer.
* Rendered in StripOverlayLayer at position: absolute; bottom: 0.
* Supports instant-replace rule (zero crossfade for consecutive bars).
*
* Import rule: may import from src/core/, src/contracts/, src/utils/ ONLY.
* Constructors are side-effect free — call init() from module.js Hooks.once('ready').
*
* Story 3.2: Scene Auto-Apply & ConfirmationBar
*
* @module ui/gm/ConfirmationBar
*/
/**
* Strip-local feedback bar for preset apply operations.
* Provides immediate visual confirmation with one-click Undo.
*/
export class ConfirmationBar {
/**
* @param {import('../../foundry/FoundryAdapter.js').FoundryAdapter} adapter
* Injected FoundryAdapter surface.
* @param {import('../../core/VisibilityManager.js').VisibilityManager} visibilityManager
* Injected VisibilityManager for reverting matrix on undo.
* @param {import('../../core/SocketHandler.js').SocketHandler} socketHandler
* Injected SocketHandler (for potential future use).
* @param {import('../shared/StripOverlayLayer.js').StripOverlayLayer} stripOverlayLayer
* Injected StripOverlayLayer for rendering.
*/
constructor(adapter, visibilityManager, socketHandler, stripOverlayLayer) {
this._adapter = adapter;
this._visibilityManager = visibilityManager;
this._socketHandler = socketHandler;
this._stripOverlayLayer = stripOverlayLayer;
// State
/** @type {object|null} */
this._previousMatrix = null;
/** @type {object|null} */
this._lastPayload = null;
/** @type {number|null} */
this._dismissTimer = null;
/** @type {boolean} */
this._isVisible = false;
/** @type {number} */
this._lastAppliedTimestamp = 0;
/** @type {number} */
this._recentApplyCount = 0;
/** @type {boolean} */
this._hookRegistered = false;
/** @type {Function} */
this._hookHandler = this._onPresetApplied.bind(this);
/** @type {Function|null} */
this._clickHandler = null;
// Constants
/** @type {number} Default bar duration in ms */
this._DEFAULT_DURATION = 8000;
/** @type {number} Short duration when >=2 applies within 60s */
this._SHORT_DURATION = 4000;
/** @type {number} Recent apply window in ms */
this._RECENT_WINDOW_MS = 60000;
}
/**
* Initializes the ConfirmationBar by registering hook listeners.
* Side-effect: registers listener for scrying-pool:presetApplied hook.
* Idempotent - safe to call multiple times.
*/
init() {
// Only register hook if not already registered
if (!this._hookRegistered) {
this._adapter.hooks.on('scrying-pool:presetApplied', this._hookHandler);
this._hookRegistered = true;
}
this._setupEventListeners();
}
/**
* Sets up DOM event listeners for the confirmation bar.
* Uses event delegation via StripOverlayLayer's element.
* @private
*/
_setupEventListeners() {
if (!this._stripOverlayLayer || !this._stripOverlayLayer.element) {
return;
}
const element = this._stripOverlayLayer.element;
// Store bound handler
this._clickHandler = (event) => {
const target = event.target.closest?.('[data-action="confirmation-bar-undo"]');
if (target) {
event.preventDefault();
event.stopPropagation();
this._onUndo();
}
};
// Use event delegation for undo button clicks
element.addEventListener('click', this._clickHandler);
}
/**
* Cleans up internal state and unregisters listeners.
* Safe to call multiple times.
*/
teardown() {
this._adapter.hooks.off('scrying-pool:presetApplied', this._hookHandler);
this._hookRegistered = false;
this._removeEventListeners();
this._clearDismissTimer();
this._previousMatrix = null;
this._lastPayload = null;
this._isVisible = false;
this._recentApplyCount = 0;
}
/**
* Removes DOM event listeners.
* @private
*/
_removeEventListeners() {
if (!this._stripOverlayLayer || !this._stripOverlayLayer.element || !this._clickHandler) {
return;
}
const element = this._stripOverlayLayer.element;
element.removeEventListener('click', this._clickHandler);
this._clickHandler = null;
}
/**
* Shows the confirmation bar with the given payload.
* Captures previous matrix for undo, renders bar, starts dismiss timer.
*
* @param {object} payload - The preset applied payload.
* @param {string} payload.presetName - Name of the applied preset.
* @param {object} payload.matrix - The visibility matrix that was applied.
* @param {boolean} payload.autoApplied - Whether this was an auto-apply.
* @param {boolean} [payload.partialFail] - Whether some participants failed to update.
* @param {number} [payload.timestamp] - When the preset was applied.
*/
show(payload) {
if (!payload) {
return;
}
// Store previous matrix for undo
if (payload.matrix) {
this._previousMatrix = payload.matrix;
}
this._lastPayload = payload;
this._lastAppliedTimestamp = payload.timestamp ?? Date.now();
// Track recent applies for short duration logic
const now = Date.now();
if (now - this._lastAppliedTimestamp < this._RECENT_WINDOW_MS) {
this._recentApplyCount++;
} else {
this._recentApplyCount = 1;
}
// Render the bar
this._render();
// Start dismiss timer
this._startDismissTimer();
}
/**
* Hides the confirmation bar.
* Clears timer, previous matrix, and updates visibility state.
*/
hide() {
this._clearDismissTimer();
this._previousMatrix = null;
this._lastPayload = null;
this._isVisible = false;
this._recentApplyCount = 0;
}
/**
* Handles the Undo button click.
* Reverts to previous matrix and hides the bar.
* Emits hook for undo notification.
* @private
*/
_onUndo() {
if (!this._previousMatrix || !this._visibilityManager) {
return;
}
try {
this._visibilityManager.applyMatrix(this._previousMatrix);
// Emit undo notification
this._adapter.hooks.callAll('scrying-pool:presetUndo', {
presetName: this._lastPayload?.presetName ?? 'unknown',
timestamp: Date.now(),
});
} catch (err) {
console.error(
'[ScryingPool] ConfirmationBar: failed to undo preset apply',
err instanceof Error ? err.message : String(err)
);
} finally {
this.hide();
}
}
/**
* Renders the confirmation bar in the strip overlay.
* Calculates hidden/visible counts from matrix.
* Determines variant (default or amber for partial fail).
* @private
*/
_render() {
if (!this._lastPayload || !this._stripOverlayLayer) {
return;
}
const { presetName, partialFail, matrix } = this._lastPayload;
const counts = this._calculateCounts(matrix);
const variant = partialFail ? 'amber' : 'default';
const message = this._buildMessage(presetName, counts, variant);
// Build HTML content
const html = this._buildHtml(message, variant);
// Render via strip overlay
this._stripOverlayLayer.render(html);
this._isVisible = true;
}
/**
* Calculates hidden and visible counts from a visibility matrix.
* Only counts states that affect visibility (active, hidden).
* @param {object} matrix - The visibility matrix.
* @returns {object} Hidden and visible counts.
* @private
*/
_calculateCounts(matrix) {
if (!matrix || !matrix.matrix) {
return { hidden: 0, visible: 0 };
}
let hidden = 0;
let visible = 0;
for (const [, state] of Object.entries(matrix.matrix)) {
if (state === 'hidden') {
hidden++;
} else if (state === 'active') {
visible++;
}
// Other states (self-muted, offline, etc.) are not counted
// as they don't affect the "hidden from table" status
}
return { hidden, visible };
}
/**
* Builds the display message for the confirmation bar.
* @param {string} presetName - Name of the preset.
* @param {object} counts - Hidden and visible counts.
* @param {string} variant - 'default' or 'amber'.
* @returns {string} The formatted message.
* @private
*/
_buildMessage(presetName, counts, variant) {
const baseMsg = this._adapter.i18n.localize('video-view-manager.presets.confirmation.applied')
.replace('{name}', presetName);
const countMsg = this._adapter.i18n.localize('video-view-manager.presets.confirmation.counts')
.replace('{hidden}', counts.hidden)
.replace('{visible}', counts.visible);
if (variant === 'amber') {
const suffix = this._adapter.i18n.localize('video-view-manager.presets.confirmation.partial-fail');
return `${baseMsg} ${countMsg} ${suffix}`;
}
return `${baseMsg} ${countMsg}`;
}
/**
* Builds the HTML content for the confirmation bar.
* Includes message, undo button, accessibility attributes.
* Uses data-action pattern for event delegation.
* @param {string} message - The display message.
* @param {string} variant - 'default' or 'amber'.
* @returns {string} HTML string.
* @private
*/
_buildHtml(message, variant) {
const variantClass = variant === 'amber' ? 'sp-confirmation-bar--amber' : 'sp-confirmation-bar--default';
const undoLabel = this._adapter.i18n.localize('video-view-manager.presets.confirmation.undo');
// Use data-action for event delegation via StripOverlayLayer
// The onclick handler is set up in _setupEventListeners
return `
<div class="scrying-pool__confirmation-bar ${variantClass}"
role="status"
aria-live="polite"
aria-label="${message}. ${undoLabel}">
<span class="sp-confirmation-bar__message">${this._escapeHtml(message)}</span>
<button class="sp-confirmation-bar__undo-btn"
role="button"
aria-label="${undoLabel}"
data-action="confirmation-bar-undo">
${undoLabel}
</button>
</div>
`;
}
/**
* Escapes HTML special characters to prevent XSS.
* @param {string} str - String to escape.
* @returns {string} Escaped string.
* @private
*/
_escapeHtml(str) {
if (!str || typeof str !== 'string') {
return '';
}
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
}
/**
* Starts the auto-dismiss timer.
* Uses short duration (4000ms) if >=2 applies within 60s window.
* Otherwise uses default duration (8000ms).
* @private
*/
_startDismissTimer() {
// Clear any existing timer
this._clearDismissTimer();
// Determine duration based on recent activity
const now = Date.now();
const useShort = this._recentApplyCount >= 2 &&
(now - this._lastAppliedTimestamp) < this._RECENT_WINDOW_MS;
const duration = useShort ? this._SHORT_DURATION : this._DEFAULT_DURATION;
this._dismissTimer = setTimeout(() => {
this.hide();
}, duration);
}
/**
* Clears the active dismiss timer.
* @private
*/
_clearDismissTimer() {
if (this._dismissTimer) {
clearTimeout(this._dismissTimer);
this._dismissTimer = null;
}
}
/**
* Hook handler for scrying-pool:presetApplied events.
* Shows the bar, implementing instant-replace rule.
* @param {object} payload - The preset applied payload.
* @private
*/
_onPresetApplied(payload) {
// Instant-replace: just show again, no crossfade
this.show(payload);
}
}
+93
View File
@@ -6,6 +6,7 @@ import { PresetSaveDialog } from './PresetSaveDialog.js';
import { PresetLoadDialog } from './PresetLoadDialog.js';
import { PresetExportDialog } from './PresetExportDialog.js';
import { PresetImportDialog } from './PresetImportDialog.js';
import { ScenePresetPanel } from './ScenePresetPanel.js';
// Conditional base class — test environment lacks foundry globals.
// At module load time in tests, foundry is undefined → fallback class is used.
@@ -86,11 +87,27 @@ export class DirectorsBoard extends _AppBase {
this._exportDialog = null;
/** @type {PresetImportDialog|null} Reference to open import dialog for cleanup */
this._importDialog = null;
/** @type {ScenePresetPanel|null} Reference to scene preset panel for cleanup */
this._presetPanel = null;
// Load saved position from user flags
this._loadPosition();
}
/**
* Returns the ScenePresetPanel instance, creating it lazily if needed.
* Story 3.2: Scene Auto-Apply & ConfirmationBar
* @returns {ScenePresetPanel} The panel instance.
* @private
*/
_getPresetPanel() {
if (!this._presetPanel) {
this._presetPanel = new ScenePresetPanel(this._adapter, this._scenePresetManager);
this._presetPanel.init();
}
return this._presetPanel;
}
/** Loads saved window position from GM user flag. */
_loadPosition() {
try {
@@ -119,6 +136,12 @@ export class DirectorsBoard extends _AppBase {
Hooks.off('scrying-pool:stateChanged', this._hookId);
this._hookId = null;
}
// Story 3.2: Tear down ScenePresetPanel
if (this._presetPanel) {
this._presetPanel.teardown();
this._presetPanel = null;
}
}
/** Opens the board if closed; closes it if open (singleton toggle behaviour). */
@@ -316,12 +339,32 @@ export class DirectorsBoard extends _AppBase {
async _prepareContext() {
const base = buildBoardContext(this._stateStore, this._controller, this._adapter);
const presetCount = this._scenePresetManager?.list?.().length ?? 0;
// Get auto-apply config for current scene (Story 3.2)
let autoApplyConfig = { enabled: false, presetName: null, preDelay: 0 };
try {
const scenes = this._adapter.scenes;
const currentScene = scenes?.current?.();
if (currentScene) {
const flagData = this._scenePresetManager?._getSceneFlagData?.(currentScene);
autoApplyConfig = this._scenePresetManager?._getAutoApplyConfig?.(flagData) ?? autoApplyConfig;
}
} catch (err) {
console.warn('[ScryingPool] Failed to get auto-apply config for context:', err);
}
return {
...base,
hasUndo: this._undoSnapshot !== null,
hasRestore: this._spotlightSnapshot !== null,
presetCount,
hasPresets: presetCount > 0,
// Story 3.2: Auto-apply configuration
hasScene: !!this._adapter.scenes?.current?.(),
autoApplyEnabled: autoApplyConfig.enabled,
autoApplyPresetName: autoApplyConfig.presetName,
autoApplyPreDelay: autoApplyConfig.preDelay,
presets: this._scenePresetManager?.list?.() ?? [],
};
}
@@ -362,6 +405,8 @@ export class DirectorsBoard extends _AppBase {
case 'load-preset': this._onLoadPreset(); break;
case 'export-presets': this._onExportPresets(); break;
case 'import-presets': this._onImportPresets(); break;
// Story 3.2: Scene auto-apply panel toggle
case 'toggle-preset-panel': this._togglePresetPanel(); break;
}
};
this._focusinHandler = (e) => {
@@ -374,6 +419,43 @@ export class DirectorsBoard extends _AppBase {
root.addEventListener('click', this._clickHandler);
root.addEventListener('focusin', this._focusinHandler);
root.addEventListener('keydown', this._keydownHandler);
// Story 3.2: Append ScenePresetPanel to DOM and refresh
this._appendPresetPanel(root);
this._refreshPresetPanel();
}
/**
* Appends the ScenePresetPanel to the DirectorsBoard DOM.
* Story 3.2: Scene Auto-Apply & ConfirmationBar
* @param {HTMLElement} root - The DirectorsBoard root element.
* @private
*/
_appendPresetPanel(root) {
const panel = this._getPresetPanel();
if (!panel || !panel.element) return;
// Find where to insert the panel (after content, before footer)
const content = root.querySelector('.directors-board__content');
if (content) {
// Insert after content
content.after(panel.element);
} else {
// Fallback: prepend to root
root.prepend(panel.element);
}
}
/**
* Refreshes the ScenePresetPanel content.
* Story 3.2: Scene Auto-Apply & ConfirmationBar
* @private
*/
_refreshPresetPanel() {
const panel = this._getPresetPanel();
if (panel) {
panel._refresh?.();
}
}
/**
@@ -490,6 +572,17 @@ export class DirectorsBoard extends _AppBase {
}
}
/**
* Toggles the ScenePresetPanel visibility.
* Story 3.2: Scene Auto-Apply & ConfirmationBar
*/
_togglePresetPanel() {
const panel = this._getPresetPanel();
if (panel) {
panel.toggle();
}
}
/**
* Opens the PresetSaveDialog for saving the current visibility matrix as a preset.
*/
+2
View File
@@ -97,6 +97,8 @@ export class PresetImportDialog extends _AppBase {
previewItems: this._previewItems,
requiresConfirmation: this._requiresConfirmation,
selectedFileName: this._selectedFile?.name ?? null,
mergeLabel: 'Merge',
replaceLabel: 'Replace',
};
}
+176
View File
@@ -0,0 +1,176 @@
// @ts-nocheck
// Conditional base class — test environment lacks foundry globals.
// At module load time in tests, foundry is undefined → fallback class is used.
/** @private */
const _AppBase =
typeof foundry !== 'undefined' &&
foundry.applications?.api?.HandlebarsApplicationMixin &&
foundry.applications?.api?.ApplicationV2
? foundry.applications.api.HandlebarsApplicationMixin(
foundry.applications.api.ApplicationV2
)
: class _FallbackApp {
static DEFAULT_OPTIONS = {};
static PARTS = {};
get rendered() { return this._rendered ?? false; }
set rendered(v) { this._rendered = v; }
get element() { return this._element ?? null; }
set element(v) { this._element = v; }
async render() { this._rendered = true; }
async close() { this._rendered = false; }
async _prepareContext() { return {}; }
_onRender() {}
_onClose() {}
_onPosition() {}
};
/**
* Dialog for loading a scene preset.
* Extends ApplicationV2 via HandlebarsApplicationMixin.
*/
export class PresetLoadDialog extends _AppBase {
static DEFAULT_OPTIONS = {
id: 'scrying-pool-preset-load-dialog',
classes: ['scrying-pool', 'preset-load-dialog'],
window: { title: 'Load Scene Preset', resizable: false },
position: { width: 320, height: 'auto' },
};
static PARTS = {
dialog: {
template: 'modules/video-view-manager/templates/preset-load-dialog.hbs',
},
};
/**
* @param {import('../../core/ScenePresetManager.js').ScenePresetManager} scenePresetManager
* Injected ScenePresetManager for preset operations.
* @param {import('../../foundry/FoundryAdapter.js').FoundryAdapter} adapter
* Injected FoundryAdapter surface.
* @param {object} [options]
*/
constructor(scenePresetManager, adapter, options = {}) {
// Validate dependencies
if (!scenePresetManager || typeof scenePresetManager !== 'object') {
throw new TypeError('PresetLoadDialog: scenePresetManager argument is required and must be an object');
}
if (!adapter || typeof adapter !== 'object') {
throw new TypeError('PresetLoadDialog: adapter argument is required and must be an object');
}
super(options);
this._scenePresetManager = scenePresetManager;
this._adapter = adapter;
/** @type {Array<import('../../contracts/scene-preset.js').ScenePreset>} */
this._presets = [];
}
/**
* Prepares the template context with i18n labels and preset list.
* @returns {Promise<object>} Template context.
*/
async _prepareContext() {
const i18n = this._adapter.i18n;
// Get list of presets from manager
this._presets = this._scenePresetManager.list();
return {
presets: this._presets,
hasPresets: this._presets.length > 0,
loadLabel: i18n.localize('video-view-manager.presets.load.loadButton'),
cancelLabel: i18n.localize('video-view-manager.presets.load.cancelButton'),
title: i18n.localize('video-view-manager.presets.load.title'),
emptyMessage: i18n.localize('video-view-manager.presets.load.emptyMessage'),
};
}
/**
* Sets up event handlers after rendering.
* @param {HTMLElement} element - The dialog element.
*/
_onRender(element) {
// Set up load button handlers for each preset
const loadButtons = element.querySelectorAll('[data-action="load"]');
loadButtons.forEach((btn) => {
btn.addEventListener('click', () => {
const presetName = btn.dataset.presetName;
if (presetName) {
this._onLoad(presetName);
}
});
});
// Cancel button handler
const cancelBtn = element.querySelector('[data-action="cancel"]');
if (cancelBtn) {
cancelBtn.addEventListener('click', () => this._onCancel());
}
// Keyboard support
element.addEventListener('keydown', (event) => this._onKeydown(event));
}
/**
* Handles keyboard events for dialog navigation.
* @param {KeyboardEvent} event - The keyboard event.
*/
_onKeydown(event) {
// Escape key cancels the dialog
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
this._onCancel();
}
// Enter key on a load button triggers load
else if (event.key === 'Enter' && event.target.dataset?.action === 'load') {
event.preventDefault();
event.stopPropagation();
const presetName = event.target.dataset.presetName;
if (presetName) {
this._onLoad(presetName);
}
}
}
/**
* Handles loading a preset by name.
* @param {string} presetName - The preset name to load.
* @throws {TypeError} If preset name is invalid.
*/
async _onLoad(presetName) {
if (typeof presetName !== 'string' || presetName.length === 0) {
throw new TypeError('PresetLoadDialog._onLoad: presetName must be a non-empty string');
}
try {
await this._scenePresetManager.load(presetName);
// Show success notification
this._adapter.notifications.info(
this._adapter.i18n.localize('video-view-manager.presets.notifications.applied')
.replace('{name}', presetName)
);
// Close dialog
await this.close();
} catch (err) {
// Re-throw validation errors from ScenePresetManager
if (err instanceof TypeError) {
throw err;
}
// For other errors, log and re-throw
console.error('[ScryingPool] PresetLoadDialog: failed to load preset:', err);
throw err;
}
}
/**
* Handles cancel action — closes the dialog.
*/
_onCancel() {
this.close();
}
}
+192
View File
@@ -0,0 +1,192 @@
// @ts-nocheck
// Conditional base class — test environment lacks foundry globals.
// At module load time in tests, foundry is undefined → fallback class is used.
/** @private */
const _AppBase =
typeof foundry !== 'undefined' &&
foundry.applications?.api?.HandlebarsApplicationMixin &&
foundry.applications?.api?.ApplicationV2
? foundry.applications.api.HandlebarsApplicationMixin(
foundry.applications.api.ApplicationV2
)
: class _FallbackApp {
static DEFAULT_OPTIONS = {};
static PARTS = {};
get rendered() { return this._rendered ?? false; }
set rendered(v) { this._rendered = v; }
get element() { return this._element ?? null; }
set element(v) { this._element = v; }
async render() { this._rendered = true; }
async close() { this._rendered = false; }
async _prepareContext() { return {}; }
_onRender() {}
_onClose() {}
_onPosition() {}
};
/**
* Dialog for saving a scene preset.
* Extends ApplicationV2 via HandlebarsApplicationMixin.
*/
export class PresetSaveDialog extends _AppBase {
static DEFAULT_OPTIONS = {
id: 'scrying-pool-preset-save-dialog',
classes: ['scrying-pool', 'preset-save-dialog'],
window: { title: 'Save Scene Preset', resizable: false },
position: { width: 320, height: 'auto' },
};
static PARTS = {
dialog: {
template: 'modules/video-view-manager/templates/preset-save-dialog.hbs',
},
};
/**
* @param {import('../../core/ScenePresetManager.js').ScenePresetManager} scenePresetManager
* Injected ScenePresetManager for preset operations.
* @param {import('../../foundry/FoundryAdapter.js').FoundryAdapter} adapter
* Injected FoundryAdapter surface.
* @param {object} [options]
*/
constructor(scenePresetManager, adapter, options = {}) {
// Validate dependencies
if (!scenePresetManager || typeof scenePresetManager !== 'object') {
throw new TypeError('PresetSaveDialog: scenePresetManager argument is required and must be an object');
}
if (!adapter || typeof adapter !== 'object') {
throw new TypeError('PresetSaveDialog: adapter argument is required and must be an object');
}
super(options);
this._scenePresetManager = scenePresetManager;
this._adapter = adapter;
/** @type {HTMLElement|null} */
this._nameInput = null;
}
/**
* Prepares the template context with i18n labels and default values.
* @returns {Promise<object>} Template context.
*/
async _prepareContext() {
const i18n = this._adapter.i18n;
return {
defaultName: '',
saveLabel: i18n.localize('video-view-manager.presets.save.saveButton'),
cancelLabel: i18n.localize('video-view-manager.presets.save.cancelButton'),
title: i18n.localize('video-view-manager.presets.save.title'),
nameLabel: i18n.localize('video-view-manager.presets.save.nameLabel'),
namePlaceholder: i18n.localize('video-view-manager.presets.save.namePlaceholder'),
};
}
/**
* Sets up event handlers after rendering.
* @param {HTMLElement} element - The dialog element.
*/
_onRender(element) {
// Cache the name input
this._nameInput = element.querySelector('[name="presetName"]');
// Focus the name input
if (this._nameInput) {
this._nameInput.focus();
}
// Form submit handler
const form = element.querySelector('form');
if (form) {
form.addEventListener('submit', (event) => this._onSubmit(event));
}
// Cancel button handler
const cancelBtn = element.querySelector('[data-action="cancel"]');
if (cancelBtn) {
cancelBtn.addEventListener('click', () => this._onCancel());
}
// Keyboard support
element.addEventListener('keydown', (event) => this._onKeydown(event));
}
/**
* Handles keyboard events for dialog navigation.
* @param {KeyboardEvent} event - The keyboard event.
*/
_onKeydown(event) {
// Enter key on input field triggers save
if (event.key === 'Enter' && event.target.tagName === 'INPUT') {
event.preventDefault();
event.stopPropagation();
const submitEvent = { preventDefault: () => {}, stopPropagation: () => {}, target: event.target.form };
this._onSubmit(submitEvent);
}
// Escape key cancels the dialog
else if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
this._onCancel();
}
}
/**
* Handles form submission to save a preset.
* @param {Event} event - The form submit event.
* @throws {TypeError} If name is invalid.
*/
async _onSubmit(event) {
if (!event) {
throw new TypeError('PresetSaveDialog._onSubmit: event is required');
}
event.preventDefault();
event.stopPropagation();
// Get preset name from form
const form = event.target;
const nameInput = form.querySelector('[name="presetName"]');
if (!nameInput) {
throw new TypeError('PresetSaveDialog._onSubmit: preset name input not found');
}
const name = nameInput.value?.trim();
// Validate name
if (typeof name !== 'string' || name.length === 0) {
throw new TypeError('PresetSaveDialog._onSubmit: preset name must be a non-empty string');
}
// Save preset via manager
try {
await this._scenePresetManager.save(name);
// Show success notification
this._adapter.notifications.info(
this._adapter.i18n.localize('video-view-manager.presets.notifications.saved')
.replace('{name}', name)
);
// Close dialog
await this.close();
} catch (err) {
// Re-throw validation errors from ScenePresetManager
if (err instanceof TypeError) {
throw err;
}
// For other errors, log and re-throw
console.error('[ScryingPool] PresetSaveDialog: failed to save preset:', err);
throw err;
}
}
/**
* Handles cancel action — closes the dialog.
*/
_onCancel() {
this.close();
}
}
+450
View File
@@ -0,0 +1,450 @@
/**
* ScenePresetPanel — Per-scene auto-apply configuration UI.
*
* Owns: Toggle, preset selector, pre-delay slider for per-scene auto-apply settings.
* Embedded in DirectorsBoard as collapsible drawer/tab.
*
* Import rule: may import from src/core/, src/contracts/, src/utils/ ONLY.
* Constructors are side-effect free — call init() from DirectorsBoard.
*
* Story 3.2: Scene Auto-Apply & ConfirmationBar
*
* @module ui/gm/ScenePresetPanel
*/
/**
* Per-scene auto-apply configuration panel.
* Allows GM to configure which preset (if any) auto-applies when a scene activates.
*/
export class ScenePresetPanel {
/**
* @param {import('../../foundry/FoundryAdapter.js').FoundryAdapter} adapter
* Injected FoundryAdapter surface.
* @param {import('../../core/ScenePresetManager.js').ScenePresetManager} scenePresetManager
* Injected ScenePresetManager for preset operations.
*/
constructor(adapter, scenePresetManager) {
this._adapter = adapter;
this._scenePresetManager = scenePresetManager;
// State
/** @type {HTMLElement|null} */
this._element = null;
/** @type {boolean} */
this._isOpen = false;
/** @type {object|null} */
this._currentScene = null;
/** @type {Function|null} */
this._clickHandler = null;
/** @type {Function|null} */
this._changeHandler = null;
/** @type {Function|null} */
this._inputHandler = null;
// Constants
/** @type {number} Maximum pre-delay in milliseconds */
this._MAX_PREDELAY = 5000;
/** @type {number} Minimum pre-delay in milliseconds */
this._MIN_PREDELAY = 0;
}
/**
* Initializes the panel. Creates DOM element and sets up event listeners.
* Called from DirectorsBoard constructor.
*/
init() {
this._createElement();
this._setupEventListeners();
this._refresh();
}
/**
* Creates the panel DOM element.
* @private
*/
_createElement() {
this._element = document.createElement('div');
this._element.className = 'directors-board__preset-panel';
this._element.setAttribute('role', 'region');
this._element.setAttribute('aria-label', this._adapter.i18n.localize('video-view-manager.scenePresetPanel.title'));
this._element.setAttribute('aria-expanded', 'false');
// Initially hidden
this._element.style.display = 'none';
}
/**
* Returns the panel DOM element.
* @returns {HTMLElement|null}
*/
get element() {
return this._element;
}
/**
* Toggles the panel visibility.
*/
toggle() {
if (this._isOpen) {
this.close();
} else {
this.open();
}
}
/**
* Opens the panel and refreshes its content.
*/
open() {
if (!this._element) return;
this._isOpen = true;
this._element.style.display = 'block';
this._element.setAttribute('aria-expanded', 'true');
this._refresh();
}
/**
* Closes the panel.
*/
close() {
if (!this._element) return;
this._isOpen = false;
this._element.style.display = 'none';
this._element.setAttribute('aria-expanded', 'false');
}
/**
* Refreshes the panel content with current scene data.
* @private
*/
async _refresh() {
if (!this._element) return;
const currentScene = this._adapter.scenes.current?.();
if (!currentScene) {
this._element.innerHTML = this._buildEmptyHtml();
return;
}
this._currentScene = currentScene;
// Get current auto-apply config
const autoApplyConfig = this._scenePresetManager._getAutoApplyConfig(
this._scenePresetManager._getSceneFlagData(currentScene)
);
// Get available presets
const presets = this._scenePresetManager.list();
this._element.innerHTML = this._buildHtml({
enabled: autoApplyConfig.enabled,
presetName: autoApplyConfig.presetName,
preDelay: autoApplyConfig.preDelay,
presets,
});
// Update toggle state
const toggle = this._element.querySelector('[data-action="toggle-auto-apply"]');
if (toggle) {
toggle.setAttribute('aria-pressed', String(autoApplyConfig.enabled));
}
}
/**
* Builds the HTML for the panel when no scene is active.
* @returns {string}
* @private
*/
_buildEmptyHtml() {
const message = this._adapter.i18n.localize('video-view-manager.scenePresetPanel.noScene');
return `
<div class="directors-board__preset-panel-header">
<h3 class="directors-board__preset-panel-title">
${this._escapeHtml(this._adapter.i18n.localize('video-view-manager.scenePresetPanel.title'))}
</h3>
</div>
<p class="directors-board__preset-panel-message">${this._escapeHtml(message)}</p>
`;
}
/**
* Builds the HTML for the panel.
* @param {object} context - Panel context.
* @param {boolean} context.enabled - Whether auto-apply is enabled.
* @param {string|null} context.presetName - Selected preset name.
* @param {number} context.preDelay - Pre-delay in milliseconds.
* @param {Array<object>} context.presets - Available presets.
* @returns {string}
* @private
*/
_buildHtml(context) {
const { enabled, presetName, preDelay, presets } = context;
const localize = this._adapter.i18n.localize;
// Build preset options
const presetOptions = presets
.map(preset => `
<option value="${this._escapeHtml(preset.name)}" ${preset.name === presetName ? 'selected' : ''}>
${this._escapeHtml(preset.name)}
</option>
`)
.join('');
// Add default option
const defaultOption = `
<option value="" ${!presetName ? 'selected' : ''}>
${this._escapeHtml(localize('video-view-manager.scenePresetPanel.selectPreset'))}
</option>
`;
return `
<div class="directors-board__preset-panel-header">
<h3 class="directors-board__preset-panel-title">
${this._escapeHtml(localize('video-view-manager.scenePresetPanel.title'))}
</h3>
</div>
<div class="directors-board__preset-panel-body">
<div class="directors-board__preset-panel-row">
<label class="directors-board__preset-panel-label">
<input type="checkbox"
class="directors-board__preset-panel-toggle"
data-action="toggle-auto-apply"
${enabled ? 'checked' : ''}
role="switch"
aria-label="${this._escapeHtml(localize('video-view-manager.scenePresetPanel.enableAutoApply'))}">
${this._escapeHtml(localize('video-view-manager.scenePresetPanel.enableAutoApply'))}
</label>
</div>
<div class="directors-board__preset-panel-row">
<label class="directors-board__preset-panel-label">
${this._escapeHtml(localize('video-view-manager.scenePresetPanel.preset'))}
<select class="directors-board__preset-panel-select"
data-action="select-preset"
${!presets.length ? 'disabled' : ''}
aria-label="${this._escapeHtml(localize('video-view-manager.scenePresetPanel.selectPreset'))}">
${defaultOption}
${presetOptions}
</select>
</label>
</div>
<div class="directors-board__preset-panel-row">
<label class="directors-board__preset-panel-label">
${this._escapeHtml(localize('video-view-manager.scenePresetPanel.preDelay'))}
<span class="directors-board__preset-panel-delay-value">${preDelay}ms</span>
<input type="range"
class="directors-board__preset-panel-slider"
data-action="change-delay"
min="${this._MIN_PREDELAY}"
max="${this._MAX_PREDELAY}"
value="${preDelay}"
step="100"
aria-label="${this._escapeHtml(localize('video-view-manager.scenePresetPanel.preDelay'))}"
aria-valuemin="${this._MIN_PREDELAY}"
aria-valuemax="${this._MAX_PREDELAY}"
aria-valuenow="${preDelay}">
</label>
</div>
<div class="directors-board__preset-panel-row directors-board__preset-panel-row--hint">
<span>${this._escapeHtml(localize('video-view-manager.scenePresetPanel.globalSettingsHint'))}</span>
</div>
</div>
`;
}
/**
* Sets up event listeners for the panel.
* @private
*/
_setupEventListeners() {
if (!this._element) return;
// Store bound handlers
this._clickHandler = (event) => {
const target = event.target.closest?.('[data-action]');
if (!target) return;
const action = target.getAttribute('data-action');
switch (action) {
case 'toggle-auto-apply':
this._onToggleAutoApply(target);
break;
case 'select-preset':
this._onPresetSelected(target);
break;
case 'change-delay':
// Deliberate fallthrough - handled by input handler
break;
}
};
this._inputHandler = (event) => {
const target = event.target.closest?.('[data-action="change-delay"]');
if (!target) return;
this._onDelayChanged(target);
};
this._element.addEventListener('click', this._clickHandler);
this._element.addEventListener('input', this._inputHandler);
}
/**
* Removes event listeners.
* @private
*/
_removeEventListeners() {
if (!this._element) return;
if (this._clickHandler) {
this._element.removeEventListener('click', this._clickHandler);
this._clickHandler = null;
}
if (this._inputHandler) {
this._element.removeEventListener('input', this._inputHandler);
this._inputHandler = null;
}
}
/**
* Handles the auto-apply toggle change.
* @param {HTMLElement} target - The toggle element.
* @private
*/
async _onToggleAutoApply(target) {
const isChecked = target instanceof HTMLInputElement ? target.checked : false;
const currentScene = this._adapter.scenes.current?.();
if (!currentScene) return;
try {
// Get current config
const flagData = this._scenePresetManager._getSceneFlagData(currentScene);
const autoApplyConfig = this._scenePresetManager._getAutoApplyConfig(flagData);
// Update and save
await this._scenePresetManager.configureAutoApply(currentScene, {
enabled: isChecked,
presetName: autoApplyConfig.presetName,
preDelay: autoApplyConfig.preDelay,
});
// Update UI state
target.setAttribute('aria-pressed', String(isChecked));
// Notify
this._adapter.notifications.info(
isChecked
? this._adapter.i18n.localize('video-view-manager.scenePresetPanel.notifications.enabled')
: this._adapter.i18n.localize('video-view-manager.scenePresetPanel.notifications.disabled')
);
} catch (err) {
console.error('[ScryingPool] ScenePresetPanel: failed to toggle auto-apply', err);
// Revert toggle state
target.checked = !isChecked;
}
}
/**
* Handles preset selection change.
* @param {HTMLElement} target - The select element.
* @private
*/
async _onPresetSelected(target) {
const presetName = target.value;
const currentScene = this._adapter.scenes.current?.();
if (!currentScene) return;
try {
// Get current config
const flagData = this._scenePresetManager._getSceneFlagData(currentScene);
const autoApplyConfig = this._scenePresetManager._getAutoApplyConfig(flagData);
// Update and save
await this._scenePresetManager.configureAutoApply(currentScene, {
enabled: autoApplyConfig.enabled,
presetName: presetName || null,
preDelay: autoApplyConfig.preDelay,
});
// Notify
if (presetName) {
this._adapter.notifications.info(
this._adapter.i18n.localize('video-view-manager.scenePresetPanel.notifications.presetSelected')
.replace('{name}', presetName)
);
}
} catch (err) {
console.error('[ScryingPool] ScenePresetPanel: failed to select preset', err);
}
}
/**
* Handles pre-delay slider change.
* @param {HTMLElement} target - The slider element.
* @private
*/
async _onDelayChanged(target) {
const preDelay = parseInt(target.value, 10) || 0;
const currentScene = this._adapter.scenes.current?.();
if (!currentScene) return;
// Update displayed value
const valueDisplay = this._element?.querySelector('.directors-board__preset-panel-delay-value');
if (valueDisplay) {
valueDisplay.textContent = `${preDelay}ms`;
valueDisplay.setAttribute('aria-valuenow', String(preDelay));
}
try {
// Get current config
const flagData = this._scenePresetManager._getSceneFlagData(currentScene);
const autoApplyConfig = this._scenePresetManager._getAutoApplyConfig(flagData);
// Update and save
await this._scenePresetManager.configureAutoApply(currentScene, {
enabled: autoApplyConfig.enabled,
presetName: autoApplyConfig.presetName,
preDelay,
});
} catch (err) {
console.error('[ScryingPool] ScenePresetPanel: failed to change delay', err);
}
}
/**
* Cleans up the panel.
*/
teardown() {
this._removeEventListeners();
this.close();
if (this._element && this._element.parentNode) {
this._element.parentNode.removeChild(this._element);
}
this._element = null;
this._isOpen = false;
this._currentScene = null;
}
/**
* Escapes HTML special characters to prevent XSS.
* @param {string} str - String to escape.
* @returns {string} Escaped string.
* @private
*/
_escapeHtml(str) {
if (!str || typeof str !== 'string') {
return '';
}
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
}
}
+536
View File
@@ -0,0 +1,536 @@
// @ts-nocheck
/**
* Canonical player-state → display label map.
* `active` maps to null — no label is shown when the feed is live.
* @type {Readonly<Record<string, string|null>>}
*/
const PLAYER_STATE_LABELS = Object.freeze({
hidden: 'Hidden from table',
'self-muted': 'Camera paused',
offline: 'Not connected',
'cam-lost': 'Camera unavailable',
reconnecting: 'Rejoining view',
'never-connected': 'Not yet connected',
ghost: 'Leaving',
active: null,
});
// ---------------------------------------------------------------------------
// VisibilityDetailsPanel
// ---------------------------------------------------------------------------
/**
* Native <dialog>-based panel showing full detail about a player's camera state.
* Not exported — internal to the player/ layer.
*/
class VisibilityDetailsPanel {
/**
* @param {object|null} controller - ScryingPoolController (may be null if unavailable)
*/
constructor(controller) {
this._controller = controller;
/** @type {HTMLDialogElement|null} */
this._dialog = null;
/** @type {HTMLElement|null} */
this._triggerEl = null;
}
/**
* Creates and shows the details panel as a modal dialog.
* @param {string} state - Current player visibility state
* @param {object|null} actor - User object with .name property (or null)
* @param {string|null} reason - Reason for state change (e.g., "Hidden by: GM Name")
* @param {HTMLElement} triggerEl - Element to return focus to on close
*/
show(state, actor, reason, triggerEl) {
if (this._dialog) return; // already open
this._triggerEl = triggerEl;
const stateLabel = PLAYER_STATE_LABELS[state] ?? state;
const isHidden = state === 'hidden';
const isDataStale = !this._controller;
const dialog = document.createElement('dialog');
dialog.className = 'sp-visibility-details-panel';
dialog.setAttribute('aria-modal', 'true');
// --- State explanation ---
const stateEl = document.createElement('p');
stateEl.className = 'sp-visibility-details-panel__state';
stateEl.textContent = stateLabel;
dialog.appendChild(stateEl);
// --- Reason/actor display ---
if (reason) {
const reasonEl = document.createElement('p');
reasonEl.className = 'sp-visibility-details-panel__reason';
reasonEl.textContent = reason;
dialog.appendChild(reasonEl);
} else if (actor?.name) {
const actorEl = document.createElement('p');
actorEl.className = 'sp-visibility-details-panel__actor';
actorEl.textContent = `State changed by: ${actor.name}`;
dialog.appendChild(actorEl);
}
// --- Audience section ---
if (isHidden) {
const reassuranceEl = document.createElement('p');
reassuranceEl.className = 'sp-visibility-details-panel__reassurance';
reassuranceEl.textContent = 'Other players cannot see your feed';
dialog.appendChild(reassuranceEl);
} else {
const audioEl = document.createElement('p');
audioEl.className = 'sp-visibility-details-panel__audio-note';
audioEl.textContent = 'Your audio is active for all participants.';
dialog.appendChild(audioEl);
}
// --- Stale data indicator ---
if (isDataStale) {
const staleEl = document.createElement('p');
staleEl.className = 'sp-visibility-details-panel__stale';
staleEl.textContent = 'Data may be outdated';
dialog.appendChild(staleEl);
}
// --- Close button ---
const closeBtn = document.createElement('button');
closeBtn.className = 'sp-visibility-details-panel__close';
closeBtn.setAttribute('data-action', 'close-details');
closeBtn.textContent = 'Close';
closeBtn.addEventListener('click', () => dialog.close());
dialog.appendChild(closeBtn);
// --- Dismiss handlers ---
// Backdrop click: only when the click target IS the dialog backdrop
dialog.addEventListener('click', e => {
if (e.target === dialog) dialog.close();
});
// Clean up on close (native Esc + programmatic close)
dialog.addEventListener('close', () => this._onClose());
document.body.appendChild(dialog);
this._dialog = dialog;
if (typeof dialog.showModal === 'function') {
dialog.showModal();
}
}
/**
* Removes the dialog from the DOM and returns focus to the trigger element.
*/
_onClose() {
if (this._dialog) {
this._dialog.remove();
this._dialog = null;
}
this._triggerEl?.focus();
}
}
// ---------------------------------------------------------------------------
// FirstEncounterPanel
// ---------------------------------------------------------------------------
/**
* Non-modal explanatory panel shown the first time a player's badge updates.
* Collapses after a 10s idle timer into a persistent chip.
* Not exported — internal to the player/ layer.
*/
class FirstEncounterPanel {
/**
* @param {Function} setEncounteredFn - async fn that sets the firstBadgeEncounter flag
* @param {Function} openDetailsFn - fn() that opens VisibilityDetailsPanel
*/
#collapseTimer = null;
constructor(setEncounteredFn, openDetailsFn) {
this._setEncountered = setEncounteredFn;
this._openDetails = openDetailsFn;
/** @type {HTMLElement|null} */
this._panel = null;
/** @type {HTMLElement|null} */
this._chip = null;
/** @type {number} */
this._remainingMs = 10_000;
/** @type {number|null} */
this._timerStartedAt = null;
}
/**
* Creates and shows the explanatory panel.
* anchorEl is accepted for API consistency but positioning is handled via CSS.
* @param {HTMLElement} _anchorEl
*/
// eslint-disable-next-line no-unused-vars
show(_anchorEl) {
if (this._panel) return; // already shown
const panel = document.createElement('div');
panel.className = 'sp-first-encounter-panel';
panel.setAttribute('role', 'dialog');
panel.setAttribute('aria-modal', 'false');
panel.setAttribute('aria-label', 'Camera visibility explanation');
const title = document.createElement('p');
title.className = 'sp-first-encounter-panel__title';
title.textContent = 'Your camera visibility changed.';
panel.appendChild(title);
const body = document.createElement('p');
body.className = 'sp-first-encounter-panel__body';
body.textContent = 'Audio continues normally.';
panel.appendChild(body);
const gotItBtn = document.createElement('button');
gotItBtn.className = 'sp-first-encounter-panel__got-it';
gotItBtn.setAttribute('data-action', 'got-it');
gotItBtn.textContent = 'Got it';
gotItBtn.addEventListener('click', async () => {
await this._onGotIt();
});
panel.appendChild(gotItBtn);
// Timer pause/resume on hover/focus
panel.addEventListener('mouseenter', () => this._pauseTimer());
panel.addEventListener('mouseleave', () => this._resumeTimer());
panel.addEventListener('focusin', () => this._pauseTimer());
panel.addEventListener('focusout', () => this._resumeTimer());
document.body.appendChild(panel);
this._panel = panel;
this._startTimer();
}
/**
* Starts the collapse timer, recording when it started.
*/
_startTimer() {
this._timerStartedAt = Date.now();
this.#collapseTimer = setTimeout(() => this._collapse(), this._remainingMs);
}
/**
* Pauses the collapse timer, storing remaining time.
*/
_pauseTimer() {
if (this.#collapseTimer === null) return;
const elapsed = Date.now() - (this._timerStartedAt ?? Date.now());
this._remainingMs = Math.max(0, this._remainingMs - elapsed);
clearTimeout(this.#collapseTimer);
this.#collapseTimer = null;
}
/**
* Resumes the collapse timer with remaining time.
*/
_resumeTimer() {
if (this.#collapseTimer !== null) return; // already running
this._timerStartedAt = Date.now();
this.#collapseTimer = setTimeout(() => this._collapse(), this._remainingMs);
}
/**
* "Got it" handler — clears timer, sets flag, dismisses panel.
* Uses async to ensure flag is persisted before dismissing.
*/
async _onGotIt() {
clearTimeout(this.#collapseTimer); // ghost prevention
this.#collapseTimer = null;
try {
await this._setEncountered();
} catch (err) {
console.error('[ScryingPool] Failed to set firstBadgeEncounter flag:', err);
}
this._dismiss();
}
/**
* Collapses the panel (via CSS class + 300ms timer matching CSS ease-out) and replaces it with a chip.
*/
_collapse() {
// Clear any pending timer from _startTimer before creating new one
clearTimeout(this.#collapseTimer);
this.#collapseTimer = null;
if (!this._panel) return;
const panel = this._panel;
// Apply collapse animation via CSS class
panel.classList.add('sp-first-encounter-panel--collapsing');
const activeEl = document.activeElement;
const wasInsidePanel = activeEl ? panel.contains(activeEl) : false;
// Replace after CSS transition duration (300ms ease-out per AC)
this.#collapseTimer = setTimeout(() => {
this.#collapseTimer = null;
if (!this._panel) return;
this._panel.remove();
this._panel = null;
const chip = this._createChip();
document.body.appendChild(chip);
this._chip = chip;
if (wasInsidePanel) {
chip.focus();
}
}, 300);
}
/**
* Creates the collapsed chip element.
* @returns {HTMLElement}
*/
_createChip() {
const chip = document.createElement('div');
chip.className = 'sp-visibility-chip';
chip.setAttribute('role', 'button');
chip.setAttribute('tabindex', '0');
chip.setAttribute('aria-label', 'Camera visibility — click for details');
chip.addEventListener('click', () => this._openDetails());
chip.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this._openDetails();
}
});
return chip;
}
/**
* Removes the panel from the DOM without triggering collapse animation.
*/
_dismiss() {
if (this._panel) {
this._panel.remove();
this._panel = null;
}
}
/**
* Must be called on teardown — clears timer to prevent ghost timers.
*/
_onClose() {
clearTimeout(this.#collapseTimer); // ghost prevention
this.#collapseTimer = null;
}
/**
* Removes both panel and chip from DOM.
*/
cleanup() {
this._onClose();
if (this._panel) {
this._panel.remove();
this._panel = null;
}
if (this._chip) {
this._chip.remove();
this._chip = null;
}
}
}
// ---------------------------------------------------------------------------
// VisibilityBadge
// ---------------------------------------------------------------------------
/**
* Player-facing camera visibility badge.
* Mounted on the player's own AV tile via AVTileAdapter.
* Shows the current visibility state and triggers first-encounter education UI.
*
* @class
*/
export class VisibilityBadge {
/**
* @param {object} stateStore - StateStore instance
* @param {object|null} controller - ScryingPoolController (may be null)
* @param {object} avTileAdapter - AVTileAdapter instance (shared with RoleRenderer)
* @param {object} adapter - FoundryAdapter instance
*/
constructor(stateStore, controller, avTileAdapter, adapter) {
this._stateStore = stateStore;
this._controller = controller;
this._avTileAdapter = avTileAdapter;
this._adapter = adapter;
/** @type {string|null} */
this._currentUserId = null;
/** @type {string} */
this._currentState = 'active';
/** @type {object|null} */
this._currentStateActor = null;
/** @type {string|null} */
this._currentStateReason = null;
/** @type {HTMLElement|null} */
this._badgeEl = null;
/** @type {FirstEncounterPanel|null} */
this._firstEncounterPanel = null;
/** @type {Function|null} */
this._stateChangedHandler = null;
}
/**
* Initialises the badge — resolves the current user, subscribes to state changes,
* and mounts the initial badge element.
* No-ops if no current user can be resolved.
*/
init() {
const currentUser = this._adapter.users.current?.();
if (!currentUser?.id) {
this._currentUserId = null;
return;
}
this._currentUserId = currentUser.id;
// Subscribe to state changes
this._stateChangedHandler = data => this._onStateChanged(data);
Hooks.on('scrying-pool:stateChanged', this._stateChangedHandler);
// Mount initial badge
const initialState = this._stateStore.getState?.(this._currentUserId) ?? 'active';
this._currentState = initialState;
this._badgeEl = this._createBadgeElement(initialState);
this._avTileAdapter.mount(this._currentUserId, this._badgeEl);
// Re-mount badge if Foundry re-renders the AV tile
this._avTileAdapter.onTileRerender(this._currentUserId, () => {
this._mountBadge(this._currentState);
});
}
/**
* Creates the badge DOM element for the given state.
* @param {string} state
* @returns {HTMLElement}
*/
_createBadgeElement(state) {
const stateLabel = PLAYER_STATE_LABELS[state] ?? null;
const ariaLabel = `Camera visibility: ${stateLabel ?? 'Active'}`;
const el = document.createElement('div');
el.className = 'sp-visibility-badge';
el.dataset.spRole = 'visibility-badge';
el.setAttribute('role', 'status');
el.setAttribute('aria-live', 'polite');
el.setAttribute('aria-label', ariaLabel);
const labelSpan = document.createElement('span');
labelSpan.className = 'sp-visibility-badge__label';
labelSpan.textContent = stateLabel ?? '';
el.appendChild(labelSpan);
el.addEventListener('click', () => this._openDetailsPanel(el));
return el;
}
/**
* Re-mounts the badge at the given state (idempotent via AVTileAdapter).
* @param {string} state
*/
_mountBadge(state) {
if (!this._currentUserId) return;
this._currentState = state;
const stateLabel = PLAYER_STATE_LABELS[state] ?? null;
const ariaLabel = `Camera visibility: ${stateLabel ?? 'Active'}`;
if (!this._badgeEl) {
this._badgeEl = this._createBadgeElement(state);
} else {
this._badgeEl.setAttribute('aria-label', ariaLabel);
const labelSpan = this._badgeEl.querySelector('.sp-visibility-badge__label');
if (labelSpan) {
labelSpan.textContent = stateLabel ?? '';
}
}
this._avTileAdapter.mount(this._currentUserId, this._badgeEl);
}
/**
* Handles a `scrying-pool:stateChanged` hook event.
* Guards to only process events for the current user.
* @param {{ userId: string, state: string, actor?: object, reason?: string }} data
*/
_onStateChanged(data) {
// Validate data shape
if (!data || typeof data !== 'object' || !data.userId || !data.state) {
return;
}
if (data.userId !== this._currentUserId) return;
this._currentState = data.state;
this._currentStateActor = data.actor;
this._currentStateReason = data.reason;
this._mountBadge(data.state);
// Trigger first-encounter panel if not yet shown
if (!this._getFirstBadgeEncountered() && !this._firstEncounterPanel) {
this._firstEncounterPanel = new FirstEncounterPanel(
() => this._setFirstBadgeEncountered(),
() => this._openDetailsPanel(this._badgeEl)
);
this._firstEncounterPanel.show(this._badgeEl);
}
}
/**
* Opens the VisibilityDetailsPanel for the current state.
* @param {HTMLElement|null} triggerEl - Element to return focus to on close
*/
_openDetailsPanel(triggerEl) {
if (!triggerEl) return;
// Use stored actor/reason from state change, or fall back to current user
const actor = this._currentStateActor ?? this._adapter.users.current?.() ?? null;
const panel = new VisibilityDetailsPanel(this._controller);
panel.show(this._currentState, actor, this._currentStateReason, triggerEl);
}
/**
* Returns whether the player has already seen the first-encounter panel.
* @returns {boolean}
*/
_getFirstBadgeEncountered() {
return this._adapter.users.current()?.getFlag('video-view-manager', 'firstBadgeEncounter') ?? false;
}
/**
* Persists the firstBadgeEncounter flag via Foundry user flags.
* @returns {Promise<void>}
*/
async _setFirstBadgeEncountered() {
await this._adapter.users.current()?.setFlag('video-view-manager', 'firstBadgeEncounter', true);
}
/**
* Tears down badge subscriptions and releases AVTileAdapter observers.
* Cleans up DOM elements and event listeners.
*/
teardown() {
if (this._stateChangedHandler) {
Hooks.off('scrying-pool:stateChanged', this._stateChangedHandler);
this._stateChangedHandler = null;
}
if (this._firstEncounterPanel) {
this._firstEncounterPanel.cleanup();
this._firstEncounterPanel = null;
}
if (this._badgeEl) {
this._badgeEl.remove();
this._badgeEl = null;
}
this._avTileAdapter.disconnect();
this._currentUserId = null;
this._currentState = 'active';
}
}
+162
View File
@@ -0,0 +1,162 @@
/**
* StripOverlayLayer — Single overlay container for all positioned overlays.
*
* Owns: DOM element with position: absolute; inset: 0; pointer-events: none; overflow: visible
* Children restore pointer-events: auto
* Used by: ActionPopover (Story 1.5), ConfirmationBar (Story 3.2)
*
* Import rule: may import from src/core/, src/contracts/, src/utils/ ONLY.
* Constructors are side-effect free — call init() from module.js Hooks.once('ready').
*
* Story 1.5: Original creation for ActionPopover support
* Story 3.2: Extended to support ConfirmationBar
*
* @module ui/shared/StripOverlayLayer
*/
/**
* Single overlay container for all positioned overlays.
* Provides a common parent element with pointer-events: none that allows
* children to restore pointer-events: auto for specific interactive areas.
*/
export class StripOverlayLayer {
/**
* @param {import('../../foundry/FoundryAdapter.js').FoundryAdapter} adapter
* Injected FoundryAdapter surface.
*/
constructor(adapter) {
this._adapter = adapter;
/** @type {HTMLElement|null} The overlay container element */
this._element = null;
/** @type {Map<string, HTMLElement>} Track rendered overlays by key */
this._overlays = new Map();
}
/**
* Initializes the StripOverlayLayer by creating the DOM element.
* Side-effect: Creates and appends the overlay container to the ScryingPoolStrip.
*/
init() {
// Create overlay container element
this._element = document.createElement('div');
this._element.className = 'sp-strip__overlay-layer';
this._element.setAttribute('aria-hidden', 'true');
// Critical styles per UX-DR6
this._element.style.cssText = `
position: absolute;
inset: 0;
pointer-events: none;
overflow: visible;
`;
// Try to find the ScryingPoolStrip element to append to
// The strip is created in Story 1.5 as a floating ApplicationV2 window
const stripElement = document.querySelector?.('.scrying-pool__roster-strip');
if (stripElement) {
stripElement.appendChild(this._element);
} else {
// Fallback: if strip not found, append to body (shouldn't happen in normal flow)
console.warn('[ScryingPool] StripOverlayLayer: ScryingPoolStrip not found, appending to body');
document.body.appendChild(this._element);
}
}
/**
* Returns the overlay container element.
* @returns {HTMLElement|null} The overlay element.
*/
get element() {
return this._element;
}
/**
* Renders content into the overlay layer.
* The content will have pointer-events: auto to allow interaction.
*
* @param {string|HTMLElement} content - HTML string or DOM element to render.
* @param {string} [key] - Optional key to track this overlay for replacement.
* @returns {HTMLElement|null} The rendered element, or null if failed.
*/
render(content, key = null) {
if (!this._element) {
console.warn('[ScryingPool] StripOverlayLayer: Cannot render, element not initialized');
return null;
}
// Remove previous overlay if key is provided
if (key && this._overlays.has(key)) {
const previous = this._overlays.get(key);
if (previous && previous.parentNode) {
previous.parentNode.removeChild(previous);
}
this._overlays.delete(key);
}
// Create container for the content
const container = document.createElement('div');
container.style.pointerEvents = 'auto';
// Set content
if (typeof content === 'string') {
container.innerHTML = content;
} else if (content instanceof HTMLElement) {
container.appendChild(content);
} else {
console.warn('[ScryingPool] StripOverlayLayer: Invalid content type');
return null;
}
// Append to overlay layer
this._element.appendChild(container);
// Track by key if provided
if (key) {
this._overlays.set(key, container);
}
return container;
}
/**
* Removes an overlay by key.
*
* @param {string} key - The key of the overlay to remove.
*/
remove(key) {
if (!this._overlays.has(key)) {
return;
}
const overlay = this._overlays.get(key);
if (overlay && overlay.parentNode) {
overlay.parentNode.removeChild(overlay);
}
this._overlays.delete(key);
}
/**
* Removes all overlays from the layer.
*/
clearAll() {
for (const [, overlay] of this._overlays) {
if (overlay && overlay.parentNode) {
overlay.parentNode.removeChild(overlay);
}
}
this._overlays.clear();
}
/**
* Cleans up the StripOverlayLayer by removing the DOM element.
* Safe to call multiple times.
*/
teardown() {
this.clearAll();
if (this._element && this._element.parentNode) {
this._element.parentNode.removeChild(this._element);
}
this._element = null;
}
}