From 5dc9b3b8d4b36312a111d7680bd86e22cddf21cb Mon Sep 17 00:00:00 2001 From: LeRatierBretonnier Date: Sun, 24 May 2026 23:13:45 +0200 Subject: [PATCH] Module cleanup and tests --- lang/en.json | 82 +- module.js | 46 +- module.json | 4 +- package-lock.json | 4 +- package.json | 2 +- src/contracts/privacy-settings.js | 15 +- src/contracts/scene-preset.js | 2 +- src/core/PlayerPrivacyManager.js | 17 +- src/core/ScenePresetManager.js | 16 +- src/core/ScryingPoolController.js | 20 +- src/core/StateStore.js | 9 + src/foundry/FoundryAdapter.js | 4 +- src/notifications/NotificationBus.js | 8 +- src/ui/gm/ConfirmationBar.js | 8 +- src/ui/gm/DirectorsBoard.js | 95 +- src/ui/gm/GMPlayerPrivacySelector.js | 4 +- src/ui/gm/PresetExportDialog.js | 2 +- src/ui/gm/PresetImportDialog.js | 2 +- src/ui/gm/PresetLoadDialog.js | 12 +- src/ui/gm/PresetSaveDialog.js | 17 +- src/ui/gm/ScenePresetPanel.js | 30 +- src/ui/gm/ScryingPoolStrip.js | 61 +- src/ui/player/PlayerPrivacyPanel.js | 66 +- src/ui/player/VisibilityBadge.js | 4 +- src/ui/shared/ScryingPoolCameraViews.js | 90 ++ src/utils/boardUtils.js | 20 +- styles/components/_directors-board.less | 238 +++- styles/components/_participant-card.less | 4 + styles/components/_player-privacy-panel.less | 276 +++-- styles/components/_preset-import-export.less | 14 +- styles/components/_preset-load-dialog.less | 55 +- styles/components/_preset-save-dialog.less | 113 +- styles/components/_roster-strip.less | 167 ++- styles/components/_scene-preset-panel.less | 234 ++-- styles/scrying-pool.css | 1030 ++++++++++++++--- styles/scrying-pool.less | 8 + styles/tokens/_base.less | 54 +- templates/directors-board.hbs | 106 +- templates/participant-card.hbs | 1 + templates/player-privacy-panel.hbs | 22 +- templates/preset-export.hbs | 50 +- templates/preset-import.hbs | 166 +-- templates/preset-load-dialog.hbs | 6 +- templates/preset-save-dialog.hbs | 12 +- templates/roster-strip.hbs | 38 +- templates/scene-preset-panel.hbs | 22 +- tests/e2e/fixtures/foundry-setup.js | 16 +- tests/e2e/fixtures/foundry-world-setup.js | 14 +- tests/e2e/package-lock.json | 4 +- tests/e2e/package.json | 4 +- tests/e2e/playwright.config.js | 4 +- tests/e2e/specs/epic-1-visibility.spec.js | 10 +- tests/e2e/specs/epic-2-notifications.spec.js | 8 +- tests/e2e/specs/epic-3-presets.spec.js | 28 +- tests/e2e/specs/epic-4-privacy.spec.js | 91 +- tests/e2e/specs/module-init.spec.js | 6 +- tests/e2e/utils/foundry-helpers.js | 6 +- tests/helpers/playerPrivacyManagerMock.js | 1 - tests/unit/contracts/privacy-settings.test.js | 74 -- tests/unit/core/PlayerPrivacyManager.test.js | 57 +- tests/unit/core/ScenePresetManager.test.js | 2 +- tests/unit/core/ScryingPoolController.test.js | 54 +- tests/unit/foundry/FoundryAdapter.test.js | 20 +- .../notifications/NotificationBus.test.js | 8 +- tests/unit/ui/gm/ConfirmationBar.test.js | 8 +- tests/unit/ui/gm/DirectorsBoard.test.js | 10 +- tests/unit/ui/gm/PresetLoadDialog.test.js | 14 +- tests/unit/ui/gm/PresetSaveDialog.test.js | 18 +- tests/unit/ui/gm/ScenePresetPanel.test.js | 10 +- tests/unit/ui/gm/ScryingPoolStrip.test.js | 24 + .../unit/ui/player/PlayerPrivacyPanel.test.js | 14 +- tests/unit/ui/player/VisibilityBadge.test.js | 4 +- 72 files changed, 2545 insertions(+), 1220 deletions(-) create mode 100644 src/ui/shared/ScryingPoolCameraViews.js diff --git a/lang/en.json b/lang/en.json index a50e10c..41eb341 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1,5 +1,5 @@ { - "video-view-manager": { + "scrying-pool": { "badge": { "state": { "hidden": "Hidden from table", @@ -26,9 +26,14 @@ "gmHid": "GM hid {name}'s camera", "gmShowed": "GM showed {name}'s camera", "personalHidden": "GM has hidden your camera. Your portrait is shown to other Participants.", - "personalShowed": "Your camera is now visible to the table." + "personalShowed": "Your camera is now visible to the table.", + "avConfigGMOnly": "A/V settings are managed by the GM." }, "settings": { + "showGMSelfFeed": { + "label": "Show GM Self Feed", + "hint": "When enabled, the GM's own camera feed is shown in the Scrying Pool strip." + }, "notificationVerbosity": { "label": "Notification Verbosity", "hint": "Controls which camera-state notifications you see. 'All' shows every change; 'GM Only' shows changes only to the GM and affected participant; 'Silent' suppresses all notifications except your own camera changes.", @@ -41,13 +46,19 @@ }, "directorsBoard": { "title": "Director's Board", + "close": "Close", "empty": "No participants connected.", "openButton": "Open Director's Board", "footer": { - "savePreset": "Save Preset…", - "loadPreset": "Load Preset…", - "exportPresets": "Export Presets…", - "importPresets": "Import Presets…" + "savePreset": "Save Layout", + "loadPreset": "Load Layout", + "exportPresets": "Export", + "importPresets": "Import", + "autoApplySettings": "Auto-Apply…", + "avModeEnable": "Enable A/V", + "avModeDisable": "Disable A/V", + "avConfig": "A/V Settings…", + "avConfigTitle": "Open Foundry A/V server settings (signaling server, LiveKit, etc.)" }, "bulk": { "showAll": "Show All", @@ -81,46 +92,61 @@ }, "presets": { "save": { - "saveButton": "Save", + "saveButton": "Save Layout", "cancelButton": "Cancel", - "title": "Save Scene Preset", - "nameLabel": "Preset Name", - "namePlaceholder": "Enter a name for this camera layout" + "title": "Save Camera Layout", + "nameLabel": "Layout Name", + "namePlaceholder": "e.g. Combat, Roleplay, Intro…", + "descriptionHint": "Saves the current camera visibility layout for all participants." }, "load": { "loadButton": "Load", "cancelButton": "Cancel", - "title": "Load Scene Preset", - "emptyMessage": "No presets saved yet. Use 'Save Preset' to create one." + "title": "Load Camera Layout", + "emptyMessage": "No layouts saved yet. Use 'Save Layout' to create one." }, "notifications": { - "saved": "Scene preset '{name}' saved.", - "applied": "Scene preset '{name}' applied.", + "saved": "Layout '{name}' saved.", + "applied": "Layout '{name}' applied.", "scene-applied": "Scene changed: camera layout updated" } }, + "scenePresetPanel": { + "title": "Auto-Apply Settings", + "enableAutoApply": "Enable Auto-Apply", + "preset": "Layout", + "selectPreset": "Select a layout…", + "preDelay": "Pre-Delay", + "globalSettingsHint": "Auto-apply can also be toggled globally in module settings.", + "noScene": "No scene is currently active.", + "notifications": { + "enabled": "Auto-Apply enabled for this scene.", + "disabled": "Auto-Apply disabled for this scene.", + "presetSelected": "Auto-Apply layout set to: {name}" + } + }, "presetExport": { - "title": "Export Scene Presets", - "description": "Download all scene presets as a JSON file that can be imported into another world.", + "title": "Export Camera Layouts", + "description": "Download all camera layouts as a JSON file that can be imported into another world.", "scene": "Scene", - "presetCount": "Presets", + "presetCount": "Layouts", "filename": "Filename", "export": "Export", "cancel": "Cancel", "exporting": "Exporting…", - "exportSuccess": "Scene presets exported successfully.", - "exportFailed": "Failed to export presets" + "exportSuccess": "Camera layouts exported successfully.", + "exportFailed": "Failed to export layouts" }, "presetImport": { - "title": "Import Scene Presets", - "description": "Upload a JSON file containing scene presets to add to this scene.", + "title": "Import Camera Layouts", + "description": "Upload a JSON file containing camera layouts to add to this scene.", "selectFile": "Select File", "chooseFile": "Choose a JSON file…", "importMode": "Import Mode", "importModeMerge": "Merge", "importModeReplace": "Replace", - "importModeMergeHint": "Add new presets, skip duplicates", - "importModeReplaceHint": "Delete all existing presets and import new ones", + "importModeMergeHint": "Add new layouts, skip duplicates", + "importModeReplaceHint": "Delete all existing layouts and import new ones", "previewTitle": "Preview", "previewWillImport": "Will import", "previewWillSkip": "Will skip (already exists)", @@ -128,10 +154,10 @@ "confirmReplace": "Replace All", "cancel": "Cancel", "importing": "Importing…", - "importFailed": "Failed to import presets", + "importFailed": "Failed to import layouts", "selectFileFirst": "Please select a file first", - "existingPresetsWarning": "This scene has {existingPresetCount} existing preset(s).", - "replaceConfirmation": "This will delete all {existingPresetCount} existing preset(s) and replace them with the imported ones. This cannot be undone." + "existingPresetsWarning": "This scene has {existingPresetCount} existing layout(s).", + "replaceConfirmation": "This will delete all {existingPresetCount} existing layout(s) and replace them with the imported ones. This cannot be undone." } }, "SCRYING_POOL": { @@ -143,8 +169,6 @@ "sectionDescription": "Control which automation features can affect your camera and on-screen presence.", "reactionCamLabel": "Reaction Cam", "reactionCamDescription": "Automatically show your camera during key moments (combat, rolls, etc.)", - "hpReactiveCamStylingLabel": "HP-Reactive Cam Styling", - "hpReactiveCamStylingDescription": "Apply visual styling to your camera based on your character's HP", "toggleOn": "Enabled", "toggleOff": "Disabled", "readOnlyNotice": "This player's privacy settings are read-only. You cannot modify another player's consent preferences.", @@ -164,7 +188,7 @@ "Settings": { "PlayerPrivacyPanel": "Player Privacy Panel", "PlayerPrivacyPanelLabel": "Control automation effects for your camera", - "PlayerPrivacyPanelHint": "Opt in or out of Reaction Cam, HP-Reactive Cam Styling, and other automation features", + "PlayerPrivacyPanelHint": "Opt in or out of Reaction Cam and other automation features", "GMPlayerPrivacySelector": "View Player Privacy Settings", "GMPlayerPrivacySelectorLabel": "View and manage player privacy consent settings", "GMPlayerPrivacySelectorHint": "Select a player to view their automation opt-in preferences (read-only)" diff --git a/module.js b/module.js index ef4f162..df082b2 100644 --- a/module.js +++ b/module.js @@ -1,7 +1,7 @@ // @ts-nocheck — Module entry point with FoundryVTT globals, no exports needed /* global Handlebars */ /** - * module.js — Entry point and wiring diagram for Video View Manager (Scrying Pool). + * module.js — Entry point and wiring diagram for Scrying Pool. * * This file is the wiring diagram ONLY. It imports all modules, constructs them * with injected dependencies, and holds NO business logic. @@ -34,6 +34,7 @@ import { DirectorsBoard } from './src/ui/gm/DirectorsBoard.js'; import { ConfirmationBar } from './src/ui/gm/ConfirmationBar.js'; import { PlayerPrivacyPanelMenu, initPlayerPrivacyPanelMenu } from './src/ui/player/PlayerPrivacyPanelMenu.js'; import { initGMPlayerPrivacySelector } from './src/ui/gm/GMPlayerPrivacySelector.js'; +import { ScryingPoolCameraViews, initScryingPoolCameraViews } from './src/ui/shared/ScryingPoolCameraViews.js'; import { SOCKET_EVENTS } from './src/contracts/socket-message.js'; // Module-level references — constructed in init hook, used across hooks @@ -57,6 +58,18 @@ let directorsBoardButtonAdded = false; Hooks.once("init", () => { console.log("[ScryingPool] init — module loading"); + // Foundry sets body class from URL route. If the URL has a query string + // (e.g. /game?presetName=ALL), the class becomes "game?presetName=ALL" + // instead of "game", breaking body.game { display:flex } and collapsing the UI. + const badClass = Array.from(document.body.classList).find(c => c.startsWith("game") && c !== "game"); + if (badClass) { + document.body.classList.remove(badClass); + document.body.classList.add("game"); + } + + // Take over Foundry's camera dock — must be set before Foundry instantiates ui.webrtc + CONFIG.ui.webrtc = ScryingPoolCameraViews; + // WebRTC mode setting — determines how the module handles AV integration // Updated for FULL REPLACEMENT architecture (hiding Foundry's dock, showing our own) game.settings.register("scrying-pool", "webrtcMode", { @@ -88,6 +101,8 @@ Hooks.once("init", () => { config: true, type: Boolean, default: true, + name: "Show GM Self Feed", + hint: "When enabled, the GM's own camera feed is shown in the Scrying Pool strip.", }); // Story 2.1: per-user notification verbosity preference (client-scoped) @@ -95,6 +110,8 @@ Hooks.once("init", () => { scope: "client", config: true, type: String, + name: "Notification Verbosity", + hint: "Controls which camera-state notifications you see. 'All' shows every change; 'GM Only' shows changes only to the GM and affected participant; 'Silent' suppresses all notifications except your own camera changes.", choices: { all: "All", "gm-only": "GM Only", @@ -109,8 +126,8 @@ Hooks.once("init", () => { config: true, type: Boolean, default: true, - name: "Enable Scene Preset Auto-Apply", - hint: "When enabled, scenes with configured presets will automatically apply them on activation", + name: "Enable Camera Layout Auto-Apply", + hint: "When enabled, scenes with a configured camera layout will automatically apply it on activation", }); // Construct data layer — constructors are side-effect-free @@ -129,22 +146,22 @@ Hooks.once("init", () => { // Story 2.3: Bulk-action keybindings (GM only, migrated to scrying-pool namespace) game.keybindings.register('scrying-pool', 'showAll', { - name: game.i18n.localize('video-view-manager.keybindings.showAll.name'), - hint: game.i18n.localize('video-view-manager.keybindings.showAll.hint'), + name: game.i18n.localize('scrying-pool.keybindings.showAll.name'), + hint: game.i18n.localize('scrying-pool.keybindings.showAll.hint'), editable: [{ key: 'KeyS', modifiers: ['Control', 'Shift'] }], restricted: true, onDown: () => directorsBoard?.showAll(), }); game.keybindings.register('scrying-pool', 'hideAll', { - name: game.i18n.localize('video-view-manager.keybindings.hideAll.name'), - hint: game.i18n.localize('video-view-manager.keybindings.hideAll.hint'), + name: game.i18n.localize('scrying-pool.keybindings.hideAll.name'), + hint: game.i18n.localize('scrying-pool.keybindings.hideAll.hint'), editable: [{ key: 'KeyH', modifiers: ['Control', 'Shift'] }], restricted: true, onDown: () => directorsBoard?.hideAll(), }); game.keybindings.register('scrying-pool', 'spotlightParticipant', { - name: game.i18n.localize('video-view-manager.keybindings.spotlightParticipant.name'), - hint: game.i18n.localize('video-view-manager.keybindings.spotlightParticipant.hint'), + name: game.i18n.localize('scrying-pool.keybindings.spotlightParticipant.name'), + hint: game.i18n.localize('scrying-pool.keybindings.spotlightParticipant.hint'), editable: [{ key: 'KeyP', modifiers: ['Control', 'Shift'] }], restricted: true, onDown: () => directorsBoard?.spotlightFocused(), @@ -302,13 +319,20 @@ Hooks.once("ready", () => { window.directorsBoard = directorsBoard; } + // Inject Scrying Pool deps into our camera views replacement (all clients) + // Directors Board reference is GM-only — players get null so _onConfigure is a no-op + initScryingPoolCameraViews( + adapter.users.isGM() ? directorsBoard : null, + stateStore + ); + // Pre-load participant-card as a Handlebars partial for directors-board // ApplicationV2 requires partials to be registered explicitly (async () => { try { - const resp = await fetch('modules/video-view-manager/templates/participant-card.hbs'); + const resp = await fetch('modules/scrying-pool/templates/participant-card.hbs'); const source = await resp.text(); - Handlebars.registerPartial('modules/video-view-manager/templates/participant-card.hbs', source); + Handlebars.registerPartial('modules/scrying-pool/templates/participant-card.hbs', source); } catch (err) { console.warn('[ScryingPool] Failed to register participant-card partial:', err); } diff --git a/module.json b/module.json index bb719db..eee9777 100644 --- a/module.json +++ b/module.json @@ -1,6 +1,6 @@ { - "id": "video-view-manager", - "title": "Video View Manager (Scrying Pool)", + "id": "scrying-pool", + "title": "Scrying Pool", "version": "0.1.0", "description": "GM camera visibility control for FoundryVTT v14 — hide, show, and manage participant feeds in real time.", "authors": [ diff --git a/package-lock.json b/package-lock.json index 3947305..3cf8fa9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "video-view-manager", + "name": "scrying-pool", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "video-view-manager", + "name": "scrying-pool", "version": "0.1.0", "devDependencies": { "@league-of-foundry-developers/foundry-vtt-types": "9.280.1", diff --git a/package.json b/package.json index 19ebfc6..cd0958a 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "video-view-manager", + "name": "scrying-pool", "version": "0.1.0", "description": "FoundryVTT v14 module — Scrying Pool camera visibility control", "type": "module", diff --git a/src/contracts/privacy-settings.js b/src/contracts/privacy-settings.js index 177378f..6123673 100644 --- a/src/contracts/privacy-settings.js +++ b/src/contracts/privacy-settings.js @@ -4,8 +4,8 @@ * Privacy settings control player opt-in/out for automation features that affect * their on-screen presence. Settings are stored as user flags on the user document. * - * Storage key: game.user.setFlag('video-view-manager', key, value) - * Shape: { reactionCamEnabled: boolean, hpReactiveCamStylingEnabled: boolean, customPortraitFallback: string | null } + * Storage key: game.user.setFlag('scrying-pool', key, value) + * Shape: { reactionCamEnabled: boolean, customPortraitFallback: string | null } * * @module contracts/privacy-settings */ @@ -31,7 +31,6 @@ export const VALID_PORTRAIT_FORMATS = Object.freeze([ /** * @typedef {Object} PrivacySettings * @property {boolean} reactionCamEnabled - Whether Reaction Cam automation is enabled for this user. - * @property {boolean} hpReactiveCamStylingEnabled - Whether HP-Reactive Cam Styling is enabled for this user. * @property {string|null} customPortraitFallback - DataURL string for custom portrait fallback image, or null if not set. */ @@ -43,7 +42,6 @@ export const PRIVACY_SETTINGS_VERSION = 1; */ export const PRIVACY_SETTINGS_DEFAULT = { reactionCamEnabled: false, - hpReactiveCamStylingEnabled: false, customPortraitFallback: null, }; @@ -53,7 +51,6 @@ export const PRIVACY_SETTINGS_DEFAULT = { */ export const PRIVACY_SETTING_KEYS = Object.freeze([ "reactionCamEnabled", - "hpReactiveCamStylingEnabled", "customPortraitFallback", ]); @@ -63,7 +60,6 @@ export const PRIVACY_SETTING_KEYS = Object.freeze([ */ export const FEATURE_NAME_MAP = Object.freeze({ reactionCam: "reactionCamEnabled", - hpReactiveCamStyling: "hpReactiveCamStylingEnabled", }); /** @@ -164,13 +160,6 @@ export function isValidPrivacySettings(data) { ); } } - if ("hpReactiveCamStylingEnabled" in obj) { - if (typeof obj.hpReactiveCamStylingEnabled !== "boolean") { - throw new TypeError( - `PrivacySettings: hpReactiveCamStylingEnabled must be a boolean, got ${typeof obj.hpReactiveCamStylingEnabled}` - ); - } - } if ("customPortraitFallback" in obj) { if (obj.customPortraitFallback !== null && typeof obj.customPortraitFallback !== "string") { throw new TypeError( diff --git a/src/contracts/scene-preset.js b/src/contracts/scene-preset.js index 2f7f7d8..c1ce40a 100644 --- a/src/contracts/scene-preset.js +++ b/src/contracts/scene-preset.js @@ -4,7 +4,7 @@ * A Scene Preset is a named snapshot of the Visibility Matrix, stored as a * flag on a FoundryVTT Scene document. Up to 50 presets per world. * - * Storage key: scene.getFlag('video-view-manager', 'preset') + * Storage key: scene.getFlag('scrying-pool', 'preset') * Shape: { _version: 1, presets: { [name: string]: ScenePreset } } * * @module contracts/scene-preset diff --git a/src/core/PlayerPrivacyManager.js b/src/core/PlayerPrivacyManager.js index 7056cf2..f9cff60 100644 --- a/src/core/PlayerPrivacyManager.js +++ b/src/core/PlayerPrivacyManager.js @@ -26,8 +26,7 @@ import { * Manages player privacy settings for automation opt-ins. * * Settings are stored as world-level user flags: - * - game.user.setFlag('video-view-manager', 'reactionCamEnabled', boolean) - * - game.user.setFlag('video-view-manager', 'hpReactiveCamStylingEnabled', boolean) + * - game.user.setFlag('scrying-pool', 'reactionCamEnabled', boolean) * * Players can only edit their own settings. * GM can read (but not edit) all players' settings. @@ -87,7 +86,7 @@ export class PlayerPrivacyManager { /** * Retrieves privacy settings for a specific user. * - * Reads from user flags with module scope 'video-view-manager'. + * Reads from user flags with module scope 'scrying-pool'. * Missing settings are merged with defaults (all false). * * @param {string} userId - The user ID to retrieve settings for. @@ -104,7 +103,7 @@ export class PlayerPrivacyManager { const settings = { ...PRIVACY_SETTINGS_DEFAULT }; for (const key of PRIVACY_SETTING_KEYS) { - const value = user.getFlag("video-view-manager", key); + const value = user.getFlag("scrying-pool", key); if (value !== undefined && value !== null) { settings[key] = value; } @@ -157,7 +156,7 @@ export class PlayerPrivacyManager { // Persist the setting via user flag // Note: FoundryVTT user.setFlag returns a Promise - await user.setFlag("video-view-manager", key, value); + await user.setFlag("scrying-pool", key, value); // Notify subscribers this._notifySubscribers(userId, key, value, previousValue); @@ -167,7 +166,7 @@ export class PlayerPrivacyManager { * Checks if a user has opted in to a specific automation feature. * * @param {string} userId - The user ID to check. - * @param {string} feature - The feature name ('reactionCam' or 'hpReactiveCamStyling'). + * @param {string} feature - The feature name ('reactionCam'). * @returns {boolean} True if the user has opted in, false otherwise. * @throws {TypeError} If feature name is invalid. */ @@ -306,7 +305,7 @@ export class PlayerPrivacyManager { const previousValue = this.getPortraitFallback(userId); // Persist the setting via user flag - await user.setFlag("video-view-manager", "customPortraitFallback", dataURL); + await user.setFlag("scrying-pool", "customPortraitFallback", dataURL); // Notify subscribers with special portrait type this._notifyPortraitChange(userId, dataURL, previousValue); @@ -326,7 +325,7 @@ export class PlayerPrivacyManager { return null; } - const dataURL = user.getFlag("video-view-manager", "customPortraitFallback"); + const dataURL = user.getFlag("scrying-pool", "customPortraitFallback"); // Validate the stored DataURL (defensive programming) if (dataURL !== null && dataURL !== undefined) { @@ -392,7 +391,7 @@ export class PlayerPrivacyManager { const previousValue = this.getPortraitFallback(userId); // Remove the setting via user flag - await user.unsetFlag("video-view-manager", "customPortraitFallback"); + await user.unsetFlag("scrying-pool", "customPortraitFallback"); // Notify subscribers with special portrait type this._notifyPortraitChange(userId, null, previousValue); diff --git a/src/core/ScenePresetManager.js b/src/core/ScenePresetManager.js index 90f9361..0883e8c 100644 --- a/src/core/ScenePresetManager.js +++ b/src/core/ScenePresetManager.js @@ -123,7 +123,7 @@ export class ScenePresetManager { // Emit notification this._adapter.notifications.info( - this._adapter.i18n.localize('video-view-manager.presets.notifications.saved') + this._adapter.i18n.localize('scrying-pool.presets.notifications.saved') .replace('{name}', name) ); @@ -177,7 +177,7 @@ export class ScenePresetManager { // Emit notification this._adapter.notifications.info( - this._adapter.i18n.localize('video-view-manager.presets.notifications.applied') + this._adapter.i18n.localize('scrying-pool.presets.notifications.applied') .replace('{name}', name) ); } @@ -295,7 +295,7 @@ export class ScenePresetManager { } try { - const flagData = /** @type {object & { getFlag?: (scope: string, key: string) => unknown }} */ (currentScene).getFlag?.('video-view-manager', 'presets'); + const flagData = /** @type {object & { getFlag?: (scope: string, key: string) => unknown }} */ (currentScene).getFlag?.('scrying-pool', 'presets'); if (!flagData || typeof flagData !== 'object') { return; // No presets or invalid format } @@ -360,7 +360,7 @@ export class ScenePresetManager { }; try { - await /** @type {object & { setFlag?: (scope: string, key: string, value: unknown) => Promise }} */ (currentScene).setFlag?.('video-view-manager', 'presets', flagData); + await /** @type {object & { setFlag?: (scope: string, key: string, value: unknown) => Promise }} */ (currentScene).setFlag?.('scrying-pool', 'presets', flagData); } catch (err) { console.error( '[ScryingPool] ScenePresetManager: failed to save scene presets', @@ -383,7 +383,7 @@ export class ScenePresetManager { */ async onSceneActivate(scene) { // Check if auto-apply is globally enabled - const globalEnabled = this._adapter.settings.get?.('video-view-manager.autoApplyEnabled') ?? true; + const globalEnabled = this._adapter.settings.get?.('autoApplyEnabled') ?? true; if (!globalEnabled) { return; // Global disable } @@ -528,7 +528,7 @@ export class ScenePresetManager { // Persist to scene flag try { - await /** @type {object & { setFlag?: (scope: string, key: string, value: unknown) => Promise }} */ (scene).setFlag?.('video-view-manager', 'presets', newFlagData); + await /** @type {object & { setFlag?: (scope: string, key: string, value: unknown) => Promise }} */ (scene).setFlag?.('scrying-pool', 'presets', newFlagData); } catch (err) { console.error( '[ScryingPool] ScenePresetManager: failed to configure auto-apply', @@ -579,7 +579,7 @@ export class ScenePresetManager { // Notify via ui.notifications this._adapter.notifications.info( - this._adapter.i18n.localize('video-view-manager.presets.notifications.scene-applied') + this._adapter.i18n.localize('scrying-pool.presets.notifications.scene-applied') .replace('{name}', presetName) ); } catch (err) { @@ -638,7 +638,7 @@ export class ScenePresetManager { */ _getSceneFlagData(scene) { try { - const flagData = /** @type {object & { getFlag?: (scope: string, key: string) => unknown }} */ (scene).getFlag?.('video-view-manager', 'presets'); + const flagData = /** @type {object & { getFlag?: (scope: string, key: string) => unknown }} */ (scene).getFlag?.('scrying-pool', 'presets'); if (!flagData || typeof flagData !== 'object') { return null; } diff --git a/src/core/ScryingPoolController.js b/src/core/ScryingPoolController.js index 269d14e..6fac317 100644 --- a/src/core/ScryingPoolController.js +++ b/src/core/ScryingPoolController.js @@ -173,7 +173,10 @@ export class ScryingPoolController { if (currentState === targetState) return; // 5. Register PendingOp - const previousState = currentState ?? 'never-connected'; + // Use 'active' (not 'never-connected') as the fallback previousState for users not yet in + // the matrix: 'active' matches the render-time default in buildParticipantList and prevents + // a socket-timeout revert from persisting 'never-connected' into the world settings. + const previousState = currentState ?? 'active'; const pendingOp = createPendingOp(opId, participantId, targetState, previousState); this._pendingOps.set(participantId, pendingOp); @@ -186,19 +189,20 @@ export class ScryingPoolController { return; } - // 7. Socket emit + // 7. Socket emit (best-effort broadcast to player clients) 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); - } + // 9. Self-confirm: the GM is the source of truth. + // The state was already applied and persisted optimistically (step 6). + // Waiting for a socket echo would require a server-side relay that doesn't + // exist, resulting in a 6-second timeout + spurious revert warning. + // Confirm immediately to close the pending op lifecycle cleanly. + // _onEcho also fires scrying-pool:controllerAction, so skip step 10. + this._onEcho({ opId, userId: participantId, state: targetState, revision: baseRevision + 1 }); } /** diff --git a/src/core/StateStore.js b/src/core/StateStore.js index da33a72..46848be 100644 --- a/src/core/StateStore.js +++ b/src/core/StateStore.js @@ -48,6 +48,15 @@ export class StateStore { this._matrix = { ...validated.matrix }; this._version = validated._version ?? 1; this._revision = validated._revision ?? 0; + + // Migration: 'never-connected' was incorrectly written to the matrix by socket-timeout + // reverts when currentState was null (the fallback should have been 'active'). + // Replace persisted 'never-connected' entries with 'active' at hydration time. + for (const userId of Object.keys(this._matrix)) { + if (this._matrix[userId] === 'never-connected') { + this._matrix[userId] = 'active'; + } + } } } catch (err) { if (err instanceof TypeError) { diff --git a/src/foundry/FoundryAdapter.js b/src/foundry/FoundryAdapter.js index a61d6c2..c8a2b9c 100644 --- a/src/foundry/FoundryAdapter.js +++ b/src/foundry/FoundryAdapter.js @@ -35,7 +35,7 @@ export class FoundryAdapter { static SETTING_WEBRTC_MODE = 'webrtcMode'; /** Flag scope/namespace for module-specific user flags. */ - static FLAG_SCOPE = 'video-view-manager'; + static FLAG_SCOPE = 'scrying-pool'; /** * Creates a FoundryAdapter. Side-effect-free — no hooks or listeners registered. @@ -294,7 +294,7 @@ export class FoundryAdapter { 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 {string} key - The translation key (e.g., 'scrying-pool.notifications.gmHid') * @param {object} [data] - Optional data for string interpolation * @returns {string} The localized string */ diff --git a/src/notifications/NotificationBus.js b/src/notifications/NotificationBus.js index 3d9445c..4534ec3 100644 --- a/src/notifications/NotificationBus.js +++ b/src/notifications/NotificationBus.js @@ -116,8 +116,8 @@ export class NotificationBus { */ _notifyPersonal(newState) { const key = newState === 'hidden' - ? 'video-view-manager.notifications.personalHidden' - : 'video-view-manager.notifications.personalShowed'; + ? 'scrying-pool.notifications.personalHidden' + : 'scrying-pool.notifications.personalShowed'; const msg = this._adapter.i18n.localize(key); this._adapter.notifications.info(msg); } @@ -170,8 +170,8 @@ export class NotificationBus { 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'; + ? 'scrying-pool.notifications.gmHid' + : 'scrying-pool.notifications.gmShowed'; // Note: changeCount is included in the message suffix for AC-3 compliance const msg = this._adapter.i18n.localize(key, { name }) + count; diff --git a/src/ui/gm/ConfirmationBar.js b/src/ui/gm/ConfirmationBar.js index 76ebdad..379f48c 100644 --- a/src/ui/gm/ConfirmationBar.js +++ b/src/ui/gm/ConfirmationBar.js @@ -288,14 +288,14 @@ export class ConfirmationBar { * @private */ _buildMessage(presetName, counts, variant) { - const baseMsg = this._adapter.i18n.localize('video-view-manager.presets.confirmation.applied') + const baseMsg = this._adapter.i18n.localize('scrying-pool.presets.confirmation.applied') .replace('{name}', presetName); - const countMsg = this._adapter.i18n.localize('video-view-manager.presets.confirmation.counts') + const countMsg = this._adapter.i18n.localize('scrying-pool.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'); + const suffix = this._adapter.i18n.localize('scrying-pool.presets.confirmation.partial-fail'); return `${baseMsg} ${countMsg} ${suffix}`; } @@ -313,7 +313,7 @@ export class ConfirmationBar { */ _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'); + const undoLabel = this._adapter.i18n.localize('scrying-pool.presets.confirmation.undo'); // Use data-action for event delegation via StripOverlayLayer // The onclick handler is set up in _setupEventListeners diff --git a/src/ui/gm/DirectorsBoard.js b/src/ui/gm/DirectorsBoard.js index a2deaa6..c55bf1f 100644 --- a/src/ui/gm/DirectorsBoard.js +++ b/src/ui/gm/DirectorsBoard.js @@ -47,12 +47,12 @@ export class DirectorsBoard extends _AppBase { id: 'scrying-pool-directors-board', classes: ['scrying-pool', 'directors-board'], window: { title: "Director's Board", resizable: true }, - position: { width: 400, height: 300 }, + position: { width: 420, height: 480 }, }; static PARTS = { board: { - template: 'modules/video-view-manager/templates/directors-board.hbs', + template: 'modules/scrying-pool/templates/directors-board.hbs', }, }; @@ -116,7 +116,7 @@ export class DirectorsBoard extends _AppBase { /** Loads saved window position from GM user flag. */ _loadPosition() { try { - const saved = game.user?.getFlag('video-view-manager', 'directorsBoardState'); + const saved = game.user?.getFlag('scrying-pool', 'directorsBoardState'); if (saved?.open === true && saved.left != null && saved.top != null) { // Ensure options.position exists and is mutable if (this.options?.position) { @@ -124,8 +124,8 @@ export class DirectorsBoard extends _AppBase { Object.assign(this.options.position, { left: saved.left, top: saved.top, - width: saved.width ?? 400, - height: saved.height ?? 300, + width: saved.width ?? 420, + height: saved.height ?? 480, }); } } @@ -374,6 +374,8 @@ export class DirectorsBoard extends _AppBase { autoApplyPresetName: autoApplyConfig.presetName, autoApplyPreDelay: autoApplyConfig.preDelay, presets: this._scenePresetManager?.list?.() ?? [], + // A/V mode — reflects current world AV state (0 = disabled, 3 = audio+video) + avModeEnabled: (game.webrtc?.settings?.world?.mode ?? 0) !== 0, }; } @@ -416,6 +418,9 @@ export class DirectorsBoard extends _AppBase { case 'import-presets': this._onImportPresets(); break; // Story 3.2: Scene auto-apply panel toggle case 'toggle-preset-panel': this._togglePresetPanel(); break; + case 'toggle-av-mode': this._onToggleAVMode(); break; + case 'open-av-config': this._onOpenAVConfig(); break; + case 'close': this.close(); break; } }; this._focusinHandler = (e) => { @@ -428,6 +433,28 @@ export class DirectorsBoard extends _AppBase { root.addEventListener('click', this._clickHandler); root.addEventListener('focusin', this._focusinHandler); root.addEventListener('keydown', this._keydownHandler); + + // Drag grip — custom drag (ApplicationV2 header is hidden) + const grip = root.querySelector('[data-action="drag-grip"]'); + if (grip) { + grip.addEventListener('mousedown', e => { + if (e.button !== 0) return; + e.preventDefault(); + const startX = e.clientX; + const startY = e.clientY; + const { left: startLeft, top: startTop } = this.position; + const onMove = mv => this.setPosition({ + left: startLeft + (mv.clientX - startX), + top: startTop + (mv.clientY - startY), + }); + const onUp = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }); + } // Story 3.2: Append ScenePresetPanel to DOM and refresh this._appendPresetPanel(root); @@ -542,8 +569,8 @@ export class DirectorsBoard extends _AppBase { const localize = (key) => game.i18n?.localize(key) ?? key; const getBinding = (actionKey) => { - // Check both namespaces due to migration from video-view-manager to scrying-pool - const namespaces = ['scrying-pool', 'video-view-manager']; + // Check both namespaces due to migration from scrying-pool to scrying-pool + const namespaces = ['scrying-pool', 'scrying-pool']; for (const ns of namespaces) { const bindings = game.keybindings?.bindings?.get(`${ns}.${actionKey}`); if (bindings?.[0]) { @@ -556,10 +583,10 @@ export class DirectorsBoard extends _AppBase { }; const shortcuts = [ - { label: localize('video-view-manager.directorsBoard.shortcuts.openBoard'), binding: getBinding('openDirectorsBoard') ?? 'Ctrl+Shift+V' }, - { label: localize('video-view-manager.directorsBoard.shortcuts.showAll'), binding: getBinding('showAll') ?? 'Ctrl+Shift+S' }, - { label: localize('video-view-manager.directorsBoard.shortcuts.hideAll'), binding: getBinding('hideAll') ?? 'Ctrl+Shift+H' }, - { label: localize('video-view-manager.directorsBoard.shortcuts.spotlight'), binding: getBinding('spotlightParticipant') ?? 'Ctrl+Shift+P' }, + { label: localize('scrying-pool.directorsBoard.shortcuts.openBoard'), binding: getBinding('openDirectorsBoard') ?? 'Ctrl+Shift+V' }, + { label: localize('scrying-pool.directorsBoard.shortcuts.showAll'), binding: getBinding('showAll') ?? 'Ctrl+Shift+S' }, + { label: localize('scrying-pool.directorsBoard.shortcuts.hideAll'), binding: getBinding('hideAll') ?? 'Ctrl+Shift+H' }, + { label: localize('scrying-pool.directorsBoard.shortcuts.spotlight'), binding: getBinding('spotlightParticipant') ?? 'Ctrl+Shift+P' }, ]; // Escape HTML to prevent injection via localised strings or keybinding labels @@ -570,7 +597,7 @@ export class DirectorsBoard extends _AppBase { if (typeof Dialog !== 'undefined') { new Dialog({ - title: localize('video-view-manager.directorsBoard.shortcuts.title'), + title: localize('scrying-pool.directorsBoard.shortcuts.title'), content, buttons: { close: { label: 'Close' } }, default: 'close', @@ -592,6 +619,48 @@ export class DirectorsBoard extends _AppBase { } } + /** + * Toggles Foundry A/V mode between AUDIO_VIDEO (3) and DISABLED (0). + * The module is the single point of control for A/V activation — Foundry's + * native AVConfig dialog is redirected here by initScryingPoolCameraViews. + * + * Uses reestablish() rather than explicit connect()/disconnect() to avoid + * racing with Foundry's internal mode-change listeners (AVMaster hooks into + * the settings change event itself). + */ + async _onToggleAVMode() { + if (!game.webrtc) { + console.warn('[ScryingPool] DirectorsBoard: game.webrtc not available'); + return; + } + const currentMode = game.webrtc.settings?.world?.mode ?? 0; + // AV_MODES: DISABLED=0, AUDIO=1, VIDEO=2, AUDIO_VIDEO=3 + const newMode = currentMode === 0 ? 3 : 0; + try { + await game.webrtc.settings.set('world', 'mode', newMode); + // reestablish() tears down and rebuilds the WebRTC connection honouring the + // new mode — same approach used by Foundry's own AVConfig save handler. + await game.webrtc.reestablish?.(); + } catch (err) { + console.error('[ScryingPool] DirectorsBoard: failed to toggle A/V mode:', err); + } + if (this.rendered) this.render({ force: true }); + } + + /** + * Opens Foundry's native AVConfig dialog for signaling server configuration. + * This is separate from the A/V mode toggle — AVConfig is where you set up the + * LiveKit/WebRTC server address, username, password, etc. + * The module controls A/V mode (on/off); Foundry's dialog handles infrastructure. + */ + _onOpenAVConfig() { + if (!game.webrtc?.config) { + console.warn('[ScryingPool] DirectorsBoard: game.webrtc.config not available'); + return; + } + game.webrtc.config.render({ force: true }); + } + /** * Opens the PresetSaveDialog for saving the current visibility matrix as a preset. */ @@ -749,7 +818,7 @@ export class DirectorsBoard extends _AppBase { */ _savePosition(state) { try { - game.user?.setFlag('video-view-manager', 'directorsBoardState', state); + game.user?.setFlag('scrying-pool', 'directorsBoardState', state); } catch (err) { console.error('[ScryingPool] Failed to save directors board position:', err); } diff --git a/src/ui/gm/GMPlayerPrivacySelector.js b/src/ui/gm/GMPlayerPrivacySelector.js index 89fdf64..1225ee4 100644 --- a/src/ui/gm/GMPlayerPrivacySelector.js +++ b/src/ui/gm/GMPlayerPrivacySelector.js @@ -190,7 +190,9 @@ export class GMPlayerPrivacySelectorMenu extends _AppBase { const id = escapeHtml(user.id ?? ''); const role = user.isGM ? 'GM' : 'Player'; return ` -
+
${name} ${role}
diff --git a/src/ui/gm/PresetExportDialog.js b/src/ui/gm/PresetExportDialog.js index 66753ab..fbf0c6d 100644 --- a/src/ui/gm/PresetExportDialog.js +++ b/src/ui/gm/PresetExportDialog.js @@ -77,7 +77,7 @@ export class PresetExportDialog extends _AppBase { static PARTS = { dialog: { - template: 'modules/video-view-manager/templates/preset-export.hbs', + template: 'modules/scrying-pool/templates/preset-export.hbs', }, }; diff --git a/src/ui/gm/PresetImportDialog.js b/src/ui/gm/PresetImportDialog.js index 04dbb02..9d86306 100644 --- a/src/ui/gm/PresetImportDialog.js +++ b/src/ui/gm/PresetImportDialog.js @@ -92,7 +92,7 @@ export class PresetImportDialog extends _AppBase { static PARTS = { dialog: { - template: 'modules/video-view-manager/templates/preset-import.hbs', + template: 'modules/scrying-pool/templates/preset-import.hbs', }, }; diff --git a/src/ui/gm/PresetLoadDialog.js b/src/ui/gm/PresetLoadDialog.js index e7e9aef..418ebd5 100644 --- a/src/ui/gm/PresetLoadDialog.js +++ b/src/ui/gm/PresetLoadDialog.js @@ -40,7 +40,7 @@ export class PresetLoadDialog extends _AppBase { static PARTS = { dialog: { - template: 'modules/video-view-manager/templates/preset-load-dialog.hbs', + template: 'modules/scrying-pool/templates/preset-load-dialog.hbs', }, }; @@ -80,10 +80,10 @@ export class PresetLoadDialog extends _AppBase { 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'), + loadLabel: i18n.localize('scrying-pool.presets.load.loadButton'), + cancelLabel: i18n.localize('scrying-pool.presets.load.cancelButton'), + title: i18n.localize('scrying-pool.presets.load.title'), + emptyMessage: i18n.localize('scrying-pool.presets.load.emptyMessage'), }; } @@ -157,7 +157,7 @@ export class PresetLoadDialog extends _AppBase { // Show success notification this._adapter.notifications.info( - this._adapter.i18n.localize('video-view-manager.presets.notifications.applied') + this._adapter.i18n.localize('scrying-pool.presets.notifications.applied') .replace('{name}', presetName) ); diff --git a/src/ui/gm/PresetSaveDialog.js b/src/ui/gm/PresetSaveDialog.js index a7b91a0..1d6140e 100644 --- a/src/ui/gm/PresetSaveDialog.js +++ b/src/ui/gm/PresetSaveDialog.js @@ -35,12 +35,12 @@ export class PresetSaveDialog extends _AppBase { id: 'scrying-pool-preset-save-dialog', classes: ['scrying-pool', 'preset-save-dialog'], window: { title: 'Save Scene Preset', resizable: false }, - position: { width: 320, height: 'auto' }, + position: { width: 360, height: 'auto' }, }; static PARTS = { dialog: { - template: 'modules/video-view-manager/templates/preset-save-dialog.hbs', + template: 'modules/scrying-pool/templates/preset-save-dialog.hbs', }, }; @@ -76,11 +76,12 @@ export class PresetSaveDialog extends _AppBase { 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'), + saveLabel: i18n.localize('scrying-pool.presets.save.saveButton'), + cancelLabel: i18n.localize('scrying-pool.presets.save.cancelButton'), + title: i18n.localize('scrying-pool.presets.save.title'), + nameLabel: i18n.localize('scrying-pool.presets.save.nameLabel'), + namePlaceholder: i18n.localize('scrying-pool.presets.save.namePlaceholder'), + descriptionHint: i18n.localize('scrying-pool.presets.save.descriptionHint'), }; } @@ -173,7 +174,7 @@ export class PresetSaveDialog extends _AppBase { // Show success notification this._adapter.notifications.info( - this._adapter.i18n.localize('video-view-manager.presets.notifications.saved') + this._adapter.i18n.localize('scrying-pool.presets.notifications.saved') .replace('{name}', name) ); diff --git a/src/ui/gm/ScenePresetPanel.js b/src/ui/gm/ScenePresetPanel.js index 0ed1239..a206326 100644 --- a/src/ui/gm/ScenePresetPanel.js +++ b/src/ui/gm/ScenePresetPanel.js @@ -66,7 +66,7 @@ export class ScenePresetPanel { 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-label', this._adapter.i18n.localize('scrying-pool.scenePresetPanel.title')); this._element.setAttribute('aria-expanded', 'false'); // Initially hidden @@ -158,11 +158,11 @@ export class ScenePresetPanel { * @private */ _buildEmptyHtml() { - const message = this._adapter.i18n.localize('video-view-manager.scenePresetPanel.noScene'); + const message = this._adapter.i18n.localize('scrying-pool.scenePresetPanel.noScene'); return `

- ${this._escapeHtml(this._adapter.i18n.localize('video-view-manager.scenePresetPanel.title'))} + ${this._escapeHtml(this._adapter.i18n.localize('scrying-pool.scenePresetPanel.title'))}

${this._escapeHtml(message)}

@@ -195,14 +195,14 @@ export class ScenePresetPanel { // Add default option const defaultOption = ` `; return `

- ${this._escapeHtml(localize('video-view-manager.scenePresetPanel.title'))} + ${this._escapeHtml(localize('scrying-pool.scenePresetPanel.title'))}

@@ -214,18 +214,18 @@ export class ScenePresetPanel { 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'))} + aria-label="${this._escapeHtml(localize('scrying-pool.scenePresetPanel.enableAutoApply'))}"> + ${this._escapeHtml(localize('scrying-pool.scenePresetPanel.enableAutoApply'))}
`; @@ -338,8 +338,8 @@ export class ScenePresetPanel { // 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') + ? this._adapter.i18n.localize('scrying-pool.scenePresetPanel.notifications.enabled') + : this._adapter.i18n.localize('scrying-pool.scenePresetPanel.notifications.disabled') ); } catch (err) { console.error('[ScryingPool] ScenePresetPanel: failed to toggle auto-apply', err); @@ -373,7 +373,7 @@ export class ScenePresetPanel { // Notify if (presetName) { this._adapter.notifications.info( - this._adapter.i18n.localize('video-view-manager.scenePresetPanel.notifications.presetSelected') + this._adapter.i18n.localize('scrying-pool.scenePresetPanel.notifications.presetSelected') .replace('{name}', presetName) ); } diff --git a/src/ui/gm/ScryingPoolStrip.js b/src/ui/gm/ScryingPoolStrip.js index 5cff196..39a5334 100644 --- a/src/ui/gm/ScryingPoolStrip.js +++ b/src/ui/gm/ScryingPoolStrip.js @@ -96,7 +96,7 @@ export class ScryingPoolStrip extends _AppBase { : super.defaultOptions ?? {}; return Object.assign({}, base, { id: 'scrying-pool-strip', - template: 'modules/video-view-manager/templates/roster-strip.hbs', + template: 'modules/scrying-pool/templates/roster-strip.hbs', popOut: true, resizable: false, title: 'Scrying Pool', @@ -140,7 +140,7 @@ export class ScryingPoolStrip extends _AppBase { getData() { const savedState = typeof game !== 'undefined' - ? game.user?.getFlag?.('video-view-manager', 'stripState') + ? game.user?.getFlag?.('scrying-pool', 'stripState') : null; if (savedState?.expanded !== undefined) { this._isExpanded = savedState.expanded; @@ -148,17 +148,24 @@ export class ScryingPoolStrip extends _AppBase { const showFirstOpenTip = typeof game !== 'undefined' && - !game.user?.getFlag?.('video-view-manager', 'firstStripOpen'); + !game.user?.getFlag?.('scrying-pool', 'firstStripOpen'); const userIds = this._adapter.users.all ? this._adapter.users.all().map(u => u.id) : []; + // Respect the showGMSelfFeed setting: if false, exclude the current GM user + const showGMSelfFeed = this._adapter.settings?.get?.('showGMSelfFeed') ?? true; + const currentUserId = this._adapter.users.current?.()?.id; + const filteredUserIds = showGMSelfFeed + ? userIds + : userIds.filter(id => id !== currentUserId); + // Check if we have stream access for video replacement (full AV replacement mode) const hasStreamAccess = this._adapter.webrtc?.getMediaStreamForUser !== undefined; const participants = buildParticipantList( - userIds, + filteredUserIds, this._stateStore, this._controller, this._adapter, @@ -208,18 +215,56 @@ export class ScryingPoolStrip extends _AppBase { }); } + // Custom close button (replaces Foundry window header close) + const closeBtn = el.querySelector('[data-action="close-strip"]'); + if (closeBtn) { + closeBtn.addEventListener('click', () => this.close()); + } + + // Drag grip — custom drag implementation (Foundry v14 ApplicationV1 does not expose its drag handler) + const grip = el.querySelector('[data-action="drag-grip"]'); + if (grip) { + grip.addEventListener('mousedown', e => { + if (e.button !== 0) return; + e.preventDefault(); + const startX = e.clientX; + const startY = e.clientY; + const { left: startLeft, top: startTop } = this.position; + + const onMove = mv => { + this.setPosition({ + left: startLeft + (mv.clientX - startX), + top: startTop + (mv.clientY - startY), + }); + }; + const onUp = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }); + } + // First open tip: set flag so it doesn't show again const isFirstOpen = typeof game !== 'undefined' && - !game.user?.getFlag?.('video-view-manager', 'firstStripOpen'); + !game.user?.getFlag?.('scrying-pool', 'firstStripOpen'); if (isFirstOpen) { - game.user?.setFlag?.('video-view-manager', 'firstStripOpen', true); + game.user?.setFlag?.('scrying-pool', 'firstStripOpen', true); } // Attach video streams if we have stream access (full AV replacement mode) if (this._adapter.webrtc?.getMediaStreamForUser !== undefined) { this._attachVideoStreams(el); } + + // Sync the outer Application window width with the expanded/collapsed state. + // The LESS max-width only applies to the inner template div (.scrying-pool.scrying-pool-strip); + // the outer window must be explicitly resized so it doesn't clip the expanded content. + if (typeof this.setPosition === 'function') { + this.setPosition({ width: this._isExpanded ? 240 : 44, height: 'auto' }); + } } /** @inheritdoc */ @@ -233,7 +278,7 @@ export class ScryingPoolStrip extends _AppBase { this._cleanupVideoStreams(); if (typeof game !== 'undefined') { - game.user?.setFlag?.('video-view-manager', 'stripState', { + game.user?.setFlag?.('scrying-pool', 'stripState', { left: this.position?.left, top: this.position?.top, open: false, @@ -267,7 +312,7 @@ export class ScryingPoolStrip extends _AppBase { _toggleExpanded() { this._isExpanded = !this._isExpanded; if (typeof game !== 'undefined') { - game.user?.setFlag?.('video-view-manager', 'stripState', { + game.user?.setFlag?.('scrying-pool', 'stripState', { left: this.position?.left, top: this.position?.top, open: true, diff --git a/src/ui/player/PlayerPrivacyPanel.js b/src/ui/player/PlayerPrivacyPanel.js index c4fe5b5..d57ab2e 100644 --- a/src/ui/player/PlayerPrivacyPanel.js +++ b/src/ui/player/PlayerPrivacyPanel.js @@ -58,7 +58,7 @@ export class PlayerPrivacyPanel extends _AppBase { static PARTS = { dialog: { - template: 'modules/video-view-manager/templates/player-privacy-panel.hbs', + template: 'modules/scrying-pool/templates/player-privacy-panel.hbs', }, }; @@ -157,13 +157,6 @@ export class PlayerPrivacyPanel extends _AppBase { enabled: settings.reactionCamEnabled, settingKey: 'reactionCamEnabled', }, - { - key: 'hpReactiveCamStyling', - label: i18n.localize('SCRYING_POOL.PrivacyPanel.hpReactiveCamStylingLabel'), - description: i18n.localize('SCRYING_POOL.PrivacyPanel.hpReactiveCamStylingDescription'), - enabled: settings.hpReactiveCamStylingEnabled, - settingKey: 'hpReactiveCamStylingEnabled', - }, ], // Toggle labels @@ -187,19 +180,28 @@ export class PlayerPrivacyPanel extends _AppBase { /** * Sets up event handlers after rendering. - * @param {HTMLElement|JQuery|object} html - The dialog element, jQuery object, or plain object with querySelector. + * @param {HTMLElement|JQuery|object} context - ApplicationV2 context, jQuery object, or plain object with querySelector. */ - _onRender(html) { - // Normalize html to element with querySelector - // FoundryVTT ApplicationV2 passes jQuery object, tests pass plain objects - const element = html instanceof HTMLElement - ? html - : (html?.[0] ?? html); + _onRender(context) { + // ApplicationV2 passes the template context as the first argument, not the element. + // Prefer this.element (the rendered root). Fall back to treating the argument as an element + // (for jQuery objects or test mocks that expose querySelector directly). + let element; + if (this.element instanceof HTMLElement) { + element = this.element; + } else if (context instanceof HTMLElement) { + element = context; + } else { + element = context?.[0] ?? context; + } if (!element || typeof element.querySelector !== 'function') return; // Cache toggle elements this._reactionCamToggle = element.querySelector('[data-setting="reactionCamEnabled"]'); - this._hpReactiveCamToggle = element.querySelector('[data-setting="hpReactiveCamStylingEnabled"]'); + + // Sync badge text with actual checkbox state (Handlebars renders from context which may + // be the default false state, while the real setting is already true in the DB). + this._syncToggleBadges(element); // Story 4.2: Set up portrait section event handlers this._setupPortraitHandlers(element); @@ -208,6 +210,27 @@ export class PlayerPrivacyPanel extends _AppBase { this._setupToggleHandlers(element); } + /** + * Syncs toggle badge text/icon with current checkbox state. + * Needed because Handlebars renders from context at render time, which may not + * match the actual persisted setting if it was already enabled. + * @param {HTMLElement} element - The panel root element. + * @private + */ + _syncToggleBadges(element) { + const onLabel = this._adapter.i18n.localize('SCRYING_POOL.PrivacyPanel.toggleOn'); + const offLabel = this._adapter.i18n.localize('SCRYING_POOL.PrivacyPanel.toggleOff'); + const toggles = element.querySelectorAll('.player-privacy-panel__toggle-input'); + for (const toggle of toggles) { + const span = toggle.nextElementSibling; + if (span?.classList.contains('player-privacy-panel__toggle-text')) { + span.innerHTML = toggle.checked + ? ` ${onLabel}` + : ` ${offLabel}`; + } + } + } + /** * Sets up event handlers for toggle switches. * @param {HTMLElement} element - The dialog element. @@ -428,6 +451,16 @@ export class PlayerPrivacyPanel extends _AppBase { this._currentSettings[settingKey] = newValue; } + // Update toggle badge text+icon dynamically + const span = checkbox.nextElementSibling; + if (span?.classList.contains('player-privacy-panel__toggle-text')) { + const onLabel = this._adapter.i18n.localize('SCRYING_POOL.PrivacyPanel.toggleOn'); + const offLabel = this._adapter.i18n.localize('SCRYING_POOL.PrivacyPanel.toggleOff'); + span.innerHTML = newValue + ? ` ${onLabel}` + : ` ${offLabel}`; + } + // Show success notification this._adapter.notifications.info( this._adapter.i18n.localize('SCRYING_POOL.PrivacyPanel.savedNotification') @@ -466,7 +499,6 @@ export class PlayerPrivacyPanel extends _AppBase { _onClose() { // Clear cached elements this._reactionCamToggle = null; - this._hpReactiveCamToggle = null; this._fileInput = null; this._portraitPreview = null; this._currentSettings = null; diff --git a/src/ui/player/VisibilityBadge.js b/src/ui/player/VisibilityBadge.js index cf4b07e..b967f63 100644 --- a/src/ui/player/VisibilityBadge.js +++ b/src/ui/player/VisibilityBadge.js @@ -501,7 +501,7 @@ export class VisibilityBadge { * @returns {boolean} */ _getFirstBadgeEncountered() { - return this._adapter.users.current()?.getFlag('video-view-manager', 'firstBadgeEncounter') ?? false; + return this._adapter.users.current()?.getFlag('scrying-pool', 'firstBadgeEncounter') ?? false; } /** @@ -509,7 +509,7 @@ export class VisibilityBadge { * @returns {Promise} */ async _setFirstBadgeEncountered() { - await this._adapter.users.current()?.setFlag('video-view-manager', 'firstBadgeEncounter', true); + await this._adapter.users.current()?.setFlag('scrying-pool', 'firstBadgeEncounter', true); } /** diff --git a/src/ui/shared/ScryingPoolCameraViews.js b/src/ui/shared/ScryingPoolCameraViews.js new file mode 100644 index 0000000..cf84aa3 --- /dev/null +++ b/src/ui/shared/ScryingPoolCameraViews.js @@ -0,0 +1,90 @@ +/** + * ScryingPoolCameraViews — replaces Foundry's CameraViews as CONFIG.ui.webrtc. + * + * Two responsibilities: + * 1. Redirect the "configure" camera action to the Scrying Pool Directors Board + * instead of Foundry's native AVConfig dialog. + * 2. Inject the Scrying Pool visibility state (sp-cam-hidden) into each user's + * camera context so the dock reflects the same hidden/active state as the + * module's state machine. + * + * Set as CONFIG.ui.webrtc in the 'init' hook (before Foundry instantiates ui.webrtc). + * Dependencies are injected after 'ready' via initScryingPoolCameraViews(). + */ + +// Lazy base class — avoids ReferenceError at module load time in tests where +// foundry globals are not defined. +function _getCameraViewsBase() { + if (typeof foundry !== 'undefined') { + const cls = foundry.applications?.apps?.av?.CameraViews; + if (cls) return cls; + } + // Minimal test-environment fallback — mirrors the pattern in DirectorsBoard.js + return class _FallbackCameraViews { + static DEFAULT_OPTIONS = {}; + static PARTS = {}; + constructor(options = {}) { this.options = options; } + async render() {} + async close() {} + _prepareUserContext(_id) { return {}; } + _onConfigure() {} + }; +} + +/** @type {object|null} DirectorsBoard instance — set via initScryingPoolCameraViews */ +let _directorsBoard = null; + +/** @type {object|null} StateStore instance — set via initScryingPoolCameraViews */ +let _stateStore = null; + +/** + * Inject module dependencies. Called from module.js after 'ready' resolves. + * @param {object|null} directorsBoard - The singleton DirectorsBoard (GM only, else null) + * @param {object} stateStore - The module StateStore + */ +export function initScryingPoolCameraViews(directorsBoard, stateStore) { + _directorsBoard = directorsBoard; + _stateStore = stateStore; +} + +export class ScryingPoolCameraViews extends _getCameraViewsBase() { + /** + * Intercept the configure camera button. + * Opens the Scrying Pool Directors Board instead of Foundry's AVConfig dialog. + * For non-GM players, shows an informational notification since A/V config + * is GM-controlled in this module. + * @override + */ + _onConfigure(event, target) { + if (_directorsBoard) { + _directorsBoard.render({ force: true }); + } else if (typeof ui !== 'undefined') { + ui.notifications?.info( + game?.i18n?.localize('scrying-pool.notifications.avConfigGMOnly') ?? + 'A/V settings are managed by the GM.' + ); + } + } + + /** + * Inject Scrying Pool visibility state into each user's camera tile context. + * Adds the 'sp-cam-hidden' CSS class when the SP state machine considers + * this user hidden, allowing the dock to visually reflect module state. + * @override + * @param {string} id - User ID + * @returns {object|undefined} + */ + _prepareUserContext(id) { + const ctx = super._prepareUserContext(id); + if (!ctx) return ctx; + + const spState = _stateStore?.getState?.(id) ?? 'active'; + const spHidden = spState === 'hidden'; + + if (spHidden) { + ctx.css = [ctx.css, 'sp-cam-hidden'].filter(Boolean).join(' '); + } + + return ctx; + } +} diff --git a/src/utils/boardUtils.js b/src/utils/boardUtils.js index bf5a371..4e51abc 100644 --- a/src/utils/boardUtils.js +++ b/src/utils/boardUtils.js @@ -23,16 +23,30 @@ export function resolveToggleTarget(currentState) { * @param {string} userId * @param {object} [user] - Optional user object for additional data * @param {object} [privacyManager] - Optional PlayerPrivacyManager for privacy settings + * @param {object} [controller] - Optional ScryingPoolController for pending-op state * @returns {object} Participant context */ -export function buildSimpleParticipantContext(stateStore, userId, user, privacyManager) { +export function buildSimpleParticipantContext(stateStore, userId, user, privacyManager, controller) { const state = stateStore.getState(userId); + const resolvedState = state ?? 'active'; const context = { userId, - state: state ?? 'active', + state: resolvedState, isGhost: state === 'ghost', + isHidden: resolvedState === 'hidden', + hasPendingOp: controller?._pendingOps?.has?.(userId) ?? false, }; + // Name, avatar and aria labels from the user object + if (user && typeof user === 'object') { + context.name = user.name ?? ''; + context.avatarSrc = user.avatar ?? 'icons/svg/mystery-man.svg'; + context.cardAriaLabel = user.name ?? userId; + context.toggleAriaLabel = resolvedState === 'hidden' + ? `Show ${user.name}` + : `Hide ${user.name}`; + } + // Add privacy settings if privacyManager is provided if (privacyManager && user) { try { @@ -60,7 +74,7 @@ export function buildBoardContext(stateStore, controller, adapter, privacyManage const users = adapter.users.all?.() ?? []; const participants = users.map(u => { const userId = u.id ?? u; - return buildSimpleParticipantContext(stateStore, userId, u, privacyManager); + return buildSimpleParticipantContext(stateStore, userId, u, privacyManager, controller); }); return { participants, isEmpty: participants.length === 0 }; } catch (err) { diff --git a/styles/components/_directors-board.less b/styles/components/_directors-board.less index fc7ad3d..92614bc 100644 --- a/styles/components/_directors-board.less +++ b/styles/components/_directors-board.less @@ -10,15 +10,79 @@ // via DEFAULT_OPTIONS.classes. The content section and footer live inside PARTS. .scrying-pool.directors-board { - background: var(--sp-surface); + background: linear-gradient(175deg, hsl(220, 18%, 13%) 0%, hsl(220, 15%, 10%) 100%); color: var(--sp-text-primary); + border: 1px solid rgba(255, 255, 255, 0.08); + border-top: 2px solid hsl(200, 55%, 40%); + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.55), 0 2px 8px rgba(0, 0, 0, 0.35), + inset 0 1px 0 rgba(255, 255, 255, 0.04); + + // ── Hide Foundry's default window header ────────────────────────────────── + header.window-header { display: none; } + + // The inner container is position:relative so the close btn can be absolute. + .directors-board__inner { + position: relative; + display: flex; + flex-direction: column; + height: 100%; + } + + // ── Drag grip ───────────────────────────────────────────────────────────── + .directors-board__grip { + width: 100%; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + cursor: grab; + color: var(--sp-text-muted, hsl(0, 0%, 70%)); + opacity: 0.35; + font-size: 10px; + flex-shrink: 0; + transition: opacity 0.15s, background 0.15s; + user-select: none; + border-radius: 8px 8px 0 0; + + &:hover { opacity: 0.8; background: rgba(255, 255, 255, 0.03); } + &:active { cursor: grabbing; opacity: 1; } + } + + // ── Custom close button ─────────────────────────────────────────────────── + .directors-board__close-btn { + position: absolute; + top: 2px; + right: 6px; + z-index: 10; + width: 20px; + height: 20px; + padding: 0; + line-height: 20px; + font-size: 14px; + font-weight: 400; + background: transparent; + color: var(--sp-text-muted); + border: none; + border-radius: var(--sp-radius-sm, 3px); + cursor: pointer; + opacity: 0.5; + transition: opacity 0.15s, background 0.15s, color 0.15s; + + &:hover { + opacity: 1; + background: rgba(255, 255, 255, 0.08); + color: var(--sp-text-primary); + } + &:active { opacity: 0.75; } + } // ── Participant grid ────────────────────────────────────────────────────── .directors-board__content { display: grid; - grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); - gap: 8px; - padding: 12px; + grid-template-columns: repeat(auto-fill, 68px); + gap: 6px; + padding: 8px; overflow-y: auto; flex: 1 1 auto; list-style: none; @@ -30,8 +94,8 @@ grid-column: 1 / -1; text-align: center; color: var(--sp-text-muted); - font-size: 13px; - padding: 24px 0; + font-size: 12px; + padding: 20px 0; margin: 0; } @@ -39,51 +103,57 @@ .directors-board__bulk-bar { display: flex; align-items: center; - gap: 6px; - padding: 6px 12px; - border-top: 1px solid var(--sp-border); + gap: 4px; + padding: 4px 8px; + border-top: 1px solid rgba(255, 255, 255, 0.06); flex-shrink: 0; flex-wrap: wrap; + background: rgba(0, 0, 0, 0.15); } .directors-board__bulk-btn { - font-size: 12px; - background: var(--sp-accent, #4a6f9c); - color: #fff; - border: none; + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + background: rgba(74, 111, 156, 0.7); + color: #e8edf2; + border: 1px solid rgba(74, 111, 156, 0.4); border-radius: 3px; - padding: 4px 10px; + padding: 3px 8px; cursor: pointer; - transition: opacity 0.15s; + transition: background 0.15s, opacity 0.15s; - &:hover { opacity: 0.85; } - &:active { opacity: 0.7; } + i { font-size: 10px; } + + &:hover { background: rgba(74, 111, 156, 0.95); } + &:active { opacity: 0.8; } - // Undo — secondary style &--undo { background: transparent; color: var(--sp-text-muted); - border: 1px solid var(--sp-border); + border: 1px solid rgba(255, 255, 255, 0.12); - &:hover { color: var(--sp-text, inherit); border-color: currentColor; } + &:hover { color: var(--sp-text-primary, #dde2e8); border-color: rgba(255,255,255,0.25); background: rgba(255,255,255,0.05); } } - // Restore — spotlight accent (distinct from Undo) &--restore { - background: var(--sp-spotlight-accent, #7b4fa6); + background: rgba(123, 79, 166, 0.7); + border-color: rgba(123, 79, 166, 0.4); + &:hover { background: rgba(123, 79, 166, 0.95); } } } // ── Help / shortcut-panel button ─────────────────────────────────────────── .directors-board__help-btn { margin-left: auto; - width: 22px; - height: 22px; + width: 20px; + height: 20px; border-radius: 50%; - border: 1px solid var(--sp-border); + border: 1px solid rgba(255, 255, 255, 0.15); background: transparent; color: var(--sp-text-muted); - font-size: 12px; + font-size: 11px; font-weight: bold; line-height: 1; cursor: pointer; @@ -92,37 +162,105 @@ align-items: center; justify-content: center; - &:hover { background: var(--sp-accent, #4a6f9c); color: #fff; border-color: transparent; } + &:hover { background: rgba(74, 111, 156, 0.8); color: #fff; border-color: transparent; } } - // ── Footer (preset actions) ──────────────────────────────────────────── + // ── Footer (preset actions) ──────────────────────────────────────────────── .directors-board__footer { display: flex; - gap: 8px; - padding: 8px 12px; - border-top: 1px solid var(--sp-border); + flex-direction: column; + gap: 3px; + padding: 5px 8px 6px; + border-top: 1px solid rgba(255, 255, 255, 0.06); flex-shrink: 0; + background: rgba(0, 0, 0, 0.2); + border-radius: 0 0 8px 8px; + } - &-btn { - flex: 1; - font-size: 12px; - background: var(--sp-accent, #4a6f9c); - color: #fff; - border: none; - border-radius: 3px; - padding: 4px 8px; - cursor: pointer; - transition: opacity 0.15s; + .directors-board__footer-group { + display: flex; + align-items: center; + gap: 3px; - &:hover { opacity: 0.85; } - &:active { opacity: 0.7; } - &[disabled] { - cursor: not-allowed; - opacity: 0.5; - background: transparent; - color: var(--sp-text-muted); - border: 1px solid var(--sp-border); - } + &--presets { flex-wrap: wrap; } + + &--controls { + padding-top: 3px; + border-top: 1px solid rgba(255, 255, 255, 0.05); + } + } + + .directors-board__footer-sep { + width: 1px; + align-self: stretch; + background: rgba(255, 255, 255, 0.1); + margin: 0 3px; + flex-shrink: 0; + } + + .directors-board__footer-btn { + flex: 1; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + font-size: 10px; + background: rgba(74, 111, 156, 0.65); + color: #d8e4f0; + border: 1px solid rgba(74, 111, 156, 0.35); + border-radius: 3px; + padding: 4px 6px; + cursor: pointer; + transition: background 0.15s, opacity 0.15s; + white-space: nowrap; + + i { font-size: 0.9em; } + + &:hover { background: rgba(74, 111, 156, 0.9); } + &:active { opacity: 0.75; } + + &[disabled] { + cursor: not-allowed; + opacity: 0.4; + background: transparent; + color: var(--sp-text-muted); + border: 1px solid rgba(255, 255, 255, 0.08); + } + + &--secondary { + background: rgba(255, 255, 255, 0.05); + color: var(--sp-text-secondary, #9aa4b0); + border: 1px solid rgba(255, 255, 255, 0.08); + &:hover { background: rgba(255, 255, 255, 0.1); color: #dde2e8; } + } + + &--auto-apply { + background: rgba(255, 255, 255, 0.05); + color: var(--sp-text-secondary, #9aa4b0); + border: 1px solid rgba(255, 255, 255, 0.08); + &:hover { background: rgba(255, 255, 255, 0.1); color: #dde2e8; } + } + + &--av { + background: rgba(30, 35, 45, 0.8); + border: 1px solid rgba(255, 255, 255, 0.1); + color: var(--sp-text-muted, #aaa); + &:hover { background: rgba(50, 55, 70, 0.9); } + } + + &--av-active { + background: rgba(90, 42, 42, 0.85); + border-color: rgba(160, 64, 64, 0.6); + color: #f0a0a0; + &:hover { background: rgba(110, 50, 50, 0.95); } + } + + &--av-config { + background: rgba(30, 35, 45, 0.8); + border: 1px solid rgba(255, 255, 255, 0.1); + color: var(--sp-text-muted, #aaa); + flex: 0 0 auto; + &:hover { background: rgba(50, 55, 70, 0.9); } } } } diff --git a/styles/components/_participant-card.less b/styles/components/_participant-card.less index f12a8c5..3ccf735 100644 --- a/styles/components/_participant-card.less +++ b/styles/components/_participant-card.less @@ -96,6 +96,10 @@ &__toggle { position: absolute; inset: 0; + // Foundry applies an explicit height to +
+ aria-label="{{localize "scrying-pool.directorsBoard.title"}}"> {{#unless isEmpty}} {{#each participants}} - {{> "modules/video-view-manager/templates/participant-card.hbs"}} + {{> "modules/scrying-pool/templates/participant-card.hbs"}} {{/each}} {{else}}

- {{localize "video-view-manager.directorsBoard.empty"}} + {{localize "scrying-pool.directorsBoard.empty"}}

{{/unless}}
- - {{#if hasUndo}} - {{/if}} {{#if hasRestore}} - {{/if}} - +
- - - - - + +
{{!-- Scene Preset Panel - rendered via JavaScript, not Handlebars --}} diff --git a/templates/participant-card.hbs b/templates/participant-card.hbs index a744141..a0e48da 100644 --- a/templates/participant-card.hbs +++ b/templates/participant-card.hbs @@ -29,6 +29,7 @@ data-user-id="{{userId}}" role="button" aria-label="{{toggleAriaLabel}}" + data-tooltip="{{toggleAriaLabel}}" tabindex="-1"> diff --git a/templates/player-privacy-panel.hbs b/templates/player-privacy-panel.hbs index 113d9e4..d842d17 100644 --- a/templates/player-privacy-panel.hbs +++ b/templates/player-privacy-panel.hbs @@ -1,9 +1,5 @@ {{!-- Player Privacy Panel --}}
-
-

{{title}}

-
-
{{#if isReadOnly}}
@@ -15,7 +11,7 @@

{{sectionHeader}}

- {{SCRYING_POOL.PrivacyPanel.sectionDescription}} + {{localize "SCRYING_POOL.PrivacyPanel.sectionDescription"}}

@@ -24,18 +20,22 @@

{{label}}

-