Story 4.1: Tasks 3-6 Complete - Director's Board Integration & Settings Menu

- Task 3: Extended FoundryAdapter with user flag access methods
  - Added getFlag(userId, scope, key) method
  - Added setFlag(userId, scope, key, value) method
  - Added getFlagModule(userId, key) convenience method
  - Added setFlagModule(userId, key, value) convenience method

- Task 4: Integrated Privacy Settings with Director's Board
  - Updated participant-card.hbs to show Reaction Cam badge
  - Modified boardUtils.js to pass playerPrivacyManager through context
  - Updated DirectorsBoard to accept and pass playerPrivacyManager
  - Added CSS styles for Reaction Cam badge (SP accent color)

- Task 5: Registered PlayerPrivacyPanel in module settings
  - Added settings menu registration in module.js Hooks.once('ready')
  - Available to all users (restricted: false)
  - Uses localized labels and hints

- Task 6: Added all localization strings
  - Added SCRYING_POOL.PrivacyPanel.* strings for panel UI
  - Added SCRYING_POOL.Settings.* strings for settings menu

- Updated story file with task completion status

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
2026-05-23 21:29:58 +02:00
parent 61f362004e
commit fd0a7868f3
13 changed files with 1049 additions and 47 deletions
+50
View File
@@ -102,6 +102,56 @@ export class FoundryAdapter {
},
/** @returns {object|null} */
current: () => g.user ?? null,
/**
* Gets a user flag for a specific user.
* @param {string} userId - The user ID to get the flag for.
* @param {string} scope - The flag scope/namespace.
* @param {string} key - The flag key.
* @returns {unknown|null} The flag value, or null if not found.
*/
getFlag: (userId, scope, key) => {
const user = g.users?.get(userId);
if (user && typeof user.getFlag === 'function') {
return user.getFlag(scope, key) ?? null;
}
return null;
},
/**
* Sets a user flag for a specific user.
* Note: In FoundryVTT, users can only set their own flags. GM can set flags for any user.
* This method wraps the native user.setFlag() which returns a Promise.
* @param {string} userId - The user ID to set the flag for.
* @param {string} scope - The flag scope/namespace.
* @param {string} key - The flag key.
* @param {unknown} value - The flag value to set.
* @returns {Promise<unknown>|null} The promise from user.setFlag(), or null if user not found.
*/
setFlag: (userId, scope, key, value) => {
const user = g.users?.get(userId);
if (user && typeof user.setFlag === 'function') {
return /** @type {Promise<unknown>} */ (user.setFlag(scope, key, value));
}
return null;
},
/**
* Convenience method to get a module-scoped flag.
* @param {string} userId - The user ID to get the flag for.
* @param {string} key - The flag key (will be scoped to 'video-view-manager').
* @returns {unknown|null} The flag value, or null if not found.
*/
getFlagModule: (userId, key) => {
return this.getFlag(userId, 'video-view-manager', key);
},
/**
* Convenience method to set a module-scoped flag.
* @param {string} userId - The user ID to set the flag for.
* @param {string} key - The flag key (will be scoped to 'video-view-manager').
* @param {unknown} value - The flag value to set.
* @returns {Promise<unknown>|null} The promise from user.setFlag(), or null if user not found.
*/
setFlagModule: (userId, key, value) => {
return this.setFlag(userId, 'video-view-manager', key, value);
},
};
/** Scenes surface — wraps game.scenes. */
+4 -2
View File
@@ -58,14 +58,16 @@ export class DirectorsBoard extends _AppBase {
* @param {object} controller
* @param {object} adapter
* @param {import('../core/ScenePresetManager.js').ScenePresetManager} scenePresetManager
* @param {import('../../core/PlayerPrivacyManager.js').PlayerPrivacyManager} playerPrivacyManager
* @param {object} [options]
*/
constructor(stateStore, controller, adapter, scenePresetManager, options = {}) {
constructor(stateStore, controller, adapter, scenePresetManager, playerPrivacyManager, options = {}) {
super(options);
this._stateStore = stateStore;
this._controller = controller;
this._adapter = adapter;
this._scenePresetManager = scenePresetManager;
this._playerPrivacyManager = playerPrivacyManager;
this._hookId = null;
/** @type {Map<string, string>|null} Pre-bulk-action snapshot for single-step undo */
this._undoSnapshot = null;
@@ -337,7 +339,7 @@ export class DirectorsBoard extends _AppBase {
/** @inheritdoc */
async _prepareContext() {
const base = buildBoardContext(this._stateStore, this._controller, this._adapter);
const base = buildBoardContext(this._stateStore, this._controller, this._adapter, this._playerPrivacyManager);
const presetCount = this._scenePresetManager?.list?.().length ?? 0;
// Get auto-apply config for current scene (Story 3.2)
+258
View File
@@ -0,0 +1,258 @@
// @ts-nocheck
// Conditional base class — test environment lacks foundry globals.
// At module load time in tests, foundry is undefined → fallback class is used.
/** @private */
const _AppBase =
typeof foundry !== 'undefined' &&
foundry.applications?.api?.HandlebarsApplicationMixin &&
foundry.applications?.api?.ApplicationV2
? foundry.applications.api.HandlebarsApplicationMixin(
foundry.applications.api.ApplicationV2
)
: class _FallbackApp {
static DEFAULT_OPTIONS = {};
static PARTS = {};
get rendered() { return this._rendered ?? false; }
set rendered(v) { this._rendered = v; }
get element() { return this._element ?? null; }
set element(v) { this._element = v; }
async render() { this._rendered = true; }
async close() { this._rendered = false; }
async _prepareContext() { return {}; }
_onRender() {}
_onClose() {}
_onPosition() {}
};
/**
* Player Privacy Panel — UI component for players to control automation opt-ins.
*
* Extends ApplicationV2 via HandlebarsApplicationMixin.
* Displays privacy settings and allows toggling (for own user) or read-only view (for other users).
*
* Import rule: may import from src/core/, src/foundry/, src/contracts/.
* Constructors are side-effect free.
*
* @module ui/player/PlayerPrivacyPanel
*/
/**
* Player Privacy Panel dialog for managing automation opt-in settings.
*/
export class PlayerPrivacyPanel extends _AppBase {
static DEFAULT_OPTIONS = {
id: 'scrying-pool-player-privacy-panel',
classes: ['scrying-pool', 'player-privacy-panel'],
window: {
title: 'Player Privacy Panel',
resizable: false,
width: 380,
height: 'auto',
},
position: {},
};
static PARTS = {
dialog: {
template: 'modules/video-view-manager/templates/player-privacy-panel.hbs',
},
};
/**
* @param {import('../../foundry/FoundryAdapter.js').FoundryAdapter} adapter
* Injected FoundryAdapter surface.
* @param {import('../../core/PlayerPrivacyManager.js').PlayerPrivacyManager} playerPrivacyManager
* Injected PlayerPrivacyManager for privacy settings operations.
* @param {string} targetUserId - The user ID whose settings are being viewed/edited.
* @param {object} [options]
* @throws {TypeError} If adapter or playerPrivacyManager is invalid.
*/
constructor(adapter, playerPrivacyManager, targetUserId, options = {}) {
// Validate dependencies
if (!adapter || typeof adapter !== 'object') {
throw new TypeError(
'PlayerPrivacyPanel: adapter argument is required and must be an object'
);
}
if (!playerPrivacyManager || typeof playerPrivacyManager !== 'object') {
throw new TypeError(
'PlayerPrivacyPanel: playerPrivacyManager argument is required and must be an object'
);
}
if (typeof targetUserId !== 'string' || targetUserId.length === 0) {
throw new TypeError(
'PlayerPrivacyPanel: targetUserId must be a non-empty string'
);
}
super(options);
this._adapter = adapter;
this._playerPrivacyManager = playerPrivacyManager;
this._targetUserId = targetUserId;
// Cache for DOM elements
/** @type {HTMLElement|null} */
this._reactionCamToggle = null;
/** @type {HTMLElement|null} */
this._hpReactiveCamToggle = null;
// Current settings state
/** @type {import('../../contracts/privacy-settings.js').PrivacySettings|null} */
this._currentSettings = null;
}
/**
* Prepares the template context with i18n labels and current settings.
* @returns {Promise<object>} Template context.
*/
async _prepareContext() {
const i18n = this._adapter.i18n;
// Get current settings for the target user
const settings = this._playerPrivacyManager.getSettings(this._targetUserId);
this._currentSettings = settings;
// Determine if this is the current user (editable) or another user (read-only)
const currentUserId = this._adapter.users.current?.()?.id;
const isOwnUser = this._targetUserId === currentUserId;
const isReadOnly = !isOwnUser;
return {
// Panel metadata
title: i18n.localize('SCRYING_POOL.PrivacyPanel.title'),
sectionHeader: i18n.localize('SCRYING_POOL.PrivacyPanel.sectionHeader'),
readOnlyNotice: i18n.localize('SCRYING_POOL.PrivacyPanel.readOnlyNotice'),
// Automation effects
automationEffects: [
{
key: 'reactionCam',
label: i18n.localize('SCRYING_POOL.PrivacyPanel.reactionCamLabel'),
description: i18n.localize('SCRYING_POOL.PrivacyPanel.reactionCamDescription'),
enabled: settings.reactionCamEnabled,
settingKey: 'reactionCamEnabled',
},
{
key: 'hpReactiveCamStyling',
label: i18n.localize('SCRYING_POOL.PrivacyPanel.hpReactiveCamStylingLabel'),
description: i18n.localize('SCRYING_POOL.PrivacyPanel.hpReactiveCamStylingDescription'),
enabled: settings.hpReactiveCamStylingEnabled,
settingKey: 'hpReactiveCamStylingEnabled',
},
],
// Toggle labels
toggleOnLabel: i18n.localize('SCRYING_POOL.PrivacyPanel.toggleOn'),
toggleOffLabel: i18n.localize('SCRYING_POOL.PrivacyPanel.toggleOff'),
// State
isReadOnly,
isOwnUser,
};
}
/**
* Sets up event handlers after rendering.
* @param {HTMLElement} element - The dialog element.
*/
_onRender(element) {
// Cache toggle elements
this._reactionCamToggle = element.querySelector('[data-setting="reactionCamEnabled"]');
this._hpReactiveCamToggle = element.querySelector('[data-setting="hpReactiveCamStylingEnabled"]');
// Set up toggle change handlers
this._setupToggleHandlers(element);
}
/**
* Sets up event handlers for toggle switches.
* @param {HTMLElement} element - The dialog element.
*/
_setupToggleHandlers(element) {
const toggles = element.querySelectorAll('.player-privacy-panel__toggle input[type="checkbox"]');
for (const toggle of toggles) {
toggle.addEventListener('change', (event) => this._onToggleChange(event));
}
}
/**
* Handles toggle change events.
* @param {Event} event - The change event from the checkbox.
*/
async _onToggleChange(event) {
const checkbox = event.target;
if (!checkbox || checkbox.type !== 'checkbox') return;
const settingKey = checkbox.getAttribute('data-setting');
const newValue = checkbox.checked;
// Don't allow changes in read-only mode
if (this._isReadOnlyMode()) {
checkbox.checked = !newValue; // Revert the change
return;
}
if (!settingKey) {
console.warn('[ScryingPool] PlayerPrivacyPanel: toggle missing data-setting attribute');
return;
}
try {
// Update the setting via PlayerPrivacyManager
await this._playerPrivacyManager.setSetting(
this._targetUserId,
settingKey,
newValue
);
// Update our cached settings
if (this._currentSettings) {
this._currentSettings[settingKey] = newValue;
}
// Show success notification
this._adapter.notifications.info(
this._adapter.i18n.localize('SCRYING_POOL.PrivacyPanel.savedNotification')
);
} catch (err) {
// Revert the checkbox if the update failed
checkbox.checked = !newValue;
// Log error
console.error('[ScryingPool] PlayerPrivacyPanel: failed to update setting:', err);
// Show error notification
if (err instanceof TypeError) {
this._adapter.notifications.error(err.message);
} else {
this._adapter.notifications.error(
this._adapter.i18n.localize('SCRYING_POOL.PrivacyPanel.saveError')
);
}
}
}
/**
* Checks if the panel is in read-only mode.
* @returns {boolean} True if in read-only mode.
* @private
*/
_isReadOnlyMode() {
const currentUserId = this._adapter.users.current?.()?.id;
return this._targetUserId !== currentUserId;
}
/**
* Cleans up event listeners.
*/
_onClose() {
// Clear cached elements
this._reactionCamToggle = null;
this._hpReactiveCamToggle = null;
this._currentSettings = null;
}
}
+23 -4
View File
@@ -21,15 +21,30 @@ export function resolveToggleTarget(currentState) {
* Builds context for a single participant in the Director's Board.
* @param {object} stateStore
* @param {string} userId
* @param {object} [user] - Optional user object for additional data
* @param {object} [privacyManager] - Optional PlayerPrivacyManager for privacy settings
* @returns {object} Participant context
*/
export function buildSimpleParticipantContext(stateStore, userId) {
export function buildSimpleParticipantContext(stateStore, userId, user, privacyManager) {
const state = stateStore.getState(userId);
return {
const context = {
userId,
state: state ?? 'active',
isGhost: state === 'ghost',
};
// Add privacy settings if privacyManager is provided
if (privacyManager && user) {
try {
const settings = privacyManager.getSettings(userId);
context.isReactionCamEnabled = settings.reactionCamEnabled;
} catch {
// If privacy manager fails, just skip adding the badge
context.isReactionCamEnabled = false;
}
}
return context;
}
/**
@@ -37,12 +52,16 @@ export function buildSimpleParticipantContext(stateStore, userId) {
* @param {object} stateStore
* @param {object} controller
* @param {object} adapter
* @param {object} [privacyManager] - Optional PlayerPrivacyManager for privacy settings
* @returns {object} Board context
*/
export function buildBoardContext(stateStore, controller, adapter) {
export function buildBoardContext(stateStore, controller, adapter, privacyManager) {
try {
const users = adapter.users.all?.() ?? [];
const participants = users.map(u => buildSimpleParticipantContext(stateStore, u.id ?? u));
const participants = users.map(u => {
const userId = u.id ?? u;
return buildSimpleParticipantContext(stateStore, userId, u, privacyManager);
});
return { participants, isEmpty: participants.length === 0 };
} catch (err) {
console.error('[ScryingPool] buildBoardContext failed:', err);