Module cleanup and tests
CI / ci (push) Failing after 7s

This commit is contained in:
2026-05-24 23:13:45 +02:00
parent 63d83e999a
commit 5dc9b3b8d4
72 changed files with 2545 additions and 1220 deletions
+53 -29
View File
@@ -1,5 +1,5 @@
{ {
"video-view-manager": { "scrying-pool": {
"badge": { "badge": {
"state": { "state": {
"hidden": "Hidden from table", "hidden": "Hidden from table",
@@ -26,9 +26,14 @@
"gmHid": "GM hid {name}'s camera", "gmHid": "GM hid {name}'s camera",
"gmShowed": "GM showed {name}'s camera", "gmShowed": "GM showed {name}'s camera",
"personalHidden": "GM has hidden your camera. Your portrait is shown to other Participants.", "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": { "settings": {
"showGMSelfFeed": {
"label": "Show GM Self Feed",
"hint": "When enabled, the GM's own camera feed is shown in the Scrying Pool strip."
},
"notificationVerbosity": { "notificationVerbosity": {
"label": "Notification Verbosity", "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.", "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": { "directorsBoard": {
"title": "Director's Board", "title": "Director's Board",
"close": "Close",
"empty": "No participants connected.", "empty": "No participants connected.",
"openButton": "Open Director's Board", "openButton": "Open Director's Board",
"footer": { "footer": {
"savePreset": "Save Preset…", "savePreset": "Save Layout",
"loadPreset": "Load Preset…", "loadPreset": "Load Layout",
"exportPresets": "Export Presets…", "exportPresets": "Export",
"importPresets": "Import Presets…" "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": { "bulk": {
"showAll": "Show All", "showAll": "Show All",
@@ -81,46 +92,61 @@
}, },
"presets": { "presets": {
"save": { "save": {
"saveButton": "Save", "saveButton": "Save Layout",
"cancelButton": "Cancel", "cancelButton": "Cancel",
"title": "Save Scene Preset", "title": "Save Camera Layout",
"nameLabel": "Preset Name", "nameLabel": "Layout Name",
"namePlaceholder": "Enter a name for this camera layout" "namePlaceholder": "e.g. Combat, Roleplay, Intro…",
"descriptionHint": "Saves the current camera visibility layout for all participants."
}, },
"load": { "load": {
"loadButton": "Load", "loadButton": "Load",
"cancelButton": "Cancel", "cancelButton": "Cancel",
"title": "Load Scene Preset", "title": "Load Camera Layout",
"emptyMessage": "No presets saved yet. Use 'Save Preset' to create one." "emptyMessage": "No layouts saved yet. Use 'Save Layout' to create one."
}, },
"notifications": { "notifications": {
"saved": "Scene preset '{name}' saved.", "saved": "Layout '{name}' saved.",
"applied": "Scene preset '{name}' applied.", "applied": "Layout '{name}' applied.",
"scene-applied": "Scene changed: camera layout updated" "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": { "presetExport": {
"title": "Export Scene Presets", "title": "Export Camera Layouts",
"description": "Download all scene presets as a JSON file that can be imported into another world.", "description": "Download all camera layouts as a JSON file that can be imported into another world.",
"scene": "Scene", "scene": "Scene",
"presetCount": "Presets", "presetCount": "Layouts",
"filename": "Filename", "filename": "Filename",
"export": "Export", "export": "Export",
"cancel": "Cancel", "cancel": "Cancel",
"exporting": "Exporting…", "exporting": "Exporting…",
"exportSuccess": "Scene presets exported successfully.", "exportSuccess": "Camera layouts exported successfully.",
"exportFailed": "Failed to export presets" "exportFailed": "Failed to export layouts"
}, },
"presetImport": { "presetImport": {
"title": "Import Scene Presets", "title": "Import Camera Layouts",
"description": "Upload a JSON file containing scene presets to add to this scene.", "description": "Upload a JSON file containing camera layouts to add to this scene.",
"selectFile": "Select File", "selectFile": "Select File",
"chooseFile": "Choose a JSON file…", "chooseFile": "Choose a JSON file…",
"importMode": "Import Mode", "importMode": "Import Mode",
"importModeMerge": "Merge", "importModeMerge": "Merge",
"importModeReplace": "Replace", "importModeReplace": "Replace",
"importModeMergeHint": "Add new presets, skip duplicates", "importModeMergeHint": "Add new layouts, skip duplicates",
"importModeReplaceHint": "Delete all existing presets and import new ones", "importModeReplaceHint": "Delete all existing layouts and import new ones",
"previewTitle": "Preview", "previewTitle": "Preview",
"previewWillImport": "Will import", "previewWillImport": "Will import",
"previewWillSkip": "Will skip (already exists)", "previewWillSkip": "Will skip (already exists)",
@@ -128,10 +154,10 @@
"confirmReplace": "Replace All", "confirmReplace": "Replace All",
"cancel": "Cancel", "cancel": "Cancel",
"importing": "Importing…", "importing": "Importing…",
"importFailed": "Failed to import presets", "importFailed": "Failed to import layouts",
"selectFileFirst": "Please select a file first", "selectFileFirst": "Please select a file first",
"existingPresetsWarning": "This scene has {existingPresetCount} existing preset(s).", "existingPresetsWarning": "This scene has {existingPresetCount} existing layout(s).",
"replaceConfirmation": "This will delete all {existingPresetCount} existing preset(s) and replace them with the imported ones. This cannot be undone." "replaceConfirmation": "This will delete all {existingPresetCount} existing layout(s) and replace them with the imported ones. This cannot be undone."
} }
}, },
"SCRYING_POOL": { "SCRYING_POOL": {
@@ -143,8 +169,6 @@
"sectionDescription": "Control which automation features can affect your camera and on-screen presence.", "sectionDescription": "Control which automation features can affect your camera and on-screen presence.",
"reactionCamLabel": "Reaction Cam", "reactionCamLabel": "Reaction Cam",
"reactionCamDescription": "Automatically show your camera during key moments (combat, rolls, etc.)", "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", "toggleOn": "Enabled",
"toggleOff": "Disabled", "toggleOff": "Disabled",
"readOnlyNotice": "This player's privacy settings are read-only. You cannot modify another player's consent preferences.", "readOnlyNotice": "This player's privacy settings are read-only. You cannot modify another player's consent preferences.",
@@ -164,7 +188,7 @@
"Settings": { "Settings": {
"PlayerPrivacyPanel": "Player Privacy Panel", "PlayerPrivacyPanel": "Player Privacy Panel",
"PlayerPrivacyPanelLabel": "Control automation effects for your camera", "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", "GMPlayerPrivacySelector": "View Player Privacy Settings",
"GMPlayerPrivacySelectorLabel": "View and manage player privacy consent settings", "GMPlayerPrivacySelectorLabel": "View and manage player privacy consent settings",
"GMPlayerPrivacySelectorHint": "Select a player to view their automation opt-in preferences (read-only)" "GMPlayerPrivacySelectorHint": "Select a player to view their automation opt-in preferences (read-only)"
+35 -11
View File
@@ -1,7 +1,7 @@
// @ts-nocheck — Module entry point with FoundryVTT globals, no exports needed // @ts-nocheck — Module entry point with FoundryVTT globals, no exports needed
/* global Handlebars */ /* 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 * This file is the wiring diagram ONLY. It imports all modules, constructs them
* with injected dependencies, and holds NO business logic. * 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 { ConfirmationBar } from './src/ui/gm/ConfirmationBar.js';
import { PlayerPrivacyPanelMenu, initPlayerPrivacyPanelMenu } from './src/ui/player/PlayerPrivacyPanelMenu.js'; import { PlayerPrivacyPanelMenu, initPlayerPrivacyPanelMenu } from './src/ui/player/PlayerPrivacyPanelMenu.js';
import { initGMPlayerPrivacySelector } from './src/ui/gm/GMPlayerPrivacySelector.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'; import { SOCKET_EVENTS } from './src/contracts/socket-message.js';
// Module-level references — constructed in init hook, used across hooks // Module-level references — constructed in init hook, used across hooks
@@ -57,6 +58,18 @@ let directorsBoardButtonAdded = false;
Hooks.once("init", () => { Hooks.once("init", () => {
console.log("[ScryingPool] init — module loading"); 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 // WebRTC mode setting — determines how the module handles AV integration
// Updated for FULL REPLACEMENT architecture (hiding Foundry's dock, showing our own) // Updated for FULL REPLACEMENT architecture (hiding Foundry's dock, showing our own)
game.settings.register("scrying-pool", "webrtcMode", { game.settings.register("scrying-pool", "webrtcMode", {
@@ -88,6 +101,8 @@ Hooks.once("init", () => {
config: true, config: true,
type: Boolean, type: Boolean,
default: true, 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) // Story 2.1: per-user notification verbosity preference (client-scoped)
@@ -95,6 +110,8 @@ Hooks.once("init", () => {
scope: "client", scope: "client",
config: true, config: true,
type: String, 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: { choices: {
all: "All", all: "All",
"gm-only": "GM Only", "gm-only": "GM Only",
@@ -109,8 +126,8 @@ Hooks.once("init", () => {
config: true, config: true,
type: Boolean, type: Boolean,
default: true, default: true,
name: "Enable Scene Preset Auto-Apply", name: "Enable Camera Layout Auto-Apply",
hint: "When enabled, scenes with configured presets will automatically apply them on activation", hint: "When enabled, scenes with a configured camera layout will automatically apply it on activation",
}); });
// Construct data layer — constructors are side-effect-free // 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) // Story 2.3: Bulk-action keybindings (GM only, migrated to scrying-pool namespace)
game.keybindings.register('scrying-pool', 'showAll', { game.keybindings.register('scrying-pool', 'showAll', {
name: game.i18n.localize('video-view-manager.keybindings.showAll.name'), name: game.i18n.localize('scrying-pool.keybindings.showAll.name'),
hint: game.i18n.localize('video-view-manager.keybindings.showAll.hint'), hint: game.i18n.localize('scrying-pool.keybindings.showAll.hint'),
editable: [{ key: 'KeyS', modifiers: ['Control', 'Shift'] }], editable: [{ key: 'KeyS', modifiers: ['Control', 'Shift'] }],
restricted: true, restricted: true,
onDown: () => directorsBoard?.showAll(), onDown: () => directorsBoard?.showAll(),
}); });
game.keybindings.register('scrying-pool', 'hideAll', { game.keybindings.register('scrying-pool', 'hideAll', {
name: game.i18n.localize('video-view-manager.keybindings.hideAll.name'), name: game.i18n.localize('scrying-pool.keybindings.hideAll.name'),
hint: game.i18n.localize('video-view-manager.keybindings.hideAll.hint'), hint: game.i18n.localize('scrying-pool.keybindings.hideAll.hint'),
editable: [{ key: 'KeyH', modifiers: ['Control', 'Shift'] }], editable: [{ key: 'KeyH', modifiers: ['Control', 'Shift'] }],
restricted: true, restricted: true,
onDown: () => directorsBoard?.hideAll(), onDown: () => directorsBoard?.hideAll(),
}); });
game.keybindings.register('scrying-pool', 'spotlightParticipant', { game.keybindings.register('scrying-pool', 'spotlightParticipant', {
name: game.i18n.localize('video-view-manager.keybindings.spotlightParticipant.name'), name: game.i18n.localize('scrying-pool.keybindings.spotlightParticipant.name'),
hint: game.i18n.localize('video-view-manager.keybindings.spotlightParticipant.hint'), hint: game.i18n.localize('scrying-pool.keybindings.spotlightParticipant.hint'),
editable: [{ key: 'KeyP', modifiers: ['Control', 'Shift'] }], editable: [{ key: 'KeyP', modifiers: ['Control', 'Shift'] }],
restricted: true, restricted: true,
onDown: () => directorsBoard?.spotlightFocused(), onDown: () => directorsBoard?.spotlightFocused(),
@@ -302,13 +319,20 @@ Hooks.once("ready", () => {
window.directorsBoard = directorsBoard; 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 // Pre-load participant-card as a Handlebars partial for directors-board
// ApplicationV2 requires partials to be registered explicitly // ApplicationV2 requires partials to be registered explicitly
(async () => { (async () => {
try { 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(); 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) { } catch (err) {
console.warn('[ScryingPool] Failed to register participant-card partial:', err); console.warn('[ScryingPool] Failed to register participant-card partial:', err);
} }
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"id": "video-view-manager", "id": "scrying-pool",
"title": "Video View Manager (Scrying Pool)", "title": "Scrying Pool",
"version": "0.1.0", "version": "0.1.0",
"description": "GM camera visibility control for FoundryVTT v14 — hide, show, and manage participant feeds in real time.", "description": "GM camera visibility control for FoundryVTT v14 — hide, show, and manage participant feeds in real time.",
"authors": [ "authors": [
+2 -2
View File
@@ -1,11 +1,11 @@
{ {
"name": "video-view-manager", "name": "scrying-pool",
"version": "0.1.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "video-view-manager", "name": "scrying-pool",
"version": "0.1.0", "version": "0.1.0",
"devDependencies": { "devDependencies": {
"@league-of-foundry-developers/foundry-vtt-types": "9.280.1", "@league-of-foundry-developers/foundry-vtt-types": "9.280.1",
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "video-view-manager", "name": "scrying-pool",
"version": "0.1.0", "version": "0.1.0",
"description": "FoundryVTT v14 module — Scrying Pool camera visibility control", "description": "FoundryVTT v14 module — Scrying Pool camera visibility control",
"type": "module", "type": "module",
+2 -13
View File
@@ -4,8 +4,8 @@
* Privacy settings control player opt-in/out for automation features that affect * 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. * their on-screen presence. Settings are stored as user flags on the user document.
* *
* Storage key: game.user.setFlag('video-view-manager', key, value) * Storage key: game.user.setFlag('scrying-pool', key, value)
* Shape: { reactionCamEnabled: boolean, hpReactiveCamStylingEnabled: boolean, customPortraitFallback: string | null } * Shape: { reactionCamEnabled: boolean, customPortraitFallback: string | null }
* *
* @module contracts/privacy-settings * @module contracts/privacy-settings
*/ */
@@ -31,7 +31,6 @@ export const VALID_PORTRAIT_FORMATS = Object.freeze([
/** /**
* @typedef {Object} PrivacySettings * @typedef {Object} PrivacySettings
* @property {boolean} reactionCamEnabled - Whether Reaction Cam automation is enabled for this user. * @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. * @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 = { export const PRIVACY_SETTINGS_DEFAULT = {
reactionCamEnabled: false, reactionCamEnabled: false,
hpReactiveCamStylingEnabled: false,
customPortraitFallback: null, customPortraitFallback: null,
}; };
@@ -53,7 +51,6 @@ export const PRIVACY_SETTINGS_DEFAULT = {
*/ */
export const PRIVACY_SETTING_KEYS = Object.freeze([ export const PRIVACY_SETTING_KEYS = Object.freeze([
"reactionCamEnabled", "reactionCamEnabled",
"hpReactiveCamStylingEnabled",
"customPortraitFallback", "customPortraitFallback",
]); ]);
@@ -63,7 +60,6 @@ export const PRIVACY_SETTING_KEYS = Object.freeze([
*/ */
export const FEATURE_NAME_MAP = Object.freeze({ export const FEATURE_NAME_MAP = Object.freeze({
reactionCam: "reactionCamEnabled", 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 ("customPortraitFallback" in obj) {
if (obj.customPortraitFallback !== null && typeof obj.customPortraitFallback !== "string") { if (obj.customPortraitFallback !== null && typeof obj.customPortraitFallback !== "string") {
throw new TypeError( throw new TypeError(
+1 -1
View File
@@ -4,7 +4,7 @@
* A Scene Preset is a named snapshot of the Visibility Matrix, stored as a * 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. * 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 } } * Shape: { _version: 1, presets: { [name: string]: ScenePreset } }
* *
* @module contracts/scene-preset * @module contracts/scene-preset
+8 -9
View File
@@ -26,8 +26,7 @@ import {
* Manages player privacy settings for automation opt-ins. * Manages player privacy settings for automation opt-ins.
* *
* Settings are stored as world-level user flags: * Settings are stored as world-level user flags:
* - game.user.setFlag('video-view-manager', 'reactionCamEnabled', boolean) * - game.user.setFlag('scrying-pool', 'reactionCamEnabled', boolean)
* - game.user.setFlag('video-view-manager', 'hpReactiveCamStylingEnabled', boolean)
* *
* Players can only edit their own settings. * Players can only edit their own settings.
* GM can read (but not edit) all players' settings. * GM can read (but not edit) all players' settings.
@@ -87,7 +86,7 @@ export class PlayerPrivacyManager {
/** /**
* Retrieves privacy settings for a specific user. * 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). * Missing settings are merged with defaults (all false).
* *
* @param {string} userId - The user ID to retrieve settings for. * @param {string} userId - The user ID to retrieve settings for.
@@ -104,7 +103,7 @@ export class PlayerPrivacyManager {
const settings = { ...PRIVACY_SETTINGS_DEFAULT }; const settings = { ...PRIVACY_SETTINGS_DEFAULT };
for (const key of PRIVACY_SETTING_KEYS) { 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) { if (value !== undefined && value !== null) {
settings[key] = value; settings[key] = value;
} }
@@ -157,7 +156,7 @@ export class PlayerPrivacyManager {
// Persist the setting via user flag // Persist the setting via user flag
// Note: FoundryVTT user.setFlag returns a Promise // Note: FoundryVTT user.setFlag returns a Promise
await user.setFlag("video-view-manager", key, value); await user.setFlag("scrying-pool", key, value);
// Notify subscribers // Notify subscribers
this._notifySubscribers(userId, key, value, previousValue); 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. * Checks if a user has opted in to a specific automation feature.
* *
* @param {string} userId - The user ID to check. * @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. * @returns {boolean} True if the user has opted in, false otherwise.
* @throws {TypeError} If feature name is invalid. * @throws {TypeError} If feature name is invalid.
*/ */
@@ -306,7 +305,7 @@ export class PlayerPrivacyManager {
const previousValue = this.getPortraitFallback(userId); const previousValue = this.getPortraitFallback(userId);
// Persist the setting via user flag // 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 // Notify subscribers with special portrait type
this._notifyPortraitChange(userId, dataURL, previousValue); this._notifyPortraitChange(userId, dataURL, previousValue);
@@ -326,7 +325,7 @@ export class PlayerPrivacyManager {
return null; return null;
} }
const dataURL = user.getFlag("video-view-manager", "customPortraitFallback"); const dataURL = user.getFlag("scrying-pool", "customPortraitFallback");
// Validate the stored DataURL (defensive programming) // Validate the stored DataURL (defensive programming)
if (dataURL !== null && dataURL !== undefined) { if (dataURL !== null && dataURL !== undefined) {
@@ -392,7 +391,7 @@ export class PlayerPrivacyManager {
const previousValue = this.getPortraitFallback(userId); const previousValue = this.getPortraitFallback(userId);
// Remove the setting via user flag // 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 // Notify subscribers with special portrait type
this._notifyPortraitChange(userId, null, previousValue); this._notifyPortraitChange(userId, null, previousValue);
+8 -8
View File
@@ -123,7 +123,7 @@ export class ScenePresetManager {
// Emit notification // Emit notification
this._adapter.notifications.info( 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) .replace('{name}', name)
); );
@@ -177,7 +177,7 @@ export class ScenePresetManager {
// Emit notification // Emit notification
this._adapter.notifications.info( 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) .replace('{name}', name)
); );
} }
@@ -295,7 +295,7 @@ export class ScenePresetManager {
} }
try { 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') { if (!flagData || typeof flagData !== 'object') {
return; // No presets or invalid format return; // No presets or invalid format
} }
@@ -360,7 +360,7 @@ export class ScenePresetManager {
}; };
try { 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) { } catch (err) {
console.error( console.error(
'[ScryingPool] ScenePresetManager: failed to save scene presets', '[ScryingPool] ScenePresetManager: failed to save scene presets',
@@ -383,7 +383,7 @@ export class ScenePresetManager {
*/ */
async onSceneActivate(scene) { async onSceneActivate(scene) {
// Check if auto-apply is globally enabled // 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) { if (!globalEnabled) {
return; // Global disable return; // Global disable
} }
@@ -528,7 +528,7 @@ export class ScenePresetManager {
// Persist to scene flag // Persist to scene flag
try { 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) { } catch (err) {
console.error( console.error(
'[ScryingPool] ScenePresetManager: failed to configure auto-apply', '[ScryingPool] ScenePresetManager: failed to configure auto-apply',
@@ -579,7 +579,7 @@ export class ScenePresetManager {
// Notify via ui.notifications // Notify via ui.notifications
this._adapter.notifications.info( 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) .replace('{name}', presetName)
); );
} catch (err) { } catch (err) {
@@ -638,7 +638,7 @@ export class ScenePresetManager {
*/ */
_getSceneFlagData(scene) { _getSceneFlagData(scene) {
try { 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') { if (!flagData || typeof flagData !== 'object') {
return null; return null;
} }
+12 -8
View File
@@ -173,7 +173,10 @@ export class ScryingPoolController {
if (currentState === targetState) return; if (currentState === targetState) return;
// 5. Register PendingOp // 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); const pendingOp = createPendingOp(opId, participantId, targetState, previousState);
this._pendingOps.set(participantId, pendingOp); this._pendingOps.set(participantId, pendingOp);
@@ -186,19 +189,20 @@ export class ScryingPoolController {
return; return;
} }
// 7. Socket emit // 7. Socket emit (best-effort broadcast to player clients)
const msg = createSocketIntentMessage(opId, participantId, targetState, baseRevision); const msg = createSocketIntentMessage(opId, participantId, targetState, baseRevision);
this._socketHandler.emit(msg.event, msg.payload); this._socketHandler.emit(msg.event, msg.payload);
// 8. Start acknowledgement timer // 8. Start acknowledgement timer
this._socketHandler.registerPendingOp(pendingOp, msg.event, msg.payload); this._socketHandler.registerPendingOp(pendingOp, msg.event, msg.payload);
// 9. Notify UI subscribers // 9. Self-confirm: the GM is the source of truth.
try { // The state was already applied and persisted optimistically (step 6).
this._adapter.hooks.callAll('scrying-pool:controllerAction', { participantId, targetState, source, opId }); // Waiting for a socket echo would require a server-side relay that doesn't
} catch (hookErr) { // exist, resulting in a 6-second timeout + spurious revert warning.
console.error('[ScryingPool] ScryingPoolController.action: hook emission failed', hookErr); // 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 });
} }
/** /**
+9
View File
@@ -48,6 +48,15 @@ export class StateStore {
this._matrix = { ...validated.matrix }; this._matrix = { ...validated.matrix };
this._version = validated._version ?? 1; this._version = validated._version ?? 1;
this._revision = validated._revision ?? 0; 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) { } catch (err) {
if (err instanceof TypeError) { if (err instanceof TypeError) {
+2 -2
View File
@@ -35,7 +35,7 @@ export class FoundryAdapter {
static SETTING_WEBRTC_MODE = 'webrtcMode'; static SETTING_WEBRTC_MODE = 'webrtcMode';
/** Flag scope/namespace for module-specific user flags. */ /** 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. * Creates a FoundryAdapter. Side-effect-free — no hooks or listeners registered.
@@ -294,7 +294,7 @@ export class FoundryAdapter {
this.i18n = { this.i18n = {
/** /**
* Localize a string using the module's translation keys. * 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 * @param {object} [data] - Optional data for string interpolation
* @returns {string} The localized string * @returns {string} The localized string
*/ */
+4 -4
View File
@@ -116,8 +116,8 @@ export class NotificationBus {
*/ */
_notifyPersonal(newState) { _notifyPersonal(newState) {
const key = newState === 'hidden' const key = newState === 'hidden'
? 'video-view-manager.notifications.personalHidden' ? 'scrying-pool.notifications.personalHidden'
: 'video-view-manager.notifications.personalShowed'; : 'scrying-pool.notifications.personalShowed';
const msg = this._adapter.i18n.localize(key); const msg = this._adapter.i18n.localize(key);
this._adapter.notifications.info(msg); this._adapter.notifications.info(msg);
} }
@@ -170,8 +170,8 @@ export class NotificationBus {
const name = this._adapter.users.get(userId)?.name ?? userId; const name = this._adapter.users.get(userId)?.name ?? userId;
const count = entry.changeCount > 1 ? ` (${entry.changeCount} changes)` : ''; const count = entry.changeCount > 1 ? ` (${entry.changeCount} changes)` : '';
const key = entry.lastState === 'hidden' const key = entry.lastState === 'hidden'
? 'video-view-manager.notifications.gmHid' ? 'scrying-pool.notifications.gmHid'
: 'video-view-manager.notifications.gmShowed'; : 'scrying-pool.notifications.gmShowed';
// Note: changeCount is included in the message suffix for AC-3 compliance // Note: changeCount is included in the message suffix for AC-3 compliance
const msg = this._adapter.i18n.localize(key, { name }) + count; const msg = this._adapter.i18n.localize(key, { name }) + count;
+4 -4
View File
@@ -288,14 +288,14 @@ export class ConfirmationBar {
* @private * @private
*/ */
_buildMessage(presetName, counts, variant) { _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); .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('{hidden}', counts.hidden)
.replace('{visible}', counts.visible); .replace('{visible}', counts.visible);
if (variant === 'amber') { 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}`; return `${baseMsg} ${countMsg} ${suffix}`;
} }
@@ -313,7 +313,7 @@ export class ConfirmationBar {
*/ */
_buildHtml(message, variant) { _buildHtml(message, variant) {
const variantClass = variant === 'amber' ? 'sp-confirmation-bar--amber' : 'sp-confirmation-bar--default'; 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 // Use data-action for event delegation via StripOverlayLayer
// The onclick handler is set up in _setupEventListeners // The onclick handler is set up in _setupEventListeners
+82 -13
View File
@@ -47,12 +47,12 @@ export class DirectorsBoard extends _AppBase {
id: 'scrying-pool-directors-board', id: 'scrying-pool-directors-board',
classes: ['scrying-pool', 'directors-board'], classes: ['scrying-pool', 'directors-board'],
window: { title: "Director's Board", resizable: true }, window: { title: "Director's Board", resizable: true },
position: { width: 400, height: 300 }, position: { width: 420, height: 480 },
}; };
static PARTS = { static PARTS = {
board: { 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. */ /** Loads saved window position from GM user flag. */
_loadPosition() { _loadPosition() {
try { 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) { if (saved?.open === true && saved.left != null && saved.top != null) {
// Ensure options.position exists and is mutable // Ensure options.position exists and is mutable
if (this.options?.position) { if (this.options?.position) {
@@ -124,8 +124,8 @@ export class DirectorsBoard extends _AppBase {
Object.assign(this.options.position, { Object.assign(this.options.position, {
left: saved.left, left: saved.left,
top: saved.top, top: saved.top,
width: saved.width ?? 400, width: saved.width ?? 420,
height: saved.height ?? 300, height: saved.height ?? 480,
}); });
} }
} }
@@ -374,6 +374,8 @@ export class DirectorsBoard extends _AppBase {
autoApplyPresetName: autoApplyConfig.presetName, autoApplyPresetName: autoApplyConfig.presetName,
autoApplyPreDelay: autoApplyConfig.preDelay, autoApplyPreDelay: autoApplyConfig.preDelay,
presets: this._scenePresetManager?.list?.() ?? [], 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; case 'import-presets': this._onImportPresets(); break;
// Story 3.2: Scene auto-apply panel toggle // Story 3.2: Scene auto-apply panel toggle
case 'toggle-preset-panel': this._togglePresetPanel(); break; 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) => { this._focusinHandler = (e) => {
@@ -429,6 +434,28 @@ export class DirectorsBoard extends _AppBase {
root.addEventListener('focusin', this._focusinHandler); root.addEventListener('focusin', this._focusinHandler);
root.addEventListener('keydown', this._keydownHandler); 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 // Story 3.2: Append ScenePresetPanel to DOM and refresh
this._appendPresetPanel(root); this._appendPresetPanel(root);
this._refreshPresetPanel(); this._refreshPresetPanel();
@@ -542,8 +569,8 @@ export class DirectorsBoard extends _AppBase {
const localize = (key) => game.i18n?.localize(key) ?? key; const localize = (key) => game.i18n?.localize(key) ?? key;
const getBinding = (actionKey) => { const getBinding = (actionKey) => {
// Check both namespaces due to migration from video-view-manager to scrying-pool // Check both namespaces due to migration from scrying-pool to scrying-pool
const namespaces = ['scrying-pool', 'video-view-manager']; const namespaces = ['scrying-pool', 'scrying-pool'];
for (const ns of namespaces) { for (const ns of namespaces) {
const bindings = game.keybindings?.bindings?.get(`${ns}.${actionKey}`); const bindings = game.keybindings?.bindings?.get(`${ns}.${actionKey}`);
if (bindings?.[0]) { if (bindings?.[0]) {
@@ -556,10 +583,10 @@ export class DirectorsBoard extends _AppBase {
}; };
const shortcuts = [ const shortcuts = [
{ label: localize('video-view-manager.directorsBoard.shortcuts.openBoard'), binding: getBinding('openDirectorsBoard') ?? 'Ctrl+Shift+V' }, { label: localize('scrying-pool.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('scrying-pool.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('scrying-pool.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.spotlight'), binding: getBinding('spotlightParticipant') ?? 'Ctrl+Shift+P' },
]; ];
// Escape HTML to prevent injection via localised strings or keybinding labels // Escape HTML to prevent injection via localised strings or keybinding labels
@@ -570,7 +597,7 @@ export class DirectorsBoard extends _AppBase {
if (typeof Dialog !== 'undefined') { if (typeof Dialog !== 'undefined') {
new Dialog({ new Dialog({
title: localize('video-view-manager.directorsBoard.shortcuts.title'), title: localize('scrying-pool.directorsBoard.shortcuts.title'),
content, content,
buttons: { close: { label: 'Close' } }, buttons: { close: { label: 'Close' } },
default: '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. * Opens the PresetSaveDialog for saving the current visibility matrix as a preset.
*/ */
@@ -749,7 +818,7 @@ export class DirectorsBoard extends _AppBase {
*/ */
_savePosition(state) { _savePosition(state) {
try { try {
game.user?.setFlag('video-view-manager', 'directorsBoardState', state); game.user?.setFlag('scrying-pool', 'directorsBoardState', state);
} catch (err) { } catch (err) {
console.error('[ScryingPool] Failed to save directors board position:', err); console.error('[ScryingPool] Failed to save directors board position:', err);
} }
+3 -1
View File
@@ -190,7 +190,9 @@ export class GMPlayerPrivacySelectorMenu extends _AppBase {
const id = escapeHtml(user.id ?? ''); const id = escapeHtml(user.id ?? '');
const role = user.isGM ? 'GM' : 'Player'; const role = user.isGM ? 'GM' : 'Player';
return ` 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-name">${name}</span>
<span class="sp-user-role">${role}</span> <span class="sp-user-role">${role}</span>
</div> </div>
+1 -1
View File
@@ -77,7 +77,7 @@ export class PresetExportDialog extends _AppBase {
static PARTS = { static PARTS = {
dialog: { dialog: {
template: 'modules/video-view-manager/templates/preset-export.hbs', template: 'modules/scrying-pool/templates/preset-export.hbs',
}, },
}; };
+1 -1
View File
@@ -92,7 +92,7 @@ export class PresetImportDialog extends _AppBase {
static PARTS = { static PARTS = {
dialog: { dialog: {
template: 'modules/video-view-manager/templates/preset-import.hbs', template: 'modules/scrying-pool/templates/preset-import.hbs',
}, },
}; };
+6 -6
View File
@@ -40,7 +40,7 @@ export class PresetLoadDialog extends _AppBase {
static PARTS = { static PARTS = {
dialog: { 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 { return {
presets: this._presets, presets: this._presets,
hasPresets: this._presets.length > 0, hasPresets: this._presets.length > 0,
loadLabel: i18n.localize('video-view-manager.presets.load.loadButton'), loadLabel: i18n.localize('scrying-pool.presets.load.loadButton'),
cancelLabel: i18n.localize('video-view-manager.presets.load.cancelButton'), cancelLabel: i18n.localize('scrying-pool.presets.load.cancelButton'),
title: i18n.localize('video-view-manager.presets.load.title'), title: i18n.localize('scrying-pool.presets.load.title'),
emptyMessage: i18n.localize('video-view-manager.presets.load.emptyMessage'), emptyMessage: i18n.localize('scrying-pool.presets.load.emptyMessage'),
}; };
} }
@@ -157,7 +157,7 @@ export class PresetLoadDialog extends _AppBase {
// Show success notification // Show success notification
this._adapter.notifications.info( 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) .replace('{name}', presetName)
); );
+9 -8
View File
@@ -35,12 +35,12 @@ export class PresetSaveDialog extends _AppBase {
id: 'scrying-pool-preset-save-dialog', id: 'scrying-pool-preset-save-dialog',
classes: ['scrying-pool', 'preset-save-dialog'], classes: ['scrying-pool', 'preset-save-dialog'],
window: { title: 'Save Scene Preset', resizable: false }, window: { title: 'Save Scene Preset', resizable: false },
position: { width: 320, height: 'auto' }, position: { width: 360, height: 'auto' },
}; };
static PARTS = { static PARTS = {
dialog: { 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 { return {
defaultName: '', defaultName: '',
saveLabel: i18n.localize('video-view-manager.presets.save.saveButton'), saveLabel: i18n.localize('scrying-pool.presets.save.saveButton'),
cancelLabel: i18n.localize('video-view-manager.presets.save.cancelButton'), cancelLabel: i18n.localize('scrying-pool.presets.save.cancelButton'),
title: i18n.localize('video-view-manager.presets.save.title'), title: i18n.localize('scrying-pool.presets.save.title'),
nameLabel: i18n.localize('video-view-manager.presets.save.nameLabel'), nameLabel: i18n.localize('scrying-pool.presets.save.nameLabel'),
namePlaceholder: i18n.localize('video-view-manager.presets.save.namePlaceholder'), 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 // Show success notification
this._adapter.notifications.info( 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) .replace('{name}', name)
); );
+15 -15
View File
@@ -66,7 +66,7 @@ export class ScenePresetPanel {
this._element = document.createElement('div'); this._element = document.createElement('div');
this._element.className = 'directors-board__preset-panel'; this._element.className = 'directors-board__preset-panel';
this._element.setAttribute('role', 'region'); 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'); this._element.setAttribute('aria-expanded', 'false');
// Initially hidden // Initially hidden
@@ -158,11 +158,11 @@ export class ScenePresetPanel {
* @private * @private
*/ */
_buildEmptyHtml() { _buildEmptyHtml() {
const message = this._adapter.i18n.localize('video-view-manager.scenePresetPanel.noScene'); const message = this._adapter.i18n.localize('scrying-pool.scenePresetPanel.noScene');
return ` return `
<div class="directors-board__preset-panel-header"> <div class="directors-board__preset-panel-header">
<h3 class="directors-board__preset-panel-title"> <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> </h3>
</div> </div>
<p class="directors-board__preset-panel-message">${this._escapeHtml(message)}</p> <p class="directors-board__preset-panel-message">${this._escapeHtml(message)}</p>
@@ -195,14 +195,14 @@ export class ScenePresetPanel {
// Add default option // Add default option
const defaultOption = ` const defaultOption = `
<option value="" ${!presetName ? 'selected' : ''}> <option value="" ${!presetName ? 'selected' : ''}>
${this._escapeHtml(localize('video-view-manager.scenePresetPanel.selectPreset'))} ${this._escapeHtml(localize('scrying-pool.scenePresetPanel.selectPreset'))}
</option> </option>
`; `;
return ` return `
<div class="directors-board__preset-panel-header"> <div class="directors-board__preset-panel-header">
<h3 class="directors-board__preset-panel-title"> <h3 class="directors-board__preset-panel-title">
${this._escapeHtml(localize('video-view-manager.scenePresetPanel.title'))} ${this._escapeHtml(localize('scrying-pool.scenePresetPanel.title'))}
</h3> </h3>
</div> </div>
@@ -214,18 +214,18 @@ export class ScenePresetPanel {
data-action="toggle-auto-apply" data-action="toggle-auto-apply"
${enabled ? 'checked' : ''} ${enabled ? 'checked' : ''}
role="switch" role="switch"
aria-label="${this._escapeHtml(localize('video-view-manager.scenePresetPanel.enableAutoApply'))}"> aria-label="${this._escapeHtml(localize('scrying-pool.scenePresetPanel.enableAutoApply'))}">
${this._escapeHtml(localize('video-view-manager.scenePresetPanel.enableAutoApply'))} ${this._escapeHtml(localize('scrying-pool.scenePresetPanel.enableAutoApply'))}
</label> </label>
</div> </div>
<div class="directors-board__preset-panel-row"> <div class="directors-board__preset-panel-row">
<label class="directors-board__preset-panel-label"> <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" <select class="directors-board__preset-panel-select"
data-action="select-preset" data-action="select-preset"
${!presets.length ? 'disabled' : ''} ${!presets.length ? 'disabled' : ''}
aria-label="${this._escapeHtml(localize('video-view-manager.scenePresetPanel.selectPreset'))}"> aria-label="${this._escapeHtml(localize('scrying-pool.scenePresetPanel.selectPreset'))}">
${defaultOption} ${defaultOption}
${presetOptions} ${presetOptions}
</select> </select>
@@ -234,7 +234,7 @@ export class ScenePresetPanel {
<div class="directors-board__preset-panel-row"> <div class="directors-board__preset-panel-row">
<label class="directors-board__preset-panel-label"> <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> <span class="directors-board__preset-panel-delay-value">${preDelay}ms</span>
<input type="range" <input type="range"
class="directors-board__preset-panel-slider" class="directors-board__preset-panel-slider"
@@ -243,7 +243,7 @@ export class ScenePresetPanel {
max="${this._MAX_PREDELAY}" max="${this._MAX_PREDELAY}"
value="${preDelay}" value="${preDelay}"
step="100" 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-valuemin="${this._MIN_PREDELAY}"
aria-valuemax="${this._MAX_PREDELAY}" aria-valuemax="${this._MAX_PREDELAY}"
aria-valuenow="${preDelay}"> aria-valuenow="${preDelay}">
@@ -251,7 +251,7 @@ export class ScenePresetPanel {
</div> </div>
<div class="directors-board__preset-panel-row directors-board__preset-panel-row--hint"> <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>
</div> </div>
`; `;
@@ -338,8 +338,8 @@ export class ScenePresetPanel {
// Notify // Notify
this._adapter.notifications.info( this._adapter.notifications.info(
isChecked isChecked
? this._adapter.i18n.localize('video-view-manager.scenePresetPanel.notifications.enabled') ? this._adapter.i18n.localize('scrying-pool.scenePresetPanel.notifications.enabled')
: this._adapter.i18n.localize('video-view-manager.scenePresetPanel.notifications.disabled') : this._adapter.i18n.localize('scrying-pool.scenePresetPanel.notifications.disabled')
); );
} catch (err) { } catch (err) {
console.error('[ScryingPool] ScenePresetPanel: failed to toggle auto-apply', err); console.error('[ScryingPool] ScenePresetPanel: failed to toggle auto-apply', err);
@@ -373,7 +373,7 @@ export class ScenePresetPanel {
// Notify // Notify
if (presetName) { if (presetName) {
this._adapter.notifications.info( 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) .replace('{name}', presetName)
); );
} }
+53 -8
View File
@@ -96,7 +96,7 @@ export class ScryingPoolStrip extends _AppBase {
: super.defaultOptions ?? {}; : super.defaultOptions ?? {};
return Object.assign({}, base, { return Object.assign({}, base, {
id: 'scrying-pool-strip', id: 'scrying-pool-strip',
template: 'modules/video-view-manager/templates/roster-strip.hbs', template: 'modules/scrying-pool/templates/roster-strip.hbs',
popOut: true, popOut: true,
resizable: false, resizable: false,
title: 'Scrying Pool', title: 'Scrying Pool',
@@ -140,7 +140,7 @@ export class ScryingPoolStrip extends _AppBase {
getData() { getData() {
const savedState = const savedState =
typeof game !== 'undefined' typeof game !== 'undefined'
? game.user?.getFlag?.('video-view-manager', 'stripState') ? game.user?.getFlag?.('scrying-pool', 'stripState')
: null; : null;
if (savedState?.expanded !== undefined) { if (savedState?.expanded !== undefined) {
this._isExpanded = savedState.expanded; this._isExpanded = savedState.expanded;
@@ -148,17 +148,24 @@ export class ScryingPoolStrip extends _AppBase {
const showFirstOpenTip = const showFirstOpenTip =
typeof game !== 'undefined' && typeof game !== 'undefined' &&
!game.user?.getFlag?.('video-view-manager', 'firstStripOpen'); !game.user?.getFlag?.('scrying-pool', 'firstStripOpen');
const userIds = this._adapter.users.all const userIds = this._adapter.users.all
? this._adapter.users.all().map(u => u.id) ? 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) // Check if we have stream access for video replacement (full AV replacement mode)
const hasStreamAccess = this._adapter.webrtc?.getMediaStreamForUser !== undefined; const hasStreamAccess = this._adapter.webrtc?.getMediaStreamForUser !== undefined;
const participants = buildParticipantList( const participants = buildParticipantList(
userIds, filteredUserIds,
this._stateStore, this._stateStore,
this._controller, this._controller,
this._adapter, 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 // First open tip: set flag so it doesn't show again
const isFirstOpen = const isFirstOpen =
typeof game !== 'undefined' && typeof game !== 'undefined' &&
!game.user?.getFlag?.('video-view-manager', 'firstStripOpen'); !game.user?.getFlag?.('scrying-pool', 'firstStripOpen');
if (isFirstOpen) { 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) // Attach video streams if we have stream access (full AV replacement mode)
if (this._adapter.webrtc?.getMediaStreamForUser !== undefined) { if (this._adapter.webrtc?.getMediaStreamForUser !== undefined) {
this._attachVideoStreams(el); 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 */ /** @inheritdoc */
@@ -233,7 +278,7 @@ export class ScryingPoolStrip extends _AppBase {
this._cleanupVideoStreams(); this._cleanupVideoStreams();
if (typeof game !== 'undefined') { if (typeof game !== 'undefined') {
game.user?.setFlag?.('video-view-manager', 'stripState', { game.user?.setFlag?.('scrying-pool', 'stripState', {
left: this.position?.left, left: this.position?.left,
top: this.position?.top, top: this.position?.top,
open: false, open: false,
@@ -267,7 +312,7 @@ export class ScryingPoolStrip extends _AppBase {
_toggleExpanded() { _toggleExpanded() {
this._isExpanded = !this._isExpanded; this._isExpanded = !this._isExpanded;
if (typeof game !== 'undefined') { if (typeof game !== 'undefined') {
game.user?.setFlag?.('video-view-manager', 'stripState', { game.user?.setFlag?.('scrying-pool', 'stripState', {
left: this.position?.left, left: this.position?.left,
top: this.position?.top, top: this.position?.top,
open: true, open: true,
+49 -17
View File
@@ -58,7 +58,7 @@ export class PlayerPrivacyPanel extends _AppBase {
static PARTS = { static PARTS = {
dialog: { 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, enabled: settings.reactionCamEnabled,
settingKey: '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 // Toggle labels
@@ -187,19 +180,28 @@ export class PlayerPrivacyPanel extends _AppBase {
/** /**
* Sets up event handlers after rendering. * 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) { _onRender(context) {
// Normalize html to element with querySelector // ApplicationV2 passes the template context as the first argument, not the element.
// FoundryVTT ApplicationV2 passes jQuery object, tests pass plain objects // Prefer this.element (the rendered root). Fall back to treating the argument as an element
const element = html instanceof HTMLElement // (for jQuery objects or test mocks that expose querySelector directly).
? html let element;
: (html?.[0] ?? html); 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; if (!element || typeof element.querySelector !== 'function') return;
// Cache toggle elements // Cache toggle elements
this._reactionCamToggle = element.querySelector('[data-setting="reactionCamEnabled"]'); 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 // Story 4.2: Set up portrait section event handlers
this._setupPortraitHandlers(element); this._setupPortraitHandlers(element);
@@ -208,6 +210,27 @@ export class PlayerPrivacyPanel extends _AppBase {
this._setupToggleHandlers(element); 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. * Sets up event handlers for toggle switches.
* @param {HTMLElement} element - The dialog element. * @param {HTMLElement} element - The dialog element.
@@ -428,6 +451,16 @@ export class PlayerPrivacyPanel extends _AppBase {
this._currentSettings[settingKey] = newValue; 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 // Show success notification
this._adapter.notifications.info( this._adapter.notifications.info(
this._adapter.i18n.localize('SCRYING_POOL.PrivacyPanel.savedNotification') this._adapter.i18n.localize('SCRYING_POOL.PrivacyPanel.savedNotification')
@@ -466,7 +499,6 @@ export class PlayerPrivacyPanel extends _AppBase {
_onClose() { _onClose() {
// Clear cached elements // Clear cached elements
this._reactionCamToggle = null; this._reactionCamToggle = null;
this._hpReactiveCamToggle = null;
this._fileInput = null; this._fileInput = null;
this._portraitPreview = null; this._portraitPreview = null;
this._currentSettings = null; this._currentSettings = null;
+2 -2
View File
@@ -501,7 +501,7 @@ export class VisibilityBadge {
* @returns {boolean} * @returns {boolean}
*/ */
_getFirstBadgeEncountered() { _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>} * @returns {Promise<void>}
*/ */
async _setFirstBadgeEncountered() { async _setFirstBadgeEncountered() {
await this._adapter.users.current()?.setFlag('video-view-manager', 'firstBadgeEncounter', true); await this._adapter.users.current()?.setFlag('scrying-pool', 'firstBadgeEncounter', true);
} }
/** /**
+90
View File
@@ -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
View File
@@ -23,16 +23,30 @@ export function resolveToggleTarget(currentState) {
* @param {string} userId * @param {string} userId
* @param {object} [user] - Optional user object for additional data * @param {object} [user] - Optional user object for additional data
* @param {object} [privacyManager] - Optional PlayerPrivacyManager for privacy settings * @param {object} [privacyManager] - Optional PlayerPrivacyManager for privacy settings
* @param {object} [controller] - Optional ScryingPoolController for pending-op state
* @returns {object} Participant context * @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 state = stateStore.getState(userId);
const resolvedState = state ?? 'active';
const context = { const context = {
userId, userId,
state: state ?? 'active', state: resolvedState,
isGhost: state === 'ghost', 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 // Add privacy settings if privacyManager is provided
if (privacyManager && user) { if (privacyManager && user) {
try { try {
@@ -60,7 +74,7 @@ export function buildBoardContext(stateStore, controller, adapter, privacyManage
const users = adapter.users.all?.() ?? []; const users = adapter.users.all?.() ?? [];
const participants = users.map(u => { const participants = users.map(u => {
const userId = u.id ?? 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 }; return { participants, isEmpty: participants.length === 0 };
} catch (err) { } catch (err) {
+181 -43
View File
@@ -10,15 +10,79 @@
// via DEFAULT_OPTIONS.classes. The content section and footer live inside PARTS. // via DEFAULT_OPTIONS.classes. The content section and footer live inside PARTS.
.scrying-pool.directors-board { .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); 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 ────────────────────────────────────────────────────── // ── Participant grid ──────────────────────────────────────────────────────
.directors-board__content { .directors-board__content {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); grid-template-columns: repeat(auto-fill, 68px);
gap: 8px; gap: 6px;
padding: 12px; padding: 8px;
overflow-y: auto; overflow-y: auto;
flex: 1 1 auto; flex: 1 1 auto;
list-style: none; list-style: none;
@@ -30,8 +94,8 @@
grid-column: 1 / -1; grid-column: 1 / -1;
text-align: center; text-align: center;
color: var(--sp-text-muted); color: var(--sp-text-muted);
font-size: 13px; font-size: 12px;
padding: 24px 0; padding: 20px 0;
margin: 0; margin: 0;
} }
@@ -39,51 +103,57 @@
.directors-board__bulk-bar { .directors-board__bulk-bar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 4px;
padding: 6px 12px; padding: 4px 8px;
border-top: 1px solid var(--sp-border); border-top: 1px solid rgba(255, 255, 255, 0.06);
flex-shrink: 0; flex-shrink: 0;
flex-wrap: wrap; flex-wrap: wrap;
background: rgba(0, 0, 0, 0.15);
} }
.directors-board__bulk-btn { .directors-board__bulk-btn {
font-size: 12px; display: inline-flex;
background: var(--sp-accent, #4a6f9c); align-items: center;
color: #fff; gap: 4px;
border: none; font-size: 11px;
background: rgba(74, 111, 156, 0.7);
color: #e8edf2;
border: 1px solid rgba(74, 111, 156, 0.4);
border-radius: 3px; border-radius: 3px;
padding: 4px 10px; padding: 3px 8px;
cursor: pointer; cursor: pointer;
transition: opacity 0.15s; transition: background 0.15s, opacity 0.15s;
&:hover { opacity: 0.85; } i { font-size: 10px; }
&:active { opacity: 0.7; }
&:hover { background: rgba(74, 111, 156, 0.95); }
&:active { opacity: 0.8; }
// Undo — secondary style
&--undo { &--undo {
background: transparent; background: transparent;
color: var(--sp-text-muted); 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 { &--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 ─────────────────────────────────────────── // ── Help / shortcut-panel button ───────────────────────────────────────────
.directors-board__help-btn { .directors-board__help-btn {
margin-left: auto; margin-left: auto;
width: 22px; width: 20px;
height: 22px; height: 20px;
border-radius: 50%; border-radius: 50%;
border: 1px solid var(--sp-border); border: 1px solid rgba(255, 255, 255, 0.15);
background: transparent; background: transparent;
color: var(--sp-text-muted); color: var(--sp-text-muted);
font-size: 12px; font-size: 11px;
font-weight: bold; font-weight: bold;
line-height: 1; line-height: 1;
cursor: pointer; cursor: pointer;
@@ -92,37 +162,105 @@
align-items: center; align-items: center;
justify-content: 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 { .directors-board__footer {
display: flex; display: flex;
gap: 8px; flex-direction: column;
padding: 8px 12px; gap: 3px;
border-top: 1px solid var(--sp-border); padding: 5px 8px 6px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
flex-shrink: 0; 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; flex: 1;
font-size: 12px; display: inline-flex;
background: var(--sp-accent, #4a6f9c); align-items: center;
color: #fff; justify-content: center;
border: none; 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; border-radius: 3px;
padding: 4px 8px; padding: 4px 6px;
cursor: pointer; 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] { &[disabled] {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.5; opacity: 0.4;
background: transparent; background: transparent;
color: var(--sp-text-muted); 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); }
} }
} }
} }
+4
View File
@@ -96,6 +96,10 @@
&__toggle { &__toggle {
position: absolute; position: absolute;
inset: 0; 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; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
+202 -74
View File
@@ -10,9 +10,10 @@
.scrying-pool { .scrying-pool {
// Dialog root element // Dialog root element
.player-privacy-panel { &.player-privacy-panel {
background: var(--sp-surface); background: var(--sp-surface);
color: var(--sp-text-primary); color: var(--sp-text-primary);
font-family: var(--font-primary, inherit);
} }
// Container // Container
@@ -23,57 +24,50 @@
max-width: 400px; 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 // Body
.player-privacy-panel__body { .player-privacy-panel__body {
padding: var(--sp-spacing-md, 12px); padding: 12px;
background: var(--sp-surface); background: var(--sp-surface);
display: flex;
flex-direction: column;
gap: 4px;
} }
// Notice (read-only) // Notice (read-only)
.player-privacy-panel__notice { .player-privacy-panel__notice {
padding: var(--sp-spacing-sm, 8px) var(--sp-spacing-md, 12px); padding: 8px 12px;
margin-bottom: var(--sp-spacing-md, 12px); margin-bottom: 10px;
border-radius: var(--sp-border-radius, 4px); border-radius: 4px;
font-size: 0.85em; font-size: 12px;
text-align: center; text-align: center;
} }
.player-privacy-panel__notice--readonly { .player-privacy-panel__notice--readonly {
background: var(--sp-urgency-awareness); background: hsla(48, 88%, 55%, 0.10);
color: var(--sp-text-secondary); color: hsl(48, 88%, 60%);
border: 1px solid var(--sp-border); border: 1px solid hsla(48, 88%, 55%, 0.25);
} }
// Section // Section
.player-privacy-panel__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 { .player-privacy-panel__section-header {
margin: 0 0 var(--sp-spacing-xs, 4px) 0; margin: 0 0 4px 0;
font-size: 0.95em; font-family: var(--font-primary, ui-sans-serif, system-ui, sans-serif);
font-weight: 600; font-size: 13px;
font-weight: 700;
color: var(--sp-text-primary); color: var(--sp-text-primary);
letter-spacing: 0.02em;
text-transform: uppercase;
opacity: 0.85;
} }
.player-privacy-panel__section-description { .player-privacy-panel__section-description {
margin: 0 0 var(--sp-spacing-md, 12px) 0; margin: 0 0 10px 0;
font-size: 0.85em; font-size: 12px;
color: var(--sp-text-secondary); color: var(--sp-text-secondary);
line-height: 1.4; line-height: 1.4;
} }
@@ -82,40 +76,51 @@
.player-privacy-panel__effects-list { .player-privacy-panel__effects-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--sp-spacing-md, 12px); gap: 8px;
} }
// Individual effect // Individual effect card
.player-privacy-panel__effect { .player-privacy-panel__effect {
padding: var(--sp-spacing-sm, 8px); padding: 8px 10px;
border: 1px solid var(--sp-border); border: 1px solid var(--sp-border);
border-radius: var(--sp-border-radius, 4px); border-radius: 5px;
background: var(--sp-surface-elevated); 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 { .player-privacy-panel__effect-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; 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 { .player-privacy-panel__effect-label {
margin: 0; margin: 0;
font-size: 0.9em; font-family: var(--font-primary, ui-sans-serif, system-ui, sans-serif);
font-weight: 500; font-size: 13px;
font-weight: 600;
color: var(--sp-text-primary); color: var(--sp-text-primary);
letter-spacing: 0;
} }
.player-privacy-panel__effect-description { .player-privacy-panel__effect-description {
margin: 0; margin: 0;
font-size: 0.8em; font-size: 11px;
color: var(--sp-text-secondary); color: var(--sp-text-secondary);
line-height: 1.4; line-height: 1.4;
opacity: 0.8;
} }
// Toggle switch // Toggle container
.player-privacy-panel__toggle { .player-privacy-panel__toggle {
flex-shrink: 0;
display: flex; display: flex;
align-items: center; align-items: center;
} }
@@ -123,13 +128,13 @@
.player-privacy-panel__toggle-label { .player-privacy-panel__toggle-label {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--sp-spacing-xs, 4px); gap: 4px;
cursor: pointer; cursor: pointer;
user-select: none; 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 { .player-privacy-panel__toggle-input {
position: absolute; position: absolute;
opacity: 0; opacity: 0;
@@ -142,53 +147,66 @@
} }
&:disabled + .player-privacy-panel__toggle-text { &:disabled + .player-privacy-panel__toggle-text {
opacity: 0.6; opacity: 0.45;
cursor: not-allowed; cursor: not-allowed;
pointer-events: none;
} }
} }
// Toggle badge — clearly different ON vs OFF
.player-privacy-panel__toggle-text { .player-privacy-panel__toggle-text {
display: inline-block; display: inline-flex;
padding: var(--sp-spacing-xs, 4px) var(--sp-spacing-sm, 8px); align-items: center;
gap: 5px;
padding: 3px 9px;
border: 1px solid var(--sp-border); border: 1px solid var(--sp-border);
border-radius: var(--sp-border-radius, 4px); border-radius: 20px;
background: var(--sp-surface); font-family: var(--font-primary, ui-sans-serif, system-ui, sans-serif);
color: var(--sp-text-primary); font-size: 11px;
font-weight: 500; font-weight: 600;
transition: all 0.15s ease; 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 + & { .player-privacy-panel__toggle-input:checked + & {
background: var(--sp-accent); background: rgba(46, 160, 67, 0.18);
color: white; 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); border-color: var(--sp-accent);
} }
.player-privacy-panel__toggle-input:checked + &:hover,
.player-privacy-panel__toggle-input:disabled + & { .player-privacy-panel__toggle-label:hover .player-privacy-panel__toggle-input:checked + & {
background: var(--sp-surface); background: rgba(46, 160, 67, 0.25);
border-color: var(--sp-border);
}
.player-privacy-panel__toggle-input:checked:disabled + & {
background: var(--sp-accent);
color: white;
opacity: 0.6;
} }
} }
// Story 4.2: Portrait fallback section // Portrait section
.player-privacy-panel__portrait-container { .player-privacy-panel__portrait-container {
display: flex; display: flex;
flex-direction: column; align-items: flex-start;
gap: var(--sp-spacing-sm, 8px); gap: 12px;
} }
.player-privacy-panel__portrait-preview { .player-privacy-panel__portrait-preview {
width: 100px; flex-shrink: 0;
height: 100px; width: 80px;
height: 80px;
border: 2px solid var(--sp-border); border: 2px solid var(--sp-border);
border-radius: var(--sp-border-radius, 4px); border-radius: 6px;
overflow: hidden; overflow: hidden;
background: var(--sp-surface-elevated); background: hsl(215,25%,11%);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -202,13 +220,123 @@
.player-privacy-panel__portrait-actions { .player-privacy-panel__portrait-actions {
display: flex; display: flex;
gap: var(--sp-spacing-sm, 8px); flex-direction: column;
flex-wrap: wrap; gap: 6px;
justify-content: center;
} }
.player-privacy-panel__portrait-choose, .player-privacy-panel__portrait-choose,
.player-privacy-panel__portrait-remove { .player-privacy-panel__portrait-remove {
font-size: 0.85em; font-size: 12px;
padding: var(--sp-spacing-xs, 4px) var(--sp-spacing-sm, 8px); 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;
}
}
+9 -5
View File
@@ -1,5 +1,12 @@
// ============================================================================ // Wrapper divs we added to satisfy the single-root-element requirement
// Preset Import/Export Dialogs .sp-preset-export-dialog,
.sp-preset-import-dialog {
display: flex;
flex-direction: column;
height: 100%;
}
// Story 3.3: Preset Import & Export // Story 3.3: Preset Import & Export
// ============================================================================ // ============================================================================
@@ -159,9 +166,6 @@
height: 100%; height: 100%;
cursor: pointer; cursor: pointer;
z-index: 2; z-index: 2;
// Prevent pointer events from bubbling through
pointer-events: none;
} }
.sp-file-label { .sp-file-label {
+20 -35
View File
@@ -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 { .scrying-pool.preset-load-dialog {
background: var(--sp-surface); background: var(--sp-surface);
color: var(--sp-text-primary); color: var(--sp-text-primary);
@@ -21,23 +10,9 @@
gap: 0; 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 ────────────────────────────────────────────────────────────── // ── Body ──────────────────────────────────────────────────────────────
.preset-load-dialog__body { .preset-load-dialog__body {
padding: 16px; padding: 12px;
overflow-y: auto; overflow-y: auto;
flex: 1 1 auto; flex: 1 1 auto;
} }
@@ -49,6 +24,7 @@
font-size: 13px; font-size: 13px;
padding: 24px 0; padding: 24px 0;
margin: 0; margin: 0;
font-style: italic;
} }
// ── Preset list ──────────────────────────────────────────────────────── // ── Preset list ────────────────────────────────────────────────────────
@@ -71,18 +47,28 @@
text-align: left; text-align: left;
font-size: 13px; font-size: 13px;
padding: 8px 12px; padding: 8px 12px;
border-radius: 3px; border-radius: 4px;
cursor: pointer; cursor: pointer;
transition: background-color 0.15s, border-color 0.15s; 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 { &--load {
background: var(--sp-accent, #4a6f9c); background: var(--sp-surface-elevated, rgba(255,255,255,0.06));
color: #fff; color: var(--sp-text-primary);
border: none; border: 1px solid var(--sp-border);
&:hover { opacity: 0.85; } &:hover {
&:active { opacity: 0.7; } background: var(--sp-accent, #4a6f9c);
border-color: var(--sp-accent, #4a6f9c);
color: #fff;
i { opacity: 1; }
}
&:active { opacity: 0.85; }
} }
// Cancel button — secondary // Cancel button — secondary
@@ -90,7 +76,6 @@
background: transparent; background: transparent;
color: var(--sp-text-muted); color: var(--sp-text-muted);
border: 1px solid var(--sp-border); border: 1px solid var(--sp-border);
&:hover { color: var(--sp-text, inherit); border-color: currentColor; } &:hover { color: var(--sp-text, inherit); border-color: currentColor; }
} }
} }
@@ -100,7 +85,7 @@
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 8px; gap: 8px;
padding: 12px 16px; padding: 10px 12px;
border-top: 1px solid var(--sp-border); border-top: 1px solid var(--sp-border);
flex-shrink: 0; flex-shrink: 0;
} }
+75 -38
View File
@@ -1,19 +1,20 @@
/** /**
* styles/components/_preset-save-dialog.less * Preset Save Dialog — compact, polished design.
* * Follows SP token system; mirrors Directors Board aesthetic.
* Layout for the Preset Save 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-save-dialog applied @import "../tokens/_base.less";
// via DEFAULT_OPTIONS.classes. The content lives inside PARTS.
.scrying-pool.preset-save-dialog { .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); 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 { .preset-save-dialog__form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -21,21 +22,33 @@
gap: 0; gap: 0;
} }
// ── Header ──────────────────────────────────────────────────────────── // ── Header ────────────────────────────────────────────────────────────────
.preset-save-dialog__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); border-bottom: 1px solid var(--sp-border);
background: rgba(255, 255, 255, 0.02);
}
.preset-save-dialog__header-icon {
flex-shrink: 0; 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; margin: 0;
font-size: 14px; font-family: var(--font-primary, ui-sans-serif, system-ui, sans-serif);
font-weight: bold; font-size: 12px;
color: var(--sp-text, inherit); color: var(--sp-text-secondary);
line-height: 1.45;
} }
// ── Body ────────────────────────────────────────────────────────────── // ── Body ──────────────────────────────────────────────────────────────────
.preset-save-dialog__body { .preset-save-dialog__body {
padding: 16px; padding: 16px;
overflow-y: auto; overflow-y: auto;
@@ -45,69 +58,93 @@
.preset-save-dialog__field { .preset-save-dialog__field {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 7px;
} }
.preset-save-dialog__label { .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); color: var(--sp-text-muted);
cursor: default; cursor: default;
} }
.preset-save-dialog__input { .preset-save-dialog__input {
width: 100%;
font-family: var(--font-primary, ui-sans-serif, system-ui, sans-serif);
font-size: 14px; font-size: 14px;
padding: 6px 10px; padding: 9px 12px;
border: 1px solid var(--sp-border); border: 1px solid var(--sp-border);
border-radius: 3px; border-radius: 5px;
background: var(--sp-bg, #fff); background: var(--sp-control-bg, #1a1d20);
color: var(--sp-text, inherit); color: var(--sp-text-primary);
outline: none; outline: none;
transition: border-color 0.15s, box-shadow 0.15s; transition: border-color 0.15s, box-shadow 0.15s;
box-sizing: border-box;
&:focus { &:focus {
border-color: var(--sp-accent, #4a6f9c); border-color: var(--sp-accent);
box-shadow: 0 0 0 1px var(--sp-accent, #4a6f9c); box-shadow: 0 0 0 2px color-mix(in srgb, var(--sp-accent) 22%, transparent);
} }
&::placeholder { &::placeholder {
color: var(--sp-text-muted); color: var(--sp-text-muted);
font-style: italic;
font-size: 13px;
} }
} }
// ── Footer ──────────────────────────────────────────────────────────── // ── Footer ────────────────────────────────────────────────────────────────
.preset-save-dialog__footer { .preset-save-dialog__footer {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
align-items: center;
gap: 8px; gap: 8px;
padding: 12px 16px; padding: 10px 16px;
border-top: 1px solid var(--sp-border); border-top: 1px solid var(--sp-border);
background: rgba(0, 0, 0, 0.15);
flex-shrink: 0; flex-shrink: 0;
} }
.preset-save-dialog__btn { .preset-save-dialog__btn {
font-family: var(--font-primary, ui-sans-serif, system-ui, sans-serif);
font-size: 12px; font-size: 12px;
padding: 6px 14px; font-weight: 600;
border-radius: 3px; padding: 7px 14px;
border-radius: 5px;
cursor: pointer; 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 { &--save {
background: var(--sp-accent, #4a6f9c); background: var(--sp-accent);
color: #fff; color: var(--sp-accent-text, #fff);
border: none; border: 1px solid transparent;
letter-spacing: 0.02em;
&:hover { opacity: 0.85; } &:hover { background: var(--sp-accent-hover); }
&:active { opacity: 0.7; } &:active { background: var(--sp-accent-active); }
i { opacity: 1; }
} }
// Cancel button — secondary
&--cancel { &--cancel {
background: transparent; background: transparent;
color: var(--sp-text-muted); color: var(--sp-text-secondary);
border: 1px solid var(--sp-border); 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);
}
} }
} }
} }
+163 -4
View File
@@ -18,7 +18,27 @@
// ============================================================ // ============================================================
// ScryingPoolStrip Layout // 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 { .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; position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -26,17 +46,72 @@
max-width: 44px; max-width: 44px;
overflow: hidden; overflow: hidden;
transition: max-width 200ms ease-in-out; transition: max-width 200ms ease-in-out;
background: var(--sp-bg, hsl(220, 15%, 12%));
border-radius: 8px;
&.is-expanded { &.is-expanded {
max-width: 240px; 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 { .sp-strip__toggle {
width: 44px; width: 44px;
height: 44px; min-width: 44px;
height: 28px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -44,7 +119,39 @@
border: none; border: none;
cursor: pointer; cursor: pointer;
color: var(--sp-text, hsl(0, 0%, 80%)); color: var(--sp-text, hsl(0, 0%, 80%));
font-size: 11px;
flex-shrink: 0; 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 { .sp-strip__participants {
@@ -89,7 +196,59 @@
.is-expanded & { .is-expanded & {
width: 100%; 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;
}
} }
} }
+82 -120
View File
@@ -6,239 +6,201 @@
// Panel container // Panel container
.directors-board__preset-panel { .directors-board__preset-panel {
// Base styles background: linear-gradient(160deg, hsl(215,28%,13%) 0%, hsl(215,25%,10%) 100%);
background-color: var(--sp-surface);
border: 1px solid var(--sp-border); border: 1px solid var(--sp-border);
border-radius: 6px; border-radius: 6px;
padding: 12px; padding: 10px 12px 12px;
margin-top: 12px; margin-top: 8px;
// Layout
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 0;
// Typography font-size: 13px;
font-size: 14px;
line-height: 1.4; line-height: 1.4;
color: var(--sp-text-primary); color: var(--sp-text-primary);
} }
// Panel header // Panel header
.directors-board__preset-panel-header { .directors-board__preset-panel-header {
display: flex; margin-bottom: 8px;
align-items: center; padding-bottom: 7px;
justify-content: space-between;
margin-bottom: 4px;
padding-bottom: 8px;
border-bottom: 1px solid var(--sp-border-subtle); border-bottom: 1px solid var(--sp-border-subtle);
} }
.directors-board__preset-panel-title { .directors-board__preset-panel-title {
margin: 0; margin: 0;
font-size: 15px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: var(--sp-text-primary); color: var(--sp-text-primary);
letter-spacing: 0.02em;
text-transform: uppercase;
opacity: 0.75;
} }
// Panel body // Panel body
.directors-board__preset-panel-body { .directors-board__preset-panel-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 8px;
} }
// Panel row // Panel row — single horizontal line
.directors-board__preset-panel-row { .directors-board__preset-panel-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
width: 100%;
&--hint { &--hint {
font-size: 12px; font-size: 11px;
color: var(--sp-text-secondary); color: var(--sp-text-secondary);
margin-top: 4px; margin-top: 4px;
padding-top: 8px; padding-top: 8px;
border-top: 1px solid var(--sp-border-subtle); 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 { .directors-board__preset-panel-label {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
flex: 1;
cursor: default; cursor: default;
user-select: none; user-select: none;
white-space: nowrap;
// Ensure proper spacing when label wraps
flex-wrap: wrap;
align-items: flex-start;
} }
// Toggle switch // Toggle checkbox — compact and styled
.directors-board__preset-panel-toggle { .directors-board__preset-panel-toggle {
// Button reset flex-shrink: 0;
background: transparent; appearance: none;
border: none; -webkit-appearance: none;
padding: 0; width: 14px;
margin: 0; height: 14px;
cursor: pointer; border: 1.5px solid var(--sp-border);
border-radius: 3px;
// Appearance
width: 18px;
height: 18px;
border: 2px solid var(--sp-border);
border-radius: 4px;
background-color: var(--sp-surface); background-color: var(--sp-surface);
cursor: pointer;
position: relative; 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 { &:checked {
background-color: var(--sp-accent); background-color: var(--sp-accent);
border-color: var(--sp-accent); border-color: var(--sp-accent);
}
// After pseudo-element for toggle effect &::after {
&:after {
content: ''; content: '';
position: absolute; position: absolute;
top: 1px; top: 1px;
left: 1px; left: 3px;
width: 12px; width: 5px;
height: 12px; height: 8px;
background-color: var(--sp-surface); border: 2px solid white;
border-radius: 2px; border-top: none;
transition: transform 150ms ease, background-color 150ms ease; border-left: none;
transform: rotate(40deg);
}
} }
&:checked:after {
transform: translateX(100%);
background-color: var(--sp-surface-inverse, white);
}
// Focus state
&:focus { &:focus {
outline: 2px solid var(--sp-focus); outline: 2px solid var(--sp-focus);
outline-offset: 2px; 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 { .directors-board__preset-panel-select {
// Button reset flex: 1;
background: transparent; min-width: 0;
background: var(--sp-surface);
border: 1px solid var(--sp-border); 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; 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) { &:hover:not(:disabled) {
border-color: var(--sp-border-hover, var(--sp-accent)); border-color: var(--sp-accent);
} }
// Focus state
&:focus { &:focus {
outline: none; outline: none;
border-color: var(--sp-focus); border-color: var(--sp-focus);
box-shadow: 0 0 0 2px rgba(var(--sp-focus-rgb, 0, 0, 255), 0.2);
} }
// Disabled state
&:disabled { &:disabled {
opacity: 0.5; opacity: 0.45;
cursor: not-allowed; 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 { .directors-board__preset-panel-slider {
// Remove default slider styling flex: 1;
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
width: 120px; height: 4px;
height: 6px; background: rgba(255,255,255,0.12);
background: var(--sp-surface-subtle, rgba(0, 0, 0, 0.1)); border-radius: 2px;
border-radius: 3px;
outline: none; outline: none;
cursor: pointer; cursor: pointer;
margin: 0;
// Webkit slider thumb
&::-webkit-slider-thumb { &::-webkit-slider-thumb {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
width: 16px; width: 12px;
height: 16px; height: 12px;
background: var(--sp-accent); background: var(--sp-accent);
border-radius: 50%; border-radius: 50%;
cursor: pointer; cursor: pointer;
transition: transform 150ms ease; transition: transform 120ms ease;
&:hover { &:hover { transform: scale(1.2); }
transform: scale(1.1);
}
} }
// Firefox slider thumb
&::-moz-range-thumb { &::-moz-range-thumb {
width: 16px; width: 12px;
height: 16px; height: 12px;
background: var(--sp-accent); background: var(--sp-accent);
border-radius: 50%; border-radius: 50%;
cursor: pointer; cursor: pointer;
border: none; border: none;
transition: transform 150ms ease; transition: transform 120ms ease;
&:hover { &:hover { transform: scale(1.2); }
transform: scale(1.1);
} }
} }
// Focus state // Panel message (when no scene active)
&:focus {
outline: none;
}
}
// 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)
.directors-board__preset-panel-message { .directors-board__preset-panel-message {
margin: 0; margin: 4px 0 0;
color: var(--sp-text-secondary); color: var(--sp-text-secondary);
font-style: italic; font-style: italic;
text-align: center; text-align: center;
padding: 8px 0; font-size: 12px;
} }
// Reduced motion: disable transitions // Reduced motion
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.directors-board__preset-panel-toggle, .directors-board__preset-panel-toggle,
.directors-board__preset-panel-select, .directors-board__preset-panel-select,
@@ -246,9 +208,9 @@
.directors-board__preset-panel-slider::-moz-range-thumb { .directors-board__preset-panel-slider::-moz-range-thumb {
transition: none; transition: none;
} }
.directors-board__preset-panel-slider::-webkit-slider-thumb:hover, .directors-board__preset-panel-slider::-webkit-slider-thumb:hover,
.directors-board__preset-panel-slider::-moz-range-thumb:hover { .directors-board__preset-panel-slider::-moz-range-thumb:hover {
transform: none; transform: none;
} }
} }
+843 -185
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -25,6 +25,9 @@
// Story 3.2: ConfirmationBar and StripOverlayLayer // Story 3.2: ConfirmationBar and StripOverlayLayer
@import "components/_strip-overlay-layer.less"; @import "components/_strip-overlay-layer.less";
@import "components/_confirmation-bar.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 // Story 3.3: Preset Import/Export Dialogs
@import "components/_preset-import-export.less"; @import "components/_preset-import-export.less";
// Story 4.1: Player Privacy Panel // Story 4.1: Player Privacy Panel
@@ -75,6 +78,11 @@
display: none !important; 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 // Also hide individual camera views in case they're rendered elsewhere
.camera-view { .camera-view {
display: none !important; display: none !important;
+26
View File
@@ -21,18 +21,39 @@
--sp-surface: var(--sp-theme-surface, var(--color-bg-option, #141618)); --sp-surface: var(--sp-theme-surface, var(--color-bg-option, #141618));
--sp-surface-raised: var(--sp-theme-surface-raised, #1c1f22); --sp-surface-raised: var(--sp-theme-surface-raised, #1c1f22);
--sp-surface-elevated: var(--sp-theme-surface-elevated, var(--sp-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: var(--sp-theme-border, var(--color-border, #282c30));
--sp-border-subtle: var(--sp-theme-border-subtle, rgba(255, 255, 255, 0.07));
/* Typography */ /* Typography */
--sp-text-primary: var(--sp-theme-text-primary, var(--color-text-primary, #dde2e8)); --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-secondary: var(--sp-theme-text-secondary,var(--color-text-secondary, #7a8390));
--sp-text-muted: var(--sp-theme-text-muted, #555d66); --sp-text-muted: var(--sp-theme-text-muted, #555d66);
--sp-font-size-sm: 11px;
/* Accent & interaction */ /* Accent & interaction */
--sp-accent: var(--sp-theme-accent, var(--color-warm-2, #4a9e6b)); --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-surface-interactive: var(--sp-theme-interactive, #242830);
--sp-control-bg: var(--sp-theme-control, #1a1d20); --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 */ /* Focus ring — module-wide keyboard navigation anchor */
--sp-focus: var(--sp-theme-focus, var(--color-focus-outline, #63c287)); --sp-focus: var(--sp-theme-focus, var(--color-focus-outline, #63c287));
--sp-focus-ring: 0 0 0 2px var(--sp-focus); --sp-focus-ring: 0 0 0 2px var(--sp-focus);
@@ -51,11 +72,16 @@
--sp-theme-surface: #141618; --sp-theme-surface: #141618;
--sp-theme-surface-raised: #1c1f22; --sp-theme-surface-raised: #1c1f22;
--sp-theme-surface-elevated: #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: #282c30;
--sp-theme-border-subtle: rgba(255, 255, 255, 0.07);
--sp-theme-text-primary: #dde2e8; --sp-theme-text-primary: #dde2e8;
--sp-theme-text-secondary: #7a8390; --sp-theme-text-secondary: #7a8390;
--sp-theme-text-muted: #555d66; --sp-theme-text-muted: #555d66;
--sp-theme-accent: #4a9e6b; --sp-theme-accent: #4a9e6b;
--sp-theme-accent-hover: #3d8f5e;
--sp-theme-accent-active: #2d7a4f;
--sp-theme-interactive: #242830; --sp-theme-interactive: #242830;
--sp-theme-control: #1a1d20; --sp-theme-control: #1a1d20;
--sp-theme-focus: #63c287; --sp-theme-focus: #63c287;
+74 -22
View File
@@ -1,58 +1,110 @@
{{!-- Director's Board — GM camera-management overview window --}} {{!-- Director's Board — GM camera-management overview window --}}
<div class="directors-board__inner"> <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" <section class="scrying-pool directors-board__content"
role="list" role="list"
aria-label="{{localize "video-view-manager.directorsBoard.title"}}"> aria-label="{{localize "scrying-pool.directorsBoard.title"}}">
{{#unless isEmpty}} {{#unless isEmpty}}
{{#each participants}} {{#each participants}}
{{> "modules/video-view-manager/templates/participant-card.hbs"}} {{> "modules/scrying-pool/templates/participant-card.hbs"}}
{{/each}} {{/each}}
{{else}} {{else}}
<p class="directors-board__empty" role="listitem"> <p class="directors-board__empty" role="listitem">
{{localize "video-view-manager.directorsBoard.empty"}} {{localize "scrying-pool.directorsBoard.empty"}}
</p> </p>
{{/unless}} {{/unless}}
</section> </section>
<div class="directors-board__bulk-bar"> <div class="directors-board__bulk-bar">
<button type="button" class="directors-board__bulk-btn" data-action="show-all"> <button type="button" class="directors-board__bulk-btn" data-action="show-all"
{{localize "video-view-manager.directorsBoard.bulk.showAll"}} 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>
<button type="button" class="directors-board__bulk-btn" data-action="hide-all"> <button type="button" class="directors-board__bulk-btn" data-action="hide-all"
{{localize "video-view-manager.directorsBoard.bulk.hideAll"}} 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> </button>
{{#if hasUndo}} {{#if hasUndo}}
<button type="button" class="directors-board__bulk-btn directors-board__bulk-btn--undo" data-action="undo"> <button type="button" class="directors-board__bulk-btn directors-board__bulk-btn--undo" data-action="undo"
{{localize "video-view-manager.directorsBoard.bulk.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> </button>
{{/if}} {{/if}}
{{#if hasRestore}} {{#if hasRestore}}
<button type="button" class="directors-board__bulk-btn directors-board__bulk-btn--restore" data-action="restore-spotlight"> <button type="button" class="directors-board__bulk-btn directors-board__bulk-btn--restore" data-action="restore-spotlight"
{{localize "video-view-manager.directorsBoard.bulk.restore"}} data-tooltip="{{localize "scrying-pool.directorsBoard.bulk.restore"}}">
<i class="fas fa-star" aria-hidden="true"></i>
{{localize "scrying-pool.directorsBoard.bulk.restore"}}
</button> </button>
{{/if}} {{/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> </div>
<footer class="directors-board__footer"> <footer class="directors-board__footer">
<button type="button" class="directors-board__footer-btn" data-action="save-preset"> <div class="directors-board__footer-group directors-board__footer-group--presets">
{{localize "video-view-manager.directorsBoard.footer.savePreset"}} <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>
<button type="button" class="directors-board__footer-btn" data-action="load-preset" {{#unless hasPresets}}disabled{{/unless}}> <button type="button" class="directors-board__footer-btn" data-action="load-preset"
{{localize "video-view-manager.directorsBoard.footer.loadPreset"}} 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>
<button type="button" class="directors-board__footer-btn" data-action="export-presets"> <button type="button" class="directors-board__footer-btn directors-board__footer-btn--secondary" data-action="export-presets"
{{localize "video-view-manager.directorsBoard.footer.exportPresets"}} 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>
<button type="button" class="directors-board__footer-btn" data-action="import-presets"> <button type="button" class="directors-board__footer-btn directors-board__footer-btn--secondary" data-action="import-presets"
{{localize "video-view-manager.directorsBoard.footer.importPresets"}} 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>
<button type="button" class="directors-board__footer-btn" data-action="toggle-preset-panel"> </div>
{{localize "video-view-manager.directorsBoard.footer.autoApplySettings"}} <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> </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> </footer>
{{!-- Scene Preset Panel - rendered via JavaScript, not Handlebars --}} {{!-- Scene Preset Panel - rendered via JavaScript, not Handlebars --}}
+1
View File
@@ -29,6 +29,7 @@
data-user-id="{{userId}}" data-user-id="{{userId}}"
role="button" role="button"
aria-label="{{toggleAriaLabel}}" aria-label="{{toggleAriaLabel}}"
data-tooltip="{{toggleAriaLabel}}"
tabindex="-1"> tabindex="-1">
<i class="fas {{#if isHidden}}fa-eye{{else}}fa-eye-slash{{/if}}" aria-hidden="true"></i> <i class="fas {{#if isHidden}}fa-eye{{else}}fa-eye-slash{{/if}}" aria-hidden="true"></i>
</button> </button>
+14 -8
View File
@@ -1,9 +1,5 @@
{{!-- Player Privacy Panel --}} {{!-- Player Privacy Panel --}}
<div class="player-privacy-panel__container"> <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"> <div class="player-privacy-panel__body">
{{#if isReadOnly}} {{#if isReadOnly}}
<div class="player-privacy-panel__notice player-privacy-panel__notice--readonly"> <div class="player-privacy-panel__notice player-privacy-panel__notice--readonly">
@@ -15,7 +11,7 @@
<h3 class="player-privacy-panel__section-header">{{sectionHeader}}</h3> <h3 class="player-privacy-panel__section-header">{{sectionHeader}}</h3>
<p class="player-privacy-panel__section-description"> <p class="player-privacy-panel__section-description">
{{SCRYING_POOL.PrivacyPanel.sectionDescription}} {{localize "SCRYING_POOL.PrivacyPanel.sectionDescription"}}
</p> </p>
<div class="player-privacy-panel__effects-list"> <div class="player-privacy-panel__effects-list">
@@ -24,18 +20,22 @@
<div class="player-privacy-panel__effect-header"> <div class="player-privacy-panel__effect-header">
<h4 class="player-privacy-panel__effect-label">{{label}}</h4> <h4 class="player-privacy-panel__effect-label">{{label}}</h4>
<div class="player-privacy-panel__toggle"> <div class="player-privacy-panel__toggle">
<label class="player-privacy-panel__toggle-label"> <label class="player-privacy-panel__toggle-label"
data-tooltip="{{description}}">
<input <input
type="checkbox" type="checkbox"
{{#if enabled}}checked{{/if}} {{#if enabled}}checked{{/if}}
{{#if ../isReadOnly}}disabled{{/if}} {{#if ../isReadOnly}}disabled{{/if}}
data-setting="{{settingKey}}" data-setting="{{settingKey}}"
class="player-privacy-panel__toggle-input" class="player-privacy-panel__toggle-input"
aria-label="{{label}}"
> >
<span class="player-privacy-panel__toggle-text"> <span class="player-privacy-panel__toggle-text">
{{#if enabled}} {{#if enabled}}
<i class="fas fa-check" aria-hidden="true"></i>
{{../toggleOnLabel}} {{../toggleOnLabel}}
{{else}} {{else}}
<i class="fas fa-times" aria-hidden="true"></i>
{{../toggleOffLabel}} {{../toggleOffLabel}}
{{/if}} {{/if}}
</span> </span>
@@ -64,7 +64,10 @@
{{#unless isReadOnly}} {{#unless isReadOnly}}
<div class="player-privacy-panel__portrait-actions"> <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}} {{chooseImageLabel}}
</button> </button>
<input <input
@@ -74,7 +77,10 @@
style="display: none;" style="display: none;"
> >
{{#if hasCustomPortrait}} {{#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}} {{removeImageLabel}}
</button> </button>
{{/if}} {{/if}}
+8 -6
View File
@@ -1,29 +1,31 @@
<div class="sp-preset-export-dialog">
<div class="sp-dialog-content"> <div class="sp-dialog-content">
<p class="sp-export-description"> <p class="sp-export-description">
{{localize "video-view-manager.presetExport.description"}} {{localize "scrying-pool.presetExport.description"}}
</p> </p>
<div class="sp-export-info"> <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> <span class="sp-info-value">{{sceneName}}</span>
</div> </div>
<div class="sp-export-info"> <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> <span class="sp-info-value">{{presetCount}}</span>
</div> </div>
<div class="sp-export-info"> <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> <span class="sp-info-value sp-filename">{{filename}}</span>
</div> </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"> <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>
<button type="button" class="sp-btn sp-btn-secondary" data-action="close"> <button type="button" class="sp-btn sp-btn-secondary" data-action="close">
{{localize "video-view-manager.presetExport.cancel"}} {{localize "scrying-pool.presetExport.cancel"}}
</button> </button>
</div> </div>
</div>
+14 -12
View File
@@ -1,23 +1,24 @@
<div class="sp-preset-import-dialog">
<div class="sp-dialog-content"> <div class="sp-dialog-content">
<p class="sp-import-description"> <p class="sp-import-description">
{{localize "video-view-manager.presetImport.description"}} {{localize "scrying-pool.presetImport.description"}}
</p> </p>
{{#if hasExistingPresets}} {{#if hasExistingPresets}}
<div class="sp-warning-box"> <div class="sp-warning-box">
<i class="fas fa-exclamation-triangle"></i> <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> </div>
{{/if}} {{/if}}
{{!-- File Selection --}} {{!-- File Selection --}}
<div class="sp-form-group"> <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"> <div class="sp-file-upload">
<input type="file" class="sp-file-input" accept=".json" /> <input type="file" class="sp-file-input" accept=".json" />
<label class="sp-file-label"> <label class="sp-file-label">
<i class="fas fa-upload"></i> <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> </label>
</div> </div>
{{#if selectedFileName}} {{#if selectedFileName}}
@@ -30,17 +31,17 @@
{{!-- Mode Selection --}} {{!-- Mode Selection --}}
<div class="sp-form-group"> <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"> <div class="sp-radio-group">
<label class="sp-radio-label"> <label class="sp-radio-label">
<input type="radio" name="import-mode" class="sp-mode-merge" value="merge" {{checked (eq mode "merge")}} /> <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-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>
<label class="sp-radio-label"> <label class="sp-radio-label">
<input type="radio" name="import-mode" class="sp-mode-replace" value="replace" {{checked (eq mode "replace")}} /> <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-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> </label>
</div> </div>
</div> </div>
@@ -48,7 +49,7 @@
{{!-- Preview Section --}} {{!-- Preview Section --}}
{{#if previewItems.length}} {{#if previewItems.length}}
<div class="sp-preview-section"> <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"> <ul class="sp-preview-list">
{{#each previewItems as |item|}} {{#each previewItems as |item|}}
<li class="sp-preview-item {{unless item.valid 'sp-preview-item--invalid'}}"> <li class="sp-preview-item {{unless item.valid 'sp-preview-item--invalid'}}">
@@ -68,7 +69,7 @@
<div class="sp-confirmation-section"> <div class="sp-confirmation-section">
<div class="sp-confirmation-warning"> <div class="sp-confirmation-warning">
<i class="fas fa-exclamation-triangle"></i> <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>
</div> </div>
{{/if}} {{/if}}
@@ -77,14 +78,15 @@
<div class="sp-dialog-buttons"> <div class="sp-dialog-buttons">
{{#unless requiresConfirmation}} {{#unless requiresConfirmation}}
<button type="button" class="sp-btn sp-btn-primary sp-import-btn" {{disabled (not previewItems.length) }}> <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> </button>
{{else}} {{else}}
<button type="button" class="sp-btn sp-btn-danger sp-confirm-btn"> <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> </button>
{{/unless}} {{/unless}}
<button type="button" class="sp-btn sp-btn-secondary sp-cancel-btn"> <button type="button" class="sp-btn sp-btn-secondary sp-cancel-btn">
{{localize "video-view-manager.presetImport.cancel"}} {{localize "scrying-pool.presetImport.cancel"}}
</button> </button>
</div> </div>
</div>
+2 -4
View File
@@ -1,8 +1,5 @@
{{!-- Load Scene Preset Dialog --}} {{!-- Load Camera Layout Dialog --}}
<div class="preset-load-dialog__content"> <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"> <div class="preset-load-dialog__body">
{{#if hasPresets}} {{#if hasPresets}}
@@ -15,6 +12,7 @@
data-action="load" data-action="load"
data-preset-name="{{name}}" data-preset-name="{{name}}"
> >
<i class="fas fa-layer-group" aria-hidden="true"></i>
{{name}} {{name}}
</button> </button>
</li> </li>
+8 -4
View File
@@ -1,8 +1,10 @@
{{!-- Save Scene Preset Dialog --}} {{!-- Save Camera Layout Dialog --}}
<form class="preset-save-dialog__form"> <form class="preset-save-dialog__form">
<header class="preset-save-dialog__header">
<h2 class="preset-save-dialog__title">{{title}}</h2> <div class="preset-save-dialog__header">
</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__body">
<div class="preset-save-dialog__field"> <div class="preset-save-dialog__field">
@@ -25,9 +27,11 @@
<footer class="preset-save-dialog__footer"> <footer class="preset-save-dialog__footer">
<button type="button" class="preset-save-dialog__btn preset-save-dialog__btn--cancel" data-action="cancel"> <button type="button" class="preset-save-dialog__btn preset-save-dialog__btn--cancel" data-action="cancel">
<i class="fas fa-times"></i>
{{cancelLabel}} {{cancelLabel}}
</button> </button>
<button type="submit" class="preset-save-dialog__btn preset-save-dialog__btn--save"> <button type="submit" class="preset-save-dialog__btn preset-save-dialog__btn--save">
<i class="fas fa-bookmark"></i>
{{saveLabel}} {{saveLabel}}
</button> </button>
</footer> </footer>
+18 -4
View File
@@ -3,6 +3,16 @@
role="complementary" role="complementary"
aria-label="Scrying Pool"> 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) --}} {{!-- First-open tip (right-click affordance) --}}
{{#if showFirstOpenTip}} {{#if showFirstOpenTip}}
<p class="sp-strip__first-tip"> <p class="sp-strip__first-tip">
@@ -11,19 +21,22 @@
</p> </p>
{{/if}} {{/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" <button class="sp-strip__toggle" data-action="toggle-expanded"
aria-label="{{#if isExpanded}}Collapse Scrying Pool{{else}}Expand Scrying Pool{{/if}}" 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> <i class="fas fa-chevron-{{#if isExpanded}}left{{else}}right{{/if}}"></i>
</button> </button>
{{!-- Director's Board CTA button (fallback when sidebar API unavailable) --}}
<button class="sp-strip__directors-board-cta" data-action="open-directors-board" <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> <i class="fas fa-border-all" aria-hidden="true"></i>
<span>Director's Board</span> <span>Director's Board</span>
</button> </button>
</div>
{{!-- Participant list --}} {{!-- Participant list --}}
<ul class="sp-strip__participants" role="list"> <ul class="sp-strip__participants" role="list">
@@ -42,6 +55,7 @@
data-action="open-popover" data-action="open-popover"
role="button" role="button"
aria-label="{{name}}{{stateLabel}}" aria-label="{{name}}{{stateLabel}}"
data-tooltip="{{name}}{{stateLabel}}"
aria-pressed="false"> aria-pressed="false">
{{!-- Video container for stream-access mode (full AV replacement) --}} {{!-- Video container for stream-access mode (full AV replacement) --}}
+11 -11
View File
@@ -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"> <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> </div>
{{#if hasScene}} {{#if hasScene}}
@@ -12,20 +12,20 @@
data-action="toggle-auto-apply" data-action="toggle-auto-apply"
{{#if enabled}}checked{{/if}} {{#if enabled}}checked{{/if}}
role="switch" role="switch"
aria-label="{{localize 'video-view-manager.scenePresetPanel.enableAutoApply'}}"> aria-label="{{localize 'scrying-pool.scenePresetPanel.enableAutoApply'}}">
{{localize "video-view-manager.scenePresetPanel.enableAutoApply"}} {{localize "scrying-pool.scenePresetPanel.enableAutoApply"}}
</label> </label>
</div> </div>
<div class="directors-board__preset-panel-row"> <div class="directors-board__preset-panel-row">
<label class="directors-board__preset-panel-label"> <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" <select class="directors-board__preset-panel-select"
data-action="select-preset" data-action="select-preset"
{{#unless hasPresets}}disabled{{/unless}} {{#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}}> <option value="" {{#unless presetName}}selected{{/unless}}>
{{localize "video-view-manager.scenePresetPanel.selectPreset"}} {{localize "scrying-pool.scenePresetPanel.selectPreset"}}
</option> </option>
{{#each presets}} {{#each presets}}
<option value="{{this.name}}" {{#if (eq this.name ../presetName)}}selected{{/if}}> <option value="{{this.name}}" {{#if (eq this.name ../presetName)}}selected{{/if}}>
@@ -38,7 +38,7 @@
<div class="directors-board__preset-panel-row"> <div class="directors-board__preset-panel-row">
<label class="directors-board__preset-panel-label"> <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> <span class="directors-board__preset-panel-delay-value">{{preDelay}}ms</span>
<input type="range" <input type="range"
class="directors-board__preset-panel-slider" class="directors-board__preset-panel-slider"
@@ -47,7 +47,7 @@
max="5000" max="5000"
value="{{preDelay}}" value="{{preDelay}}"
step="100" step="100"
aria-label="{{localize 'video-view-manager.scenePresetPanel.preDelay'}}" aria-label="{{localize 'scrying-pool.scenePresetPanel.preDelay'}}"
aria-valuemin="0" aria-valuemin="0"
aria-valuemax="5000" aria-valuemax="5000"
aria-valuenow="{{preDelay}}"> aria-valuenow="{{preDelay}}">
@@ -55,9 +55,9 @@
</div> </div>
<div class="directors-board__preset-panel-row directors-board__preset-panel-row--hint"> <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>
</div> </div>
{{else}} {{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}} {{/if}}
+8 -8
View File
@@ -5,7 +5,7 @@
* - Serveur FoundryVTT sur https://localhost:31000 * - Serveur FoundryVTT sur https://localhost:31000
* - Monde déjà disponible * - Monde déjà disponible
* - Utilisateur: gamemaster (pas de mot de passe) * - 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. * Ce setup vérifie simplement que tout est accessible.
*/ */
@@ -41,33 +41,33 @@ async function verifyFoundryAccessible(page) {
console.error('💡 Vérifiez que:'); console.error('💡 Vérifiez que:');
console.error(' - FoundryVTT est en cours d\'exécution sur https://localhost:31000'); console.error(' - FoundryVTT est en cours d\'exécution sur https://localhost:31000');
console.error(' - Le monde est accessible'); 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; 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) { async function verifyModuleActive(page) {
console.log('📦 Vérification du module Video View Manager...'); console.log('📦 Vérification du module Scrying Pool...');
try { try {
// Attendre que le module soit initialisé (check pour un élément spécifique) // Attendre que le module soit initialisé (check pour un élément spécifique)
await page.waitForFunction(() => { await page.waitForFunction(() => {
return typeof game !== 'undefined' && return typeof game !== 'undefined' &&
game.modules?.get?.('video-view-manager')?.active; game.modules?.get?.('scrying-pool')?.active;
}, { timeout: 15000 }); }, { timeout: 15000 });
const isActive = await page.evaluate(() => { const isActive = await page.evaluate(() => {
const module = game.modules.get('video-view-manager'); const module = game.modules.get('scrying-pool');
return module?.active || false; return module?.active || false;
}); });
if (isActive) { if (isActive) {
console.log('✅ Module Video View Manager est actif'); console.log('✅ Module Scrying Pool est actif');
} else { } 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)'); console.warn(' Essayez de recharger la page (Ctrl+R)');
} }
} catch (error) { } catch (error) {
+7 -7
View File
@@ -5,7 +5,7 @@
* - Démarre le serveur FoundryVTT (si non déjà démarré) * - Démarre le serveur FoundryVTT (si non déjà démarré)
* - Crée un monde de test * - Crée un monde de test
* - Configure les utilisateurs de test * - Configure les utilisateurs de test
* - Installe le module Video View Manager * - Installe le module Scrying Pool
*/ */
import { chromium } from '@playwright/test'; 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) { async function installVVMModule(page) {
console.log('📦 Installing Video View Manager module...'); console.log('📦 Installing Scrying Pool module...');
// Naviguer vers la gestion des modules // Naviguer vers la gestion des modules
await page.goto(`${FOUNDRY_BASE_URL}/setup/modules`); await page.goto(`${FOUNDRY_BASE_URL}/setup/modules`);
await page.waitForSelector('#modules-list', { timeout: 30000 }); await page.waitForSelector('#modules-list', { timeout: 30000 });
// Vérifier si le module est déjà installé // 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) { if (moduleInstalled > 0) {
console.log('✅ Video View Manager module already installed'); console.log('✅ Scrying Pool module already installed');
return; return;
} }
@@ -121,7 +121,7 @@ async function installVVMModule(page) {
// Note: En environnement de test, le module devrait déjà être dans le dossier modules/ // Note: En environnement de test, le module devrait déjà être dans le dossier modules/
// Sinon, il faut le copier manuellement // Sinon, il faut le copier manuellement
console.log('⚠️ Module must be manually placed in FoundryVTT modules/ folder'); 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(` - Player User: ${TEST_PLAYER_USER}`);
console.log(` - Foundry URL: ${FOUNDRY_BASE_URL}`); console.log(` - Foundry URL: ${FOUNDRY_BASE_URL}`);
console.log('\n💡 Ensure FoundryVTT server is running on localhost:30000'); 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) { } catch (error) {
console.error('❌ Setup failed:', error); console.error('❌ Setup failed:', error);
+2 -2
View File
@@ -1,11 +1,11 @@
{ {
"name": "video-view-manager-e2e", "name": "scrying-pool-e2e",
"version": "1.0.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "video-view-manager-e2e", "name": "scrying-pool-e2e",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@playwright/test": "^1.40.0", "@playwright/test": "^1.40.0",
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "video-view-manager-e2e", "name": "scrying-pool-e2e",
"version": "1.0.0", "version": "1.0.0",
"description": "E2E tests for Video View Manager FoundryVTT module", "description": "E2E tests for Scrying Pool FoundryVTT module",
"type": "module", "type": "module",
"scripts": { "scripts": {
"test": "playwright test", "test": "playwright test",
+2 -2
View File
@@ -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 * Tests FoundryVTT module in a live browser environment
* Configuration adaptée pour l'environnement local : * Configuration adaptée pour l'environnement local :
@@ -14,7 +14,7 @@ import { defineConfig, devices } from '@playwright/test';
* Configuration pour les tests E2E avec FoundryVTT * Configuration pour les tests E2E avec FoundryVTT
* *
* Environnement : * Environnement :
* - FoundryVTT v14 avec Video View Manager installé * - FoundryVTT v14 avec Scrying Pool installé
* - Serveur FoundryVTT en cours sur https://localhost:31000 * - Serveur FoundryVTT en cours sur https://localhost:31000
* - Monde déjà disponible avec utilisateur "gamemaster" * - Monde déjà disponible avec utilisateur "gamemaster"
* - Pas de mot de passe requis * - Pas de mot de passe requis
+5 -5
View File
@@ -134,9 +134,9 @@ test.describe('Epic 1: Core Camera Visibility Control', () => {
// Note: Cela nécessite une implémentation spécifique ou un mock // Note: Cela nécessite une implémentation spécifique ou un mock
// Pour les tests E2E, on peut utiliser l'API du module directement // Pour les tests E2E, on peut utiliser l'API du module directement
await page.evaluate((userId) => { await page.evaluate((userId) => {
if (game.modules.get('video-view-manager')) { if (game.modules.get('scrying-pool')) {
// Appeler l'API interne si disponible // 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) { if (controller) {
controller.action('test', userId, 'self-muted'); 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 }) => { test('Cam-lost participants show portrait fallback', async ({ page }) => {
// Simuler la perte de caméra // Simuler la perte de caméra
await page.evaluate((userId) => { await page.evaluate((userId) => {
if (game.modules.get('video-view-manager')) { if (game.modules.get('scrying-pool')) {
const controller = game.modules.get('video-view-manager').api?.controller; const controller = game.modules.get('scrying-pool').api?.controller;
if (controller) { if (controller) {
controller.action('test', userId, 'cam-lost'); 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 }) => { test('First encounter shows explanatory tooltip', async ({ page }) => {
// Effacer le flag de premier badge // Effacer le flag de premier badge
await page.evaluate(() => { 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 // Recharger la page pour déclencher le first encounter
+4 -4
View File
@@ -499,8 +499,8 @@ test.describe('Epic 2: Player Notifications & Director\'s Board', () => {
await page.locator('button[aria-label="Configure Settings"]').click(); await page.locator('button[aria-label="Configure Settings"]').click();
await page.waitForSelector('.app-v2.settings', { state: 'visible' }); await page.waitForSelector('.app-v2.settings', { state: 'visible' });
// Sélectionner Video View Manager // Sélectionner Scrying Pool
await page.locator('button:has-text("Video View Manager")').click(); await page.locator('button:has-text("Scrying Pool")').click();
// Trouver le paramètre de verbosité // Trouver le paramètre de verbosité
await page.waitForSelector('.sp-notification-verbosity-select', { timeout: 5000 }); 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.locator('button[aria-label="Configure Settings"]').click();
await page.waitForSelector('.app-v2.settings', { state: 'visible' }); 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.waitForSelector('.sp-notification-verbosity-select', { timeout: 5000 });
await page.locator('.sp-notification-verbosity-select').selectOption('GM Only'); 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.locator('button[aria-label="Configure Settings"]').click();
await page.waitForSelector('.app-v2.settings', { state: 'visible' }); 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.waitForSelector('.sp-notification-verbosity-select', { timeout: 5000 });
await page.locator('.sp-notification-verbosity-select').selectOption('Silent'); await page.locator('.sp-notification-verbosity-select').selectOption('Silent');
+14 -14
View File
@@ -130,7 +130,7 @@ test.describe('FR-15: Save Scene Preset', () => {
// Vérifier que le preset contient la matrice // Vérifier que le preset contient la matrice
const presetData = await page.evaluate((presetName) => { 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) { if (module && module.api?.scenePresetManager) {
const preset = module.api.scenePresetManager.getPreset(presetName); const preset = module.api.scenePresetManager.getPreset(presetName);
return preset?.matrix; return preset?.matrix;
@@ -289,7 +289,7 @@ test.describe('FR-17: Scene Auto-Apply', () => {
// Associer le preset à la scène actuelle // Associer le preset à la scène actuelle
await page.evaluate((presetName) => { await page.evaluate((presetName) => {
const module = game.modules.get('video-view-manager'); const module = game.modules.get('scrying-pool');
if (module && module.api?.scenePresetManager) { if (module && module.api?.scenePresetManager) {
const currentScene = game.scenes?.active; const currentScene = game.scenes?.active;
if (currentScene) { if (currentScene) {
@@ -326,7 +326,7 @@ test.describe('FR-17: Scene Auto-Apply', () => {
// Ce test vérifie que le délai est configurable // Ce test vérifie que le délai est configurable
// La valeur par défaut est 0ms // La valeur par défaut est 0ms
const delay = await page.evaluate(() => { 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) { if (module && module.api?.scenePresetManager) {
return module.api.scenePresetManager.autoApplyDelay; return module.api.scenePresetManager.autoApplyDelay;
} }
@@ -345,7 +345,7 @@ test.describe('FR-17: Scene Auto-Apply', () => {
await saveScenePreset(page, TEST_PRESET_NAME); await saveScenePreset(page, TEST_PRESET_NAME);
await page.evaluate((presetName) => { await page.evaluate((presetName) => {
const module = game.modules.get('video-view-manager'); const module = game.modules.get('scrying-pool');
if (module && module.api?.scenePresetManager) { if (module && module.api?.scenePresetManager) {
const currentScene = game.scenes?.active; const currentScene = game.scenes?.active;
if (currentScene) { if (currentScene) {
@@ -389,7 +389,7 @@ test.describe('FR-18: Disable Auto-Apply', () => {
// Désactiver l'auto-apply pour la scène actuelle // Désactiver l'auto-apply pour la scène actuelle
await page.evaluate((presetName) => { await page.evaluate((presetName) => {
const module = game.modules.get('video-view-manager'); const module = game.modules.get('scrying-pool');
if (module && module.api?.scenePresetManager) { if (module && module.api?.scenePresetManager) {
const currentScene = game.scenes?.active; const currentScene = game.scenes?.active;
if (currentScene) { if (currentScene) {
@@ -427,7 +427,7 @@ test.describe('FR-18: Disable Auto-Apply', () => {
test('Auto-apply can be disabled globally', async ({ page }) => { test('Auto-apply can be disabled globally', async ({ page }) => {
// Désactiver l'auto-apply globalement // Désactiver l'auto-apply globalement
await page.evaluate(() => { await page.evaluate(() => {
const module = game.modules.get('video-view-manager'); const module = game.modules.get('scrying-pool');
if (module && module.api?.scenePresetManager) { if (module && module.api?.scenePresetManager) {
module.api.scenePresetManager.setAutoApplyEnabled(false); module.api.scenePresetManager.setAutoApplyEnabled(false);
} }
@@ -437,7 +437,7 @@ test.describe('FR-18: Disable Auto-Apply', () => {
await saveScenePreset(page, TEST_PRESET_NAME); await saveScenePreset(page, TEST_PRESET_NAME);
await page.evaluate((presetName) => { await page.evaluate((presetName) => {
const module = game.modules.get('video-view-manager'); const module = game.modules.get('scrying-pool');
if (module && module.api?.scenePresetManager) { if (module && module.api?.scenePresetManager) {
const currentScene = game.scenes?.active; const currentScene = game.scenes?.active;
if (currentScene) { if (currentScene) {
@@ -472,7 +472,7 @@ test.describe('FR-18: Disable Auto-Apply', () => {
// Réactiver l'auto-apply global // Réactiver l'auto-apply global
await page.evaluate(() => { await page.evaluate(() => {
const module = game.modules.get('video-view-manager'); const module = game.modules.get('scrying-pool');
if (module && module.api?.scenePresetManager) { if (module && module.api?.scenePresetManager) {
module.api.scenePresetManager.setAutoApplyEnabled(true); 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 }) => { test('Director\'s Board always provides manual override', async ({ page }) => {
// Désactiver l'auto-apply // Désactiver l'auto-apply
await page.evaluate(() => { await page.evaluate(() => {
const module = game.modules.get('video-view-manager'); const module = game.modules.get('scrying-pool');
if (module && module.api?.scenePresetManager) { if (module && module.api?.scenePresetManager) {
module.api.scenePresetManager.setAutoApplyEnabled(false); module.api.scenePresetManager.setAutoApplyEnabled(false);
} }
@@ -510,7 +510,7 @@ test.describe('FR-18: Disable Auto-Apply', () => {
// Réactiver // Réactiver
await page.evaluate(() => { await page.evaluate(() => {
const module = game.modules.get('video-view-manager'); const module = game.modules.get('scrying-pool');
if (module && module.api?.scenePresetManager) { if (module && module.api?.scenePresetManager) {
module.api.scenePresetManager.setAutoApplyEnabled(true); module.api.scenePresetManager.setAutoApplyEnabled(true);
} }
@@ -558,7 +558,7 @@ test.describe('FR-19: Preset Import/Export', () => {
// Capturer le contenu JSON // Capturer le contenu JSON
const jsonContent = await page.evaluate(() => { 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) { if (module && module.api?.scenePresetManager) {
return JSON.stringify(module.api.scenePresetManager.getAllPresets(), null, 2); return JSON.stringify(module.api.scenePresetManager.getAllPresets(), null, 2);
} }
@@ -596,7 +596,7 @@ test.describe('FR-19: Preset Import/Export', () => {
// Importer via l'API // Importer via l'API
await page.evaluate((data) => { await page.evaluate((data) => {
const module = game.modules.get('video-view-manager'); const module = game.modules.get('scrying-pool');
if (module && module.api?.scenePresetManager) { if (module && module.api?.scenePresetManager) {
module.api.scenePresetManager.importPresets(data, { merge: true }); 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 // Vérifier que les nouveaux presets existent
const presetNames = await page.evaluate(() => { 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) { if (module && module.api?.scenePresetManager) {
return Object.keys(module.api.scenePresetManager.getAllPresets()); return Object.keys(module.api.scenePresetManager.getAllPresets());
} }
@@ -624,7 +624,7 @@ test.describe('FR-19: Preset Import/Export', () => {
// Essayer d'importer du JSON invalide // Essayer d'importer du JSON invalide
const result = await page.evaluate(() => { 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) { if (module && module.api?.scenePresetManager) {
try { try {
module.api.scenePresetManager.importPresets('invalid json', { merge: true }); module.api.scenePresetManager.importPresets('invalid json', { merge: true });
+8 -83
View File
@@ -3,7 +3,6 @@
* *
* FR-23: Player Privacy Panel accessible from module settings * FR-23: Player Privacy Panel accessible from module settings
* FR-24: Reaction Cam automation requires explicit opt-in * 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 * 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 // Vérifier que les sections existent
const reactionCamSection = page.locator('.sp-automation-item:has-text("Reaction Cam")'); 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(reactionCamSection).toBeVisible({ timeout: 5000 });
await expect(hpStylingSection).toBeVisible({ timeout: 5000 });
// Vérifier les toggles // Vérifier les toggles
const toggles = page.locator('.sp-toggle-switch'); 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 }) => { 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 // Les contrôles devraient être désactivés
const toggles = page.locator('.sp-toggle-switch:disabled'); 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 }) => { 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 // Si désactivé, le trigger ne devrait pas fonctionner
// Simuler un trigger // Simuler un trigger
const triggerResult = await page.evaluate(() => { 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) { if (module && module.api?.reactionCam) {
// Vérifier que Reaction Cam est désactivé // Vérifier que Reaction Cam est désactivé
return module.api.reactionCam.isEnabled(); return module.api.reactionCam.isEnabled();
@@ -229,7 +225,7 @@ test.describe('FR-24: Reaction Cam Automation', () => {
// Simuler un trigger // Simuler un trigger
const result = await page.evaluate(() => { 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) { if (module && module.api?.reactionCam) {
// Le trigger devrait skipper ce joueur // Le trigger devrait skipper ce joueur
return { skipped: true, reason: 'opted-out' }; 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 // FR-26: Custom Portrait Fallback
// ============================================================================ // ============================================================================
@@ -388,7 +313,7 @@ test.describe('FR-26: Custom Portrait Fallback', () => {
// Via l'API, définir un portrait // Via l'API, définir un portrait
await page.evaluate(() => { await page.evaluate(() => {
const module = game.modules.get('video-view-manager'); const module = game.modules.get('scrying-pool');
if (module && module.api?.portraitFallbackHandler) { if (module && module.api?.portraitFallbackHandler) {
const dataURL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; const dataURL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
module.api.portraitFallbackHandler.setPortraitFallback('gamemaster', dataURL); module.api.portraitFallbackHandler.setPortraitFallback('gamemaster', dataURL);
@@ -412,7 +337,7 @@ test.describe('FR-26: Custom Portrait Fallback', () => {
// Définir un portrait personnalisé // Définir un portrait personnalisé
await page.evaluate(() => { await page.evaluate(() => {
const module = game.modules.get('video-view-manager'); const module = game.modules.get('scrying-pool');
if (module && module.api?.portraitFallbackHandler) { if (module && module.api?.portraitFallbackHandler) {
const dataURL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; const dataURL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
module.api.portraitFallbackHandler.setPortraitFallback('gamemaster', dataURL); 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 // Forcer l'état never-connected pour un utilisateur
await page.evaluate(() => { await page.evaluate(() => {
const module = game.modules.get('video-view-manager'); const module = game.modules.get('scrying-pool');
if (module && module.api?.controller) { if (module && module.api?.controller) {
module.api.controller.setState('gamemaster', 'never-connected'); 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 // Pour l'instant, on simule via l'API
const result = await page.evaluate(({ filename, shouldSucceed }) => { 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 (module && module.api?.portraitFallbackHandler) {
if (shouldSucceed) { if (shouldSucceed) {
// Fichier valide // Fichier valide
+3 -3
View File
@@ -1,7 +1,7 @@
/** /**
* Module Initialization Tests - Tests E2E * 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. * dans FoundryVTT et que toutes les fonctionnalités de base sont disponibles.
*/ */
@@ -12,8 +12,8 @@ import {
clickFoundryButton, clickFoundryButton,
} from '../utils/foundry-helpers'; } from '../utils/foundry-helpers';
const MODULE_ID = 'video-view-manager'; const MODULE_ID = 'scrying-pool';
const MODULE_NAME = 'Video View Manager'; const MODULE_NAME = 'Scrying Pool';
test.describe('Module Initialization', () => { test.describe('Module Initialization', () => {
test.setTimeout(60000); test.setTimeout(60000);
+3 -3
View File
@@ -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 {import('@playwright/test').Page} page - La page Playwright
* @param {number} timeout - Timeout en ms (défaut: 15000) * @param {number} timeout - Timeout en ms (défaut: 15000)
*/ */
export async function waitForVVMModule(page, timeout = 15000) { export async function waitForVVMModule(page, timeout = 15000) {
await page.waitForFunction(() => { await page.waitForFunction(() => {
const module = game.modules?.get?.('video-view-manager'); const module = game.modules?.get?.('scrying-pool');
return module?.active === true; return module?.active === true;
}, { timeout }); }, { timeout });
} }
@@ -100,7 +100,7 @@ export async function openPlayerPrivacyPanel(page, userId) {
await openFoundrySidebar(page); await openFoundrySidebar(page);
// Naviguer vers les paramètres du module // 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); await page.waitForTimeout(1000);
// Cliquer sur le bouton Player Privacy // Cliquer sur le bouton Player Privacy
@@ -27,7 +27,6 @@ export function createPlayerPrivacyManagerMock(overrides = {}) {
// Settings retrieval // Settings retrieval
getSettings: vi.fn(() => ({ getSettings: vi.fn(() => ({
reactionCamEnabled: false, reactionCamEnabled: false,
hpReactiveCamStylingEnabled: false,
customPortraitFallback: null, customPortraitFallback: null,
})), })),
// Portrait methods // Portrait methods
@@ -29,7 +29,6 @@ describe("privacy-settings contract", () => {
it("should export PRIVACY_SETTINGS_DEFAULT with all false and null portrait", () => { it("should export PRIVACY_SETTINGS_DEFAULT with all false and null portrait", () => {
expect(PRIVACY_SETTINGS_DEFAULT).toEqual({ expect(PRIVACY_SETTINGS_DEFAULT).toEqual({
reactionCamEnabled: false, reactionCamEnabled: false,
hpReactiveCamStylingEnabled: false,
customPortraitFallback: null, customPortraitFallback: null,
}); });
}); });
@@ -37,7 +36,6 @@ describe("privacy-settings contract", () => {
it("should export PRIVACY_SETTING_KEYS as frozen array including portrait fallback", () => { it("should export PRIVACY_SETTING_KEYS as frozen array including portrait fallback", () => {
expect(PRIVACY_SETTING_KEYS).toEqual([ expect(PRIVACY_SETTING_KEYS).toEqual([
"reactionCamEnabled", "reactionCamEnabled",
"hpReactiveCamStylingEnabled",
"customPortraitFallback", "customPortraitFallback",
]); ]);
expect(Object.isFrozen(PRIVACY_SETTING_KEYS)).toBe(true); 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", () => { it("should export FEATURE_NAME_MAP as frozen object", () => {
expect(FEATURE_NAME_MAP).toEqual({ expect(FEATURE_NAME_MAP).toEqual({
reactionCam: "reactionCamEnabled", reactionCam: "reactionCamEnabled",
hpReactiveCamStyling: "hpReactiveCamStylingEnabled",
}); });
expect(Object.isFrozen(FEATURE_NAME_MAP)).toBe(true); expect(Object.isFrozen(FEATURE_NAME_MAP)).toBe(true);
}); });
@@ -62,19 +59,6 @@ describe("privacy-settings contract", () => {
const result = createPrivacySettings({ reactionCamEnabled: true }); const result = createPrivacySettings({ reactionCamEnabled: true });
expect(result).toEqual({ expect(result).toEqual({
reactionCamEnabled: true, 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, customPortraitFallback: null,
}); });
}); });
@@ -84,7 +68,6 @@ describe("privacy-settings contract", () => {
const result = createPrivacySettings({ customPortraitFallback: dataURL }); const result = createPrivacySettings({ customPortraitFallback: dataURL });
expect(result).toEqual({ expect(result).toEqual({
reactionCamEnabled: false, reactionCamEnabled: false,
hpReactiveCamStylingEnabled: false,
customPortraitFallback: dataURL, customPortraitFallback: dataURL,
}); });
}); });
@@ -102,7 +85,6 @@ describe("privacy-settings contract", () => {
}); });
expect(result).toEqual({ expect(result).toEqual({
reactionCamEnabled: true, reactionCamEnabled: true,
hpReactiveCamStylingEnabled: false,
customPortraitFallback: null, customPortraitFallback: null,
}); });
}); });
@@ -112,7 +94,6 @@ describe("privacy-settings contract", () => {
it("should accept valid settings with all false", () => { it("should accept valid settings with all false", () => {
const valid = { const valid = {
reactionCamEnabled: false, reactionCamEnabled: false,
hpReactiveCamStylingEnabled: false,
}; };
expect(isValidPrivacySettings(valid)).toEqual(valid); expect(isValidPrivacySettings(valid)).toEqual(valid);
}); });
@@ -120,7 +101,6 @@ describe("privacy-settings contract", () => {
it("should accept valid settings with all true", () => { it("should accept valid settings with all true", () => {
const valid = { const valid = {
reactionCamEnabled: true, reactionCamEnabled: true,
hpReactiveCamStylingEnabled: true,
}; };
expect(isValidPrivacySettings(valid)).toEqual(valid); expect(isValidPrivacySettings(valid)).toEqual(valid);
}); });
@@ -128,7 +108,6 @@ describe("privacy-settings contract", () => {
it("should accept valid settings with mixed values", () => { it("should accept valid settings with mixed values", () => {
const valid = { const valid = {
reactionCamEnabled: true, reactionCamEnabled: true,
hpReactiveCamStylingEnabled: false,
}; };
expect(isValidPrivacySettings(valid)).toEqual(valid); expect(isValidPrivacySettings(valid)).toEqual(valid);
}); });
@@ -164,7 +143,6 @@ describe("privacy-settings contract", () => {
it("should throw TypeError for unknown keys", () => { it("should throw TypeError for unknown keys", () => {
const invalid = { const invalid = {
reactionCamEnabled: false, reactionCamEnabled: false,
hpReactiveCamStylingEnabled: false,
extraKey: "invalid", extraKey: "invalid",
}; };
expect(() => isValidPrivacySettings(invalid)).toThrow(TypeError); expect(() => isValidPrivacySettings(invalid)).toThrow(TypeError);
@@ -174,7 +152,6 @@ describe("privacy-settings contract", () => {
it("should throw TypeError when reactionCamEnabled is not boolean", () => { it("should throw TypeError when reactionCamEnabled is not boolean", () => {
const invalid = { const invalid = {
reactionCamEnabled: "not a boolean", reactionCamEnabled: "not a boolean",
hpReactiveCamStylingEnabled: false,
}; };
expect(() => isValidPrivacySettings(invalid)).toThrow(TypeError); expect(() => isValidPrivacySettings(invalid)).toThrow(TypeError);
expect(() => isValidPrivacySettings(invalid)).toThrow( expect(() => isValidPrivacySettings(invalid)).toThrow(
@@ -185,7 +162,6 @@ describe("privacy-settings contract", () => {
it("should throw TypeError when reactionCamEnabled is a number", () => { it("should throw TypeError when reactionCamEnabled is a number", () => {
const invalid = { const invalid = {
reactionCamEnabled: 1, reactionCamEnabled: 1,
hpReactiveCamStylingEnabled: false,
}; };
expect(() => isValidPrivacySettings(invalid)).toThrow(TypeError); expect(() => isValidPrivacySettings(invalid)).toThrow(TypeError);
expect(() => isValidPrivacySettings(invalid)).toThrow( 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)", () => { it("should accept settings with only reactionCamEnabled (backward compatible)", () => {
// Backward compatibility: settings without all keys are accepted // Backward compatibility: settings without all keys are accepted
const valid = { const valid = {
@@ -223,14 +177,6 @@ describe("privacy-settings contract", () => {
expect(() => isValidPrivacySettings(valid)).not.toThrow(); 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)", () => { it("should accept empty object (backward compatible)", () => {
// Backward compatibility: empty object is accepted // Backward compatibility: empty object is accepted
expect(() => isValidPrivacySettings({})).not.toThrow(); expect(() => isValidPrivacySettings({})).not.toThrow();
@@ -242,12 +188,6 @@ describe("privacy-settings contract", () => {
expect(validateSettingKey("reactionCamEnabled")).toBe("reactionCamEnabled"); expect(validateSettingKey("reactionCamEnabled")).toBe("reactionCamEnabled");
}); });
it("should accept valid key: hpReactiveCamStylingEnabled", () => {
expect(validateSettingKey("hpReactiveCamStylingEnabled")).toBe(
"hpReactiveCamStylingEnabled"
);
});
it("should throw TypeError for empty string", () => { it("should throw TypeError for empty string", () => {
expect(() => validateSettingKey("")).toThrow(TypeError); expect(() => validateSettingKey("")).toThrow(TypeError);
expect(() => validateSettingKey("")).toThrow( expect(() => validateSettingKey("")).toThrow(
@@ -332,12 +272,6 @@ describe("privacy-settings contract", () => {
expect(validateFeatureName("reactionCam")).toBe("reactionCam"); expect(validateFeatureName("reactionCam")).toBe("reactionCam");
}); });
it("should accept valid feature: hpReactiveCamStyling", () => {
expect(validateFeatureName("hpReactiveCamStyling")).toBe(
"hpReactiveCamStyling"
);
});
it("should throw TypeError for empty string", () => { it("should throw TypeError for empty string", () => {
expect(() => validateFeatureName("")).toThrow(TypeError); expect(() => validateFeatureName("")).toThrow(TypeError);
expect(() => validateFeatureName("")).toThrow( expect(() => validateFeatureName("")).toThrow(
@@ -399,7 +333,6 @@ describe("privacy-settings contract", () => {
it("should retain existing boolean settings", () => { it("should retain existing boolean settings", () => {
expect(PRIVACY_SETTINGS_DEFAULT.reactionCamEnabled).toBe(false); 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", () => { it("should retain existing keys", () => {
expect(PRIVACY_SETTING_KEYS).toContain("reactionCamEnabled"); 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", () => { it("should accept valid settings with customPortraitFallback as string", () => {
const valid = { const valid = {
reactionCamEnabled: false, reactionCamEnabled: false,
hpReactiveCamStylingEnabled: false,
customPortraitFallback: "data:image/png;base64,test", customPortraitFallback: "data:image/png;base64,test",
}; };
expect(isValidPrivacySettings(valid)).toEqual(valid); expect(isValidPrivacySettings(valid)).toEqual(valid);
@@ -452,7 +383,6 @@ describe("privacy-settings contract", () => {
it("should accept valid settings with customPortraitFallback as null", () => { it("should accept valid settings with customPortraitFallback as null", () => {
const valid = { const valid = {
reactionCamEnabled: false, reactionCamEnabled: false,
hpReactiveCamStylingEnabled: false,
customPortraitFallback: null, customPortraitFallback: null,
}; };
expect(isValidPrivacySettings(valid)).toEqual(valid); expect(isValidPrivacySettings(valid)).toEqual(valid);
@@ -462,7 +392,6 @@ describe("privacy-settings contract", () => {
// Backward compatibility - may not have the key // Backward compatibility - may not have the key
const valid = { const valid = {
reactionCamEnabled: false, reactionCamEnabled: false,
hpReactiveCamStylingEnabled: false,
}; };
// This should still work - null/undefined is acceptable // This should still work - null/undefined is acceptable
expect(() => isValidPrivacySettings(valid)).not.toThrow(); expect(() => isValidPrivacySettings(valid)).not.toThrow();
@@ -471,7 +400,6 @@ describe("privacy-settings contract", () => {
it("should throw TypeError when customPortraitFallback is not string or null", () => { it("should throw TypeError when customPortraitFallback is not string or null", () => {
const invalid = { const invalid = {
reactionCamEnabled: false, reactionCamEnabled: false,
hpReactiveCamStylingEnabled: false,
customPortraitFallback: 123, customPortraitFallback: 123,
}; };
expect(() => isValidPrivacySettings(invalid)).toThrow(TypeError); expect(() => isValidPrivacySettings(invalid)).toThrow(TypeError);
@@ -483,7 +411,6 @@ describe("privacy-settings contract", () => {
it("should throw TypeError when customPortraitFallback is a boolean", () => { it("should throw TypeError when customPortraitFallback is a boolean", () => {
const invalid = { const invalid = {
reactionCamEnabled: false, reactionCamEnabled: false,
hpReactiveCamStylingEnabled: false,
customPortraitFallback: true, customPortraitFallback: true,
}; };
expect(() => isValidPrivacySettings(invalid)).toThrow(TypeError); expect(() => isValidPrivacySettings(invalid)).toThrow(TypeError);
@@ -495,7 +422,6 @@ describe("privacy-settings contract", () => {
it("should throw TypeError when customPortraitFallback is an object", () => { it("should throw TypeError when customPortraitFallback is an object", () => {
const invalid = { const invalid = {
reactionCamEnabled: false, reactionCamEnabled: false,
hpReactiveCamStylingEnabled: false,
customPortraitFallback: {}, customPortraitFallback: {},
}; };
expect(() => isValidPrivacySettings(invalid)).toThrow(TypeError); expect(() => isValidPrivacySettings(invalid)).toThrow(TypeError);
+7 -50
View File
@@ -98,10 +98,10 @@ describe("PlayerPrivacyManager", () => {
}); });
it("should return saved settings when flag exists", () => { 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({ adapter.users.get.mockReturnValue({
getFlag: vi.fn((scope, key) => { getFlag: vi.fn((scope, key) => {
if (scope === "video-view-manager") { if (scope === "scrying-pool") {
return savedSettings[key]; return savedSettings[key];
} }
return undefined; return undefined;
@@ -114,7 +114,7 @@ describe("PlayerPrivacyManager", () => {
it("should return partial settings merged with defaults", () => { it("should return partial settings merged with defaults", () => {
adapter.users.get.mockReturnValue({ adapter.users.get.mockReturnValue({
getFlag: vi.fn((scope, key) => { getFlag: vi.fn((scope, key) => {
if (scope === "video-view-manager" && key === "reactionCamEnabled") { if (scope === "scrying-pool" && key === "reactionCamEnabled") {
return true; return true;
} }
return undefined; return undefined;
@@ -123,7 +123,6 @@ describe("PlayerPrivacyManager", () => {
const result = manager.getSettings("user1"); const result = manager.getSettings("user1");
expect(result).toEqual({ expect(result).toEqual({
reactionCamEnabled: true, reactionCamEnabled: true,
hpReactiveCamStylingEnabled: false,
customPortraitFallback: null, customPortraitFallback: null,
}); });
}); });
@@ -182,7 +181,7 @@ describe("PlayerPrivacyManager", () => {
adapter.users.get.mockReturnValue(mockUser); adapter.users.get.mockReturnValue(mockUser);
await manager.setSetting("user1", "reactionCamEnabled", true); await manager.setSetting("user1", "reactionCamEnabled", true);
expect(mockUser.setFlag).toHaveBeenCalledWith( expect(mockUser.setFlag).toHaveBeenCalledWith(
"video-view-manager", "scrying-pool",
"reactionCamEnabled", "reactionCamEnabled",
true true
); );
@@ -268,15 +267,6 @@ describe("PlayerPrivacyManager", () => {
expect(manager.isOptedIn("nonexistent", "reactionCam")).toBe(false); 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", () => { describe("getAllSettings", () => {
@@ -295,7 +285,6 @@ describe("PlayerPrivacyManager", () => {
id: "user1", id: "user1",
getFlag: vi.fn((scope, key) => { getFlag: vi.fn((scope, key) => {
if (key === "reactionCamEnabled") return true; if (key === "reactionCamEnabled") return true;
if (key === "hpReactiveCamStylingEnabled") return false;
return undefined; // customPortraitFallback and other keys return undefined; // customPortraitFallback and other keys
}), }),
}; };
@@ -303,7 +292,6 @@ describe("PlayerPrivacyManager", () => {
id: "user2", id: "user2",
getFlag: vi.fn((scope, key) => { getFlag: vi.fn((scope, key) => {
if (key === "reactionCamEnabled") return false; if (key === "reactionCamEnabled") return false;
if (key === "hpReactiveCamStylingEnabled") return true;
return undefined; // customPortraitFallback and other keys return undefined; // customPortraitFallback and other keys
}), }),
}; };
@@ -318,12 +306,10 @@ describe("PlayerPrivacyManager", () => {
expect(result.size).toBe(2); expect(result.size).toBe(2);
expect(result.get("user1")).toEqual({ expect(result.get("user1")).toEqual({
reactionCamEnabled: true, reactionCamEnabled: true,
hpReactiveCamStylingEnabled: false,
customPortraitFallback: null, customPortraitFallback: null,
}); });
expect(result.get("user2")).toEqual({ expect(result.get("user2")).toEqual({
reactionCamEnabled: false, reactionCamEnabled: false,
hpReactiveCamStylingEnabled: true,
customPortraitFallback: null, customPortraitFallback: null,
}); });
}); });
@@ -429,34 +415,6 @@ describe("PlayerPrivacyManager", () => {
expect(manager.isOptedIn("player1", "reactionCam")).toBe(true); 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", () => { it("should allow GM to view all players' settings", () => {
const gm = { id: "gm1", isGM: true, getFlag: vi.fn(() => false) }; const gm = { id: "gm1", isGM: true, getFlag: vi.fn(() => false) };
const player1 = { const player1 = {
@@ -465,7 +423,7 @@ describe("PlayerPrivacyManager", () => {
}; };
const player2 = { const player2 = {
id: "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]); adapter.users.all.mockReturnValue([gm, player1, player2]);
@@ -518,7 +476,7 @@ describe("PlayerPrivacyManager", () => {
).resolves.not.toThrow(); ).resolves.not.toThrow();
expect(mockUser.setFlag).toHaveBeenCalledWith( expect(mockUser.setFlag).toHaveBeenCalledWith(
"video-view-manager", "scrying-pool",
"customPortraitFallback", "customPortraitFallback",
dataURL dataURL
); );
@@ -648,7 +606,7 @@ describe("PlayerPrivacyManager", () => {
await manager.removePortraitFallback("player1"); await manager.removePortraitFallback("player1");
expect(mockUser.unsetFlag).toHaveBeenCalledWith( expect(mockUser.unsetFlag).toHaveBeenCalledWith(
"video-view-manager", "scrying-pool",
"customPortraitFallback" "customPortraitFallback"
); );
}); });
@@ -686,7 +644,6 @@ describe("PlayerPrivacyManager", () => {
getFlag: vi.fn((scope, key) => { getFlag: vi.fn((scope, key) => {
if (key === "customPortraitFallback") return dataURL; if (key === "customPortraitFallback") return dataURL;
if (key === "reactionCamEnabled") return true; if (key === "reactionCamEnabled") return true;
if (key === "hpReactiveCamStylingEnabled") return false;
return undefined; return undefined;
}), }),
}; };
+1 -1
View File
@@ -815,7 +815,7 @@ describe('ScenePresetManager', () => {
}); });
expect(mockScene.setFlag).toHaveBeenCalledWith( expect(mockScene.setFlag).toHaveBeenCalledWith(
'video-view-manager', 'scrying-pool',
'presets', 'presets',
expect.objectContaining({ expect.objectContaining({
_version: 1, _version: 1,
+33 -21
View File
@@ -84,14 +84,15 @@ describe('ScryingPoolController', () => {
// ── AC-2: action() happy path ───────────────────────────────────────────── // ── AC-2: action() happy path ─────────────────────────────────────────────
describe('action() happy path (AC-2)', () => { 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); controller.action('ui', 'user-1', 'hidden', 'op-1', 0);
expect(controller._pendingOps.has('user-1')).toBe(true); expect(socketHandler.registerPendingOp).toHaveBeenCalledWith(
expect(controller._pendingOps.get('user-1')).toMatchObject({ expect.objectContaining({ opId: 'op-1', userId: 'user-1', targetState: 'hidden' }),
opId: 'op-1', 'scrying-pool.visibility.set',
userId: 'user-1', expect.objectContaining({ opId: 'op-1' })
targetState: 'hidden', );
});
}); });
it('calls stateStore.setVisibility with the target state (optimistic update)', () => { 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); controller.action('ui', 'user-1', 'hidden', 'op-1', 0);
expect(hooksStub.callAll).toHaveBeenCalledWith( expect(hooksStub.callAll).toHaveBeenCalledWith(
'scrying-pool:controllerAction', '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); controller.action('ui', 'new-user', 'hidden', 'op-1', 0);
const op = controller._pendingOps.get('new-user'); // 'active' is the render-time default for users not in the matrix.
expect(op.previousState).toBe('never-connected'); 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]; 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', () => { it('calls socketHandler.confirmPendingOp with the opId', () => {
controller.action('ui', 'user-1', 'hidden', 'op-1', 0);
const echoHandler = getEchoHandler(); const echoHandler = getEchoHandler();
seedPendingOp('user-1', 'op-1');
echoHandler({ opId: 'op-1', userId: 'user-1', state: 'hidden', revision: 1 }); echoHandler({ opId: 'op-1', userId: 'user-1', state: 'hidden', revision: 1 });
expect(socketHandler.confirmPendingOp).toHaveBeenCalledWith('op-1'); expect(socketHandler.confirmPendingOp).toHaveBeenCalledWith('op-1');
}); });
it('stores the echo revision in _revisions for the userId', () => { it('stores the echo revision in _revisions for the userId', () => {
controller.action('ui', 'user-1', 'hidden', 'op-2', 0);
const echoHandler = getEchoHandler(); const echoHandler = getEchoHandler();
seedPendingOp('user-1', 'op-2');
echoHandler({ opId: 'op-2', userId: 'user-1', state: 'hidden', revision: 7 }); echoHandler({ opId: 'op-2', userId: 'user-1', state: 'hidden', revision: 7 });
expect(controller._revisions.get('user-1')).toBe(7); expect(controller._revisions.get('user-1')).toBe(7);
}); });
it('calls stateStore.setVisibility with the authoritative state', () => { it('calls stateStore.setVisibility with the authoritative state', () => {
controller.action('ui', 'user-1', 'active', 'op-3', 0);
const echoHandler = getEchoHandler(); const echoHandler = getEchoHandler();
seedPendingOp('user-1', 'op-3', 'active');
const setSpy = vi.spyOn(stateStore, 'setVisibility'); const setSpy = vi.spyOn(stateStore, 'setVisibility');
echoHandler({ opId: 'op-3', userId: 'user-1', state: 'active', revision: 2 }); 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', () => { it('fires Hooks.callAll scrying-pool:controllerAction with source: echo', () => {
controller.action('ui', 'user-1', 'hidden', 'op-4', 0);
const echoHandler = getEchoHandler(); const echoHandler = getEchoHandler();
seedPendingOp('user-1', 'op-4');
echoHandler({ opId: 'op-4', userId: 'user-1', state: 'hidden', revision: 1 }); echoHandler({ opId: 'op-4', userId: 'user-1', state: 'hidden', revision: 1 });
expect(hooksStub.callAll).toHaveBeenCalledWith( expect(hooksStub.callAll).toHaveBeenCalledWith(
@@ -262,20 +276,18 @@ describe('ScryingPoolController', () => {
}); });
it('removes the participant from _pendingOps after echo', () => { it('removes the participant from _pendingOps after echo', () => {
// Register a pending op first const echoHandler = getEchoHandler();
controller.action('ui', 'user-1', 'hidden', 'op-1', 0); seedPendingOp('user-1', 'op-1');
expect(controller._pendingOps.has('user-1')).toBe(true); expect(controller._pendingOps.has('user-1')).toBe(true);
const echoHandler = getEchoHandler();
echoHandler({ opId: 'op-1', userId: 'user-1', state: 'hidden', revision: 1 }); echoHandler({ opId: 'op-1', userId: 'user-1', state: 'hidden', revision: 1 });
expect(controller._pendingOps.has('user-1')).toBe(false); expect(controller._pendingOps.has('user-1')).toBe(false);
}); });
it('defaults revision to 0 when echo payload omits revision field', () => { 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(); const echoHandler = getEchoHandler();
seedPendingOp('user-1', 'op-1');
echoHandler({ opId: 'op-1', userId: 'user-1', state: 'hidden' }); // no revision echoHandler({ opId: 'op-1', userId: 'user-1', state: 'hidden' }); // no revision
expect(controller._revisions.get('user-1')).toBe(0); expect(controller._revisions.get('user-1')).toBe(0);
}); });
+10 -10
View File
@@ -527,41 +527,41 @@ describe('FoundryAdapter surface delegation', () => {
describe('user flag methods', () => { describe('user flag methods', () => {
it('users.getFlag returns flag value for valid user, scope, and key', () => { it('users.getFlag returns flag value for valid user, scope, and key', () => {
// First set a flag on the GM user // First set a flag on the GM user
GM_USER.setFlag('video-view-manager', 'testFlag', 'testValue'); GM_USER.setFlag('scrying-pool', 'testFlag', 'testValue');
const result = adapter.users.getFlag(GM_USER.id, 'video-view-manager', 'testFlag'); const result = adapter.users.getFlag(GM_USER.id, 'scrying-pool', 'testFlag');
expect(result).toBe('testValue'); expect(result).toBe('testValue');
expect(USERS_STUB.get).toHaveBeenCalledWith(GM_USER.id); expect(USERS_STUB.get).toHaveBeenCalledWith(GM_USER.id);
}); });
it('users.getFlag returns null when flag does not exist', () => { 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(); expect(result).toBeNull();
}); });
it('users.getFlag returns null when user does not exist', () => { 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(result).toBeNull();
expect(USERS_STUB.get).toHaveBeenCalledWith('unknown-user-id'); expect(USERS_STUB.get).toHaveBeenCalledWith('unknown-user-id');
}); });
it('users.setFlag sets flag value for valid user', async () => { 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(); expect(promise).not.toBeNull();
await promise; await promise;
expect(USERS_STUB.get).toHaveBeenCalledWith(PLAYER_USER.id); expect(USERS_STUB.get).toHaveBeenCalledWith(PLAYER_USER.id);
// Verify the flag was set // 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', () => { 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(promise).toBeNull();
expect(USERS_STUB.get).toHaveBeenCalledWith('unknown-user-id'); expect(USERS_STUB.get).toHaveBeenCalledWith('unknown-user-id');
}); });
it('users.getFlagModule returns module-scoped flag', () => { it('users.getFlagModule returns module-scoped flag', () => {
GM_USER.setFlag('video-view-manager', 'hpReactiveCamStylingEnabled', false); GM_USER.setFlag('scrying-pool', 'reactionCamEnabled', false);
const result = adapter.users.getFlagModule(GM_USER.id, 'hpReactiveCamStylingEnabled'); const result = adapter.users.getFlagModule(GM_USER.id, 'reactionCamEnabled');
expect(result).toBe(false); expect(result).toBe(false);
}); });
@@ -574,7 +574,7 @@ describe('FoundryAdapter surface delegation', () => {
const promise = adapter.users.setFlagModule(PLAYER_USER.id, 'reactionCamEnabled', true); const promise = adapter.users.setFlagModule(PLAYER_USER.id, 'reactionCamEnabled', true);
expect(promise).not.toBeNull(); expect(promise).not.toBeNull();
await promise; 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) => { localize: vi.fn((key, data) => {
// Simple mock that returns the key with data substituted // Simple mock that returns the key with data substituted
const messages = { const messages = {
'video-view-manager.notifications.personalHidden': 'GM has hidden your camera. Your portrait is shown to other Participants.', 'scrying-pool.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.', 'scrying-pool.notifications.personalShowed': 'Your camera is now visible to the table.',
'video-view-manager.notifications.gmHid': 'GM hid {name}\'s camera', 'scrying-pool.notifications.gmHid': 'GM hid {name}\'s camera',
'video-view-manager.notifications.gmShowed': 'GM showed {name}\'s camera', 'scrying-pool.notifications.gmShowed': 'GM showed {name}\'s camera',
}; };
let msg = messages[key] ?? key; let msg = messages[key] ?? key;
if (data?.name) { if (data?.name) {
+4 -4
View File
@@ -22,10 +22,10 @@ function createMockAdapter(overrides = {}) {
localize: vi.fn((key) => { localize: vi.fn((key) => {
// For testing, return strings with placeholders that match ConfirmationBar's .replace() calls // For testing, return strings with placeholders that match ConfirmationBar's .replace() calls
const translations = { const translations = {
'video-view-manager.presets.confirmation.applied': 'Preset applied — {name}', 'scrying-pool.presets.confirmation.applied': 'Preset applied — {name}',
'video-view-manager.presets.confirmation.counts': '{hidden} hidden, {visible} visible', 'scrying-pool.presets.confirmation.counts': '{hidden} hidden, {visible} visible',
'video-view-manager.presets.confirmation.partial-fail': '(some updates pending)', 'scrying-pool.presets.confirmation.partial-fail': '(some updates pending)',
'video-view-manager.presets.confirmation.undo': 'Undo preset apply', 'scrying-pool.presets.confirmation.undo': 'Undo preset apply',
}; };
return translations[key] ?? key; return translations[key] ?? key;
}), }),
+5 -5
View File
@@ -77,8 +77,8 @@ describe('DirectorsBoard', () => {
it('has DEFAULT_OPTIONS with position', () => { it('has DEFAULT_OPTIONS with position', () => {
expect(DirectorsBoard.DEFAULT_OPTIONS.position).toEqual({ expect(DirectorsBoard.DEFAULT_OPTIONS.position).toEqual({
width: 400, width: 420,
height: 300, height: 480,
}); });
}); });
}); });
@@ -110,7 +110,7 @@ describe('DirectorsBoard', () => {
boardWithOptions._loadPosition(); boardWithOptions._loadPosition();
expect(game.user.getFlag).toHaveBeenCalledWith( expect(game.user.getFlag).toHaveBeenCalledWith(
'video-view-manager', 'scrying-pool',
'directorsBoardState' 'directorsBoardState'
); );
// Position should be merged into options.position (not replaced) // Position should be merged into options.position (not replaced)
@@ -136,8 +136,8 @@ describe('DirectorsBoard', () => {
expect(boardWithOptions.options.position).toEqual({ expect(boardWithOptions.options.position).toEqual({
left: 100, left: 100,
top: 200, top: 200,
width: 400, width: 420,
height: 300, height: 480,
}); });
}); });
+7 -7
View File
@@ -140,7 +140,7 @@ describe('PresetLoadDialog', () => {
it('should return hasPresets false when no presets exist', async () => { it('should return hasPresets false when no presets exist', async () => {
scenePresetManager.list.mockReturnValue([]); scenePresetManager.list.mockReturnValue([]);
adapter.i18n.localize = vi.fn((key) => { 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; return key;
}); });
@@ -163,10 +163,10 @@ describe('PresetLoadDialog', () => {
it('should use i18n for labels', async () => { it('should use i18n for labels', async () => {
adapter.i18n.localize = vi.fn((key) => { adapter.i18n.localize = vi.fn((key) => {
const translations = { const translations = {
'video-view-manager.presets.load.loadButton': 'Load', 'scrying-pool.presets.load.loadButton': 'Load',
'video-view-manager.presets.load.cancelButton': 'Cancel', 'scrying-pool.presets.load.cancelButton': 'Cancel',
'video-view-manager.presets.load.title': 'Load Preset', 'scrying-pool.presets.load.title': 'Load Preset',
'video-view-manager.presets.load.emptyMessage': 'No presets', 'scrying-pool.presets.load.emptyMessage': 'No presets',
}; };
return translations[key] || key; return translations[key] || key;
}); });
@@ -276,7 +276,7 @@ describe('PresetLoadDialog', () => {
it('should show notification on successful load via adapter.notifications', async () => { it('should show notification on successful load via adapter.notifications', async () => {
adapter.i18n.localize = vi.fn((key) => { 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; return key;
}); });
@@ -398,7 +398,7 @@ describe('PresetLoadDialog', () => {
it('should use the correct template path', () => { it('should use the correct template path', () => {
expect(PresetLoadDialog.PARTS.dialog.template).toBe( expect(PresetLoadDialog.PARTS.dialog.template).toBe(
'modules/video-view-manager/templates/preset-load-dialog.hbs' 'modules/scrying-pool/templates/preset-load-dialog.hbs'
); );
}); });
+9 -9
View File
@@ -125,7 +125,7 @@ describe('PresetSaveDialog', () => {
it('should return empty string as defaultName when no presets exist', async () => { it('should return empty string as defaultName when no presets exist', async () => {
adapter.i18n.localize = vi.fn((key) => { 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; return key;
}); });
@@ -146,11 +146,11 @@ describe('PresetSaveDialog', () => {
it('should return all i18n labels', async () => { it('should return all i18n labels', async () => {
adapter.i18n.localize = vi.fn((key) => { adapter.i18n.localize = vi.fn((key) => {
const translations = { const translations = {
'video-view-manager.presets.save.saveButton': 'Save', 'scrying-pool.presets.save.saveButton': 'Save',
'video-view-manager.presets.save.cancelButton': 'Cancel', 'scrying-pool.presets.save.cancelButton': 'Cancel',
'video-view-manager.presets.save.title': 'Save Preset', 'scrying-pool.presets.save.title': 'Save Preset',
'video-view-manager.presets.save.nameLabel': 'Preset Name', 'scrying-pool.presets.save.nameLabel': 'Preset Name',
'video-view-manager.presets.save.namePlaceholder': 'Enter preset name', 'scrying-pool.presets.save.namePlaceholder': 'Enter preset name',
}; };
return translations[key] || key; return translations[key] || key;
}); });
@@ -320,7 +320,7 @@ describe('PresetSaveDialog', () => {
scenePresetManager.save = vi.fn().mockResolvedValue({ name: 'My Preset' }); scenePresetManager.save = vi.fn().mockResolvedValue({ name: 'My Preset' });
dialog.close = vi.fn().mockResolvedValue({}); dialog.close = vi.fn().mockResolvedValue({});
adapter.i18n.localize = vi.fn((key) => { 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; return key;
}); });
@@ -447,7 +447,7 @@ describe('PresetSaveDialog', () => {
it('should use the correct template path', () => { it('should use the correct template path', () => {
expect(PresetSaveDialog.PARTS.dialog.template).toBe( 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.classes).toContain('preset-save-dialog');
expect(options.window.title).toBe('Save Scene Preset'); expect(options.window.title).toBe('Save Scene Preset');
expect(options.window.resizable).toBe(false); expect(options.window.resizable).toBe(false);
expect(options.position.width).toBe(320); expect(options.position.width).toBe(360);
}); });
it('should store references to dependencies', () => { it('should store references to dependencies', () => {
+5 -5
View File
@@ -88,8 +88,8 @@ describe('ScenePresetPanel', () => {
it('sets aria-label using i18n', () => { it('sets aria-label using i18n', () => {
panel.init(); panel.init();
expect(adapter.i18n.localize).toHaveBeenCalledWith('video-view-manager.scenePresetPanel.title'); expect(adapter.i18n.localize).toHaveBeenCalledWith('scrying-pool.scenePresetPanel.title');
expect(panel._element.getAttribute('aria-label')).toBe('video-view-manager.scenePresetPanel.title'); expect(panel._element.getAttribute('aria-label')).toBe('scrying-pool.scenePresetPanel.title');
}); });
it('sets aria-expanded to false initially', () => { it('sets aria-expanded to false initially', () => {
@@ -267,7 +267,7 @@ describe('ScenePresetPanel', () => {
it('uses i18n for message', () => { it('uses i18n for message', () => {
panel._buildEmptyHtml(); 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', () => { it('escapes HTML in message', () => {
@@ -450,7 +450,7 @@ describe('ScenePresetPanel', () => {
mockTarget.checked = true; mockTarget.checked = true;
await panel._onToggleAutoApply(mockTarget); await panel._onToggleAutoApply(mockTarget);
expect(adapter.notifications.info).toHaveBeenCalledWith( 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; mockTarget.checked = false;
await panel._onToggleAutoApply(mockTarget); await panel._onToggleAutoApply(mockTarget);
expect(adapter.notifications.info).toHaveBeenCalledWith( expect(adapter.notifications.info).toHaveBeenCalledWith(
'video-view-manager.scenePresetPanel.notifications.disabled' 'scrying-pool.scenePresetPanel.notifications.disabled'
); );
}); });
+24
View File
@@ -241,6 +241,30 @@ describe('ScryingPoolStrip', () => {
const data = strip.getData(); const data = strip.getData();
expect(data.hasStreamAccess).toBe(false); 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)', () => { 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.sectionDescription': 'Control which automation effects can affect your camera.',
'SCRYING_POOL.PrivacyPanel.reactionCamLabel': 'Reaction Cam', 'SCRYING_POOL.PrivacyPanel.reactionCamLabel': 'Reaction Cam',
'SCRYING_POOL.PrivacyPanel.reactionCamDescription': 'Automatically show your camera during key moments (combat, rolls, etc.)', '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.toggleOn': 'Enabled',
'SCRYING_POOL.PrivacyPanel.toggleOff': 'Disabled', 'SCRYING_POOL.PrivacyPanel.toggleOff': 'Disabled',
'SCRYING_POOL.PrivacyPanel.readOnlyNotice': 'This player\'s privacy settings are read-only', '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 () => { it('should return context with settings', async () => {
const settings = createPrivacySettings({ const settings = createPrivacySettings({
reactionCamEnabled: true, reactionCamEnabled: true,
hpReactiveCamStylingEnabled: false,
}); });
playerPrivacyManager.getSettings.mockReturnValue(settings); playerPrivacyManager.getSettings.mockReturnValue(settings);
adapter.users.current.mockReturnValue({ id: targetUserId }); adapter.users.current.mockReturnValue({ id: targetUserId });
@@ -140,7 +137,7 @@ describe('PlayerPrivacyPanel', () => {
expect(context.title).toBe('Player Privacy Panel'); expect(context.title).toBe('Player Privacy Panel');
expect(context.sectionHeader).toBe('Automation Opt-ins'); expect(context.sectionHeader).toBe('Automation Opt-ins');
expect(context.automationEffects).toBeDefined(); expect(context.automationEffects).toBeDefined();
expect(context.automationEffects).toHaveLength(2); expect(context.automationEffects).toHaveLength(1);
expect(context.isReadOnly).toBe(false); expect(context.isReadOnly).toBe(false);
expect(context.isOwnUser).toBe(true); expect(context.isOwnUser).toBe(true);
}); });
@@ -156,25 +153,22 @@ describe('PlayerPrivacyPanel', () => {
expect(context.isOwnUser).toBe(false); 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(); 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[0].key).toBe('reactionCam');
expect(context.automationEffects[1].key).toBe('hpReactiveCamStyling');
}); });
it('should reflect current settings in context', async () => { it('should reflect current settings in context', async () => {
const settings = createPrivacySettings({ const settings = createPrivacySettings({
reactionCamEnabled: true, reactionCamEnabled: true,
hpReactiveCamStylingEnabled: true,
}); });
playerPrivacyManager.getSettings.mockReturnValue(settings); playerPrivacyManager.getSettings.mockReturnValue(settings);
const context = await panel._prepareContext(); const context = await panel._prepareContext();
expect(context.automationEffects[0].enabled).toBe(true); 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', () => { it('should clear cached elements', () => {
// Set up some cached values // Set up some cached values
panel._reactionCamToggle = document.createElement('div'); panel._reactionCamToggle = document.createElement('div');
panel._hpReactiveCamToggle = document.createElement('div');
panel._currentSettings = createPrivacySettings(); panel._currentSettings = createPrivacySettings();
panel._onClose(); panel._onClose();
expect(panel._reactionCamToggle).toBe(null); expect(panel._reactionCamToggle).toBe(null);
expect(panel._hpReactiveCamToggle).toBe(null);
expect(panel._currentSettings).toBe(null); expect(panel._currentSettings).toBe(null);
}); });
}); });
+2 -2
View File
@@ -238,7 +238,7 @@ describe('VisibilityBadge', () => {
badge.init(); badge.init();
await badge._setFirstBadgeEncountered(); await badge._setFirstBadgeEncountered();
const mockUser = adapter.users.current(); 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 // Directly call _onGotIt to avoid async click handler timing issues
await panel._onGotIt(); await panel._onGotIt();
const mockUser = adapter.users.current(); 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 () => { it('clears timer (no ghost timer after dismissal)', async () => {