+53
-29
@@ -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)"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+2
-2
@@ -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": [
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<unknown> }} */ (currentScene).setFlag?.('video-view-manager', 'presets', flagData);
|
||||
await /** @type {object & { setFlag?: (scope: string, key: string, value: unknown) => Promise<unknown> }} */ (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<unknown> }} */ (scene).setFlag?.('video-view-manager', 'presets', newFlagData);
|
||||
await /** @type {object & { setFlag?: (scope: string, key: string, value: unknown) => Promise<unknown> }} */ (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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+82
-13
@@ -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) => {
|
||||
@@ -429,6 +434,28 @@ export class DirectorsBoard extends _AppBase {
|
||||
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);
|
||||
this._refreshPresetPanel();
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -190,7 +190,9 @@ export class GMPlayerPrivacySelectorMenu extends _AppBase {
|
||||
const id = escapeHtml(user.id ?? '');
|
||||
const role = user.isGM ? 'GM' : 'Player';
|
||||
return `
|
||||
<div class="sp-user-item" data-user-id="${id}">
|
||||
<div class="sp-user-item"
|
||||
data-user-id="${id}"
|
||||
data-tooltip="${name} — ${role}">
|
||||
<span class="sp-user-name">${name}</span>
|
||||
<span class="sp-user-role">${role}</span>
|
||||
</div>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
|
||||
@@ -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 `
|
||||
<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'))}
|
||||
${this._escapeHtml(this._adapter.i18n.localize('scrying-pool.scenePresetPanel.title'))}
|
||||
</h3>
|
||||
</div>
|
||||
<p class="directors-board__preset-panel-message">${this._escapeHtml(message)}</p>
|
||||
@@ -195,14 +195,14 @@ export class ScenePresetPanel {
|
||||
// Add default option
|
||||
const defaultOption = `
|
||||
<option value="" ${!presetName ? 'selected' : ''}>
|
||||
${this._escapeHtml(localize('video-view-manager.scenePresetPanel.selectPreset'))}
|
||||
${this._escapeHtml(localize('scrying-pool.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'))}
|
||||
${this._escapeHtml(localize('scrying-pool.scenePresetPanel.title'))}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -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'))}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="directors-board__preset-panel-row">
|
||||
<label class="directors-board__preset-panel-label">
|
||||
${this._escapeHtml(localize('video-view-manager.scenePresetPanel.preset'))}
|
||||
${this._escapeHtml(localize('scrying-pool.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'))}">
|
||||
aria-label="${this._escapeHtml(localize('scrying-pool.scenePresetPanel.selectPreset'))}">
|
||||
${defaultOption}
|
||||
${presetOptions}
|
||||
</select>
|
||||
@@ -234,7 +234,7 @@ export class ScenePresetPanel {
|
||||
|
||||
<div class="directors-board__preset-panel-row">
|
||||
<label class="directors-board__preset-panel-label">
|
||||
${this._escapeHtml(localize('video-view-manager.scenePresetPanel.preDelay'))}
|
||||
${this._escapeHtml(localize('scrying-pool.scenePresetPanel.preDelay'))}
|
||||
<span class="directors-board__preset-panel-delay-value">${preDelay}ms</span>
|
||||
<input type="range"
|
||||
class="directors-board__preset-panel-slider"
|
||||
@@ -243,7 +243,7 @@ export class ScenePresetPanel {
|
||||
max="${this._MAX_PREDELAY}"
|
||||
value="${preDelay}"
|
||||
step="100"
|
||||
aria-label="${this._escapeHtml(localize('video-view-manager.scenePresetPanel.preDelay'))}"
|
||||
aria-label="${this._escapeHtml(localize('scrying-pool.scenePresetPanel.preDelay'))}"
|
||||
aria-valuemin="${this._MIN_PREDELAY}"
|
||||
aria-valuemax="${this._MAX_PREDELAY}"
|
||||
aria-valuenow="${preDelay}">
|
||||
@@ -251,7 +251,7 @@ export class ScenePresetPanel {
|
||||
</div>
|
||||
|
||||
<div class="directors-board__preset-panel-row directors-board__preset-panel-row--hint">
|
||||
<span>${this._escapeHtml(localize('video-view-manager.scenePresetPanel.globalSettingsHint'))}</span>
|
||||
<span>${this._escapeHtml(localize('scrying-pool.scenePresetPanel.globalSettingsHint'))}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
? `<i class="fas fa-check" aria-hidden="true"></i> ${onLabel}`
|
||||
: `<i class="fas fa-times" aria-hidden="true"></i> ${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
|
||||
? `<i class="fas fa-check" aria-hidden="true"></i> ${onLabel}`
|
||||
: `<i class="fas fa-times" aria-hidden="true"></i> ${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;
|
||||
|
||||
@@ -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<void>}
|
||||
*/
|
||||
async _setFirstBadgeEncountered() {
|
||||
await this._adapter.users.current()?.setFlag('video-view-manager', 'firstBadgeEncounter', true);
|
||||
await this._adapter.users.current()?.setFlag('scrying-pool', 'firstBadgeEncounter', true);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+17
-3
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
.directors-board__footer-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
|
||||
&--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;
|
||||
font-size: 12px;
|
||||
background: var(--sp-accent, #4a6f9c);
|
||||
color: #fff;
|
||||
border: none;
|
||||
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 8px;
|
||||
padding: 4px 6px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
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; }
|
||||
|
||||
&:hover { opacity: 0.85; }
|
||||
&:active { opacity: 0.7; }
|
||||
&[disabled] {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
opacity: 0.4;
|
||||
background: transparent;
|
||||
color: var(--sp-text-muted);
|
||||
border: 1px solid var(--sp-border);
|
||||
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); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,10 @@
|
||||
&__toggle {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
// Foundry applies an explicit height to <button> elements which overrides
|
||||
// the bottom:0 stretch constraint. Force 100%×100% to fill the card.
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -10,9 +10,10 @@
|
||||
|
||||
.scrying-pool {
|
||||
// Dialog root element
|
||||
.player-privacy-panel {
|
||||
&.player-privacy-panel {
|
||||
background: var(--sp-surface);
|
||||
color: var(--sp-text-primary);
|
||||
font-family: var(--font-primary, inherit);
|
||||
}
|
||||
|
||||
// Container
|
||||
@@ -23,57 +24,50 @@
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
// Header
|
||||
.player-privacy-panel__header {
|
||||
padding: var(--sp-spacing-sm, 8px) var(--sp-spacing-md, 12px);
|
||||
border-bottom: 1px solid var(--sp-border);
|
||||
background: var(--sp-surface);
|
||||
}
|
||||
|
||||
.player-privacy-panel__title {
|
||||
margin: 0;
|
||||
font-size: 1.1em;
|
||||
font-weight: 600;
|
||||
color: var(--sp-text-primary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// Body
|
||||
.player-privacy-panel__body {
|
||||
padding: var(--sp-spacing-md, 12px);
|
||||
padding: 12px;
|
||||
background: var(--sp-surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
// Notice (read-only)
|
||||
.player-privacy-panel__notice {
|
||||
padding: var(--sp-spacing-sm, 8px) var(--sp-spacing-md, 12px);
|
||||
margin-bottom: var(--sp-spacing-md, 12px);
|
||||
border-radius: var(--sp-border-radius, 4px);
|
||||
font-size: 0.85em;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.player-privacy-panel__notice--readonly {
|
||||
background: var(--sp-urgency-awareness);
|
||||
color: var(--sp-text-secondary);
|
||||
border: 1px solid var(--sp-border);
|
||||
background: hsla(48, 88%, 55%, 0.10);
|
||||
color: hsl(48, 88%, 60%);
|
||||
border: 1px solid hsla(48, 88%, 55%, 0.25);
|
||||
}
|
||||
|
||||
// Section
|
||||
.player-privacy-panel__section {
|
||||
margin-bottom: var(--sp-spacing-md, 12px);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
// Section header — force UI font, no fantasy
|
||||
.player-privacy-panel__section-header {
|
||||
margin: 0 0 var(--sp-spacing-xs, 4px) 0;
|
||||
font-size: 0.95em;
|
||||
font-weight: 600;
|
||||
margin: 0 0 4px 0;
|
||||
font-family: var(--font-primary, ui-sans-serif, system-ui, sans-serif);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--sp-text-primary);
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.player-privacy-panel__section-description {
|
||||
margin: 0 0 var(--sp-spacing-md, 12px) 0;
|
||||
font-size: 0.85em;
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 12px;
|
||||
color: var(--sp-text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
@@ -82,40 +76,51 @@
|
||||
.player-privacy-panel__effects-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-spacing-md, 12px);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
// Individual effect
|
||||
// Individual effect card
|
||||
.player-privacy-panel__effect {
|
||||
padding: var(--sp-spacing-sm, 8px);
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--sp-border);
|
||||
border-radius: var(--sp-border-radius, 4px);
|
||||
background: var(--sp-surface-elevated);
|
||||
border-radius: 5px;
|
||||
background: linear-gradient(135deg, hsl(215,25%,11%) 0%, hsl(215,22%,9%) 100%);
|
||||
transition: border-color 120ms ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--sp-border-hover, rgba(255,255,255,0.15));
|
||||
}
|
||||
}
|
||||
|
||||
.player-privacy-panel__effect-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--sp-spacing-xs, 4px);
|
||||
margin-bottom: 4px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
// Effect label — force clean UI font
|
||||
.player-privacy-panel__effect-label {
|
||||
margin: 0;
|
||||
font-size: 0.9em;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-primary, ui-sans-serif, system-ui, sans-serif);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--sp-text-primary);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.player-privacy-panel__effect-description {
|
||||
margin: 0;
|
||||
font-size: 0.8em;
|
||||
font-size: 11px;
|
||||
color: var(--sp-text-secondary);
|
||||
line-height: 1.4;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
// Toggle switch
|
||||
// Toggle container
|
||||
.player-privacy-panel__toggle {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -123,13 +128,13 @@
|
||||
.player-privacy-panel__toggle-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-spacing-xs, 4px);
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-size: 0.85em;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
// Toggle input - visually hidden, uses custom styling
|
||||
// Toggle input — visually hidden
|
||||
.player-privacy-panel__toggle-input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
@@ -142,53 +147,66 @@
|
||||
}
|
||||
|
||||
&:disabled + .player-privacy-panel__toggle-text {
|
||||
opacity: 0.6;
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle badge — clearly different ON vs OFF
|
||||
.player-privacy-panel__toggle-text {
|
||||
display: inline-block;
|
||||
padding: var(--sp-spacing-xs, 4px) var(--sp-spacing-sm, 8px);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 9px;
|
||||
border: 1px solid var(--sp-border);
|
||||
border-radius: var(--sp-border-radius, 4px);
|
||||
background: var(--sp-surface);
|
||||
color: var(--sp-text-primary);
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease;
|
||||
border-radius: 20px;
|
||||
font-family: var(--font-primary, ui-sans-serif, system-ui, sans-serif);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
color: var(--sp-text-secondary);
|
||||
background: rgba(255,255,255,0.04);
|
||||
transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
|
||||
cursor: pointer;
|
||||
|
||||
i { font-size: 9px; opacity: 0.7; }
|
||||
|
||||
// Enabled state
|
||||
.player-privacy-panel__toggle-input:checked + & {
|
||||
background: var(--sp-accent);
|
||||
color: white;
|
||||
background: rgba(46, 160, 67, 0.18);
|
||||
color: hsl(134, 61%, 60%);
|
||||
border-color: rgba(46, 160, 67, 0.4);
|
||||
|
||||
i { opacity: 1; }
|
||||
}
|
||||
|
||||
// Hover (when not disabled)
|
||||
.player-privacy-panel__toggle-label:hover & {
|
||||
border-color: var(--sp-accent);
|
||||
}
|
||||
|
||||
.player-privacy-panel__toggle-input:disabled + & {
|
||||
background: var(--sp-surface);
|
||||
border-color: var(--sp-border);
|
||||
}
|
||||
|
||||
.player-privacy-panel__toggle-input:checked:disabled + & {
|
||||
background: var(--sp-accent);
|
||||
color: white;
|
||||
opacity: 0.6;
|
||||
.player-privacy-panel__toggle-input:checked + &:hover,
|
||||
.player-privacy-panel__toggle-label:hover .player-privacy-panel__toggle-input:checked + & {
|
||||
background: rgba(46, 160, 67, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
// Story 4.2: Portrait fallback section
|
||||
// Portrait section
|
||||
.player-privacy-panel__portrait-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-spacing-sm, 8px);
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.player-privacy-panel__portrait-preview {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
flex-shrink: 0;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border: 2px solid var(--sp-border);
|
||||
border-radius: var(--sp-border-radius, 4px);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--sp-surface-elevated);
|
||||
background: hsl(215,25%,11%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -202,13 +220,123 @@
|
||||
|
||||
.player-privacy-panel__portrait-actions {
|
||||
display: flex;
|
||||
gap: var(--sp-spacing-sm, 8px);
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.player-privacy-panel__portrait-choose,
|
||||
.player-privacy-panel__portrait-remove {
|
||||
font-size: 0.85em;
|
||||
padding: var(--sp-spacing-xs, 4px) var(--sp-spacing-sm, 8px);
|
||||
font-size: 12px;
|
||||
padding: 5px 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
// GM Privacy Selector dialog
|
||||
&.gm-privacy-selector-dialog {
|
||||
background: var(--sp-surface);
|
||||
border: 1px solid var(--sp-border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
|
||||
min-width: 320px;
|
||||
max-width: 480px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sp-gm-privacy-selector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--sp-surface);
|
||||
}
|
||||
|
||||
.sp-dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--sp-border);
|
||||
background: var(--sp-surface-raised);
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-family: var(--font-primary, ui-sans-serif, system-ui, sans-serif);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--sp-text-primary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.sp-close-button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--sp-text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 3px 6px;
|
||||
border-radius: 4px;
|
||||
line-height: 1;
|
||||
transition: color 120ms ease, background 120ms ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--sp-text-primary);
|
||||
background: var(--sp-surface-interactive);
|
||||
}
|
||||
}
|
||||
|
||||
.sp-gm-privacy-selector > p {
|
||||
padding: 7px 14px;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--sp-text-secondary);
|
||||
background: var(--sp-surface);
|
||||
border-bottom: 1px solid var(--sp-border);
|
||||
}
|
||||
|
||||
.sp-user-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px;
|
||||
gap: 4px;
|
||||
background: var(--sp-surface);
|
||||
}
|
||||
|
||||
.sp-user-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 7px 12px;
|
||||
border: 1px solid var(--sp-border);
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(135deg, hsl(215,25%,11%) 0%, hsl(215,22%,9%) 100%);
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, border-color 120ms ease;
|
||||
|
||||
&:hover {
|
||||
background: hsl(215,25%,14%);
|
||||
border-color: var(--sp-accent);
|
||||
}
|
||||
}
|
||||
|
||||
.sp-user-name {
|
||||
font-family: var(--font-primary, ui-sans-serif, system-ui, sans-serif);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--sp-text-primary);
|
||||
}
|
||||
|
||||
.sp-user-role {
|
||||
font-size: 11px;
|
||||
color: var(--sp-text-secondary);
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--sp-border);
|
||||
border-radius: 3px;
|
||||
background: rgba(255,255,255,0.04);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
// ============================================================================
|
||||
// Preset Import/Export Dialogs
|
||||
// Wrapper divs we added to satisfy the single-root-element requirement
|
||||
.sp-preset-export-dialog,
|
||||
.sp-preset-import-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
|
||||
// Story 3.3: Preset Import & Export
|
||||
// ============================================================================
|
||||
|
||||
@@ -159,9 +166,6 @@
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
|
||||
// Prevent pointer events from bubbling through
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sp-file-label {
|
||||
|
||||
@@ -1,14 +1,3 @@
|
||||
/**
|
||||
* styles/components/_preset-load-dialog.less
|
||||
*
|
||||
* Layout for the Preset Load Dialog.
|
||||
* All selectors scoped under .scrying-pool.
|
||||
* Uses --sp-* tokens only — no Foundry --color-* / --font-* / --border-* tokens.
|
||||
*/
|
||||
|
||||
// The ApplicationV2 window root already has .scrying-pool .preset-load-dialog applied
|
||||
// via DEFAULT_OPTIONS.classes. The content lives inside PARTS.
|
||||
|
||||
.scrying-pool.preset-load-dialog {
|
||||
background: var(--sp-surface);
|
||||
color: var(--sp-text-primary);
|
||||
@@ -21,23 +10,9 @@
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
// ── Header ────────────────────────────────────────────────────────────
|
||||
.preset-load-dialog__header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--sp-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.preset-load-dialog__title {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: var(--sp-text, inherit);
|
||||
}
|
||||
|
||||
// ── Body ──────────────────────────────────────────────────────────────
|
||||
.preset-load-dialog__body {
|
||||
padding: 16px;
|
||||
padding: 12px;
|
||||
overflow-y: auto;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
@@ -49,6 +24,7 @@
|
||||
font-size: 13px;
|
||||
padding: 24px 0;
|
||||
margin: 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
// ── Preset list ────────────────────────────────────────────────────────
|
||||
@@ -71,18 +47,28 @@
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 3px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s, border-color 0.15s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
// Load button — primary
|
||||
i { font-size: 0.85em; opacity: 0.7; flex-shrink: 0; }
|
||||
|
||||
// Load button — styled as a row item, not a loud primary button
|
||||
&--load {
|
||||
background: var(--sp-accent, #4a6f9c);
|
||||
color: #fff;
|
||||
border: none;
|
||||
background: var(--sp-surface-elevated, rgba(255,255,255,0.06));
|
||||
color: var(--sp-text-primary);
|
||||
border: 1px solid var(--sp-border);
|
||||
|
||||
&:hover { opacity: 0.85; }
|
||||
&:active { opacity: 0.7; }
|
||||
&:hover {
|
||||
background: var(--sp-accent, #4a6f9c);
|
||||
border-color: var(--sp-accent, #4a6f9c);
|
||||
color: #fff;
|
||||
i { opacity: 1; }
|
||||
}
|
||||
&:active { opacity: 0.85; }
|
||||
}
|
||||
|
||||
// Cancel button — secondary
|
||||
@@ -90,7 +76,6 @@
|
||||
background: transparent;
|
||||
color: var(--sp-text-muted);
|
||||
border: 1px solid var(--sp-border);
|
||||
|
||||
&:hover { color: var(--sp-text, inherit); border-color: currentColor; }
|
||||
}
|
||||
}
|
||||
@@ -100,7 +85,7 @@
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid var(--sp-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
/**
|
||||
* styles/components/_preset-save-dialog.less
|
||||
*
|
||||
* Layout for the Preset Save Dialog.
|
||||
* All selectors scoped under .scrying-pool.
|
||||
* Uses --sp-* tokens only — no Foundry --color-* / --font-* / --border-* tokens.
|
||||
* Preset Save Dialog — compact, polished design.
|
||||
* Follows SP token system; mirrors Directors Board aesthetic.
|
||||
*/
|
||||
|
||||
// The ApplicationV2 window root already has .scrying-pool .preset-save-dialog applied
|
||||
// via DEFAULT_OPTIONS.classes. The content lives inside PARTS.
|
||||
@import "../tokens/_base.less";
|
||||
|
||||
.scrying-pool.preset-save-dialog {
|
||||
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 var(--sp-accent);
|
||||
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);
|
||||
|
||||
// ── Form ───────────────────────────────────────────────────────────────
|
||||
// ── Form wrapper ──────────────────────────────────────────────────────────
|
||||
.preset-save-dialog__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -21,21 +22,33 @@
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
// ── Header ────────────────────────────────────────────────────────────
|
||||
// ── Header ────────────────────────────────────────────────────────────────
|
||||
.preset-save-dialog__header {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 14px 16px 10px;
|
||||
border-bottom: 1px solid var(--sp-border);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.preset-save-dialog__header-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 16px;
|
||||
color: var(--sp-accent);
|
||||
margin-top: 1px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.preset-save-dialog__title {
|
||||
.preset-save-dialog__description {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: var(--sp-text, inherit);
|
||||
font-family: var(--font-primary, ui-sans-serif, system-ui, sans-serif);
|
||||
font-size: 12px;
|
||||
color: var(--sp-text-secondary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
// ── Body ──────────────────────────────────────────────────────────────
|
||||
// ── Body ──────────────────────────────────────────────────────────────────
|
||||
.preset-save-dialog__body {
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
@@ -45,69 +58,93 @@
|
||||
.preset-save-dialog__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.preset-save-dialog__label {
|
||||
font-size: 12px;
|
||||
font-family: var(--font-primary, ui-sans-serif, system-ui, sans-serif);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--sp-text-muted);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.preset-save-dialog__input {
|
||||
width: 100%;
|
||||
font-family: var(--font-primary, ui-sans-serif, system-ui, sans-serif);
|
||||
font-size: 14px;
|
||||
padding: 6px 10px;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--sp-border);
|
||||
border-radius: 3px;
|
||||
background: var(--sp-bg, #fff);
|
||||
color: var(--sp-text, inherit);
|
||||
border-radius: 5px;
|
||||
background: var(--sp-control-bg, #1a1d20);
|
||||
color: var(--sp-text-primary);
|
||||
outline: none;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--sp-accent, #4a6f9c);
|
||||
box-shadow: 0 0 0 1px var(--sp-accent, #4a6f9c);
|
||||
border-color: var(--sp-accent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--sp-accent) 22%, transparent);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--sp-text-muted);
|
||||
font-style: italic;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Footer ────────────────────────────────────────────────────────────
|
||||
// ── Footer ────────────────────────────────────────────────────────────────
|
||||
.preset-save-dialog__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
padding: 10px 16px;
|
||||
border-top: 1px solid var(--sp-border);
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.preset-save-dialog__btn {
|
||||
font-family: var(--font-primary, ui-sans-serif, system-ui, sans-serif);
|
||||
font-size: 12px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 3px;
|
||||
font-weight: 600;
|
||||
padding: 7px 14px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: background 0.15s, border-color 0.15s, opacity 0.15s;
|
||||
|
||||
i { font-size: 10px; opacity: 0.85; }
|
||||
|
||||
// Save button — primary
|
||||
&--save {
|
||||
background: var(--sp-accent, #4a6f9c);
|
||||
color: #fff;
|
||||
border: none;
|
||||
background: var(--sp-accent);
|
||||
color: var(--sp-accent-text, #fff);
|
||||
border: 1px solid transparent;
|
||||
letter-spacing: 0.02em;
|
||||
|
||||
&:hover { opacity: 0.85; }
|
||||
&:active { opacity: 0.7; }
|
||||
&:hover { background: var(--sp-accent-hover); }
|
||||
&:active { background: var(--sp-accent-active); }
|
||||
|
||||
i { opacity: 1; }
|
||||
}
|
||||
|
||||
// Cancel button — secondary
|
||||
&--cancel {
|
||||
background: transparent;
|
||||
color: var(--sp-text-muted);
|
||||
color: var(--sp-text-secondary);
|
||||
border: 1px solid var(--sp-border);
|
||||
font-weight: 500;
|
||||
|
||||
&:hover { color: var(--sp-text, inherit); border-color: currentColor; }
|
||||
&:hover {
|
||||
color: var(--sp-text-primary);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,27 @@
|
||||
// ============================================================
|
||||
// ScryingPoolStrip Layout
|
||||
// ============================================================
|
||||
// Outer Foundry Application window (has .scrying-pool-strip via defaultOptions.classes).
|
||||
// Only visual appearance — sizing is controlled by JS setPosition().
|
||||
// WARNING: do NOT add max-width or overflow here; the outer window also carries this class
|
||||
// and would clip the expanded inner content.
|
||||
.scrying-pool-strip {
|
||||
background: var(--sp-bg, hsl(220, 15%, 12%));
|
||||
border-radius: 8px;
|
||||
|
||||
// Hide Foundry's default window header — replaced by a lightweight in-content button.
|
||||
header.window-header { display: none; }
|
||||
|
||||
// Remove window-content padding so the strip fills the frame edge-to-edge.
|
||||
.window-content {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
// Inner template div (has BOTH .scrying-pool AND .scrying-pool-strip).
|
||||
// Controls the expand/collapse behaviour; safe to use max-width + overflow here.
|
||||
.scrying-pool.scrying-pool-strip {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -26,17 +46,72 @@
|
||||
max-width: 44px;
|
||||
overflow: hidden;
|
||||
transition: max-width 200ms ease-in-out;
|
||||
background: var(--sp-bg, hsl(220, 15%, 12%));
|
||||
border-radius: 8px;
|
||||
|
||||
&.is-expanded {
|
||||
max-width: 240px;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Drag grip (top bar, replaces window header drag affordance) ────────────────
|
||||
.sp-strip__grip {
|
||||
width: 100%;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: grab;
|
||||
color: var(--sp-text, hsl(0, 0%, 80%));
|
||||
opacity: 0.35;
|
||||
font-size: 10px;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.15s;
|
||||
user-select: none;
|
||||
|
||||
&:hover { opacity: 0.75; }
|
||||
&:active { cursor: grabbing; opacity: 1; }
|
||||
}
|
||||
|
||||
// ── Lightweight close button (replaces window header) ─────────────────────────
|
||||
.sp-strip__close-btn {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
z-index: 10;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
line-height: 18px;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
background: transparent;
|
||||
color: var(--sp-text, hsl(0, 0%, 80%));
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
opacity: 0.45;
|
||||
transition: opacity 0.15s, background 0.15s;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
&:active { opacity: 0.75; }
|
||||
}
|
||||
|
||||
// ── Toolbar row: toggle + Director's Board on the same line ───────────────────
|
||||
.sp-strip__toolbar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.sp-strip__toggle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
min-width: 44px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -44,7 +119,39 @@
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--sp-text, hsl(0, 0%, 80%));
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.6;
|
||||
|
||||
&:hover { opacity: 1; }
|
||||
}
|
||||
|
||||
// ── Director's Board CTA button (shown when sidebar injection not available) ──
|
||||
.sp-strip__directors-board-cta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.06);
|
||||
cursor: pointer;
|
||||
color: var(--sp-text-secondary, #7a8390);
|
||||
font-size: 11px;
|
||||
text-align: left;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
flex-shrink: 0;
|
||||
|
||||
i { font-size: 12px; flex-shrink: 0; }
|
||||
span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--sp-text-primary, #dde2e8);
|
||||
}
|
||||
}
|
||||
|
||||
.sp-strip__participants {
|
||||
@@ -89,7 +196,59 @@
|
||||
|
||||
.is-expanded & {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
height: 135px; // 16:9 at 240 px strip width
|
||||
padding: 0;
|
||||
align-items: flex-end;
|
||||
background: hsl(220, 15%, 14%);
|
||||
|
||||
// Gradient scrim so name text is legible over any video
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 48px;
|
||||
background: linear-gradient(transparent, hsla(0, 0%, 0%, 0.72));
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
border-radius: 0 0 4px 4px;
|
||||
}
|
||||
|
||||
.sp-avatar__img {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 1;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.sp-avatar__name {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
z-index: 3;
|
||||
color: hsl(0, 0%, 95%);
|
||||
}
|
||||
|
||||
.sp-avatar__state-label {
|
||||
position: absolute;
|
||||
bottom: 6px;
|
||||
left: 8px;
|
||||
z-index: 3;
|
||||
color: hsla(0, 0%, 85%, 0.85);
|
||||
}
|
||||
|
||||
.sp-avatar__corner-badge {
|
||||
bottom: 6px;
|
||||
right: 6px;
|
||||
z-index: 4;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,239 +6,201 @@
|
||||
|
||||
// Panel container
|
||||
.directors-board__preset-panel {
|
||||
// Base styles
|
||||
background-color: var(--sp-surface);
|
||||
background: linear-gradient(160deg, hsl(215,28%,13%) 0%, hsl(215,25%,10%) 100%);
|
||||
border: 1px solid var(--sp-border);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px 12px;
|
||||
margin-top: 8px;
|
||||
|
||||
// Layout
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 0;
|
||||
|
||||
// Typography
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: var(--sp-text-primary);
|
||||
}
|
||||
|
||||
// Panel header
|
||||
.directors-board__preset-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
padding-bottom: 8px;
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 7px;
|
||||
border-bottom: 1px solid var(--sp-border-subtle);
|
||||
}
|
||||
|
||||
.directors-board__preset-panel-title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--sp-text-primary);
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
// Panel body
|
||||
.directors-board__preset-panel-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
// Panel row
|
||||
// Panel row — single horizontal line
|
||||
.directors-board__preset-panel-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
|
||||
&--hint {
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
color: var(--sp-text-secondary);
|
||||
margin-top: 4px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--sp-border-subtle);
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
// Panel label
|
||||
// Panel label — wraps control + text in a horizontal flex row
|
||||
.directors-board__preset-panel-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
|
||||
// Ensure proper spacing when label wraps
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// Toggle switch
|
||||
// Toggle checkbox — compact and styled
|
||||
.directors-board__preset-panel-toggle {
|
||||
// Button reset
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
|
||||
// Appearance
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid var(--sp-border);
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1.5px solid var(--sp-border);
|
||||
border-radius: 3px;
|
||||
background-color: var(--sp-surface);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: background-color 150ms ease, border-color 150ms ease;
|
||||
transition: background-color 120ms ease, border-color 120ms ease;
|
||||
margin: 0;
|
||||
|
||||
// Checked state
|
||||
&:checked {
|
||||
background-color: var(--sp-accent);
|
||||
border-color: var(--sp-accent);
|
||||
}
|
||||
|
||||
// After pseudo-element for toggle effect
|
||||
&:after {
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 1px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: var(--sp-surface);
|
||||
border-radius: 2px;
|
||||
transition: transform 150ms ease, background-color 150ms ease;
|
||||
left: 3px;
|
||||
width: 5px;
|
||||
height: 8px;
|
||||
border: 2px solid white;
|
||||
border-top: none;
|
||||
border-left: none;
|
||||
transform: rotate(40deg);
|
||||
}
|
||||
}
|
||||
|
||||
&:checked:after {
|
||||
transform: translateX(100%);
|
||||
background-color: var(--sp-surface-inverse, white);
|
||||
}
|
||||
|
||||
// Focus state
|
||||
&:focus {
|
||||
outline: 2px solid var(--sp-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
// Hover state
|
||||
&:hover:not(:checked) {
|
||||
background-color: var(--sp-surface-hover, rgba(0, 0, 0, 0.05));
|
||||
}
|
||||
}
|
||||
|
||||
// Preset selector
|
||||
// Preset selector — fills remaining space
|
||||
.directors-board__preset-panel-select {
|
||||
// Button reset
|
||||
background: transparent;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: var(--sp-surface);
|
||||
border: 1px solid var(--sp-border);
|
||||
padding: 6px 8px;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
|
||||
// Typography
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
color: var(--sp-text-primary);
|
||||
|
||||
// Border and radius
|
||||
border-radius: 4px;
|
||||
padding: 4px 6px;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
color: var(--sp-text-primary);
|
||||
cursor: pointer;
|
||||
transition: border-color 120ms ease;
|
||||
|
||||
// Transition
|
||||
transition: border-color 150ms ease, box-shadow 150ms ease;
|
||||
|
||||
// Hover state
|
||||
&:hover:not(:disabled) {
|
||||
border-color: var(--sp-border-hover, var(--sp-accent));
|
||||
border-color: var(--sp-accent);
|
||||
}
|
||||
|
||||
// Focus state
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--sp-focus);
|
||||
box-shadow: 0 0 0 2px rgba(var(--sp-focus-rgb, 0, 0, 255), 0.2);
|
||||
}
|
||||
|
||||
// Disabled state
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-delay slider container
|
||||
// Pre-delay section — label | value badge | slider
|
||||
.directors-board__preset-panel-delay-value {
|
||||
flex-shrink: 0;
|
||||
min-width: 38px;
|
||||
text-align: right;
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
color: var(--sp-text-secondary);
|
||||
background: rgba(0,0,0,0.2);
|
||||
border-radius: 3px;
|
||||
padding: 1px 4px;
|
||||
}
|
||||
|
||||
.directors-board__preset-panel-slider {
|
||||
// Remove default slider styling
|
||||
flex: 1;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 120px;
|
||||
height: 6px;
|
||||
background: var(--sp-surface-subtle, rgba(0, 0, 0, 0.1));
|
||||
border-radius: 3px;
|
||||
height: 4px;
|
||||
background: rgba(255,255,255,0.12);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
|
||||
// Webkit slider thumb
|
||||
&::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: var(--sp-accent);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: transform 150ms ease;
|
||||
transition: transform 120ms ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
&:hover { transform: scale(1.2); }
|
||||
}
|
||||
|
||||
// Firefox slider thumb
|
||||
&::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: var(--sp-accent);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: transform 150ms ease;
|
||||
transition: transform 120ms ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
// Focus state
|
||||
&:focus {
|
||||
outline: none;
|
||||
&:hover { transform: scale(1.2); }
|
||||
}
|
||||
}
|
||||
|
||||
// Delay value display
|
||||
.directors-board__preset-panel-delay-value {
|
||||
display: inline-block;
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: var(--sp-text-secondary);
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
// Panel message (when no scene)
|
||||
// Panel message (when no scene active)
|
||||
.directors-board__preset-panel-message {
|
||||
margin: 0;
|
||||
margin: 4px 0 0;
|
||||
color: var(--sp-text-secondary);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: 8px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
// Reduced motion: disable transitions
|
||||
// Reduced motion
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.directors-board__preset-panel-toggle,
|
||||
.directors-board__preset-panel-select,
|
||||
@@ -246,9 +208,9 @@
|
||||
.directors-board__preset-panel-slider::-moz-range-thumb {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.directors-board__preset-panel-slider::-webkit-slider-thumb:hover,
|
||||
.directors-board__preset-panel-slider::-moz-range-thumb:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+844
-186
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,9 @@
|
||||
// Story 3.2: ConfirmationBar and StripOverlayLayer
|
||||
@import "components/_strip-overlay-layer.less";
|
||||
@import "components/_confirmation-bar.less";
|
||||
// Story 3.1: Preset Save/Load Dialogs
|
||||
@import "components/_preset-save-dialog.less";
|
||||
@import "components/_preset-load-dialog.less";
|
||||
// Story 3.3: Preset Import/Export Dialogs
|
||||
@import "components/_preset-import-export.less";
|
||||
// Story 4.1: Player Privacy Panel
|
||||
@@ -75,6 +78,11 @@
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
// Hide Foundry's native camera dock — replaced by ScryingPoolCameraViews + our strip
|
||||
#camera-views {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
// Also hide individual camera views in case they're rendered elsewhere
|
||||
.camera-view {
|
||||
display: none !important;
|
||||
|
||||
@@ -21,18 +21,39 @@
|
||||
--sp-surface: var(--sp-theme-surface, var(--color-bg-option, #141618));
|
||||
--sp-surface-raised: var(--sp-theme-surface-raised, #1c1f22);
|
||||
--sp-surface-elevated: var(--sp-theme-surface-elevated, var(--sp-surface-raised, #1c1f22));
|
||||
--sp-surface-subtle: var(--sp-theme-surface-subtle, rgba(255, 255, 255, 0.05));
|
||||
--sp-surface-hover: var(--sp-theme-surface-hover, rgba(255, 255, 255, 0.08));
|
||||
--sp-border: var(--sp-theme-border, var(--color-border, #282c30));
|
||||
--sp-border-subtle: var(--sp-theme-border-subtle, rgba(255, 255, 255, 0.07));
|
||||
|
||||
/* Typography */
|
||||
--sp-text-primary: var(--sp-theme-text-primary, var(--color-text-primary, #dde2e8));
|
||||
--sp-text-secondary: var(--sp-theme-text-secondary,var(--color-text-secondary, #7a8390));
|
||||
--sp-text-muted: var(--sp-theme-text-muted, #555d66);
|
||||
--sp-font-size-sm: 11px;
|
||||
|
||||
/* Accent & interaction */
|
||||
--sp-accent: var(--sp-theme-accent, var(--color-warm-2, #4a9e6b));
|
||||
--sp-accent-hover: var(--sp-theme-accent-hover, #3d8f5e);
|
||||
--sp-accent-active: var(--sp-theme-accent-active, #2d7a4f);
|
||||
--sp-accent-text: #fff;
|
||||
--sp-surface-interactive: var(--sp-theme-interactive, #242830);
|
||||
--sp-control-bg: var(--sp-theme-control, #1a1d20);
|
||||
|
||||
/* Semantic colours */
|
||||
--sp-color-red: hsl(0, 65%, 55%);
|
||||
--sp-color-red-dark: hsl(0, 65%, 42%);
|
||||
--sp-color-red-rgb: 204, 77, 77;
|
||||
--sp-color-red-subtle: hsla(0, 65%, 55%, 0.12);
|
||||
--sp-color-green: hsl(140, 55%, 50%);
|
||||
--sp-color-green-subtle: hsla(140, 55%, 50%, 0.12);
|
||||
--sp-color-yellow: hsl(48, 88%, 55%);
|
||||
--sp-color-yellow-subtle: hsla(48, 88%, 55%, 0.12);
|
||||
|
||||
/* Shape & motion */
|
||||
--sp-radius-sm: 3px;
|
||||
--sp-radius-md: 4px;
|
||||
|
||||
/* Focus ring — module-wide keyboard navigation anchor */
|
||||
--sp-focus: var(--sp-theme-focus, var(--color-focus-outline, #63c287));
|
||||
--sp-focus-ring: 0 0 0 2px var(--sp-focus);
|
||||
@@ -51,11 +72,16 @@
|
||||
--sp-theme-surface: #141618;
|
||||
--sp-theme-surface-raised: #1c1f22;
|
||||
--sp-theme-surface-elevated: #1c1f22;
|
||||
--sp-theme-surface-subtle: rgba(255, 255, 255, 0.05);
|
||||
--sp-theme-surface-hover: rgba(255, 255, 255, 0.08);
|
||||
--sp-theme-border: #282c30;
|
||||
--sp-theme-border-subtle: rgba(255, 255, 255, 0.07);
|
||||
--sp-theme-text-primary: #dde2e8;
|
||||
--sp-theme-text-secondary: #7a8390;
|
||||
--sp-theme-text-muted: #555d66;
|
||||
--sp-theme-accent: #4a9e6b;
|
||||
--sp-theme-accent-hover: #3d8f5e;
|
||||
--sp-theme-accent-active: #2d7a4f;
|
||||
--sp-theme-interactive: #242830;
|
||||
--sp-theme-control: #1a1d20;
|
||||
--sp-theme-focus: #63c287;
|
||||
|
||||
@@ -1,58 +1,110 @@
|
||||
{{!-- Director's Board — GM camera-management overview window --}}
|
||||
<div class="directors-board__inner">
|
||||
|
||||
{{!-- Drag grip --}}
|
||||
<div class="directors-board__grip" data-action="drag-grip" aria-hidden="true">
|
||||
<i class="fas fa-grip-lines"></i>
|
||||
</div>
|
||||
|
||||
<button type="button"
|
||||
class="directors-board__close-btn"
|
||||
data-action="close"
|
||||
aria-label="{{localize "scrying-pool.directorsBoard.close"}}"
|
||||
data-tooltip="{{localize "scrying-pool.directorsBoard.close"}}">×</button>
|
||||
|
||||
<section class="scrying-pool directors-board__content"
|
||||
role="list"
|
||||
aria-label="{{localize "video-view-manager.directorsBoard.title"}}">
|
||||
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}}
|
||||
<p class="directors-board__empty" role="listitem">
|
||||
{{localize "video-view-manager.directorsBoard.empty"}}
|
||||
{{localize "scrying-pool.directorsBoard.empty"}}
|
||||
</p>
|
||||
{{/unless}}
|
||||
|
||||
</section>
|
||||
|
||||
<div class="directors-board__bulk-bar">
|
||||
<button type="button" class="directors-board__bulk-btn" data-action="show-all">
|
||||
{{localize "video-view-manager.directorsBoard.bulk.showAll"}}
|
||||
<button type="button" class="directors-board__bulk-btn" data-action="show-all"
|
||||
data-tooltip="{{localize "scrying-pool.directorsBoard.bulk.showAll"}}">
|
||||
<i class="fas fa-eye" aria-hidden="true"></i>
|
||||
{{localize "scrying-pool.directorsBoard.bulk.showAll"}}
|
||||
</button>
|
||||
<button type="button" class="directors-board__bulk-btn" data-action="hide-all">
|
||||
{{localize "video-view-manager.directorsBoard.bulk.hideAll"}}
|
||||
<button type="button" class="directors-board__bulk-btn" data-action="hide-all"
|
||||
data-tooltip="{{localize "scrying-pool.directorsBoard.bulk.hideAll"}}">
|
||||
<i class="fas fa-eye-slash" aria-hidden="true"></i>
|
||||
{{localize "scrying-pool.directorsBoard.bulk.hideAll"}}
|
||||
</button>
|
||||
{{#if hasUndo}}
|
||||
<button type="button" class="directors-board__bulk-btn directors-board__bulk-btn--undo" data-action="undo">
|
||||
{{localize "video-view-manager.directorsBoard.bulk.undo"}}
|
||||
<button type="button" class="directors-board__bulk-btn directors-board__bulk-btn--undo" data-action="undo"
|
||||
data-tooltip="{{localize "scrying-pool.directorsBoard.bulk.undo"}}">
|
||||
<i class="fas fa-undo" aria-hidden="true"></i>
|
||||
{{localize "scrying-pool.directorsBoard.bulk.undo"}}
|
||||
</button>
|
||||
{{/if}}
|
||||
{{#if hasRestore}}
|
||||
<button type="button" class="directors-board__bulk-btn directors-board__bulk-btn--restore" data-action="restore-spotlight">
|
||||
{{localize "video-view-manager.directorsBoard.bulk.restore"}}
|
||||
<button type="button" class="directors-board__bulk-btn directors-board__bulk-btn--restore" data-action="restore-spotlight"
|
||||
data-tooltip="{{localize "scrying-pool.directorsBoard.bulk.restore"}}">
|
||||
<i class="fas fa-star" aria-hidden="true"></i>
|
||||
{{localize "scrying-pool.directorsBoard.bulk.restore"}}
|
||||
</button>
|
||||
{{/if}}
|
||||
<button type="button" class="directors-board__help-btn" data-action="open-shortcut-panel" aria-label="{{localize "video-view-manager.directorsBoard.shortcuts.openPanel"}}">?</button>
|
||||
<button type="button" class="directors-board__help-btn" data-action="open-shortcut-panel"
|
||||
aria-label="{{localize "scrying-pool.directorsBoard.shortcuts.openPanel"}}"
|
||||
data-tooltip="{{localize "scrying-pool.directorsBoard.shortcuts.openPanel"}}">?</button>
|
||||
</div>
|
||||
|
||||
<footer class="directors-board__footer">
|
||||
<button type="button" class="directors-board__footer-btn" data-action="save-preset">
|
||||
{{localize "video-view-manager.directorsBoard.footer.savePreset"}}
|
||||
<div class="directors-board__footer-group directors-board__footer-group--presets">
|
||||
<button type="button" class="directors-board__footer-btn" data-action="save-preset"
|
||||
data-tooltip="{{localize "scrying-pool.directorsBoard.footer.savePreset"}}">
|
||||
<i class="fas fa-save" aria-hidden="true"></i>
|
||||
{{localize "scrying-pool.directorsBoard.footer.savePreset"}}
|
||||
</button>
|
||||
<button type="button" class="directors-board__footer-btn" data-action="load-preset" {{#unless hasPresets}}disabled{{/unless}}>
|
||||
{{localize "video-view-manager.directorsBoard.footer.loadPreset"}}
|
||||
<button type="button" class="directors-board__footer-btn" data-action="load-preset"
|
||||
data-tooltip="{{localize "scrying-pool.directorsBoard.footer.loadPreset"}}"
|
||||
{{#unless hasPresets}}disabled{{/unless}}>
|
||||
<i class="fas fa-folder-open" aria-hidden="true"></i>
|
||||
{{localize "scrying-pool.directorsBoard.footer.loadPreset"}}
|
||||
</button>
|
||||
<button type="button" class="directors-board__footer-btn" data-action="export-presets">
|
||||
{{localize "video-view-manager.directorsBoard.footer.exportPresets"}}
|
||||
<button type="button" class="directors-board__footer-btn directors-board__footer-btn--secondary" data-action="export-presets"
|
||||
data-tooltip="{{localize "scrying-pool.directorsBoard.footer.exportPresets"}}">
|
||||
<i class="fas fa-file-export" aria-hidden="true"></i>
|
||||
{{localize "scrying-pool.directorsBoard.footer.exportPresets"}}
|
||||
</button>
|
||||
<button type="button" class="directors-board__footer-btn" data-action="import-presets">
|
||||
{{localize "video-view-manager.directorsBoard.footer.importPresets"}}
|
||||
<button type="button" class="directors-board__footer-btn directors-board__footer-btn--secondary" data-action="import-presets"
|
||||
data-tooltip="{{localize "scrying-pool.directorsBoard.footer.importPresets"}}">
|
||||
<i class="fas fa-file-import" aria-hidden="true"></i>
|
||||
{{localize "scrying-pool.directorsBoard.footer.importPresets"}}
|
||||
</button>
|
||||
<button type="button" class="directors-board__footer-btn" data-action="toggle-preset-panel">
|
||||
{{localize "video-view-manager.directorsBoard.footer.autoApplySettings"}}
|
||||
</div>
|
||||
<div class="directors-board__footer-group directors-board__footer-group--controls">
|
||||
<button type="button" class="directors-board__footer-btn directors-board__footer-btn--auto-apply" data-action="toggle-preset-panel"
|
||||
data-tooltip="{{localize "scrying-pool.directorsBoard.footer.autoApplySettings"}}">
|
||||
<i class="fas fa-magic" aria-hidden="true"></i>
|
||||
{{localize "scrying-pool.directorsBoard.footer.autoApplySettings"}}
|
||||
</button>
|
||||
<div class="directors-board__footer-sep" role="separator" aria-hidden="true"></div>
|
||||
<button type="button" class="directors-board__footer-btn directors-board__footer-btn--av{{#if avModeEnabled}} directors-board__footer-btn--av-active{{/if}}" data-action="toggle-av-mode"
|
||||
data-tooltip="{{#if avModeEnabled}}{{localize "scrying-pool.directorsBoard.footer.avModeDisable"}}{{else}}{{localize "scrying-pool.directorsBoard.footer.avModeEnable"}}{{/if}}">
|
||||
{{#if avModeEnabled}}
|
||||
<i class="fas fa-video-slash" aria-hidden="true"></i>
|
||||
{{localize "scrying-pool.directorsBoard.footer.avModeDisable"}}
|
||||
{{else}}
|
||||
<i class="fas fa-video" aria-hidden="true"></i>
|
||||
{{localize "scrying-pool.directorsBoard.footer.avModeEnable"}}
|
||||
{{/if}}
|
||||
</button>
|
||||
<button type="button" class="directors-board__footer-btn directors-board__footer-btn--av-config" data-action="open-av-config"
|
||||
data-tooltip="{{localize "scrying-pool.directorsBoard.footer.avConfigTitle"}}">
|
||||
<i class="fas fa-sliders-h" aria-hidden="true"></i>
|
||||
{{localize "scrying-pool.directorsBoard.footer.avConfig"}}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{{!-- Scene Preset Panel - rendered via JavaScript, not Handlebars --}}
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
data-user-id="{{userId}}"
|
||||
role="button"
|
||||
aria-label="{{toggleAriaLabel}}"
|
||||
data-tooltip="{{toggleAriaLabel}}"
|
||||
tabindex="-1">
|
||||
<i class="fas {{#if isHidden}}fa-eye{{else}}fa-eye-slash{{/if}}" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
{{!-- Player Privacy Panel --}}
|
||||
<div class="player-privacy-panel__container">
|
||||
<header class="player-privacy-panel__header">
|
||||
<h2 class="player-privacy-panel__title">{{title}}</h2>
|
||||
</header>
|
||||
|
||||
<div class="player-privacy-panel__body">
|
||||
{{#if isReadOnly}}
|
||||
<div class="player-privacy-panel__notice player-privacy-panel__notice--readonly">
|
||||
@@ -15,7 +11,7 @@
|
||||
<h3 class="player-privacy-panel__section-header">{{sectionHeader}}</h3>
|
||||
|
||||
<p class="player-privacy-panel__section-description">
|
||||
{{SCRYING_POOL.PrivacyPanel.sectionDescription}}
|
||||
{{localize "SCRYING_POOL.PrivacyPanel.sectionDescription"}}
|
||||
</p>
|
||||
|
||||
<div class="player-privacy-panel__effects-list">
|
||||
@@ -24,18 +20,22 @@
|
||||
<div class="player-privacy-panel__effect-header">
|
||||
<h4 class="player-privacy-panel__effect-label">{{label}}</h4>
|
||||
<div class="player-privacy-panel__toggle">
|
||||
<label class="player-privacy-panel__toggle-label">
|
||||
<label class="player-privacy-panel__toggle-label"
|
||||
data-tooltip="{{description}}">
|
||||
<input
|
||||
type="checkbox"
|
||||
{{#if enabled}}checked{{/if}}
|
||||
{{#if ../isReadOnly}}disabled{{/if}}
|
||||
data-setting="{{settingKey}}"
|
||||
class="player-privacy-panel__toggle-input"
|
||||
aria-label="{{label}}"
|
||||
>
|
||||
<span class="player-privacy-panel__toggle-text">
|
||||
{{#if enabled}}
|
||||
<i class="fas fa-check" aria-hidden="true"></i>
|
||||
{{../toggleOnLabel}}
|
||||
{{else}}
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
{{../toggleOffLabel}}
|
||||
{{/if}}
|
||||
</span>
|
||||
@@ -64,7 +64,10 @@
|
||||
|
||||
{{#unless isReadOnly}}
|
||||
<div class="player-privacy-panel__portrait-actions">
|
||||
<button type="button" class="player-privacy-panel__portrait-choose sp-btn sp-btn--primary">
|
||||
<button type="button"
|
||||
class="player-privacy-panel__portrait-choose sp-btn sp-btn--primary"
|
||||
data-tooltip="{{chooseImageLabel}}">
|
||||
<i class="fas fa-image" aria-hidden="true"></i>
|
||||
{{chooseImageLabel}}
|
||||
</button>
|
||||
<input
|
||||
@@ -74,7 +77,10 @@
|
||||
style="display: none;"
|
||||
>
|
||||
{{#if hasCustomPortrait}}
|
||||
<button type="button" class="player-privacy-panel__portrait-remove sp-btn sp-btn--secondary">
|
||||
<button type="button"
|
||||
class="player-privacy-panel__portrait-remove sp-btn sp-btn--secondary"
|
||||
data-tooltip="{{removeImageLabel}}">
|
||||
<i class="fas fa-trash-alt" aria-hidden="true"></i>
|
||||
{{removeImageLabel}}
|
||||
</button>
|
||||
{{/if}}
|
||||
|
||||
@@ -1,29 +1,31 @@
|
||||
<div class="sp-dialog-content">
|
||||
<div class="sp-preset-export-dialog">
|
||||
<div class="sp-dialog-content">
|
||||
<p class="sp-export-description">
|
||||
{{localize "video-view-manager.presetExport.description"}}
|
||||
{{localize "scrying-pool.presetExport.description"}}
|
||||
</p>
|
||||
|
||||
<div class="sp-export-info">
|
||||
<span class="sp-info-label">{{localize "video-view-manager.presetExport.scene"}}:</span>
|
||||
<span class="sp-info-label">{{localize "scrying-pool.presetExport.scene"}}:</span>
|
||||
<span class="sp-info-value">{{sceneName}}</span>
|
||||
</div>
|
||||
|
||||
<div class="sp-export-info">
|
||||
<span class="sp-info-label">{{localize "video-view-manager.presetExport.presetCount"}}:</span>
|
||||
<span class="sp-info-label">{{localize "scrying-pool.presetExport.presetCount"}}:</span>
|
||||
<span class="sp-info-value">{{presetCount}}</span>
|
||||
</div>
|
||||
|
||||
<div class="sp-export-info">
|
||||
<span class="sp-info-label">{{localize "video-view-manager.presetExport.filename"}}:</span>
|
||||
<span class="sp-info-label">{{localize "scrying-pool.presetExport.filename"}}:</span>
|
||||
<span class="sp-info-value sp-filename">{{filename}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sp-dialog-buttons">
|
||||
<div class="sp-dialog-buttons">
|
||||
<button type="button" class="sp-btn sp-btn-primary sp-export-btn">
|
||||
<i class="fas fa-download"></i> {{localize "video-view-manager.presetExport.export"}}
|
||||
<i class="fas fa-download"></i> {{localize "scrying-pool.presetExport.export"}}
|
||||
</button>
|
||||
<button type="button" class="sp-btn sp-btn-secondary" data-action="close">
|
||||
{{localize "video-view-manager.presetExport.cancel"}}
|
||||
{{localize "scrying-pool.presetExport.cancel"}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+17
-15
@@ -1,23 +1,24 @@
|
||||
<div class="sp-dialog-content">
|
||||
<div class="sp-preset-import-dialog">
|
||||
<div class="sp-dialog-content">
|
||||
<p class="sp-import-description">
|
||||
{{localize "video-view-manager.presetImport.description"}}
|
||||
{{localize "scrying-pool.presetImport.description"}}
|
||||
</p>
|
||||
|
||||
{{#if hasExistingPresets}}
|
||||
<div class="sp-warning-box">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<span>{{localize "video-view-manager.presetImport.existingPresetsWarning" existingPresetCount=existingPresetCount}}</span>
|
||||
<span>{{localize "scrying-pool.presetImport.existingPresetsWarning" existingPresetCount=existingPresetCount}}</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{!-- File Selection --}}
|
||||
<div class="sp-form-group">
|
||||
<label class="sp-form-label">{{localize "video-view-manager.presetImport.selectFile"}}</label>
|
||||
<label class="sp-form-label">{{localize "scrying-pool.presetImport.selectFile"}}</label>
|
||||
<div class="sp-file-upload">
|
||||
<input type="file" class="sp-file-input" accept=".json" />
|
||||
<label class="sp-file-label">
|
||||
<i class="fas fa-upload"></i>
|
||||
<span class="sp-file-text">{{localize "video-view-manager.presetImport.chooseFile"}}</span>
|
||||
<span class="sp-file-text">{{localize "scrying-pool.presetImport.chooseFile"}}</span>
|
||||
</label>
|
||||
</div>
|
||||
{{#if selectedFileName}}
|
||||
@@ -30,17 +31,17 @@
|
||||
|
||||
{{!-- Mode Selection --}}
|
||||
<div class="sp-form-group">
|
||||
<label class="sp-form-label">{{localize "video-view-manager.presetImport.importMode"}}</label>
|
||||
<label class="sp-form-label">{{localize "scrying-pool.presetImport.importMode"}}</label>
|
||||
<div class="sp-radio-group">
|
||||
<label class="sp-radio-label">
|
||||
<input type="radio" name="import-mode" class="sp-mode-merge" value="merge" {{checked (eq mode "merge")}} />
|
||||
<span class="sp-radio-text">{{mergeLabel}}</span>
|
||||
<span class="sp-radio-hint">{{localize "video-view-manager.presetImport.importModeMergeHint"}}</span>
|
||||
<span class="sp-radio-hint">{{localize "scrying-pool.presetImport.importModeMergeHint"}}</span>
|
||||
</label>
|
||||
<label class="sp-radio-label">
|
||||
<input type="radio" name="import-mode" class="sp-mode-replace" value="replace" {{checked (eq mode "replace")}} />
|
||||
<span class="sp-radio-text">{{replaceLabel}}</span>
|
||||
<span class="sp-radio-hint">{{localize "video-view-manager.presetImport.importModeReplaceHint"}}</span>
|
||||
<span class="sp-radio-hint">{{localize "scrying-pool.presetImport.importModeReplaceHint"}}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -48,7 +49,7 @@
|
||||
{{!-- Preview Section --}}
|
||||
{{#if previewItems.length}}
|
||||
<div class="sp-preview-section">
|
||||
<h3 class="sp-preview-title">{{localize "video-view-manager.presetImport.previewTitle"}}</h3>
|
||||
<h3 class="sp-preview-title">{{localize "scrying-pool.presetImport.previewTitle"}}</h3>
|
||||
<ul class="sp-preview-list">
|
||||
{{#each previewItems as |item|}}
|
||||
<li class="sp-preview-item {{unless item.valid 'sp-preview-item--invalid'}}">
|
||||
@@ -68,23 +69,24 @@
|
||||
<div class="sp-confirmation-section">
|
||||
<div class="sp-confirmation-warning">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<span>{{localize "video-view-manager.presetImport.replaceConfirmation" existingPresetCount=existingPresetCount}}</span>
|
||||
<span>{{localize "scrying-pool.presetImport.replaceConfirmation" existingPresetCount=existingPresetCount}}</span>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sp-dialog-buttons">
|
||||
<div class="sp-dialog-buttons">
|
||||
{{#unless requiresConfirmation}}
|
||||
<button type="button" class="sp-btn sp-btn-primary sp-import-btn" {{disabled (not previewItems.length) }}>
|
||||
<i class="fas fa-file-import"></i> {{localize "video-view-manager.presetImport.import"}}
|
||||
<i class="fas fa-file-import"></i> {{localize "scrying-pool.presetImport.import"}}
|
||||
</button>
|
||||
{{else}}
|
||||
<button type="button" class="sp-btn sp-btn-danger sp-confirm-btn">
|
||||
<i class="fas fa-check"></i> {{localize "video-view-manager.presetImport.confirmReplace"}}
|
||||
<i class="fas fa-check"></i> {{localize "scrying-pool.presetImport.confirmReplace"}}
|
||||
</button>
|
||||
{{/unless}}
|
||||
<button type="button" class="sp-btn sp-btn-secondary sp-cancel-btn">
|
||||
{{localize "video-view-manager.presetImport.cancel"}}
|
||||
{{localize "scrying-pool.presetImport.cancel"}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
{{!-- Load Scene Preset Dialog --}}
|
||||
{{!-- Load Camera Layout Dialog --}}
|
||||
<div class="preset-load-dialog__content">
|
||||
<header class="preset-load-dialog__header">
|
||||
<h2 class="preset-load-dialog__title">{{title}}</h2>
|
||||
</header>
|
||||
|
||||
<div class="preset-load-dialog__body">
|
||||
{{#if hasPresets}}
|
||||
@@ -15,6 +12,7 @@
|
||||
data-action="load"
|
||||
data-preset-name="{{name}}"
|
||||
>
|
||||
<i class="fas fa-layer-group" aria-hidden="true"></i>
|
||||
{{name}}
|
||||
</button>
|
||||
</li>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
{{!-- Save Scene Preset Dialog --}}
|
||||
{{!-- Save Camera Layout Dialog --}}
|
||||
<form class="preset-save-dialog__form">
|
||||
<header class="preset-save-dialog__header">
|
||||
<h2 class="preset-save-dialog__title">{{title}}</h2>
|
||||
</header>
|
||||
|
||||
<div class="preset-save-dialog__header">
|
||||
<i class="fas fa-bookmark preset-save-dialog__header-icon"></i>
|
||||
<p class="preset-save-dialog__description">{{descriptionHint}}</p>
|
||||
</div>
|
||||
|
||||
<div class="preset-save-dialog__body">
|
||||
<div class="preset-save-dialog__field">
|
||||
@@ -25,9 +27,11 @@
|
||||
|
||||
<footer class="preset-save-dialog__footer">
|
||||
<button type="button" class="preset-save-dialog__btn preset-save-dialog__btn--cancel" data-action="cancel">
|
||||
<i class="fas fa-times"></i>
|
||||
{{cancelLabel}}
|
||||
</button>
|
||||
<button type="submit" class="preset-save-dialog__btn preset-save-dialog__btn--save">
|
||||
<i class="fas fa-bookmark"></i>
|
||||
{{saveLabel}}
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
@@ -3,6 +3,16 @@
|
||||
role="complementary"
|
||||
aria-label="Scrying Pool">
|
||||
|
||||
{{!-- Drag grip (top bar, replaces window header drag affordance) --}}
|
||||
<div class="sp-strip__grip" data-action="drag-grip" aria-hidden="true">
|
||||
<i class="fas fa-grip-lines"></i>
|
||||
</div>
|
||||
|
||||
{{!-- Lightweight close button (top-right corner, replaces window header) --}}
|
||||
<button class="sp-strip__close-btn" data-action="close-strip"
|
||||
aria-label="Close Scrying Pool"
|
||||
data-tooltip="Close">×</button>
|
||||
|
||||
{{!-- First-open tip (right-click affordance) --}}
|
||||
{{#if showFirstOpenTip}}
|
||||
<p class="sp-strip__first-tip">
|
||||
@@ -11,19 +21,22 @@
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
{{!-- Expand/collapse toggle --}}
|
||||
{{!-- Toolbar row: toggle + Director's Board side by side --}}
|
||||
<div class="sp-strip__toolbar">
|
||||
<button class="sp-strip__toggle" data-action="toggle-expanded"
|
||||
aria-label="{{#if isExpanded}}Collapse Scrying Pool{{else}}Expand Scrying Pool{{/if}}"
|
||||
aria-expanded="{{isExpanded}}">
|
||||
aria-expanded="{{isExpanded}}"
|
||||
data-tooltip="{{#if isExpanded}}Collapse{{else}}Expand{{/if}}">
|
||||
<i class="fas fa-chevron-{{#if isExpanded}}left{{else}}right{{/if}}"></i>
|
||||
</button>
|
||||
|
||||
{{!-- Director's Board CTA button (fallback when sidebar API unavailable) --}}
|
||||
<button class="sp-strip__directors-board-cta" data-action="open-directors-board"
|
||||
aria-label="Open Director's Board">
|
||||
aria-label="Open Director's Board"
|
||||
data-tooltip="Director's Board">
|
||||
<i class="fas fa-border-all" aria-hidden="true"></i>
|
||||
<span>Director's Board</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{!-- Participant list --}}
|
||||
<ul class="sp-strip__participants" role="list">
|
||||
@@ -42,6 +55,7 @@
|
||||
data-action="open-popover"
|
||||
role="button"
|
||||
aria-label="{{name}} — {{stateLabel}}"
|
||||
data-tooltip="{{name}} — {{stateLabel}}"
|
||||
aria-pressed="false">
|
||||
|
||||
{{!-- Video container for stream-access mode (full AV replacement) --}}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{{!-- Scene Preset Panel - per-scene auto-apply configuration --}}
|
||||
{{!-- Camera Layout Auto-Apply Panel - per-scene configuration --}}
|
||||
<div class="directors-board__preset-panel-header">
|
||||
<h3 class="directors-board__preset-panel-title">{{localize "video-view-manager.scenePresetPanel.title"}}</h3>
|
||||
<h3 class="directors-board__preset-panel-title">{{localize "scrying-pool.scenePresetPanel.title"}}</h3>
|
||||
</div>
|
||||
|
||||
{{#if hasScene}}
|
||||
@@ -12,20 +12,20 @@
|
||||
data-action="toggle-auto-apply"
|
||||
{{#if enabled}}checked{{/if}}
|
||||
role="switch"
|
||||
aria-label="{{localize 'video-view-manager.scenePresetPanel.enableAutoApply'}}">
|
||||
{{localize "video-view-manager.scenePresetPanel.enableAutoApply"}}
|
||||
aria-label="{{localize 'scrying-pool.scenePresetPanel.enableAutoApply'}}">
|
||||
{{localize "scrying-pool.scenePresetPanel.enableAutoApply"}}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="directors-board__preset-panel-row">
|
||||
<label class="directors-board__preset-panel-label">
|
||||
{{localize "video-view-manager.scenePresetPanel.preset"}}
|
||||
{{localize "scrying-pool.scenePresetPanel.preset"}}
|
||||
<select class="directors-board__preset-panel-select"
|
||||
data-action="select-preset"
|
||||
{{#unless hasPresets}}disabled{{/unless}}
|
||||
aria-label="{{localize 'video-view-manager.scenePresetPanel.selectPreset'}}">
|
||||
aria-label="{{localize 'scrying-pool.scenePresetPanel.selectPreset'}}">
|
||||
<option value="" {{#unless presetName}}selected{{/unless}}>
|
||||
{{localize "video-view-manager.scenePresetPanel.selectPreset"}}
|
||||
{{localize "scrying-pool.scenePresetPanel.selectPreset"}}
|
||||
</option>
|
||||
{{#each presets}}
|
||||
<option value="{{this.name}}" {{#if (eq this.name ../presetName)}}selected{{/if}}>
|
||||
@@ -38,7 +38,7 @@
|
||||
|
||||
<div class="directors-board__preset-panel-row">
|
||||
<label class="directors-board__preset-panel-label">
|
||||
{{localize "video-view-manager.scenePresetPanel.preDelay"}}
|
||||
{{localize "scrying-pool.scenePresetPanel.preDelay"}}
|
||||
<span class="directors-board__preset-panel-delay-value">{{preDelay}}ms</span>
|
||||
<input type="range"
|
||||
class="directors-board__preset-panel-slider"
|
||||
@@ -47,7 +47,7 @@
|
||||
max="5000"
|
||||
value="{{preDelay}}"
|
||||
step="100"
|
||||
aria-label="{{localize 'video-view-manager.scenePresetPanel.preDelay'}}"
|
||||
aria-label="{{localize 'scrying-pool.scenePresetPanel.preDelay'}}"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="5000"
|
||||
aria-valuenow="{{preDelay}}">
|
||||
@@ -55,9 +55,9 @@
|
||||
</div>
|
||||
|
||||
<div class="directors-board__preset-panel-row directors-board__preset-panel-row--hint">
|
||||
<span>{{localize "video-view-manager.scenePresetPanel.globalSettingsHint"}}</span>
|
||||
<span>{{localize "scrying-pool.scenePresetPanel.globalSettingsHint"}}</span>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="directors-board__preset-panel-message">{{localize "video-view-manager.scenePresetPanel.noScene"}}</p>
|
||||
<p class="directors-board__preset-panel-message">{{localize "scrying-pool.scenePresetPanel.noScene"}}</p>
|
||||
{{/if}}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* - Serveur FoundryVTT sur https://localhost:31000
|
||||
* - Monde déjà disponible
|
||||
* - Utilisateur: gamemaster (pas de mot de passe)
|
||||
* - Module Video View Manager déjà installé
|
||||
* - Module Scrying Pool déjà installé
|
||||
*
|
||||
* Ce setup vérifie simplement que tout est accessible.
|
||||
*/
|
||||
@@ -41,33 +41,33 @@ async function verifyFoundryAccessible(page) {
|
||||
console.error('💡 Vérifiez que:');
|
||||
console.error(' - FoundryVTT est en cours d\'exécution sur https://localhost:31000');
|
||||
console.error(' - Le monde est accessible');
|
||||
console.error(' - Le module Video View Manager est installé');
|
||||
console.error(' - Le module Scrying Pool est installé');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie que le module Video View Manager est actif
|
||||
* Vérifie que le module Scrying Pool est actif
|
||||
*/
|
||||
async function verifyModuleActive(page) {
|
||||
console.log('📦 Vérification du module Video View Manager...');
|
||||
console.log('📦 Vérification du module Scrying Pool...');
|
||||
|
||||
try {
|
||||
// Attendre que le module soit initialisé (check pour un élément spécifique)
|
||||
await page.waitForFunction(() => {
|
||||
return typeof game !== 'undefined' &&
|
||||
game.modules?.get?.('video-view-manager')?.active;
|
||||
game.modules?.get?.('scrying-pool')?.active;
|
||||
}, { timeout: 15000 });
|
||||
|
||||
const isActive = await page.evaluate(() => {
|
||||
const module = game.modules.get('video-view-manager');
|
||||
const module = game.modules.get('scrying-pool');
|
||||
return module?.active || false;
|
||||
});
|
||||
|
||||
if (isActive) {
|
||||
console.log('✅ Module Video View Manager est actif');
|
||||
console.log('✅ Module Scrying Pool est actif');
|
||||
} else {
|
||||
console.warn('⚠️ Module Video View Manager n\'est pas actif');
|
||||
console.warn('⚠️ Module Scrying Pool n\'est pas actif');
|
||||
console.warn(' Essayez de recharger la page (Ctrl+R)');
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* - Démarre le serveur FoundryVTT (si non déjà démarré)
|
||||
* - Crée un monde de test
|
||||
* - Configure les utilisateurs de test
|
||||
* - Installe le module Video View Manager
|
||||
* - Installe le module Scrying Pool
|
||||
*/
|
||||
|
||||
import { chromium } from '@playwright/test';
|
||||
@@ -100,20 +100,20 @@ async function configureTestUsers(page) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Installe le module Video View Manager
|
||||
* Installe le module Scrying Pool
|
||||
*/
|
||||
async function installVVMModule(page) {
|
||||
console.log('📦 Installing Video View Manager module...');
|
||||
console.log('📦 Installing Scrying Pool module...');
|
||||
|
||||
// Naviguer vers la gestion des modules
|
||||
await page.goto(`${FOUNDRY_BASE_URL}/setup/modules`);
|
||||
await page.waitForSelector('#modules-list', { timeout: 30000 });
|
||||
|
||||
// Vérifier si le module est déjà installé
|
||||
const moduleInstalled = await page.locator(`#modules-list [data-module-id="video-view-manager"]`).count();
|
||||
const moduleInstalled = await page.locator(`#modules-list [data-module-id="scrying-pool"]`).count();
|
||||
|
||||
if (moduleInstalled > 0) {
|
||||
console.log('✅ Video View Manager module already installed');
|
||||
console.log('✅ Scrying Pool module already installed');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ async function installVVMModule(page) {
|
||||
// Note: En environnement de test, le module devrait déjà être dans le dossier modules/
|
||||
// Sinon, il faut le copier manuellement
|
||||
console.log('⚠️ Module must be manually placed in FoundryVTT modules/ folder');
|
||||
console.log(' Copy video-view-manager/ to foundrydata-dev/Data/modules/');
|
||||
console.log(' Copy scrying-pool/ to foundrydata-dev/Data/modules/');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,7 +173,7 @@ async function globalSetup() {
|
||||
console.log(` - Player User: ${TEST_PLAYER_USER}`);
|
||||
console.log(` - Foundry URL: ${FOUNDRY_BASE_URL}`);
|
||||
console.log('\n💡 Ensure FoundryVTT server is running on localhost:30000');
|
||||
console.log('💡 Ensure Video View Manager module is in modules/ folder\n');
|
||||
console.log('💡 Ensure Scrying Pool module is in modules/ folder\n');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Setup failed:', error);
|
||||
|
||||
Generated
+2
-2
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "video-view-manager-e2e",
|
||||
"name": "scrying-pool-e2e",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "video-view-manager-e2e",
|
||||
"name": "scrying-pool-e2e",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@playwright/test": "^1.40.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "video-view-manager-e2e",
|
||||
"name": "scrying-pool-e2e",
|
||||
"version": "1.0.0",
|
||||
"description": "E2E tests for Video View Manager FoundryVTT module",
|
||||
"description": "E2E tests for Scrying Pool FoundryVTT module",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "playwright test",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Playwright configuration for Video View Manager E2E tests
|
||||
* Playwright configuration for Scrying Pool E2E tests
|
||||
*
|
||||
* Tests FoundryVTT module in a live browser environment
|
||||
* Configuration adaptée pour l'environnement local :
|
||||
@@ -14,7 +14,7 @@ import { defineConfig, devices } from '@playwright/test';
|
||||
* Configuration pour les tests E2E avec FoundryVTT
|
||||
*
|
||||
* Environnement :
|
||||
* - FoundryVTT v14 avec Video View Manager installé
|
||||
* - FoundryVTT v14 avec Scrying Pool installé
|
||||
* - Serveur FoundryVTT en cours sur https://localhost:31000
|
||||
* - Monde déjà disponible avec utilisateur "gamemaster"
|
||||
* - Pas de mot de passe requis
|
||||
|
||||
@@ -134,9 +134,9 @@ test.describe('Epic 1: Core Camera Visibility Control', () => {
|
||||
// Note: Cela nécessite une implémentation spécifique ou un mock
|
||||
// Pour les tests E2E, on peut utiliser l'API du module directement
|
||||
await page.evaluate((userId) => {
|
||||
if (game.modules.get('video-view-manager')) {
|
||||
if (game.modules.get('scrying-pool')) {
|
||||
// Appeler l'API interne si disponible
|
||||
const controller = game.modules.get('video-view-manager').api?.controller;
|
||||
const controller = game.modules.get('scrying-pool').api?.controller;
|
||||
if (controller) {
|
||||
controller.action('test', userId, 'self-muted');
|
||||
}
|
||||
@@ -219,8 +219,8 @@ test.describe('Epic 1: Core Camera Visibility Control', () => {
|
||||
test('Cam-lost participants show portrait fallback', async ({ page }) => {
|
||||
// Simuler la perte de caméra
|
||||
await page.evaluate((userId) => {
|
||||
if (game.modules.get('video-view-manager')) {
|
||||
const controller = game.modules.get('video-view-manager').api?.controller;
|
||||
if (game.modules.get('scrying-pool')) {
|
||||
const controller = game.modules.get('scrying-pool').api?.controller;
|
||||
if (controller) {
|
||||
controller.action('test', userId, 'cam-lost');
|
||||
}
|
||||
@@ -258,7 +258,7 @@ test.describe('Epic 1: Core Camera Visibility Control', () => {
|
||||
test('First encounter shows explanatory tooltip', async ({ page }) => {
|
||||
// Effacer le flag de premier badge
|
||||
await page.evaluate(() => {
|
||||
game.user?.unsetFlag('video-view-manager', 'firstBadgeEncounter');
|
||||
game.user?.unsetFlag('scrying-pool', 'firstBadgeEncounter');
|
||||
});
|
||||
|
||||
// Recharger la page pour déclencher le first encounter
|
||||
|
||||
@@ -499,8 +499,8 @@ test.describe('Epic 2: Player Notifications & Director\'s Board', () => {
|
||||
await page.locator('button[aria-label="Configure Settings"]').click();
|
||||
await page.waitForSelector('.app-v2.settings', { state: 'visible' });
|
||||
|
||||
// Sélectionner Video View Manager
|
||||
await page.locator('button:has-text("Video View Manager")').click();
|
||||
// Sélectionner Scrying Pool
|
||||
await page.locator('button:has-text("Scrying Pool")').click();
|
||||
|
||||
// Trouver le paramètre de verbosité
|
||||
await page.waitForSelector('.sp-notification-verbosity-select', { timeout: 5000 });
|
||||
@@ -518,7 +518,7 @@ test.describe('Epic 2: Player Notifications & Director\'s Board', () => {
|
||||
await page.locator('button[aria-label="Configure Settings"]').click();
|
||||
await page.waitForSelector('.app-v2.settings', { state: 'visible' });
|
||||
|
||||
await page.locator('button:has-text("Video View Manager")').click();
|
||||
await page.locator('button:has-text("Scrying Pool")').click();
|
||||
|
||||
await page.waitForSelector('.sp-notification-verbosity-select', { timeout: 5000 });
|
||||
await page.locator('.sp-notification-verbosity-select').selectOption('GM Only');
|
||||
@@ -533,7 +533,7 @@ test.describe('Epic 2: Player Notifications & Director\'s Board', () => {
|
||||
await page.locator('button[aria-label="Configure Settings"]').click();
|
||||
await page.waitForSelector('.app-v2.settings', { state: 'visible' });
|
||||
|
||||
await page.locator('button:has-text("Video View Manager")').click();
|
||||
await page.locator('button:has-text("Scrying Pool")').click();
|
||||
|
||||
await page.waitForSelector('.sp-notification-verbosity-select', { timeout: 5000 });
|
||||
await page.locator('.sp-notification-verbosity-select').selectOption('Silent');
|
||||
|
||||
@@ -130,7 +130,7 @@ test.describe('FR-15: Save Scene Preset', () => {
|
||||
|
||||
// Vérifier que le preset contient la matrice
|
||||
const presetData = await page.evaluate((presetName) => {
|
||||
const module = game.modules.get('video-view-manager');
|
||||
const module = game.modules.get('scrying-pool');
|
||||
if (module && module.api?.scenePresetManager) {
|
||||
const preset = module.api.scenePresetManager.getPreset(presetName);
|
||||
return preset?.matrix;
|
||||
@@ -289,7 +289,7 @@ test.describe('FR-17: Scene Auto-Apply', () => {
|
||||
|
||||
// Associer le preset à la scène actuelle
|
||||
await page.evaluate((presetName) => {
|
||||
const module = game.modules.get('video-view-manager');
|
||||
const module = game.modules.get('scrying-pool');
|
||||
if (module && module.api?.scenePresetManager) {
|
||||
const currentScene = game.scenes?.active;
|
||||
if (currentScene) {
|
||||
@@ -326,7 +326,7 @@ test.describe('FR-17: Scene Auto-Apply', () => {
|
||||
// Ce test vérifie que le délai est configurable
|
||||
// La valeur par défaut est 0ms
|
||||
const delay = await page.evaluate(() => {
|
||||
const module = game.modules.get('video-view-manager');
|
||||
const module = game.modules.get('scrying-pool');
|
||||
if (module && module.api?.scenePresetManager) {
|
||||
return module.api.scenePresetManager.autoApplyDelay;
|
||||
}
|
||||
@@ -345,7 +345,7 @@ test.describe('FR-17: Scene Auto-Apply', () => {
|
||||
await saveScenePreset(page, TEST_PRESET_NAME);
|
||||
|
||||
await page.evaluate((presetName) => {
|
||||
const module = game.modules.get('video-view-manager');
|
||||
const module = game.modules.get('scrying-pool');
|
||||
if (module && module.api?.scenePresetManager) {
|
||||
const currentScene = game.scenes?.active;
|
||||
if (currentScene) {
|
||||
@@ -389,7 +389,7 @@ test.describe('FR-18: Disable Auto-Apply', () => {
|
||||
|
||||
// Désactiver l'auto-apply pour la scène actuelle
|
||||
await page.evaluate((presetName) => {
|
||||
const module = game.modules.get('video-view-manager');
|
||||
const module = game.modules.get('scrying-pool');
|
||||
if (module && module.api?.scenePresetManager) {
|
||||
const currentScene = game.scenes?.active;
|
||||
if (currentScene) {
|
||||
@@ -427,7 +427,7 @@ test.describe('FR-18: Disable Auto-Apply', () => {
|
||||
test('Auto-apply can be disabled globally', async ({ page }) => {
|
||||
// Désactiver l'auto-apply globalement
|
||||
await page.evaluate(() => {
|
||||
const module = game.modules.get('video-view-manager');
|
||||
const module = game.modules.get('scrying-pool');
|
||||
if (module && module.api?.scenePresetManager) {
|
||||
module.api.scenePresetManager.setAutoApplyEnabled(false);
|
||||
}
|
||||
@@ -437,7 +437,7 @@ test.describe('FR-18: Disable Auto-Apply', () => {
|
||||
await saveScenePreset(page, TEST_PRESET_NAME);
|
||||
|
||||
await page.evaluate((presetName) => {
|
||||
const module = game.modules.get('video-view-manager');
|
||||
const module = game.modules.get('scrying-pool');
|
||||
if (module && module.api?.scenePresetManager) {
|
||||
const currentScene = game.scenes?.active;
|
||||
if (currentScene) {
|
||||
@@ -472,7 +472,7 @@ test.describe('FR-18: Disable Auto-Apply', () => {
|
||||
|
||||
// Réactiver l'auto-apply global
|
||||
await page.evaluate(() => {
|
||||
const module = game.modules.get('video-view-manager');
|
||||
const module = game.modules.get('scrying-pool');
|
||||
if (module && module.api?.scenePresetManager) {
|
||||
module.api.scenePresetManager.setAutoApplyEnabled(true);
|
||||
}
|
||||
@@ -482,7 +482,7 @@ test.describe('FR-18: Disable Auto-Apply', () => {
|
||||
test('Director\'s Board always provides manual override', async ({ page }) => {
|
||||
// Désactiver l'auto-apply
|
||||
await page.evaluate(() => {
|
||||
const module = game.modules.get('video-view-manager');
|
||||
const module = game.modules.get('scrying-pool');
|
||||
if (module && module.api?.scenePresetManager) {
|
||||
module.api.scenePresetManager.setAutoApplyEnabled(false);
|
||||
}
|
||||
@@ -510,7 +510,7 @@ test.describe('FR-18: Disable Auto-Apply', () => {
|
||||
|
||||
// Réactiver
|
||||
await page.evaluate(() => {
|
||||
const module = game.modules.get('video-view-manager');
|
||||
const module = game.modules.get('scrying-pool');
|
||||
if (module && module.api?.scenePresetManager) {
|
||||
module.api.scenePresetManager.setAutoApplyEnabled(true);
|
||||
}
|
||||
@@ -558,7 +558,7 @@ test.describe('FR-19: Preset Import/Export', () => {
|
||||
|
||||
// Capturer le contenu JSON
|
||||
const jsonContent = await page.evaluate(() => {
|
||||
const module = game.modules.get('video-view-manager');
|
||||
const module = game.modules.get('scrying-pool');
|
||||
if (module && module.api?.scenePresetManager) {
|
||||
return JSON.stringify(module.api.scenePresetManager.getAllPresets(), null, 2);
|
||||
}
|
||||
@@ -596,7 +596,7 @@ test.describe('FR-19: Preset Import/Export', () => {
|
||||
|
||||
// Importer via l'API
|
||||
await page.evaluate((data) => {
|
||||
const module = game.modules.get('video-view-manager');
|
||||
const module = game.modules.get('scrying-pool');
|
||||
if (module && module.api?.scenePresetManager) {
|
||||
module.api.scenePresetManager.importPresets(data, { merge: true });
|
||||
}
|
||||
@@ -604,7 +604,7 @@ test.describe('FR-19: Preset Import/Export', () => {
|
||||
|
||||
// Vérifier que les nouveaux presets existent
|
||||
const presetNames = await page.evaluate(() => {
|
||||
const module = game.modules.get('video-view-manager');
|
||||
const module = game.modules.get('scrying-pool');
|
||||
if (module && module.api?.scenePresetManager) {
|
||||
return Object.keys(module.api.scenePresetManager.getAllPresets());
|
||||
}
|
||||
@@ -624,7 +624,7 @@ test.describe('FR-19: Preset Import/Export', () => {
|
||||
|
||||
// Essayer d'importer du JSON invalide
|
||||
const result = await page.evaluate(() => {
|
||||
const module = game.modules.get('video-view-manager');
|
||||
const module = game.modules.get('scrying-pool');
|
||||
if (module && module.api?.scenePresetManager) {
|
||||
try {
|
||||
module.api.scenePresetManager.importPresets('invalid json', { merge: true });
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
*
|
||||
* FR-23: Player Privacy Panel accessible from module settings
|
||||
* FR-24: Reaction Cam automation requires explicit opt-in
|
||||
* FR-25: HP-Reactive Cam Styling requires explicit opt-in
|
||||
* FR-26: Custom Portrait Fallback settable via file picker
|
||||
*/
|
||||
|
||||
@@ -53,14 +52,11 @@ test.describe('FR-23: Player Privacy Panel Accessibility', () => {
|
||||
|
||||
// Vérifier que les sections existent
|
||||
const reactionCamSection = page.locator('.sp-automation-item:has-text("Reaction Cam")');
|
||||
const hpStylingSection = page.locator('.sp-automation-item:has-text("HP-Reactive")');
|
||||
|
||||
await expect(reactionCamSection).toBeVisible({ timeout: 5000 });
|
||||
await expect(hpStylingSection).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Vérifier les toggles
|
||||
const toggles = page.locator('.sp-toggle-switch');
|
||||
expect(await toggles.count()).toBeGreaterThanOrEqual(2);
|
||||
await expect(toggles).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('Panel shows opt-in status as ON/OFF for each automation', async ({ page }) => {
|
||||
@@ -81,7 +77,7 @@ test.describe('FR-23: Player Privacy Panel Accessibility', () => {
|
||||
|
||||
// Les contrôles devraient être désactivés
|
||||
const toggles = page.locator('.sp-toggle-switch:disabled');
|
||||
await expect(toggles).toHaveCount(2); // Reaction Cam et HP-Reactive
|
||||
await expect(toggles).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('Settings persist in world-level user flags', async ({ page }) => {
|
||||
@@ -163,7 +159,7 @@ test.describe('FR-24: Reaction Cam Automation', () => {
|
||||
// Si désactivé, le trigger ne devrait pas fonctionner
|
||||
// Simuler un trigger
|
||||
const triggerResult = await page.evaluate(() => {
|
||||
const module = game.modules.get('video-view-manager');
|
||||
const module = game.modules.get('scrying-pool');
|
||||
if (module && module.api?.reactionCam) {
|
||||
// Vérifier que Reaction Cam est désactivé
|
||||
return module.api.reactionCam.isEnabled();
|
||||
@@ -229,7 +225,7 @@ test.describe('FR-24: Reaction Cam Automation', () => {
|
||||
|
||||
// Simuler un trigger
|
||||
const result = await page.evaluate(() => {
|
||||
const module = game.modules.get('video-view-manager');
|
||||
const module = game.modules.get('scrying-pool');
|
||||
if (module && module.api?.reactionCam) {
|
||||
// Le trigger devrait skipper ce joueur
|
||||
return { skipped: true, reason: 'opted-out' };
|
||||
@@ -242,77 +238,6 @@ test.describe('FR-24: Reaction Cam Automation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// FR-25: HP-Reactive Cam Styling Opt-In
|
||||
// ============================================================================
|
||||
test.describe('FR-25: HP-Reactive Cam Styling', () => {
|
||||
test('HP-Reactive Cam Styling requires explicit opt-in (default: off)', async ({ page }) => {
|
||||
await openPlayerPrivacyPanel(page, TEST_USER);
|
||||
|
||||
const stylingToggle = page.locator('.sp-automation-item:has-text("HP-Reactive") .sp-toggle-switch');
|
||||
|
||||
// Vérifier que l'état par défaut est OFF
|
||||
const isChecked = await stylingToggle.evaluate(el => el.checked);
|
||||
expect(isChecked).toBe(false);
|
||||
});
|
||||
|
||||
test('HP-Reactive Styling remains disabled until explicitly enabled', async ({ page }) => {
|
||||
await openPlayerPrivacyPanel(page, TEST_USER);
|
||||
|
||||
const stylingToggle = page.locator('.sp-automation-item:has-text("HP-Reactive") .sp-toggle-switch');
|
||||
|
||||
// Simuler un changement de HP
|
||||
const result = await page.evaluate(() => {
|
||||
const module = game.modules.get('video-view-manager');
|
||||
if (module && module.api?.hpStyling) {
|
||||
// Vérifier que le styling est désactivé
|
||||
return module.api.hpStyling.isEnabled();
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test('GM is not notified of individual styling opt-in statuses', async ({ page }) => {
|
||||
// Activer HP-Reactive pour un joueur
|
||||
await openPlayerPrivacyPanel(page, TEST_USER);
|
||||
const stylingToggle = page.locator('.sp-automation-item:has-text("HP-Reactive") .sp-toggle-switch');
|
||||
await stylingToggle.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Vérifier qu'il n'y a pas de notification pour le GM
|
||||
// (Contrairement à Reaction Cam qui montre un badge)
|
||||
const notification = page.locator('.notification:has-text("HP-Reactive")');
|
||||
|
||||
// Devrait ne PAS voir de notification
|
||||
expect(await notification.count()).toBe(0);
|
||||
|
||||
// Nettoyer
|
||||
await stylingToggle.click();
|
||||
});
|
||||
|
||||
test('Styling opt-in flag persists across sessions', async ({ page }) => {
|
||||
// Activer
|
||||
await openPlayerPrivacyPanel(page, TEST_USER);
|
||||
const stylingToggle = page.locator('.sp-automation-item:has-text("HP-Reactive") .sp-toggle-switch');
|
||||
await stylingToggle.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Recharger
|
||||
await page.reload();
|
||||
await waitForFoundryReady(page);
|
||||
|
||||
// Vérifier
|
||||
await openPlayerPrivacyPanel(page, TEST_USER);
|
||||
const isChecked = await stylingToggle.evaluate(el => el.checked);
|
||||
expect(isChecked).toBe(true);
|
||||
|
||||
// Désactiver
|
||||
await stylingToggle.click();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// FR-26: Custom Portrait Fallback
|
||||
// ============================================================================
|
||||
@@ -388,7 +313,7 @@ test.describe('FR-26: Custom Portrait Fallback', () => {
|
||||
|
||||
// Via l'API, définir un portrait
|
||||
await page.evaluate(() => {
|
||||
const module = game.modules.get('video-view-manager');
|
||||
const module = game.modules.get('scrying-pool');
|
||||
if (module && module.api?.portraitFallbackHandler) {
|
||||
const dataURL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||
module.api.portraitFallbackHandler.setPortraitFallback('gamemaster', dataURL);
|
||||
@@ -412,7 +337,7 @@ test.describe('FR-26: Custom Portrait Fallback', () => {
|
||||
|
||||
// Définir un portrait personnalisé
|
||||
await page.evaluate(() => {
|
||||
const module = game.modules.get('video-view-manager');
|
||||
const module = game.modules.get('scrying-pool');
|
||||
if (module && module.api?.portraitFallbackHandler) {
|
||||
const dataURL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||
module.api.portraitFallbackHandler.setPortraitFallback('gamemaster', dataURL);
|
||||
@@ -421,7 +346,7 @@ test.describe('FR-26: Custom Portrait Fallback', () => {
|
||||
|
||||
// Forcer l'état never-connected pour un utilisateur
|
||||
await page.evaluate(() => {
|
||||
const module = game.modules.get('video-view-manager');
|
||||
const module = game.modules.get('scrying-pool');
|
||||
if (module && module.api?.controller) {
|
||||
module.api.controller.setState('gamemaster', 'never-connected');
|
||||
}
|
||||
@@ -462,7 +387,7 @@ async function testPortraitUpload(page, filename, shouldSucceed) {
|
||||
|
||||
// Pour l'instant, on simule via l'API
|
||||
const result = await page.evaluate(({ filename, shouldSucceed }) => {
|
||||
const module = game.modules.get('video-view-manager');
|
||||
const module = game.modules.get('scrying-pool');
|
||||
if (module && module.api?.portraitFallbackHandler) {
|
||||
if (shouldSucceed) {
|
||||
// Fichier valide
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Module Initialization Tests - Tests E2E
|
||||
*
|
||||
* Vérifie que le module Video View Manager s'initialise correctement
|
||||
* Vérifie que le module Scrying Pool s'initialise correctement
|
||||
* dans FoundryVTT et que toutes les fonctionnalités de base sont disponibles.
|
||||
*/
|
||||
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
clickFoundryButton,
|
||||
} from '../utils/foundry-helpers';
|
||||
|
||||
const MODULE_ID = 'video-view-manager';
|
||||
const MODULE_NAME = 'Video View Manager';
|
||||
const MODULE_ID = 'scrying-pool';
|
||||
const MODULE_NAME = 'Scrying Pool';
|
||||
|
||||
test.describe('Module Initialization', () => {
|
||||
test.setTimeout(60000);
|
||||
|
||||
@@ -22,13 +22,13 @@ export async function waitForFoundryReady(page, timeout = 30000) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Attend que le module Video View Manager soit actif
|
||||
* Attend que le module Scrying Pool soit actif
|
||||
* @param {import('@playwright/test').Page} page - La page Playwright
|
||||
* @param {number} timeout - Timeout en ms (défaut: 15000)
|
||||
*/
|
||||
export async function waitForVVMModule(page, timeout = 15000) {
|
||||
await page.waitForFunction(() => {
|
||||
const module = game.modules?.get?.('video-view-manager');
|
||||
const module = game.modules?.get?.('scrying-pool');
|
||||
return module?.active === true;
|
||||
}, { timeout });
|
||||
}
|
||||
@@ -100,7 +100,7 @@ export async function openPlayerPrivacyPanel(page, userId) {
|
||||
await openFoundrySidebar(page);
|
||||
|
||||
// Naviguer vers les paramètres du module
|
||||
await clickFoundryButton(page, 'button:has-text("Video View Manager")');
|
||||
await clickFoundryButton(page, 'button:has-text("Scrying Pool")');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Cliquer sur le bouton Player Privacy
|
||||
|
||||
@@ -27,7 +27,6 @@ export function createPlayerPrivacyManagerMock(overrides = {}) {
|
||||
// Settings retrieval
|
||||
getSettings: vi.fn(() => ({
|
||||
reactionCamEnabled: false,
|
||||
hpReactiveCamStylingEnabled: false,
|
||||
customPortraitFallback: null,
|
||||
})),
|
||||
// Portrait methods
|
||||
|
||||
@@ -29,7 +29,6 @@ describe("privacy-settings contract", () => {
|
||||
it("should export PRIVACY_SETTINGS_DEFAULT with all false and null portrait", () => {
|
||||
expect(PRIVACY_SETTINGS_DEFAULT).toEqual({
|
||||
reactionCamEnabled: false,
|
||||
hpReactiveCamStylingEnabled: false,
|
||||
customPortraitFallback: null,
|
||||
});
|
||||
});
|
||||
@@ -37,7 +36,6 @@ describe("privacy-settings contract", () => {
|
||||
it("should export PRIVACY_SETTING_KEYS as frozen array including portrait fallback", () => {
|
||||
expect(PRIVACY_SETTING_KEYS).toEqual([
|
||||
"reactionCamEnabled",
|
||||
"hpReactiveCamStylingEnabled",
|
||||
"customPortraitFallback",
|
||||
]);
|
||||
expect(Object.isFrozen(PRIVACY_SETTING_KEYS)).toBe(true);
|
||||
@@ -46,7 +44,6 @@ describe("privacy-settings contract", () => {
|
||||
it("should export FEATURE_NAME_MAP as frozen object", () => {
|
||||
expect(FEATURE_NAME_MAP).toEqual({
|
||||
reactionCam: "reactionCamEnabled",
|
||||
hpReactiveCamStyling: "hpReactiveCamStylingEnabled",
|
||||
});
|
||||
expect(Object.isFrozen(FEATURE_NAME_MAP)).toBe(true);
|
||||
});
|
||||
@@ -62,19 +59,6 @@ describe("privacy-settings contract", () => {
|
||||
const result = createPrivacySettings({ reactionCamEnabled: true });
|
||||
expect(result).toEqual({
|
||||
reactionCamEnabled: true,
|
||||
hpReactiveCamStylingEnabled: false,
|
||||
customPortraitFallback: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow both boolean settings to be overridden", () => {
|
||||
const result = createPrivacySettings({
|
||||
reactionCamEnabled: true,
|
||||
hpReactiveCamStylingEnabled: true,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
reactionCamEnabled: true,
|
||||
hpReactiveCamStylingEnabled: true,
|
||||
customPortraitFallback: null,
|
||||
});
|
||||
});
|
||||
@@ -84,7 +68,6 @@ describe("privacy-settings contract", () => {
|
||||
const result = createPrivacySettings({ customPortraitFallback: dataURL });
|
||||
expect(result).toEqual({
|
||||
reactionCamEnabled: false,
|
||||
hpReactiveCamStylingEnabled: false,
|
||||
customPortraitFallback: dataURL,
|
||||
});
|
||||
});
|
||||
@@ -102,7 +85,6 @@ describe("privacy-settings contract", () => {
|
||||
});
|
||||
expect(result).toEqual({
|
||||
reactionCamEnabled: true,
|
||||
hpReactiveCamStylingEnabled: false,
|
||||
customPortraitFallback: null,
|
||||
});
|
||||
});
|
||||
@@ -112,7 +94,6 @@ describe("privacy-settings contract", () => {
|
||||
it("should accept valid settings with all false", () => {
|
||||
const valid = {
|
||||
reactionCamEnabled: false,
|
||||
hpReactiveCamStylingEnabled: false,
|
||||
};
|
||||
expect(isValidPrivacySettings(valid)).toEqual(valid);
|
||||
});
|
||||
@@ -120,7 +101,6 @@ describe("privacy-settings contract", () => {
|
||||
it("should accept valid settings with all true", () => {
|
||||
const valid = {
|
||||
reactionCamEnabled: true,
|
||||
hpReactiveCamStylingEnabled: true,
|
||||
};
|
||||
expect(isValidPrivacySettings(valid)).toEqual(valid);
|
||||
});
|
||||
@@ -128,7 +108,6 @@ describe("privacy-settings contract", () => {
|
||||
it("should accept valid settings with mixed values", () => {
|
||||
const valid = {
|
||||
reactionCamEnabled: true,
|
||||
hpReactiveCamStylingEnabled: false,
|
||||
};
|
||||
expect(isValidPrivacySettings(valid)).toEqual(valid);
|
||||
});
|
||||
@@ -164,7 +143,6 @@ describe("privacy-settings contract", () => {
|
||||
it("should throw TypeError for unknown keys", () => {
|
||||
const invalid = {
|
||||
reactionCamEnabled: false,
|
||||
hpReactiveCamStylingEnabled: false,
|
||||
extraKey: "invalid",
|
||||
};
|
||||
expect(() => isValidPrivacySettings(invalid)).toThrow(TypeError);
|
||||
@@ -174,7 +152,6 @@ describe("privacy-settings contract", () => {
|
||||
it("should throw TypeError when reactionCamEnabled is not boolean", () => {
|
||||
const invalid = {
|
||||
reactionCamEnabled: "not a boolean",
|
||||
hpReactiveCamStylingEnabled: false,
|
||||
};
|
||||
expect(() => isValidPrivacySettings(invalid)).toThrow(TypeError);
|
||||
expect(() => isValidPrivacySettings(invalid)).toThrow(
|
||||
@@ -185,7 +162,6 @@ describe("privacy-settings contract", () => {
|
||||
it("should throw TypeError when reactionCamEnabled is a number", () => {
|
||||
const invalid = {
|
||||
reactionCamEnabled: 1,
|
||||
hpReactiveCamStylingEnabled: false,
|
||||
};
|
||||
expect(() => isValidPrivacySettings(invalid)).toThrow(TypeError);
|
||||
expect(() => isValidPrivacySettings(invalid)).toThrow(
|
||||
@@ -193,28 +169,6 @@ describe("privacy-settings contract", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw TypeError when hpReactiveCamStylingEnabled is not boolean", () => {
|
||||
const invalid = {
|
||||
reactionCamEnabled: false,
|
||||
hpReactiveCamStylingEnabled: "not a boolean",
|
||||
};
|
||||
expect(() => isValidPrivacySettings(invalid)).toThrow(TypeError);
|
||||
expect(() => isValidPrivacySettings(invalid)).toThrow(
|
||||
"hpReactiveCamStylingEnabled must be a boolean"
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw TypeError when hpReactiveCamStylingEnabled is null", () => {
|
||||
const invalid = {
|
||||
reactionCamEnabled: false,
|
||||
hpReactiveCamStylingEnabled: null,
|
||||
};
|
||||
expect(() => isValidPrivacySettings(invalid)).toThrow(TypeError);
|
||||
expect(() => isValidPrivacySettings(invalid)).toThrow(
|
||||
"hpReactiveCamStylingEnabled must be a boolean"
|
||||
);
|
||||
});
|
||||
|
||||
it("should accept settings with only reactionCamEnabled (backward compatible)", () => {
|
||||
// Backward compatibility: settings without all keys are accepted
|
||||
const valid = {
|
||||
@@ -223,14 +177,6 @@ describe("privacy-settings contract", () => {
|
||||
expect(() => isValidPrivacySettings(valid)).not.toThrow();
|
||||
});
|
||||
|
||||
it("should accept settings with only hpReactiveCamStylingEnabled (backward compatible)", () => {
|
||||
// Backward compatibility: settings without all keys are accepted
|
||||
const valid = {
|
||||
hpReactiveCamStylingEnabled: false,
|
||||
};
|
||||
expect(() => isValidPrivacySettings(valid)).not.toThrow();
|
||||
});
|
||||
|
||||
it("should accept empty object (backward compatible)", () => {
|
||||
// Backward compatibility: empty object is accepted
|
||||
expect(() => isValidPrivacySettings({})).not.toThrow();
|
||||
@@ -242,12 +188,6 @@ describe("privacy-settings contract", () => {
|
||||
expect(validateSettingKey("reactionCamEnabled")).toBe("reactionCamEnabled");
|
||||
});
|
||||
|
||||
it("should accept valid key: hpReactiveCamStylingEnabled", () => {
|
||||
expect(validateSettingKey("hpReactiveCamStylingEnabled")).toBe(
|
||||
"hpReactiveCamStylingEnabled"
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw TypeError for empty string", () => {
|
||||
expect(() => validateSettingKey("")).toThrow(TypeError);
|
||||
expect(() => validateSettingKey("")).toThrow(
|
||||
@@ -332,12 +272,6 @@ describe("privacy-settings contract", () => {
|
||||
expect(validateFeatureName("reactionCam")).toBe("reactionCam");
|
||||
});
|
||||
|
||||
it("should accept valid feature: hpReactiveCamStyling", () => {
|
||||
expect(validateFeatureName("hpReactiveCamStyling")).toBe(
|
||||
"hpReactiveCamStyling"
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw TypeError for empty string", () => {
|
||||
expect(() => validateFeatureName("")).toThrow(TypeError);
|
||||
expect(() => validateFeatureName("")).toThrow(
|
||||
@@ -399,7 +333,6 @@ describe("privacy-settings contract", () => {
|
||||
|
||||
it("should retain existing boolean settings", () => {
|
||||
expect(PRIVACY_SETTINGS_DEFAULT.reactionCamEnabled).toBe(false);
|
||||
expect(PRIVACY_SETTINGS_DEFAULT.hpReactiveCamStylingEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -410,7 +343,6 @@ describe("privacy-settings contract", () => {
|
||||
|
||||
it("should retain existing keys", () => {
|
||||
expect(PRIVACY_SETTING_KEYS).toContain("reactionCamEnabled");
|
||||
expect(PRIVACY_SETTING_KEYS).toContain("hpReactiveCamStylingEnabled");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -443,7 +375,6 @@ describe("privacy-settings contract", () => {
|
||||
it("should accept valid settings with customPortraitFallback as string", () => {
|
||||
const valid = {
|
||||
reactionCamEnabled: false,
|
||||
hpReactiveCamStylingEnabled: false,
|
||||
customPortraitFallback: "data:image/png;base64,test",
|
||||
};
|
||||
expect(isValidPrivacySettings(valid)).toEqual(valid);
|
||||
@@ -452,7 +383,6 @@ describe("privacy-settings contract", () => {
|
||||
it("should accept valid settings with customPortraitFallback as null", () => {
|
||||
const valid = {
|
||||
reactionCamEnabled: false,
|
||||
hpReactiveCamStylingEnabled: false,
|
||||
customPortraitFallback: null,
|
||||
};
|
||||
expect(isValidPrivacySettings(valid)).toEqual(valid);
|
||||
@@ -462,7 +392,6 @@ describe("privacy-settings contract", () => {
|
||||
// Backward compatibility - may not have the key
|
||||
const valid = {
|
||||
reactionCamEnabled: false,
|
||||
hpReactiveCamStylingEnabled: false,
|
||||
};
|
||||
// This should still work - null/undefined is acceptable
|
||||
expect(() => isValidPrivacySettings(valid)).not.toThrow();
|
||||
@@ -471,7 +400,6 @@ describe("privacy-settings contract", () => {
|
||||
it("should throw TypeError when customPortraitFallback is not string or null", () => {
|
||||
const invalid = {
|
||||
reactionCamEnabled: false,
|
||||
hpReactiveCamStylingEnabled: false,
|
||||
customPortraitFallback: 123,
|
||||
};
|
||||
expect(() => isValidPrivacySettings(invalid)).toThrow(TypeError);
|
||||
@@ -483,7 +411,6 @@ describe("privacy-settings contract", () => {
|
||||
it("should throw TypeError when customPortraitFallback is a boolean", () => {
|
||||
const invalid = {
|
||||
reactionCamEnabled: false,
|
||||
hpReactiveCamStylingEnabled: false,
|
||||
customPortraitFallback: true,
|
||||
};
|
||||
expect(() => isValidPrivacySettings(invalid)).toThrow(TypeError);
|
||||
@@ -495,7 +422,6 @@ describe("privacy-settings contract", () => {
|
||||
it("should throw TypeError when customPortraitFallback is an object", () => {
|
||||
const invalid = {
|
||||
reactionCamEnabled: false,
|
||||
hpReactiveCamStylingEnabled: false,
|
||||
customPortraitFallback: {},
|
||||
};
|
||||
expect(() => isValidPrivacySettings(invalid)).toThrow(TypeError);
|
||||
|
||||
@@ -98,10 +98,10 @@ describe("PlayerPrivacyManager", () => {
|
||||
});
|
||||
|
||||
it("should return saved settings when flag exists", () => {
|
||||
const savedSettings = { reactionCamEnabled: true, hpReactiveCamStylingEnabled: false, customPortraitFallback: null };
|
||||
const savedSettings = { reactionCamEnabled: true, customPortraitFallback: null };
|
||||
adapter.users.get.mockReturnValue({
|
||||
getFlag: vi.fn((scope, key) => {
|
||||
if (scope === "video-view-manager") {
|
||||
if (scope === "scrying-pool") {
|
||||
return savedSettings[key];
|
||||
}
|
||||
return undefined;
|
||||
@@ -114,7 +114,7 @@ describe("PlayerPrivacyManager", () => {
|
||||
it("should return partial settings merged with defaults", () => {
|
||||
adapter.users.get.mockReturnValue({
|
||||
getFlag: vi.fn((scope, key) => {
|
||||
if (scope === "video-view-manager" && key === "reactionCamEnabled") {
|
||||
if (scope === "scrying-pool" && key === "reactionCamEnabled") {
|
||||
return true;
|
||||
}
|
||||
return undefined;
|
||||
@@ -123,7 +123,6 @@ describe("PlayerPrivacyManager", () => {
|
||||
const result = manager.getSettings("user1");
|
||||
expect(result).toEqual({
|
||||
reactionCamEnabled: true,
|
||||
hpReactiveCamStylingEnabled: false,
|
||||
customPortraitFallback: null,
|
||||
});
|
||||
});
|
||||
@@ -182,7 +181,7 @@ describe("PlayerPrivacyManager", () => {
|
||||
adapter.users.get.mockReturnValue(mockUser);
|
||||
await manager.setSetting("user1", "reactionCamEnabled", true);
|
||||
expect(mockUser.setFlag).toHaveBeenCalledWith(
|
||||
"video-view-manager",
|
||||
"scrying-pool",
|
||||
"reactionCamEnabled",
|
||||
true
|
||||
);
|
||||
@@ -268,15 +267,6 @@ describe("PlayerPrivacyManager", () => {
|
||||
expect(manager.isOptedIn("nonexistent", "reactionCam")).toBe(false);
|
||||
});
|
||||
|
||||
it("should work for hpReactiveCamStyling feature", () => {
|
||||
adapter.users.get.mockReturnValue({
|
||||
getFlag: vi.fn((scope, key) => {
|
||||
if (key === "hpReactiveCamStylingEnabled") return true;
|
||||
return false;
|
||||
}),
|
||||
});
|
||||
expect(manager.isOptedIn("user1", "hpReactiveCamStyling")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllSettings", () => {
|
||||
@@ -295,7 +285,6 @@ describe("PlayerPrivacyManager", () => {
|
||||
id: "user1",
|
||||
getFlag: vi.fn((scope, key) => {
|
||||
if (key === "reactionCamEnabled") return true;
|
||||
if (key === "hpReactiveCamStylingEnabled") return false;
|
||||
return undefined; // customPortraitFallback and other keys
|
||||
}),
|
||||
};
|
||||
@@ -303,7 +292,6 @@ describe("PlayerPrivacyManager", () => {
|
||||
id: "user2",
|
||||
getFlag: vi.fn((scope, key) => {
|
||||
if (key === "reactionCamEnabled") return false;
|
||||
if (key === "hpReactiveCamStylingEnabled") return true;
|
||||
return undefined; // customPortraitFallback and other keys
|
||||
}),
|
||||
};
|
||||
@@ -318,12 +306,10 @@ describe("PlayerPrivacyManager", () => {
|
||||
expect(result.size).toBe(2);
|
||||
expect(result.get("user1")).toEqual({
|
||||
reactionCamEnabled: true,
|
||||
hpReactiveCamStylingEnabled: false,
|
||||
customPortraitFallback: null,
|
||||
});
|
||||
expect(result.get("user2")).toEqual({
|
||||
reactionCamEnabled: false,
|
||||
hpReactiveCamStylingEnabled: true,
|
||||
customPortraitFallback: null,
|
||||
});
|
||||
});
|
||||
@@ -429,34 +415,6 @@ describe("PlayerPrivacyManager", () => {
|
||||
expect(manager.isOptedIn("player1", "reactionCam")).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle player disabling HP-Reactive Cam Styling", async () => {
|
||||
const mockUser = {
|
||||
id: "player1",
|
||||
getFlag: vi.fn((scope, key) => {
|
||||
if (key === "hpReactiveCamStylingEnabled") return true;
|
||||
return false;
|
||||
}),
|
||||
setFlag: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
adapter.users.get.mockReturnValue(mockUser);
|
||||
|
||||
// Initially opted in
|
||||
expect(manager.isOptedIn("player1", "hpReactiveCamStyling")).toBe(true);
|
||||
|
||||
// Disable HP-Reactive Cam Styling
|
||||
await manager.setSetting("player1", "hpReactiveCamStylingEnabled", false);
|
||||
// After setting, the mock should return false for hpReactiveCamStylingEnabled
|
||||
adapter.users.get.mockReturnValue({
|
||||
id: "player1",
|
||||
getFlag: vi.fn((scope, key) => {
|
||||
if (key === "hpReactiveCamStylingEnabled") return false;
|
||||
return false;
|
||||
}),
|
||||
setFlag: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
expect(manager.isOptedIn("player1", "hpReactiveCamStyling")).toBe(false);
|
||||
});
|
||||
|
||||
it("should allow GM to view all players' settings", () => {
|
||||
const gm = { id: "gm1", isGM: true, getFlag: vi.fn(() => false) };
|
||||
const player1 = {
|
||||
@@ -465,7 +423,7 @@ describe("PlayerPrivacyManager", () => {
|
||||
};
|
||||
const player2 = {
|
||||
id: "player2",
|
||||
getFlag: vi.fn((scope, key) => (key === "hpReactiveCamStylingEnabled" ? true : false)),
|
||||
getFlag: vi.fn((scope, key) => (key === "reactionCamEnabled" ? false : false)),
|
||||
};
|
||||
|
||||
adapter.users.all.mockReturnValue([gm, player1, player2]);
|
||||
@@ -518,7 +476,7 @@ describe("PlayerPrivacyManager", () => {
|
||||
).resolves.not.toThrow();
|
||||
|
||||
expect(mockUser.setFlag).toHaveBeenCalledWith(
|
||||
"video-view-manager",
|
||||
"scrying-pool",
|
||||
"customPortraitFallback",
|
||||
dataURL
|
||||
);
|
||||
@@ -648,7 +606,7 @@ describe("PlayerPrivacyManager", () => {
|
||||
await manager.removePortraitFallback("player1");
|
||||
|
||||
expect(mockUser.unsetFlag).toHaveBeenCalledWith(
|
||||
"video-view-manager",
|
||||
"scrying-pool",
|
||||
"customPortraitFallback"
|
||||
);
|
||||
});
|
||||
@@ -686,7 +644,6 @@ describe("PlayerPrivacyManager", () => {
|
||||
getFlag: vi.fn((scope, key) => {
|
||||
if (key === "customPortraitFallback") return dataURL;
|
||||
if (key === "reactionCamEnabled") return true;
|
||||
if (key === "hpReactiveCamStylingEnabled") return false;
|
||||
return undefined;
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -815,7 +815,7 @@ describe('ScenePresetManager', () => {
|
||||
});
|
||||
|
||||
expect(mockScene.setFlag).toHaveBeenCalledWith(
|
||||
'video-view-manager',
|
||||
'scrying-pool',
|
||||
'presets',
|
||||
expect.objectContaining({
|
||||
_version: 1,
|
||||
|
||||
@@ -84,14 +84,15 @@ describe('ScryingPoolController', () => {
|
||||
// ── AC-2: action() happy path ─────────────────────────────────────────────
|
||||
|
||||
describe('action() happy path (AC-2)', () => {
|
||||
it('stores a PendingOp in _pendingOps keyed by participantId', () => {
|
||||
it('registers a PendingOp via socketHandler.registerPendingOp with correct shape', () => {
|
||||
// With self-confirm, _pendingOps is cleared synchronously after action().
|
||||
// Verify the op was passed to registerPendingOp before being confirmed.
|
||||
controller.action('ui', 'user-1', 'hidden', 'op-1', 0);
|
||||
expect(controller._pendingOps.has('user-1')).toBe(true);
|
||||
expect(controller._pendingOps.get('user-1')).toMatchObject({
|
||||
opId: 'op-1',
|
||||
userId: 'user-1',
|
||||
targetState: 'hidden',
|
||||
});
|
||||
expect(socketHandler.registerPendingOp).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ opId: 'op-1', userId: 'user-1', targetState: 'hidden' }),
|
||||
'scrying-pool.visibility.set',
|
||||
expect.objectContaining({ opId: 'op-1' })
|
||||
);
|
||||
});
|
||||
|
||||
it('calls stateStore.setVisibility with the target state (optimistic update)', () => {
|
||||
@@ -117,18 +118,24 @@ describe('ScryingPoolController', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('fires Hooks.callAll scrying-pool:controllerAction with correct payload', () => {
|
||||
it('fires Hooks.callAll scrying-pool:controllerAction after self-confirm', () => {
|
||||
// Self-confirm calls _onEcho which fires the hook with source: 'echo'.
|
||||
controller.action('ui', 'user-1', 'hidden', 'op-1', 0);
|
||||
expect(hooksStub.callAll).toHaveBeenCalledWith(
|
||||
'scrying-pool:controllerAction',
|
||||
expect.objectContaining({ participantId: 'user-1', targetState: 'hidden', source: 'ui', opId: 'op-1' })
|
||||
expect.objectContaining({ participantId: 'user-1', targetState: 'hidden', source: 'echo', opId: 'op-1' })
|
||||
);
|
||||
});
|
||||
|
||||
it('sets previousState to null-coalesced "never-connected" when participant is new', () => {
|
||||
it('sets previousState to "active" when participant is new (not yet in matrix)', () => {
|
||||
// With self-confirm, _pendingOps is cleared synchronously. Verify via registerPendingOp arg.
|
||||
controller.action('ui', 'new-user', 'hidden', 'op-1', 0);
|
||||
const op = controller._pendingOps.get('new-user');
|
||||
expect(op.previousState).toBe('never-connected');
|
||||
// 'active' is the render-time default for users not in the matrix.
|
||||
expect(socketHandler.registerPendingOp).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ previousState: 'active' }),
|
||||
expect.any(String),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -226,23 +233,30 @@ describe('ScryingPoolController', () => {
|
||||
return adapter.socket.on.mock.calls[0][1];
|
||||
}
|
||||
|
||||
// Helper: directly register a pending op (bypasses action() self-confirm)
|
||||
function seedPendingOp(userId, opId, targetState = 'hidden') {
|
||||
const op = { opId, userId, targetState, previousState: 'active' };
|
||||
controller._pendingOps.set(userId, op);
|
||||
socketHandler.registerPendingOp(op, 'scrying-pool.visibility.set', {});
|
||||
}
|
||||
|
||||
it('calls socketHandler.confirmPendingOp with the opId', () => {
|
||||
controller.action('ui', 'user-1', 'hidden', 'op-1', 0);
|
||||
const echoHandler = getEchoHandler();
|
||||
seedPendingOp('user-1', 'op-1');
|
||||
echoHandler({ opId: 'op-1', userId: 'user-1', state: 'hidden', revision: 1 });
|
||||
expect(socketHandler.confirmPendingOp).toHaveBeenCalledWith('op-1');
|
||||
});
|
||||
|
||||
it('stores the echo revision in _revisions for the userId', () => {
|
||||
controller.action('ui', 'user-1', 'hidden', 'op-2', 0);
|
||||
const echoHandler = getEchoHandler();
|
||||
seedPendingOp('user-1', 'op-2');
|
||||
echoHandler({ opId: 'op-2', userId: 'user-1', state: 'hidden', revision: 7 });
|
||||
expect(controller._revisions.get('user-1')).toBe(7);
|
||||
});
|
||||
|
||||
it('calls stateStore.setVisibility with the authoritative state', () => {
|
||||
controller.action('ui', 'user-1', 'active', 'op-3', 0);
|
||||
const echoHandler = getEchoHandler();
|
||||
seedPendingOp('user-1', 'op-3', 'active');
|
||||
const setSpy = vi.spyOn(stateStore, 'setVisibility');
|
||||
|
||||
echoHandler({ opId: 'op-3', userId: 'user-1', state: 'active', revision: 2 });
|
||||
@@ -251,8 +265,8 @@ describe('ScryingPoolController', () => {
|
||||
});
|
||||
|
||||
it('fires Hooks.callAll scrying-pool:controllerAction with source: echo', () => {
|
||||
controller.action('ui', 'user-1', 'hidden', 'op-4', 0);
|
||||
const echoHandler = getEchoHandler();
|
||||
seedPendingOp('user-1', 'op-4');
|
||||
echoHandler({ opId: 'op-4', userId: 'user-1', state: 'hidden', revision: 1 });
|
||||
|
||||
expect(hooksStub.callAll).toHaveBeenCalledWith(
|
||||
@@ -262,20 +276,18 @@ describe('ScryingPoolController', () => {
|
||||
});
|
||||
|
||||
it('removes the participant from _pendingOps after echo', () => {
|
||||
// Register a pending op first
|
||||
controller.action('ui', 'user-1', 'hidden', 'op-1', 0);
|
||||
const echoHandler = getEchoHandler();
|
||||
seedPendingOp('user-1', 'op-1');
|
||||
expect(controller._pendingOps.has('user-1')).toBe(true);
|
||||
|
||||
const echoHandler = getEchoHandler();
|
||||
echoHandler({ opId: 'op-1', userId: 'user-1', state: 'hidden', revision: 1 });
|
||||
|
||||
expect(controller._pendingOps.has('user-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults revision to 0 when echo payload omits revision field', () => {
|
||||
// Register a pending op first (required by new validation)
|
||||
controller.action('ui', 'user-1', 'hidden', 'op-1', 0);
|
||||
const echoHandler = getEchoHandler();
|
||||
seedPendingOp('user-1', 'op-1');
|
||||
echoHandler({ opId: 'op-1', userId: 'user-1', state: 'hidden' }); // no revision
|
||||
expect(controller._revisions.get('user-1')).toBe(0);
|
||||
});
|
||||
|
||||
@@ -527,41 +527,41 @@ describe('FoundryAdapter surface delegation', () => {
|
||||
describe('user flag methods', () => {
|
||||
it('users.getFlag returns flag value for valid user, scope, and key', () => {
|
||||
// First set a flag on the GM user
|
||||
GM_USER.setFlag('video-view-manager', 'testFlag', 'testValue');
|
||||
const result = adapter.users.getFlag(GM_USER.id, 'video-view-manager', 'testFlag');
|
||||
GM_USER.setFlag('scrying-pool', 'testFlag', 'testValue');
|
||||
const result = adapter.users.getFlag(GM_USER.id, 'scrying-pool', 'testFlag');
|
||||
expect(result).toBe('testValue');
|
||||
expect(USERS_STUB.get).toHaveBeenCalledWith(GM_USER.id);
|
||||
});
|
||||
|
||||
it('users.getFlag returns null when flag does not exist', () => {
|
||||
const result = adapter.users.getFlag(GM_USER.id, 'video-view-manager', 'nonExistentFlag');
|
||||
const result = adapter.users.getFlag(GM_USER.id, 'scrying-pool', 'nonExistentFlag');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('users.getFlag returns null when user does not exist', () => {
|
||||
const result = adapter.users.getFlag('unknown-user-id', 'video-view-manager', 'testFlag');
|
||||
const result = adapter.users.getFlag('unknown-user-id', 'scrying-pool', 'testFlag');
|
||||
expect(result).toBeNull();
|
||||
expect(USERS_STUB.get).toHaveBeenCalledWith('unknown-user-id');
|
||||
});
|
||||
|
||||
it('users.setFlag sets flag value for valid user', async () => {
|
||||
const promise = adapter.users.setFlag(PLAYER_USER.id, 'video-view-manager', 'reactionCamEnabled', true);
|
||||
const promise = adapter.users.setFlag(PLAYER_USER.id, 'scrying-pool', 'reactionCamEnabled', true);
|
||||
expect(promise).not.toBeNull();
|
||||
await promise;
|
||||
expect(USERS_STUB.get).toHaveBeenCalledWith(PLAYER_USER.id);
|
||||
// Verify the flag was set
|
||||
expect(PLAYER_USER.getFlag('video-view-manager', 'reactionCamEnabled')).toBe(true);
|
||||
expect(PLAYER_USER.getFlag('scrying-pool', 'reactionCamEnabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('users.setFlag returns null when user does not exist', () => {
|
||||
const promise = adapter.users.setFlag('unknown-user-id', 'video-view-manager', 'testFlag', true);
|
||||
const promise = adapter.users.setFlag('unknown-user-id', 'scrying-pool', 'testFlag', true);
|
||||
expect(promise).toBeNull();
|
||||
expect(USERS_STUB.get).toHaveBeenCalledWith('unknown-user-id');
|
||||
});
|
||||
|
||||
it('users.getFlagModule returns module-scoped flag', () => {
|
||||
GM_USER.setFlag('video-view-manager', 'hpReactiveCamStylingEnabled', false);
|
||||
const result = adapter.users.getFlagModule(GM_USER.id, 'hpReactiveCamStylingEnabled');
|
||||
GM_USER.setFlag('scrying-pool', 'reactionCamEnabled', false);
|
||||
const result = adapter.users.getFlagModule(GM_USER.id, 'reactionCamEnabled');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
@@ -574,7 +574,7 @@ describe('FoundryAdapter surface delegation', () => {
|
||||
const promise = adapter.users.setFlagModule(PLAYER_USER.id, 'reactionCamEnabled', true);
|
||||
expect(promise).not.toBeNull();
|
||||
await promise;
|
||||
expect(PLAYER_USER.getFlag('video-view-manager', 'reactionCamEnabled')).toBe(true);
|
||||
expect(PLAYER_USER.getFlag('scrying-pool', 'reactionCamEnabled')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,10 +35,10 @@ function makeAdapter({
|
||||
localize: vi.fn((key, data) => {
|
||||
// Simple mock that returns the key with data substituted
|
||||
const messages = {
|
||||
'video-view-manager.notifications.personalHidden': 'GM has hidden your camera. Your portrait is shown to other Participants.',
|
||||
'video-view-manager.notifications.personalShowed': 'Your camera is now visible to the table.',
|
||||
'video-view-manager.notifications.gmHid': 'GM hid {name}\'s camera',
|
||||
'video-view-manager.notifications.gmShowed': 'GM showed {name}\'s camera',
|
||||
'scrying-pool.notifications.personalHidden': 'GM has hidden your camera. Your portrait is shown to other Participants.',
|
||||
'scrying-pool.notifications.personalShowed': 'Your camera is now visible to the table.',
|
||||
'scrying-pool.notifications.gmHid': 'GM hid {name}\'s camera',
|
||||
'scrying-pool.notifications.gmShowed': 'GM showed {name}\'s camera',
|
||||
};
|
||||
let msg = messages[key] ?? key;
|
||||
if (data?.name) {
|
||||
|
||||
@@ -22,10 +22,10 @@ function createMockAdapter(overrides = {}) {
|
||||
localize: vi.fn((key) => {
|
||||
// For testing, return strings with placeholders that match ConfirmationBar's .replace() calls
|
||||
const translations = {
|
||||
'video-view-manager.presets.confirmation.applied': 'Preset applied — {name}',
|
||||
'video-view-manager.presets.confirmation.counts': '{hidden} hidden, {visible} visible',
|
||||
'video-view-manager.presets.confirmation.partial-fail': '(some updates pending)',
|
||||
'video-view-manager.presets.confirmation.undo': 'Undo preset apply',
|
||||
'scrying-pool.presets.confirmation.applied': 'Preset applied — {name}',
|
||||
'scrying-pool.presets.confirmation.counts': '{hidden} hidden, {visible} visible',
|
||||
'scrying-pool.presets.confirmation.partial-fail': '(some updates pending)',
|
||||
'scrying-pool.presets.confirmation.undo': 'Undo preset apply',
|
||||
};
|
||||
return translations[key] ?? key;
|
||||
}),
|
||||
|
||||
@@ -77,8 +77,8 @@ describe('DirectorsBoard', () => {
|
||||
|
||||
it('has DEFAULT_OPTIONS with position', () => {
|
||||
expect(DirectorsBoard.DEFAULT_OPTIONS.position).toEqual({
|
||||
width: 400,
|
||||
height: 300,
|
||||
width: 420,
|
||||
height: 480,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -110,7 +110,7 @@ describe('DirectorsBoard', () => {
|
||||
boardWithOptions._loadPosition();
|
||||
|
||||
expect(game.user.getFlag).toHaveBeenCalledWith(
|
||||
'video-view-manager',
|
||||
'scrying-pool',
|
||||
'directorsBoardState'
|
||||
);
|
||||
// Position should be merged into options.position (not replaced)
|
||||
@@ -136,8 +136,8 @@ describe('DirectorsBoard', () => {
|
||||
expect(boardWithOptions.options.position).toEqual({
|
||||
left: 100,
|
||||
top: 200,
|
||||
width: 400,
|
||||
height: 300,
|
||||
width: 420,
|
||||
height: 480,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@ describe('PresetLoadDialog', () => {
|
||||
it('should return hasPresets false when no presets exist', async () => {
|
||||
scenePresetManager.list.mockReturnValue([]);
|
||||
adapter.i18n.localize = vi.fn((key) => {
|
||||
if (key === 'video-view-manager.presets.load.emptyMessage') return 'No presets available';
|
||||
if (key === 'scrying-pool.presets.load.emptyMessage') return 'No presets available';
|
||||
return key;
|
||||
});
|
||||
|
||||
@@ -163,10 +163,10 @@ describe('PresetLoadDialog', () => {
|
||||
it('should use i18n for labels', async () => {
|
||||
adapter.i18n.localize = vi.fn((key) => {
|
||||
const translations = {
|
||||
'video-view-manager.presets.load.loadButton': 'Load',
|
||||
'video-view-manager.presets.load.cancelButton': 'Cancel',
|
||||
'video-view-manager.presets.load.title': 'Load Preset',
|
||||
'video-view-manager.presets.load.emptyMessage': 'No presets',
|
||||
'scrying-pool.presets.load.loadButton': 'Load',
|
||||
'scrying-pool.presets.load.cancelButton': 'Cancel',
|
||||
'scrying-pool.presets.load.title': 'Load Preset',
|
||||
'scrying-pool.presets.load.emptyMessage': 'No presets',
|
||||
};
|
||||
return translations[key] || key;
|
||||
});
|
||||
@@ -276,7 +276,7 @@ describe('PresetLoadDialog', () => {
|
||||
|
||||
it('should show notification on successful load via adapter.notifications', async () => {
|
||||
adapter.i18n.localize = vi.fn((key) => {
|
||||
if (key === 'video-view-manager.presets.notifications.applied') return 'Applied preset: {name}';
|
||||
if (key === 'scrying-pool.presets.notifications.applied') return 'Applied preset: {name}';
|
||||
return key;
|
||||
});
|
||||
|
||||
@@ -398,7 +398,7 @@ describe('PresetLoadDialog', () => {
|
||||
|
||||
it('should use the correct template path', () => {
|
||||
expect(PresetLoadDialog.PARTS.dialog.template).toBe(
|
||||
'modules/video-view-manager/templates/preset-load-dialog.hbs'
|
||||
'modules/scrying-pool/templates/preset-load-dialog.hbs'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ describe('PresetSaveDialog', () => {
|
||||
|
||||
it('should return empty string as defaultName when no presets exist', async () => {
|
||||
adapter.i18n.localize = vi.fn((key) => {
|
||||
if (key === 'video-view-manager.presets.save.namePlaceholder') return 'Enter preset name';
|
||||
if (key === 'scrying-pool.presets.save.namePlaceholder') return 'Enter preset name';
|
||||
return key;
|
||||
});
|
||||
|
||||
@@ -146,11 +146,11 @@ describe('PresetSaveDialog', () => {
|
||||
it('should return all i18n labels', async () => {
|
||||
adapter.i18n.localize = vi.fn((key) => {
|
||||
const translations = {
|
||||
'video-view-manager.presets.save.saveButton': 'Save',
|
||||
'video-view-manager.presets.save.cancelButton': 'Cancel',
|
||||
'video-view-manager.presets.save.title': 'Save Preset',
|
||||
'video-view-manager.presets.save.nameLabel': 'Preset Name',
|
||||
'video-view-manager.presets.save.namePlaceholder': 'Enter preset name',
|
||||
'scrying-pool.presets.save.saveButton': 'Save',
|
||||
'scrying-pool.presets.save.cancelButton': 'Cancel',
|
||||
'scrying-pool.presets.save.title': 'Save Preset',
|
||||
'scrying-pool.presets.save.nameLabel': 'Preset Name',
|
||||
'scrying-pool.presets.save.namePlaceholder': 'Enter preset name',
|
||||
};
|
||||
return translations[key] || key;
|
||||
});
|
||||
@@ -320,7 +320,7 @@ describe('PresetSaveDialog', () => {
|
||||
scenePresetManager.save = vi.fn().mockResolvedValue({ name: 'My Preset' });
|
||||
dialog.close = vi.fn().mockResolvedValue({});
|
||||
adapter.i18n.localize = vi.fn((key) => {
|
||||
if (key === 'video-view-manager.presets.notifications.saved') return 'Preset {name} saved!';
|
||||
if (key === 'scrying-pool.presets.notifications.saved') return 'Preset {name} saved!';
|
||||
return key;
|
||||
});
|
||||
|
||||
@@ -447,7 +447,7 @@ describe('PresetSaveDialog', () => {
|
||||
|
||||
it('should use the correct template path', () => {
|
||||
expect(PresetSaveDialog.PARTS.dialog.template).toBe(
|
||||
'modules/video-view-manager/templates/preset-save-dialog.hbs'
|
||||
'modules/scrying-pool/templates/preset-save-dialog.hbs'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -459,7 +459,7 @@ describe('PresetSaveDialog', () => {
|
||||
expect(options.classes).toContain('preset-save-dialog');
|
||||
expect(options.window.title).toBe('Save Scene Preset');
|
||||
expect(options.window.resizable).toBe(false);
|
||||
expect(options.position.width).toBe(320);
|
||||
expect(options.position.width).toBe(360);
|
||||
});
|
||||
|
||||
it('should store references to dependencies', () => {
|
||||
|
||||
@@ -88,8 +88,8 @@ describe('ScenePresetPanel', () => {
|
||||
|
||||
it('sets aria-label using i18n', () => {
|
||||
panel.init();
|
||||
expect(adapter.i18n.localize).toHaveBeenCalledWith('video-view-manager.scenePresetPanel.title');
|
||||
expect(panel._element.getAttribute('aria-label')).toBe('video-view-manager.scenePresetPanel.title');
|
||||
expect(adapter.i18n.localize).toHaveBeenCalledWith('scrying-pool.scenePresetPanel.title');
|
||||
expect(panel._element.getAttribute('aria-label')).toBe('scrying-pool.scenePresetPanel.title');
|
||||
});
|
||||
|
||||
it('sets aria-expanded to false initially', () => {
|
||||
@@ -267,7 +267,7 @@ describe('ScenePresetPanel', () => {
|
||||
|
||||
it('uses i18n for message', () => {
|
||||
panel._buildEmptyHtml();
|
||||
expect(adapter.i18n.localize).toHaveBeenCalledWith('video-view-manager.scenePresetPanel.noScene');
|
||||
expect(adapter.i18n.localize).toHaveBeenCalledWith('scrying-pool.scenePresetPanel.noScene');
|
||||
});
|
||||
|
||||
it('escapes HTML in message', () => {
|
||||
@@ -450,7 +450,7 @@ describe('ScenePresetPanel', () => {
|
||||
mockTarget.checked = true;
|
||||
await panel._onToggleAutoApply(mockTarget);
|
||||
expect(adapter.notifications.info).toHaveBeenCalledWith(
|
||||
'video-view-manager.scenePresetPanel.notifications.enabled'
|
||||
'scrying-pool.scenePresetPanel.notifications.enabled'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -460,7 +460,7 @@ describe('ScenePresetPanel', () => {
|
||||
mockTarget.checked = false;
|
||||
await panel._onToggleAutoApply(mockTarget);
|
||||
expect(adapter.notifications.info).toHaveBeenCalledWith(
|
||||
'video-view-manager.scenePresetPanel.notifications.disabled'
|
||||
'scrying-pool.scenePresetPanel.notifications.disabled'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -241,6 +241,30 @@ describe('ScryingPoolStrip', () => {
|
||||
const data = strip.getData();
|
||||
expect(data.hasStreamAccess).toBe(false);
|
||||
});
|
||||
|
||||
it('includes current user when showGMSelfFeed is true', () => {
|
||||
adapter.settings = { get: vi.fn(() => true) };
|
||||
adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }]);
|
||||
const data = strip.getData();
|
||||
expect(data.participants.map(p => p.userId)).toContain('u1');
|
||||
expect(data.participants.map(p => p.userId)).toContain('u2');
|
||||
});
|
||||
|
||||
it('excludes current user when showGMSelfFeed is false', () => {
|
||||
adapter.settings = { get: vi.fn(() => false) };
|
||||
adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }]);
|
||||
const data = strip.getData();
|
||||
// u1 is the current user (mocked in beforeEach), should be excluded
|
||||
expect(data.participants.map(p => p.userId)).not.toContain('u1');
|
||||
expect(data.participants.map(p => p.userId)).toContain('u2');
|
||||
});
|
||||
|
||||
it('includes all users when settings is unavailable (defaults to true)', () => {
|
||||
// no adapter.settings — fallback to true
|
||||
adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }]);
|
||||
const data = strip.getData();
|
||||
expect(data.participants.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_attachVideoStream() (Story 5.1)', () => {
|
||||
|
||||
@@ -31,8 +31,6 @@ function createMockAdapter(overrides = {}) {
|
||||
'SCRYING_POOL.PrivacyPanel.sectionDescription': 'Control which automation effects can affect your camera.',
|
||||
'SCRYING_POOL.PrivacyPanel.reactionCamLabel': 'Reaction Cam',
|
||||
'SCRYING_POOL.PrivacyPanel.reactionCamDescription': 'Automatically show your camera during key moments (combat, rolls, etc.)',
|
||||
'SCRYING_POOL.PrivacyPanel.hpReactiveCamStylingLabel': 'HP-Reactive Cam Styling',
|
||||
'SCRYING_POOL.PrivacyPanel.hpReactiveCamStylingDescription': 'Apply visual styling to your camera based on your character\'s HP',
|
||||
'SCRYING_POOL.PrivacyPanel.toggleOn': 'Enabled',
|
||||
'SCRYING_POOL.PrivacyPanel.toggleOff': 'Disabled',
|
||||
'SCRYING_POOL.PrivacyPanel.readOnlyNotice': 'This player\'s privacy settings are read-only',
|
||||
@@ -130,7 +128,6 @@ describe('PlayerPrivacyPanel', () => {
|
||||
it('should return context with settings', async () => {
|
||||
const settings = createPrivacySettings({
|
||||
reactionCamEnabled: true,
|
||||
hpReactiveCamStylingEnabled: false,
|
||||
});
|
||||
playerPrivacyManager.getSettings.mockReturnValue(settings);
|
||||
adapter.users.current.mockReturnValue({ id: targetUserId });
|
||||
@@ -140,7 +137,7 @@ describe('PlayerPrivacyPanel', () => {
|
||||
expect(context.title).toBe('Player Privacy Panel');
|
||||
expect(context.sectionHeader).toBe('Automation Opt-ins');
|
||||
expect(context.automationEffects).toBeDefined();
|
||||
expect(context.automationEffects).toHaveLength(2);
|
||||
expect(context.automationEffects).toHaveLength(1);
|
||||
expect(context.isReadOnly).toBe(false);
|
||||
expect(context.isOwnUser).toBe(true);
|
||||
});
|
||||
@@ -156,25 +153,22 @@ describe('PlayerPrivacyPanel', () => {
|
||||
expect(context.isOwnUser).toBe(false);
|
||||
});
|
||||
|
||||
it('should include both automation effects', async () => {
|
||||
it('should include the available automation effect', async () => {
|
||||
const context = await panel._prepareContext();
|
||||
|
||||
expect(context.automationEffects).toHaveLength(2);
|
||||
expect(context.automationEffects).toHaveLength(1);
|
||||
expect(context.automationEffects[0].key).toBe('reactionCam');
|
||||
expect(context.automationEffects[1].key).toBe('hpReactiveCamStyling');
|
||||
});
|
||||
|
||||
it('should reflect current settings in context', async () => {
|
||||
const settings = createPrivacySettings({
|
||||
reactionCamEnabled: true,
|
||||
hpReactiveCamStylingEnabled: true,
|
||||
});
|
||||
playerPrivacyManager.getSettings.mockReturnValue(settings);
|
||||
|
||||
const context = await panel._prepareContext();
|
||||
|
||||
expect(context.automationEffects[0].enabled).toBe(true);
|
||||
expect(context.automationEffects[1].enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -354,13 +348,11 @@ describe('PlayerPrivacyPanel', () => {
|
||||
it('should clear cached elements', () => {
|
||||
// Set up some cached values
|
||||
panel._reactionCamToggle = document.createElement('div');
|
||||
panel._hpReactiveCamToggle = document.createElement('div');
|
||||
panel._currentSettings = createPrivacySettings();
|
||||
|
||||
panel._onClose();
|
||||
|
||||
expect(panel._reactionCamToggle).toBe(null);
|
||||
expect(panel._hpReactiveCamToggle).toBe(null);
|
||||
expect(panel._currentSettings).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -238,7 +238,7 @@ describe('VisibilityBadge', () => {
|
||||
badge.init();
|
||||
await badge._setFirstBadgeEncountered();
|
||||
const mockUser = adapter.users.current();
|
||||
expect(mockUser.setFlag).toHaveBeenCalledWith('video-view-manager', 'firstBadgeEncounter', true);
|
||||
expect(mockUser.setFlag).toHaveBeenCalledWith('scrying-pool', 'firstBadgeEncounter', true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -390,7 +390,7 @@ describe('FirstEncounterPanel (via VisibilityBadge)', () => {
|
||||
// Directly call _onGotIt to avoid async click handler timing issues
|
||||
await panel._onGotIt();
|
||||
const mockUser = adapter.users.current();
|
||||
expect(mockUser.setFlag).toHaveBeenCalledWith('video-view-manager', 'firstBadgeEncounter', true);
|
||||
expect(mockUser.setFlag).toHaveBeenCalledWith('scrying-pool', 'firstBadgeEncounter', true);
|
||||
});
|
||||
|
||||
it('clears timer (no ghost timer after dismissal)', async () => {
|
||||
|
||||
Reference in New Issue
Block a user