@@ -49,6 +49,15 @@
|
|||||||
"close": "Close",
|
"close": "Close",
|
||||||
"empty": "No participants connected.",
|
"empty": "No participants connected.",
|
||||||
"openButton": "Open Director's Board",
|
"openButton": "Open Director's Board",
|
||||||
|
"dockLayout": {
|
||||||
|
"label": "View Layout",
|
||||||
|
"vertical-sm": "Vertical Small",
|
||||||
|
"vertical-md": "Vertical Large",
|
||||||
|
"horizontal-sm": "Horizontal Small",
|
||||||
|
"horizontal-md": "Horizontal Large",
|
||||||
|
"mosaic-sm": "Mosaic Small",
|
||||||
|
"mosaic-md": "Mosaic Large"
|
||||||
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"savePreset": "Save Layout",
|
"savePreset": "Save Layout",
|
||||||
"loadPreset": "Load Layout",
|
"loadPreset": "Load Layout",
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ Hooks.once("init", () => {
|
|||||||
config: false,
|
config: false,
|
||||||
type: Object,
|
type: Object,
|
||||||
default: { _version: 1, matrix: {} },
|
default: { _version: 1, matrix: {} },
|
||||||
|
onChange: () => { stateStore?.init(); roleRenderer?.rerenderStrip(); },
|
||||||
});
|
});
|
||||||
|
|
||||||
adapter.settings.register("showGMSelfFeed", {
|
adapter.settings.register("showGMSelfFeed", {
|
||||||
@@ -130,6 +131,25 @@ Hooks.once("init", () => {
|
|||||||
hint: "When enabled, scenes with a configured camera layout will automatically apply it on activation",
|
hint: "When enabled, scenes with a configured camera layout will automatically apply it on activation",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Dock layout — world-scoped so the GM controls the layout direction for all players
|
||||||
|
adapter.settings.register("dockLayout", {
|
||||||
|
scope: "world",
|
||||||
|
config: false,
|
||||||
|
type: String,
|
||||||
|
default: "vertical-sm",
|
||||||
|
onChange: () => roleRenderer?.rerenderStrip(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Per-user size toggle — client-scoped so each user can expand/collapse independently.
|
||||||
|
// '' = no preference (follow world dockLayout), 'sm' = force small, 'md' = force large.
|
||||||
|
adapter.settings.register("dockLayoutExpanded", {
|
||||||
|
scope: "client",
|
||||||
|
config: false,
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
onChange: () => roleRenderer?.rerenderStrip(),
|
||||||
|
});
|
||||||
|
|
||||||
// Construct data layer — constructors are side-effect-free
|
// Construct data layer — constructors are side-effect-free
|
||||||
// Note: ScenePresetManager is constructed in 'ready' hook after visibilityManager is available
|
// Note: ScenePresetManager is constructed in 'ready' hook after visibilityManager is available
|
||||||
stateStore = new StateStore(adapter.settings);
|
stateStore = new StateStore(adapter.settings);
|
||||||
@@ -191,6 +211,15 @@ Hooks.once("init", () => {
|
|||||||
Hooks.once("ready", () => {
|
Hooks.once("ready", () => {
|
||||||
console.log("[ScryingPool] ready — module active");
|
console.log("[ScryingPool] ready — module active");
|
||||||
|
|
||||||
|
// Migration: reset stale boolean dockLayoutExpanded to '' (empty string)
|
||||||
|
// Old registration used type:Boolean, saved value `false` persists in client storage
|
||||||
|
try {
|
||||||
|
const legacyVal = adapter.settings.get('dockLayoutExpanded');
|
||||||
|
if (typeof legacyVal === 'boolean') {
|
||||||
|
adapter.settings.set('dockLayoutExpanded', '').catch(() => {});
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
// Hydrate StateStore from persisted world setting (AC-6, AC-7)
|
// Hydrate StateStore from persisted world setting (AC-6, AC-7)
|
||||||
stateStore.init();
|
stateStore.init();
|
||||||
|
|
||||||
@@ -270,9 +299,9 @@ Hooks.once("ready", () => {
|
|||||||
// Story 4.2: Pass portraitFallbackHandler for custom portrait display
|
// Story 4.2: Pass portraitFallbackHandler for custom portrait display
|
||||||
roleRenderer = new RoleRenderer(stateStore, scryingPoolController, avTileAdapter, adapter, portraitFallbackHandler);
|
roleRenderer = new RoleRenderer(stateStore, scryingPoolController, avTileAdapter, adapter, portraitFallbackHandler);
|
||||||
roleRenderer.init();
|
roleRenderer.init();
|
||||||
if (adapter.users.isGM() && game.webrtc !== null) {
|
roleRenderer.openStrip();
|
||||||
roleRenderer.openStrip();
|
|
||||||
|
if (adapter.users.isGM()) {
|
||||||
// Story 3.2: Create ConfirmationBar for preset apply feedback (GM only)
|
// Story 3.2: Create ConfirmationBar for preset apply feedback (GM only)
|
||||||
// Pass roleRenderer to access ScryingPoolStrip.stripOverlayLayer (created lazily)
|
// Pass roleRenderer to access ScryingPoolStrip.stripOverlayLayer (created lazily)
|
||||||
confirmationBar = new ConfirmationBar(adapter, visibilityManager, socketHandler, roleRenderer);
|
confirmationBar = new ConfirmationBar(adapter, visibilityManager, socketHandler, roleRenderer);
|
||||||
|
|||||||
+12
-1
@@ -115,7 +115,8 @@ export class RoleRenderer {
|
|||||||
this._stateStore,
|
this._stateStore,
|
||||||
this._controller,
|
this._controller,
|
||||||
this._avTileAdapter,
|
this._avTileAdapter,
|
||||||
this._adapter
|
this._adapter,
|
||||||
|
{ portraitFallbackHandler: this._portraitFallbackHandler }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this._strip.render(true);
|
this._strip.render(true);
|
||||||
@@ -129,4 +130,14 @@ export class RoleRenderer {
|
|||||||
this._strip.close();
|
this._strip.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-renders the strip if it is currently open.
|
||||||
|
* Called by the dockLayout setting's onChange handler.
|
||||||
|
*/
|
||||||
|
rerenderStrip() {
|
||||||
|
if (this._strip?.rendered) {
|
||||||
|
this._strip.render({ force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -361,6 +361,22 @@ export class DirectorsBoard extends _AppBase {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[ScryingPool] Failed to get auto-apply config for context:', err);
|
console.warn('[ScryingPool] Failed to get auto-apply config for context:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dock layout selector
|
||||||
|
const currentDockLayout = this._adapter.settings?.get?.('dockLayout') ?? 'vertical-sm';
|
||||||
|
const DOCK_LAYOUTS = [
|
||||||
|
{ key: 'vertical-sm', icon: 'fa-grip-vertical', size: 'S', sepAfter: false },
|
||||||
|
{ key: 'vertical-md', icon: 'fa-grip-vertical', size: 'L', sepAfter: true },
|
||||||
|
{ key: 'horizontal-sm', icon: 'fa-grip-horizontal', size: 'S', sepAfter: false },
|
||||||
|
{ key: 'horizontal-md', icon: 'fa-grip-horizontal', size: 'L', sepAfter: true },
|
||||||
|
{ key: 'mosaic-sm', icon: 'fa-border-all', size: 'S', sepAfter: false },
|
||||||
|
{ key: 'mosaic-md', icon: 'fa-border-all', size: 'L', sepAfter: false },
|
||||||
|
];
|
||||||
|
const dockLayouts = DOCK_LAYOUTS.map(l => ({
|
||||||
|
...l,
|
||||||
|
isActive: l.key === currentDockLayout,
|
||||||
|
label: (typeof game !== 'undefined' ? game.i18n?.localize?.(`scrying-pool.directorsBoard.dockLayout.${l.key}`) : null) ?? l.key,
|
||||||
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
@@ -376,6 +392,8 @@ export class DirectorsBoard extends _AppBase {
|
|||||||
presets: this._scenePresetManager?.list?.() ?? [],
|
presets: this._scenePresetManager?.list?.() ?? [],
|
||||||
// A/V mode — reflects current world AV state (0 = disabled, 3 = audio+video)
|
// A/V mode — reflects current world AV state (0 = disabled, 3 = audio+video)
|
||||||
avModeEnabled: (game.webrtc?.settings?.world?.mode ?? 0) !== 0,
|
avModeEnabled: (game.webrtc?.settings?.world?.mode ?? 0) !== 0,
|
||||||
|
// Dock layout selector
|
||||||
|
dockLayouts,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,6 +438,7 @@ export class DirectorsBoard extends _AppBase {
|
|||||||
case 'toggle-preset-panel': this._togglePresetPanel(); break;
|
case 'toggle-preset-panel': this._togglePresetPanel(); break;
|
||||||
case 'toggle-av-mode': this._onToggleAVMode(); break;
|
case 'toggle-av-mode': this._onToggleAVMode(); break;
|
||||||
case 'open-av-config': this._onOpenAVConfig(); break;
|
case 'open-av-config': this._onOpenAVConfig(); break;
|
||||||
|
case 'set-dock-layout': this._onSetDockLayout(btn.dataset.layout); break;
|
||||||
case 'close': this.close(); break;
|
case 'close': this.close(); break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -661,6 +680,21 @@ export class DirectorsBoard extends _AppBase {
|
|||||||
game.webrtc.config.render({ force: true });
|
game.webrtc.config.render({ force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the selected dock layout and re-renders the board.
|
||||||
|
* The strip re-renders automatically via the setting's onChange callback.
|
||||||
|
* @param {string} layoutKey
|
||||||
|
*/
|
||||||
|
async _onSetDockLayout(layoutKey) {
|
||||||
|
if (!layoutKey) return;
|
||||||
|
try {
|
||||||
|
await this._adapter.settings?.set?.('dockLayout', layoutKey);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ScryingPool] Failed to set dockLayout:', err);
|
||||||
|
}
|
||||||
|
if (this.rendered) this.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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+174
-66
@@ -1,5 +1,5 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
/* global Application, directorsBoard */
|
/* global directorsBoard */
|
||||||
import { generateOpId } from '../../utils/uuid.js';
|
import { generateOpId } from '../../utils/uuid.js';
|
||||||
import { StripOverlayLayer } from '../shared/StripOverlayLayer.js';
|
import { StripOverlayLayer } from '../shared/StripOverlayLayer.js';
|
||||||
|
|
||||||
@@ -29,16 +29,24 @@ export function resolveTargetState(currentState) {
|
|||||||
* @param {object} controller
|
* @param {object} controller
|
||||||
* @param {object} adapter
|
* @param {object} adapter
|
||||||
* @param {boolean} hasStreamAccess - Whether stream access is available for video replacement
|
* @param {boolean} hasStreamAccess - Whether stream access is available for video replacement
|
||||||
|
* @param {object|null} [portraitFallbackHandler] - PortraitFallbackHandler for custom portrait lookup
|
||||||
* @returns {Array<object>}
|
* @returns {Array<object>}
|
||||||
*/
|
*/
|
||||||
export function buildParticipantList(userIds, stateStore, controller, adapter, hasStreamAccess = false) {
|
export function buildParticipantList(userIds, stateStore, controller, adapter, hasStreamAccess = false, portraitFallbackHandler = null) {
|
||||||
return userIds.map(userId => {
|
return userIds.map(userId => {
|
||||||
const user = adapter.users.get(userId) ?? { name: userId, avatar: null };
|
const user = adapter.users.get(userId) ?? { name: userId, avatar: null };
|
||||||
const state = stateStore.getState(userId) ?? 'active';
|
const state = stateStore.getState(userId) ?? 'active';
|
||||||
|
|
||||||
|
// Priority: custom fallback portrait → user.avatar → character portrait → mystery-man
|
||||||
|
const avatarSrc = portraitFallbackHandler?.getFallbackImageURL(userId)
|
||||||
|
|| user.avatar
|
||||||
|
|| user.character?.img
|
||||||
|
|| 'icons/svg/mystery-man.svg';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userId,
|
userId,
|
||||||
name: user.name ?? userId,
|
name: user.name ?? userId,
|
||||||
avatarSrc: user.avatar ?? 'icons/svg/mystery-man.svg',
|
avatarSrc,
|
||||||
state,
|
state,
|
||||||
stateLabel: _stateLabel(state),
|
stateLabel: _stateLabel(state),
|
||||||
hasPendingOp: controller.hasPendingOp ? controller.hasPendingOp(userId) : false,
|
hasPendingOp: controller.hasPendingOp ? controller.hasPendingOp(userId) : false,
|
||||||
@@ -67,42 +75,53 @@ function _stateLabel(state) {
|
|||||||
return LABELS_MAP[state] ?? state;
|
return LABELS_MAP[state] ?? state;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use conditional base class for test compatibility (typeof check is no-undef safe)
|
// Conditional base class — test environment lacks foundry globals.
|
||||||
|
// At module load time in tests, foundry is undefined → fallback class is used.
|
||||||
|
|
||||||
|
/** @private */
|
||||||
const _AppBase =
|
const _AppBase =
|
||||||
typeof Application !== 'undefined'
|
typeof foundry !== 'undefined' &&
|
||||||
? Application
|
foundry.applications?.api?.HandlebarsApplicationMixin &&
|
||||||
: class _FallbackApplication {
|
foundry.applications?.api?.ApplicationV2
|
||||||
static get defaultOptions() {
|
? foundry.applications.api.HandlebarsApplicationMixin(
|
||||||
return {};
|
foundry.applications.api.ApplicationV2
|
||||||
}
|
)
|
||||||
render() {}
|
: class _FallbackApp {
|
||||||
close() {}
|
static DEFAULT_OPTIONS = {};
|
||||||
get rendered() {
|
static PARTS = {};
|
||||||
return false;
|
constructor(options = {}) {
|
||||||
|
this.options = options;
|
||||||
|
this.position = { left: 0, top: 0 };
|
||||||
}
|
}
|
||||||
|
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() {}
|
||||||
|
setPosition() {}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GM-only floating control strip showing all connected participants.
|
* GM-only floating control strip showing all connected participants.
|
||||||
* Extends Foundry's Application base class.
|
* Extends ApplicationV2 via HandlebarsApplicationMixin.
|
||||||
* Uses Application (not ApplicationV2) for simplicity in FoundryVTT v14.
|
|
||||||
*/
|
*/
|
||||||
export class ScryingPoolStrip extends _AppBase {
|
export class ScryingPoolStrip extends _AppBase {
|
||||||
/** @inheritdoc */
|
static DEFAULT_OPTIONS = {
|
||||||
static get defaultOptions() {
|
id: 'scrying-pool-strip',
|
||||||
const base =
|
classes: ['scrying-pool-strip'],
|
||||||
typeof foundry !== 'undefined' && foundry.utils?.mergeObject
|
window: { title: 'Scrying Pool', resizable: false },
|
||||||
? foundry.utils.mergeObject(super.defaultOptions, {})
|
position: { width: 240 },
|
||||||
: super.defaultOptions ?? {};
|
};
|
||||||
return Object.assign({}, base, {
|
|
||||||
id: 'scrying-pool-strip',
|
static PARTS = {
|
||||||
|
strip: {
|
||||||
template: 'modules/scrying-pool/templates/roster-strip.hbs',
|
template: 'modules/scrying-pool/templates/roster-strip.hbs',
|
||||||
popOut: true,
|
},
|
||||||
resizable: false,
|
};
|
||||||
title: 'Scrying Pool',
|
|
||||||
classes: ['scrying-pool-strip'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {object} stateStore
|
* @param {object} stateStore
|
||||||
@@ -112,16 +131,32 @@ export class ScryingPoolStrip extends _AppBase {
|
|||||||
* @param {object} [options]
|
* @param {object} [options]
|
||||||
*/
|
*/
|
||||||
constructor(stateStore, controller, avTileAdapter, adapter, options = {}) {
|
constructor(stateStore, controller, avTileAdapter, adapter, options = {}) {
|
||||||
super(options);
|
const { portraitFallbackHandler = null, ...appOptions } = options;
|
||||||
|
super(appOptions);
|
||||||
this._stateStore = stateStore;
|
this._stateStore = stateStore;
|
||||||
this._controller = controller;
|
this._controller = controller;
|
||||||
this._avTileAdapter = avTileAdapter;
|
this._avTileAdapter = avTileAdapter;
|
||||||
this._adapter = adapter;
|
this._adapter = adapter;
|
||||||
this._isExpanded = true;
|
this._portraitFallbackHandler = portraitFallbackHandler;
|
||||||
/** @type {ActionPopover|null} */
|
/** @type {ActionPopover|null} */
|
||||||
this._activePopover = null;
|
this._activePopover = null;
|
||||||
/** @type {StripOverlayLayer|null} */
|
/** @type {StripOverlayLayer|null} */
|
||||||
this._stripOverlayLayer = null;
|
this._stripOverlayLayer = null;
|
||||||
|
|
||||||
|
// Load saved position from user flags
|
||||||
|
this._loadPosition();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loads saved window position from GM user flag. */
|
||||||
|
_loadPosition() {
|
||||||
|
try {
|
||||||
|
const saved = game.user?.getFlag?.('scrying-pool', 'stripState');
|
||||||
|
if (saved?.left != null && saved?.top != null) {
|
||||||
|
if (this.options?.position) {
|
||||||
|
Object.assign(this.options.position, { left: saved.left, top: saved.top });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_e) { /* no-op in test environment */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -137,15 +172,7 @@ export class ScryingPoolStrip extends _AppBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @inheritdoc */
|
/** @inheritdoc */
|
||||||
getData() {
|
async _prepareContext(options) {
|
||||||
const savedState =
|
|
||||||
typeof game !== 'undefined'
|
|
||||||
? game.user?.getFlag?.('scrying-pool', 'stripState')
|
|
||||||
: null;
|
|
||||||
if (savedState?.expanded !== undefined) {
|
|
||||||
this._isExpanded = savedState.expanded;
|
|
||||||
}
|
|
||||||
|
|
||||||
const showFirstOpenTip =
|
const showFirstOpenTip =
|
||||||
typeof game !== 'undefined' &&
|
typeof game !== 'undefined' &&
|
||||||
!game.user?.getFlag?.('scrying-pool', 'firstStripOpen');
|
!game.user?.getFlag?.('scrying-pool', 'firstStripOpen');
|
||||||
@@ -169,22 +196,43 @@ export class ScryingPoolStrip extends _AppBase {
|
|||||||
this._stateStore,
|
this._stateStore,
|
||||||
this._controller,
|
this._controller,
|
||||||
this._adapter,
|
this._adapter,
|
||||||
hasStreamAccess
|
hasStreamAccess,
|
||||||
|
this._portraitFallbackHandler
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Remove hidden participants from the strip — they are managed via the Directors Board.
|
||||||
|
const visibleParticipants = participants.filter(p => p.state !== 'hidden');
|
||||||
|
|
||||||
|
// Dock layout: world setting gives direction+canonical size; client override only applies if user toggled
|
||||||
|
const rawLayout = this._adapter.settings?.get?.('dockLayout');
|
||||||
|
const baseLayout = typeof rawLayout === 'string' ? rawLayout : 'vertical-sm';
|
||||||
|
const sizeOverride = this._adapter.settings?.get?.('dockLayoutExpanded'); // '' | 'sm' | 'md'
|
||||||
|
const parts = baseLayout.split('-');
|
||||||
|
const dir = parts.slice(0, -1).join('-');
|
||||||
|
const canonicalSize = parts[parts.length - 1]; // size from world setting (GM's choice)
|
||||||
|
const effectiveSize = (sizeOverride === 'sm' || sizeOverride === 'md') ? sizeOverride : canonicalSize;
|
||||||
|
const dockLayout = `${dir}-${effectiveSize}`;
|
||||||
|
const isExpanded = dockLayout === 'vertical-md';
|
||||||
|
const showName = dockLayout.endsWith('-md');
|
||||||
|
|
||||||
|
const isGM = this._adapter.users.isGM?.() ?? false;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
participants,
|
participants: visibleParticipants,
|
||||||
isExpanded: this._isExpanded,
|
isExpanded,
|
||||||
isEmpty: participants.length === 0,
|
showName,
|
||||||
showFirstOpenTip,
|
dockLayout,
|
||||||
|
isEmpty: visibleParticipants.length === 0,
|
||||||
|
showFirstOpenTip: showFirstOpenTip && isGM,
|
||||||
hasStreamAccess,
|
hasStreamAccess,
|
||||||
|
isGM,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @inheritdoc */
|
/** @inheritdoc */
|
||||||
activateListeners(html) {
|
_onRender(context, options) {
|
||||||
super.activateListeners(html);
|
super._onRender?.(context, options);
|
||||||
const el = html instanceof HTMLElement ? html : html?.[0];
|
const el = this.element;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
el.querySelectorAll('[data-action="open-popover"]').forEach(btn => {
|
el.querySelectorAll('[data-action="open-popover"]').forEach(btn => {
|
||||||
@@ -259,11 +307,70 @@ export class ScryingPoolStrip extends _AppBase {
|
|||||||
this._attachVideoStreams(el);
|
this._attachVideoStreams(el);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync the outer Application window width with the expanded/collapsed state.
|
// Sync the outer Application window width with the selected dock layout.
|
||||||
// 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') {
|
if (typeof this.setPosition === 'function') {
|
||||||
this.setPosition({ width: this._isExpanded ? 240 : 44, height: 'auto' });
|
const layout = context?.dockLayout ?? 'vertical-sm';
|
||||||
|
const n = context?.participants?.length ?? 0;
|
||||||
|
const width = this._computeStripWidth(layout, n);
|
||||||
|
const height = this._computeStripHeight(layout, n);
|
||||||
|
this.setPosition(height === 'auto' ? { width, height: 'auto' } : { width, height });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the strip window width for a given dock layout and participant count.
|
||||||
|
* @param {string} layout - The dock layout key.
|
||||||
|
* @param {number} participantCount - Number of visible participants.
|
||||||
|
* @returns {number} Width in pixels.
|
||||||
|
*/
|
||||||
|
_computeStripWidth(layout, participantCount) {
|
||||||
|
const n = Math.max(1, participantCount);
|
||||||
|
const GAP = 4, PAD = 8, BORDER_W = 3; // 2px real border + 1px safety
|
||||||
|
const maxCols = 4;
|
||||||
|
const rowWidth = (tileSize, cols) =>
|
||||||
|
cols * tileSize + Math.max(0, cols - 1) * GAP + PAD + BORDER_W;
|
||||||
|
switch (layout) {
|
||||||
|
case 'vertical-sm': return 85; // 83px tile + 2px border
|
||||||
|
case 'vertical-md': return 242; // 240px strip + 2px border
|
||||||
|
case 'horizontal-sm': return rowWidth(83, Math.min(maxCols, n));
|
||||||
|
case 'horizontal-md': return rowWidth(150, Math.min(maxCols, n));
|
||||||
|
case 'mosaic-sm': return rowWidth(83, Math.min(maxCols, Math.ceil(Math.sqrt(n))));
|
||||||
|
case 'mosaic-md': return rowWidth(150, Math.min(maxCols, Math.ceil(Math.sqrt(n))));
|
||||||
|
default: return 85;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the strip window height for horizontal and mosaic layouts so the
|
||||||
|
* window snaps exactly to its content (grip + toolbar + tile grid).
|
||||||
|
* Returns 'auto' for vertical layouts where CSS handles the height naturally.
|
||||||
|
*
|
||||||
|
* Measurements from CSS:
|
||||||
|
* .sp-strip__grip → 16px
|
||||||
|
* .sp-strip__toolbar → 28px
|
||||||
|
* .sp-strip__participants padding: 4px each side → 8px
|
||||||
|
* tile gap: 4px
|
||||||
|
*
|
||||||
|
* @param {string} layout - The dock layout key.
|
||||||
|
* @param {number} participantCount - Number of visible participants.
|
||||||
|
* @returns {number|'auto'} Height in pixels, or 'auto' for vertical layouts.
|
||||||
|
*/
|
||||||
|
_computeStripHeight(layout, participantCount) {
|
||||||
|
const n = Math.max(1, participantCount);
|
||||||
|
const CHROME = 16 + 29; // grip 16px + toolbar 28px content + 1px border-bottom
|
||||||
|
const BORDER_H = 2; // 1px border top + 1px border bottom on app element
|
||||||
|
const GAP = 4, TILE_PAD = 8; // 4px padding each side in .sp-strip__participants
|
||||||
|
const maxCols = 4;
|
||||||
|
const gridHeight = (tileSize, cols) => {
|
||||||
|
const rows = Math.ceil(n / cols);
|
||||||
|
return rows * tileSize + Math.max(0, rows - 1) * GAP + TILE_PAD;
|
||||||
|
};
|
||||||
|
switch (layout) {
|
||||||
|
case 'horizontal-sm': return CHROME + gridHeight(83, Math.min(maxCols, n)) + BORDER_H;
|
||||||
|
case 'horizontal-md': return CHROME + gridHeight(150, Math.min(maxCols, n)) + BORDER_H;
|
||||||
|
case 'mosaic-sm': return CHROME + gridHeight(83, Math.min(maxCols, Math.ceil(Math.sqrt(n)))) + BORDER_H;
|
||||||
|
case 'mosaic-md': return CHROME + gridHeight(150, Math.min(maxCols, Math.ceil(Math.sqrt(n)))) + BORDER_H;
|
||||||
|
default: return 'auto';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,7 +389,6 @@ export class ScryingPoolStrip extends _AppBase {
|
|||||||
left: this.position?.left,
|
left: this.position?.left,
|
||||||
top: this.position?.top,
|
top: this.position?.top,
|
||||||
open: false,
|
open: false,
|
||||||
expanded: this._isExpanded,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return super.close(options);
|
return super.close(options);
|
||||||
@@ -307,19 +413,21 @@ export class ScryingPoolStrip extends _AppBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggles the expanded/collapsed state of the strip.
|
* Toggles between small and large size for the current dock layout direction.
|
||||||
|
* Writes to the client-scoped dockLayoutExpanded setting ('' | 'sm' | 'md') so
|
||||||
|
* players can toggle without needing world-setting write permission.
|
||||||
|
* '' means no personal override — follow the GM's world setting.
|
||||||
*/
|
*/
|
||||||
_toggleExpanded() {
|
_toggleExpanded() {
|
||||||
this._isExpanded = !this._isExpanded;
|
const raw = this._adapter.settings?.get?.('dockLayout') ?? 'vertical-sm';
|
||||||
if (typeof game !== 'undefined') {
|
const canonicalSize = typeof raw === 'string' ? raw.split('-').pop() : 'sm';
|
||||||
game.user?.setFlag?.('scrying-pool', 'stripState', {
|
const sizeOverride = this._adapter.settings?.get?.('dockLayoutExpanded'); // '' | 'sm' | 'md'
|
||||||
left: this.position?.left,
|
const currentSize = (sizeOverride === 'sm' || sizeOverride === 'md') ? sizeOverride : canonicalSize;
|
||||||
top: this.position?.top,
|
const newSize = currentSize === 'md' ? 'sm' : 'md';
|
||||||
open: true,
|
this._adapter.settings?.set?.('dockLayoutExpanded', newSize).catch(err => {
|
||||||
expanded: this._isExpanded,
|
console.error('[ScryingPool] Failed to toggle dock layout:', err);
|
||||||
});
|
});
|
||||||
}
|
// Strip re-renders via the onChange callback registered in module.js
|
||||||
this.render(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -509,7 +617,7 @@ export class ScryingPoolStrip extends _AppBase {
|
|||||||
|
|
||||||
// Re-render to ensure DOM is up to date
|
// Re-render to ensure DOM is up to date
|
||||||
if (typeof this.render === 'function') {
|
if (typeof this.render === 'function') {
|
||||||
this.render(false);
|
this.render({ force: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,23 +68,16 @@ export class ScryingPoolCameraViews extends _getCameraViewsBase() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Inject Scrying Pool visibility state into each user's camera tile context.
|
* 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
|
* Marks hidden users with `spHidden: true` rather than returning null, so
|
||||||
* this user hidden, allowing the dock to visually reflect module state.
|
* Foundry's _configureRenderParts does not crash on a null part context.
|
||||||
* @override
|
* @override
|
||||||
* @param {string} id - User ID
|
* @param {string} id - User ID
|
||||||
* @returns {object|undefined}
|
* @returns {object|undefined}
|
||||||
*/
|
*/
|
||||||
_prepareUserContext(id) {
|
_prepareUserContext(id) {
|
||||||
const ctx = super._prepareUserContext(id);
|
const ctx = super._prepareUserContext(id);
|
||||||
if (!ctx) return ctx;
|
|
||||||
|
|
||||||
const spState = _stateStore?.getState?.(id) ?? 'active';
|
const spState = _stateStore?.getState?.(id) ?? 'active';
|
||||||
const spHidden = spState === 'hidden';
|
if (ctx && spState === 'hidden') ctx.spHidden = true;
|
||||||
|
|
||||||
if (spHidden) {
|
|
||||||
ctx.css = [ctx.css, 'sp-cam-hidden'].filter(Boolean).join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -263,4 +263,78 @@
|
|||||||
&:hover { background: rgba(50, 55, 70, 0.9); }
|
&:hover { background: rgba(50, 55, 70, 0.9); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Dock layout selector bar ───────────────────────────────────────────────
|
||||||
|
.directors-board__dock-layout-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.directors-board__dock-layout-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--sp-text-muted, hsl(0, 0%, 60%));
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directors-board__dock-layout-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directors-board__dock-layout-sep {
|
||||||
|
width: 1px;
|
||||||
|
height: 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
margin: 0 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directors-board__dock-layout-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--sp-text-muted, hsl(0, 0%, 65%));
|
||||||
|
padding: 0;
|
||||||
|
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||||
|
|
||||||
|
i { font-size: 10px; pointer-events: none; }
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: var(--sp-text-primary, #dde2e8);
|
||||||
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
background: hsl(200, 50%, 25%);
|
||||||
|
border-color: hsl(200, 55%, 45%);
|
||||||
|
color: hsl(200, 80%, 75%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.directors-board__dock-layout-size {
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,13 +43,92 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
max-width: 44px;
|
max-width: 83px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: max-width 200ms ease-in-out;
|
transition: max-width 200ms ease-in-out;
|
||||||
|
|
||||||
&.is-expanded {
|
&.is-expanded {
|
||||||
max-width: 240px;
|
max-width: 240px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Horizontal and mosaic layouts: width is controlled by JS setPosition
|
||||||
|
&.sp-layout-horizontal-sm,
|
||||||
|
&.sp-layout-horizontal-md,
|
||||||
|
&.sp-layout-mosaic-sm,
|
||||||
|
&.sp-layout-mosaic-md {
|
||||||
|
max-width: none;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Horizontal layout ────────────────────────────────────────────────────────
|
||||||
|
.scrying-pool.scrying-pool-strip.sp-layout-horizontal-sm,
|
||||||
|
.scrying-pool.scrying-pool-strip.sp-layout-horizontal-md {
|
||||||
|
.sp-strip__participants {
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 4px;
|
||||||
|
gap: 4px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mosaic layout ────────────────────────────────────────────────────────────
|
||||||
|
.scrying-pool.scrying-pool-strip.sp-layout-mosaic-sm,
|
||||||
|
.scrying-pool.scrying-pool-strip.sp-layout-mosaic-md {
|
||||||
|
.sp-strip__participants {
|
||||||
|
display: grid;
|
||||||
|
padding: 4px;
|
||||||
|
gap: 4px;
|
||||||
|
// Keep width: 100% (from base) so auto-fill has a definite inline size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrying-pool.scrying-pool-strip.sp-layout-mosaic-sm .sp-strip__participants {
|
||||||
|
grid-template-columns: repeat(auto-fill, 83px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrying-pool.scrying-pool-strip.sp-layout-mosaic-md .sp-strip__participants {
|
||||||
|
grid-template-columns: repeat(auto-fill, 150px);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Medium tile size for horizontal / mosaic ─────────────────────────────────
|
||||||
|
.scrying-pool.scrying-pool-strip.sp-layout-horizontal-md,
|
||||||
|
.scrying-pool.scrying-pool-strip.sp-layout-mosaic-md {
|
||||||
|
.sp-participant-avatar {
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 4px 4px;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
.sp-avatar__img {
|
||||||
|
width: 91px;
|
||||||
|
height: 91px;
|
||||||
|
border-radius: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-avatar__name {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-avatar__corner-badge {
|
||||||
|
bottom: 4px;
|
||||||
|
right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the is-expanded overlay styles that interfere with tile layout
|
||||||
|
&::after { display: none; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Drag grip (top bar, replaces window header drag affordance) ────────────────
|
// ── Drag grip (top bar, replaces window header drag affordance) ────────────────
|
||||||
@@ -109,8 +188,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sp-strip__toggle {
|
.sp-strip__toggle {
|
||||||
width: 44px;
|
width: 83px;
|
||||||
min-width: 44px;
|
min-width: 83px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -164,6 +243,10 @@
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sp-strip__participant-item {
|
||||||
|
margin: 0; // override browser/Foundry default <li> margins
|
||||||
|
}
|
||||||
|
|
||||||
.sp-strip__first-tip {
|
.sp-strip__first-tip {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--sp-text-muted, hsl(0, 0%, 60%));
|
color: var(--sp-text-muted, hsl(0, 0%, 60%));
|
||||||
@@ -176,8 +259,8 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
.sp-participant-avatar {
|
.sp-participant-avatar {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 44px;
|
width: 83px;
|
||||||
height: 44px;
|
height: 83px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
@@ -223,6 +306,7 @@
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
|
border-radius: 6px; // card view — rectangular
|
||||||
}
|
}
|
||||||
|
|
||||||
.sp-avatar__name {
|
.sp-avatar__name {
|
||||||
@@ -266,9 +350,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sp-avatar__img {
|
.sp-avatar__img {
|
||||||
width: 32px;
|
width: 60px;
|
||||||
height: 32px;
|
height: 60px;
|
||||||
border-radius: 50%;
|
border-radius: 6px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -278,7 +362,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: 50%;
|
border-radius: 6px;
|
||||||
background: hsl(220, 15%, 18%);
|
background: hsl(220, 15%, 18%);
|
||||||
|
|
||||||
.is-expanded & {
|
.is-expanded & {
|
||||||
|
|||||||
+143
-9
@@ -494,13 +494,76 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
max-width: 44px;
|
max-width: 83px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: max-width 200ms ease-in-out;
|
transition: max-width 200ms ease-in-out;
|
||||||
}
|
}
|
||||||
.scrying-pool.scrying-pool-strip.is-expanded {
|
.scrying-pool.scrying-pool-strip.is-expanded {
|
||||||
max-width: 240px;
|
max-width: 240px;
|
||||||
}
|
}
|
||||||
|
.scrying-pool.scrying-pool-strip.sp-layout-horizontal-sm,
|
||||||
|
.scrying-pool.scrying-pool-strip.sp-layout-horizontal-md,
|
||||||
|
.scrying-pool.scrying-pool-strip.sp-layout-mosaic-sm,
|
||||||
|
.scrying-pool.scrying-pool-strip.sp-layout-mosaic-md {
|
||||||
|
max-width: none;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.scrying-pool.scrying-pool-strip.sp-layout-horizontal-sm .sp-strip__participants,
|
||||||
|
.scrying-pool.scrying-pool-strip.sp-layout-horizontal-md .sp-strip__participants {
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 4px;
|
||||||
|
gap: 4px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
.scrying-pool.scrying-pool-strip.sp-layout-mosaic-sm .sp-strip__participants,
|
||||||
|
.scrying-pool.scrying-pool-strip.sp-layout-mosaic-md .sp-strip__participants {
|
||||||
|
display: grid;
|
||||||
|
padding: 4px;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.scrying-pool.scrying-pool-strip.sp-layout-mosaic-sm .sp-strip__participants {
|
||||||
|
grid-template-columns: repeat(auto-fill, 83px);
|
||||||
|
}
|
||||||
|
.scrying-pool.scrying-pool-strip.sp-layout-mosaic-md .sp-strip__participants {
|
||||||
|
grid-template-columns: repeat(auto-fill, 150px);
|
||||||
|
}
|
||||||
|
.scrying-pool.scrying-pool-strip.sp-layout-horizontal-md .sp-participant-avatar,
|
||||||
|
.scrying-pool.scrying-pool-strip.sp-layout-mosaic-md .sp-participant-avatar {
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 4px 4px;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.scrying-pool.scrying-pool-strip.sp-layout-horizontal-md .sp-participant-avatar .sp-avatar__img,
|
||||||
|
.scrying-pool.scrying-pool-strip.sp-layout-mosaic-md .sp-participant-avatar .sp-avatar__img {
|
||||||
|
width: 91px;
|
||||||
|
height: 91px;
|
||||||
|
border-radius: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.scrying-pool.scrying-pool-strip.sp-layout-horizontal-md .sp-participant-avatar .sp-avatar__name,
|
||||||
|
.scrying-pool.scrying-pool-strip.sp-layout-mosaic-md .sp-participant-avatar .sp-avatar__name {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.scrying-pool.scrying-pool-strip.sp-layout-horizontal-md .sp-participant-avatar .sp-avatar__corner-badge,
|
||||||
|
.scrying-pool.scrying-pool-strip.sp-layout-mosaic-md .sp-participant-avatar .sp-avatar__corner-badge {
|
||||||
|
bottom: 4px;
|
||||||
|
right: 4px;
|
||||||
|
}
|
||||||
|
.scrying-pool.scrying-pool-strip.sp-layout-horizontal-md .sp-participant-avatar::after,
|
||||||
|
.scrying-pool.scrying-pool-strip.sp-layout-mosaic-md .sp-participant-avatar::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.sp-strip__grip {
|
.sp-strip__grip {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
@@ -557,8 +620,8 @@
|
|||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
}
|
}
|
||||||
.sp-strip__toggle {
|
.sp-strip__toggle {
|
||||||
width: 44px;
|
width: 83px;
|
||||||
min-width: 44px;
|
min-width: 83px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -614,6 +677,9 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
.sp-strip__participant-item {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
.sp-strip__first-tip {
|
.sp-strip__first-tip {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--sp-text-muted, hsl(0, 0%, 60%));
|
color: var(--sp-text-muted, hsl(0, 0%, 60%));
|
||||||
@@ -622,8 +688,8 @@
|
|||||||
}
|
}
|
||||||
.sp-participant-avatar {
|
.sp-participant-avatar {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 44px;
|
width: 83px;
|
||||||
height: 44px;
|
height: 83px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
@@ -666,6 +732,7 @@
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
.is-expanded .sp-participant-avatar .sp-avatar__name {
|
.is-expanded .sp-participant-avatar .sp-avatar__name {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -701,9 +768,9 @@
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
.sp-avatar__img {
|
.sp-avatar__img {
|
||||||
width: 32px;
|
width: 60px;
|
||||||
height: 32px;
|
height: 60px;
|
||||||
border-radius: 50%;
|
border-radius: 6px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -711,7 +778,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: 50%;
|
border-radius: 6px;
|
||||||
background: hsl(220, 15%, 18%);
|
background: hsl(220, 15%, 18%);
|
||||||
}
|
}
|
||||||
.is-expanded .sp-participant-video__element {
|
.is-expanded .sp-participant-video__element {
|
||||||
@@ -1171,6 +1238,73 @@
|
|||||||
.scrying-pool.directors-board .directors-board__footer-btn--av-config:hover {
|
.scrying-pool.directors-board .directors-board__footer-btn--av-config:hover {
|
||||||
background: rgba(50, 55, 70, 0.9);
|
background: rgba(50, 55, 70, 0.9);
|
||||||
}
|
}
|
||||||
|
.scrying-pool.directors-board .directors-board__dock-layout-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
.scrying-pool.directors-board .directors-board__dock-layout-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--sp-text-muted, hsl(0, 0%, 60%));
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
.scrying-pool.directors-board .directors-board__dock-layout-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.scrying-pool.directors-board .directors-board__dock-layout-sep {
|
||||||
|
width: 1px;
|
||||||
|
height: 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
margin: 0 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.scrying-pool.directors-board .directors-board__dock-layout-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--sp-text-muted, hsl(0, 0%, 65%));
|
||||||
|
padding: 0;
|
||||||
|
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.scrying-pool.directors-board .directors-board__dock-layout-btn i {
|
||||||
|
font-size: 10px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.scrying-pool.directors-board .directors-board__dock-layout-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: var(--sp-text-primary, #dde2e8);
|
||||||
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
.scrying-pool.directors-board .directors-board__dock-layout-btn.is-active {
|
||||||
|
background: hsl(200, 50%, 25%);
|
||||||
|
border-color: hsl(200, 55%, 45%);
|
||||||
|
color: hsl(200, 80%, 75%);
|
||||||
|
}
|
||||||
|
.scrying-pool.directors-board .directors-board__dock-layout-size {
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
.directors-board__preset-panel {
|
.directors-board__preset-panel {
|
||||||
background: linear-gradient(160deg, hsl(215, 28%, 13%) 0%, hsl(215, 25%, 10%) 100%);
|
background: linear-gradient(160deg, hsl(215, 28%, 13%) 0%, hsl(215, 25%, 10%) 100%);
|
||||||
border: 1px solid var(--sp-border);
|
border: 1px solid var(--sp-border);
|
||||||
|
|||||||
@@ -58,6 +58,26 @@
|
|||||||
data-tooltip="{{localize "scrying-pool.directorsBoard.shortcuts.openPanel"}}">?</button>
|
data-tooltip="{{localize "scrying-pool.directorsBoard.shortcuts.openPanel"}}">?</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{!-- Dock layout selector --}}
|
||||||
|
<div class="directors-board__dock-layout-bar" role="toolbar" aria-label="{{localize "scrying-pool.directorsBoard.dockLayout.label"}}">
|
||||||
|
<span class="directors-board__dock-layout-label">{{localize "scrying-pool.directorsBoard.dockLayout.label"}}</span>
|
||||||
|
<div class="directors-board__dock-layout-group">
|
||||||
|
{{#each dockLayouts}}
|
||||||
|
<button type="button"
|
||||||
|
class="directors-board__dock-layout-btn{{#if isActive}} is-active{{/if}}"
|
||||||
|
data-action="set-dock-layout"
|
||||||
|
data-layout="{{key}}"
|
||||||
|
data-tooltip="{{label}}">
|
||||||
|
<i class="fas {{icon}}" aria-hidden="true"></i>
|
||||||
|
<span class="directors-board__dock-layout-size">{{size}}</span>
|
||||||
|
</button>
|
||||||
|
{{#if sepAfter}}
|
||||||
|
<div class="directors-board__dock-layout-sep" role="separator" aria-hidden="true"></div>
|
||||||
|
{{/if}}
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<footer class="directors-board__footer">
|
<footer class="directors-board__footer">
|
||||||
<div class="directors-board__footer-group directors-board__footer-group--presets">
|
<div class="directors-board__footer-group directors-board__footer-group--presets">
|
||||||
<button type="button" class="directors-board__footer-btn" data-action="save-preset"
|
<button type="button" class="directors-board__footer-btn" data-action="save-preset"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{{!-- ScryingPoolStrip — floating GM control strip --}}
|
{{!-- ScryingPoolStrip — floating GM control strip --}}
|
||||||
<div class="scrying-pool scrying-pool-strip{{#if isExpanded}} is-expanded{{/if}}"
|
<div class="scrying-pool scrying-pool-strip sp-layout-{{dockLayout}}{{#if isExpanded}} is-expanded{{/if}}"
|
||||||
role="complementary"
|
role="complementary"
|
||||||
aria-label="Scrying Pool">
|
aria-label="Scrying Pool">
|
||||||
|
|
||||||
@@ -30,12 +30,14 @@
|
|||||||
<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>
|
||||||
|
|
||||||
<button class="sp-strip__directors-board-cta" data-action="open-directors-board"
|
{{#if isGM}}
|
||||||
aria-label="Open Director's Board"
|
<button class="sp-strip__directors-board-cta" data-action="open-directors-board"
|
||||||
data-tooltip="Director's Board">
|
aria-label="Open Director's Board"
|
||||||
<i class="fas fa-border-all" aria-hidden="true"></i>
|
data-tooltip="Director's Board">
|
||||||
<span>Director's Board</span>
|
<i class="fas fa-border-all" aria-hidden="true"></i>
|
||||||
</button>
|
<span>Director's Board</span>
|
||||||
|
</button>
|
||||||
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{!-- Participant list --}}
|
{{!-- Participant list --}}
|
||||||
@@ -69,10 +71,13 @@
|
|||||||
{{!-- Corner badge (12px bottom-right) --}}
|
{{!-- Corner badge (12px bottom-right) --}}
|
||||||
<span class="sp-avatar__corner-badge" aria-hidden="true"></span>
|
<span class="sp-avatar__corner-badge" aria-hidden="true"></span>
|
||||||
|
|
||||||
{{!-- Expanded view: name + state rows --}}
|
{{!-- Expanded view: name + state rows (vertical-md) --}}
|
||||||
{{#if ../isExpanded}}
|
{{#if ../isExpanded}}
|
||||||
<span class="sp-avatar__name">{{name}}</span>
|
<span class="sp-avatar__name">{{name}}</span>
|
||||||
<span class="sp-avatar__state-label">{{stateLabel}}</span>
|
<span class="sp-avatar__state-label">{{stateLabel}}</span>
|
||||||
|
{{else if ../showName}}
|
||||||
|
{{!-- Horizontal-md / mosaic-md: name below avatar --}}
|
||||||
|
<span class="sp-avatar__name">{{name}}</span>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
|
||||||
// Stub Application globally before importing ScryingPoolStrip
|
// Stub foundry globals before importing ScryingPoolStrip.
|
||||||
|
// Do NOT include foundry.applications.api.ApplicationV2 so the fallback class is used.
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.stubGlobal('Application', class {
|
|
||||||
static get defaultOptions() { return {}; }
|
|
||||||
constructor() { this.position = { left: 0, top: 0 }; this.rendered = false; }
|
|
||||||
render() {}
|
|
||||||
close() {}
|
|
||||||
activateListeners() {}
|
|
||||||
});
|
|
||||||
vi.stubGlobal('foundry', {
|
vi.stubGlobal('foundry', {
|
||||||
utils: {
|
utils: {
|
||||||
mergeObject: (base, override) => Object.assign({}, base, override),
|
mergeObject: (base, override) => Object.assign({}, base, override),
|
||||||
@@ -182,89 +176,208 @@ describe('ScryingPoolStrip', () => {
|
|||||||
strip = new ScryingPoolStrip(stateStore, controller, avTileAdapter, adapter);
|
strip = new ScryingPoolStrip(stateStore, controller, avTileAdapter, adapter);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('defaultOptions', () => {
|
describe('DEFAULT_OPTIONS', () => {
|
||||||
it('has correct id', () => {
|
it('has correct id', () => {
|
||||||
expect(ScryingPoolStrip.defaultOptions.id).toBe('scrying-pool-strip');
|
expect(ScryingPoolStrip.DEFAULT_OPTIONS.id).toBe('scrying-pool-strip');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('has correct template path', () => {
|
it('has correct template path', () => {
|
||||||
expect(ScryingPoolStrip.defaultOptions.template).toContain('roster-strip.hbs');
|
expect(ScryingPoolStrip.PARTS.strip.template).toContain('roster-strip.hbs');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is not resizable', () => {
|
it('is not resizable', () => {
|
||||||
expect(ScryingPoolStrip.defaultOptions.resizable).toBe(false);
|
expect(ScryingPoolStrip.DEFAULT_OPTIONS.window?.resizable).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('popOut is true', () => {
|
it('has title set', () => {
|
||||||
expect(ScryingPoolStrip.defaultOptions.popOut).toBe(true);
|
expect(ScryingPoolStrip.DEFAULT_OPTIONS.window?.title).toBe('Scrying Pool');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getData()', () => {
|
describe('_prepareContext()', () => {
|
||||||
it('returns participants array', () => {
|
it('returns participants array', async () => {
|
||||||
const data = strip.getData();
|
const data = await strip._prepareContext({});
|
||||||
expect(Array.isArray(data.participants)).toBe(true);
|
expect(Array.isArray(data.participants)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns isExpanded property', () => {
|
it('returns isExpanded property', async () => {
|
||||||
const data = strip.getData();
|
const data = await strip._prepareContext({});
|
||||||
expect(typeof data.isExpanded).toBe('boolean');
|
expect(typeof data.isExpanded).toBe('boolean');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns isEmpty true when no participants', () => {
|
it('returns isEmpty true when no participants', async () => {
|
||||||
adapter.users.all.mockReturnValue([]);
|
adapter.users.all.mockReturnValue([]);
|
||||||
const data = strip.getData();
|
const data = await strip._prepareContext({});
|
||||||
expect(data.isEmpty).toBe(true);
|
expect(data.isEmpty).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns isEmpty false when participants exist', () => {
|
it('returns isEmpty false when participants exist', async () => {
|
||||||
const data = strip.getData();
|
const data = await strip._prepareContext({});
|
||||||
expect(data.isEmpty).toBe(false);
|
expect(data.isEmpty).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns hasStreamAccess true when webrtc.getMediaStreamForUser is available (Story 5.1)', () => {
|
it('returns hasStreamAccess true when webrtc.getMediaStreamForUser is available (Story 5.1)', async () => {
|
||||||
adapter.webrtc = {
|
adapter.webrtc = {
|
||||||
getMediaStreamForUser: vi.fn(),
|
getMediaStreamForUser: vi.fn(),
|
||||||
};
|
};
|
||||||
const data = strip.getData();
|
const data = await strip._prepareContext({});
|
||||||
expect(data.hasStreamAccess).toBe(true);
|
expect(data.hasStreamAccess).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns hasStreamAccess false when webrtc is null (Story 5.1)', () => {
|
it('returns hasStreamAccess false when webrtc is null (Story 5.1)', async () => {
|
||||||
adapter.webrtc = null;
|
adapter.webrtc = null;
|
||||||
const data = strip.getData();
|
const data = await strip._prepareContext({});
|
||||||
expect(data.hasStreamAccess).toBe(false);
|
expect(data.hasStreamAccess).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns hasStreamAccess false when webrtc has no getMediaStreamForUser (Story 5.1)', () => {
|
it('returns hasStreamAccess false when webrtc has no getMediaStreamForUser (Story 5.1)', async () => {
|
||||||
adapter.webrtc = {};
|
adapter.webrtc = {};
|
||||||
const data = strip.getData();
|
const data = await strip._prepareContext({});
|
||||||
expect(data.hasStreamAccess).toBe(false);
|
expect(data.hasStreamAccess).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('includes current user when showGMSelfFeed is true', () => {
|
it('includes current user when showGMSelfFeed is true', async () => {
|
||||||
adapter.settings = { get: vi.fn(() => true) };
|
adapter.settings = { get: vi.fn(() => true) };
|
||||||
adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }]);
|
adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }]);
|
||||||
const data = strip.getData();
|
const data = await strip._prepareContext({});
|
||||||
expect(data.participants.map(p => p.userId)).toContain('u1');
|
expect(data.participants.map(p => p.userId)).toContain('u1');
|
||||||
expect(data.participants.map(p => p.userId)).toContain('u2');
|
expect(data.participants.map(p => p.userId)).toContain('u2');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('excludes current user when showGMSelfFeed is false', () => {
|
it('excludes current user when showGMSelfFeed is false', async () => {
|
||||||
adapter.settings = { get: vi.fn(() => false) };
|
adapter.settings = { get: vi.fn(() => false) };
|
||||||
adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }]);
|
adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }]);
|
||||||
const data = strip.getData();
|
const data = await strip._prepareContext({});
|
||||||
// u1 is the current user (mocked in beforeEach), should be excluded
|
// 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)).not.toContain('u1');
|
||||||
expect(data.participants.map(p => p.userId)).toContain('u2');
|
expect(data.participants.map(p => p.userId)).toContain('u2');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('includes all users when settings is unavailable (defaults to true)', () => {
|
it('includes all users when settings is unavailable (defaults to true)', async () => {
|
||||||
// no adapter.settings — fallback to true
|
// no adapter.settings — fallback to true
|
||||||
adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }]);
|
adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }]);
|
||||||
const data = strip.getData();
|
const data = await strip._prepareContext({});
|
||||||
expect(data.participants.length).toBe(2);
|
expect(data.participants.length).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('excludes hidden participants from the strip to keep it compact', async () => {
|
||||||
|
stateStore.getState.mockImplementation(id => id === 'u1' ? 'hidden' : 'active');
|
||||||
|
adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }]);
|
||||||
|
const data = await strip._prepareContext({});
|
||||||
|
expect(data.participants.map(p => p.userId)).not.toContain('u1');
|
||||||
|
expect(data.participants.map(p => p.userId)).toContain('u2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns isEmpty true when all participants are hidden', async () => {
|
||||||
|
stateStore.getState.mockReturnValue('hidden');
|
||||||
|
adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }]);
|
||||||
|
const data = await strip._prepareContext({});
|
||||||
|
expect(data.isEmpty).toBe(true);
|
||||||
|
expect(data.participants.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns dockLayout from settings (defaults to vertical-sm when setting is not a string)', async () => {
|
||||||
|
// dockLayout non-string → fallback to vertical-sm; no sizeOverride → canonical size 'sm'
|
||||||
|
adapter.settings = { get: vi.fn(key => key === 'dockLayoutExpanded' ? '' : true) };
|
||||||
|
const data = await strip._prepareContext({});
|
||||||
|
expect(data.dockLayout).toBe('vertical-sm');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns dockLayout from settings when setting is a valid string', async () => {
|
||||||
|
// No size override → canonical size from world setting ('md') is used
|
||||||
|
adapter.settings = { get: vi.fn(key => key === 'dockLayout' ? 'horizontal-md' : '') };
|
||||||
|
const data = await strip._prepareContext({});
|
||||||
|
expect(data.dockLayout).toBe('horizontal-md');
|
||||||
|
expect(data.isExpanded).toBe(false);
|
||||||
|
expect(data.showName).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('client size override forces sm even when world layout is md', async () => {
|
||||||
|
adapter.settings = { get: vi.fn(key => key === 'dockLayout' ? 'horizontal-md' : key === 'dockLayoutExpanded' ? 'sm' : '') };
|
||||||
|
const data = await strip._prepareContext({});
|
||||||
|
expect(data.dockLayout).toBe('horizontal-sm');
|
||||||
|
expect(data.showName).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('client size override forces md even when world layout is sm', async () => {
|
||||||
|
adapter.settings = { get: vi.fn(key => key === 'dockLayout' ? 'vertical-sm' : key === 'dockLayoutExpanded' ? 'md' : '') };
|
||||||
|
const data = await strip._prepareContext({});
|
||||||
|
expect(data.dockLayout).toBe('vertical-md');
|
||||||
|
expect(data.isExpanded).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets isExpanded=true and showName=true only for vertical-md', async () => {
|
||||||
|
adapter.settings = { get: vi.fn(key => key === 'dockLayout' ? 'vertical-md' : '') };
|
||||||
|
const data = await strip._prepareContext({});
|
||||||
|
expect(data.isExpanded).toBe(true);
|
||||||
|
expect(data.showName).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_computeStripWidth()', () => {
|
||||||
|
it('returns 85 for vertical-sm', () => {
|
||||||
|
expect(strip._computeStripWidth('vertical-sm', 3)).toBe(85);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 242 for vertical-md', () => {
|
||||||
|
expect(strip._computeStripWidth('vertical-md', 3)).toBe(242);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 85 for unknown layout', () => {
|
||||||
|
expect(strip._computeStripWidth('unknown', 3)).toBe(85);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scales horizontal-sm width with participant count (max 4 cols)', () => {
|
||||||
|
const w2 = strip._computeStripWidth('horizontal-sm', 2);
|
||||||
|
const w4 = strip._computeStripWidth('horizontal-sm', 4);
|
||||||
|
const w6 = strip._computeStripWidth('horizontal-sm', 6);
|
||||||
|
expect(w4).toBeGreaterThan(w2);
|
||||||
|
expect(w6).toBe(w4); // capped at 4 cols
|
||||||
|
});
|
||||||
|
|
||||||
|
it('horizontal-md tiles are wider than horizontal-sm', () => {
|
||||||
|
const wSm = strip._computeStripWidth('horizontal-sm', 4);
|
||||||
|
const wMd = strip._computeStripWidth('horizontal-md', 4);
|
||||||
|
expect(wMd).toBeGreaterThan(wSm);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_computeStripHeight()', () => {
|
||||||
|
const CHROME = 16 + 29; // grip + toolbar (29 = 28px content + 1px border-bottom)
|
||||||
|
const BORDER_H = 2, GAP = 4, TILE_PAD = 8;
|
||||||
|
|
||||||
|
it('returns auto for vertical layouts', () => {
|
||||||
|
expect(strip._computeStripHeight('vertical-sm', 3)).toBe('auto');
|
||||||
|
expect(strip._computeStripHeight('vertical-md', 3)).toBe('auto');
|
||||||
|
expect(strip._computeStripHeight('unknown', 3)).toBe('auto');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('horizontal-sm: 1 row for ≤4 participants', () => {
|
||||||
|
// 4 tiles, 1 row: CHROME + 83 + 8pad + 2border = 138
|
||||||
|
expect(strip._computeStripHeight('horizontal-sm', 4)).toBe(CHROME + 83 + TILE_PAD + BORDER_H);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('horizontal-sm: 2 rows for 5+ participants', () => {
|
||||||
|
// 5 tiles → cols=4, rows=2: CHROME + 2*83 + 1*4 + 8 + 2 = 225
|
||||||
|
expect(strip._computeStripHeight('horizontal-sm', 5)).toBe(CHROME + 2 * 83 + GAP + TILE_PAD + BORDER_H);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('horizontal-md tiles produce taller rows than horizontal-sm', () => {
|
||||||
|
const hSm = strip._computeStripHeight('horizontal-sm', 4);
|
||||||
|
const hMd = strip._computeStripHeight('horizontal-md', 4);
|
||||||
|
expect(hMd).toBeGreaterThan(hSm);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mosaic-sm: NxN grid', () => {
|
||||||
|
// n=4 → cols=2, rows=2: CHROME + 2*83 + 1*4 + 8 + 2 = 225
|
||||||
|
expect(strip._computeStripHeight('mosaic-sm', 4)).toBe(CHROME + 2 * 83 + GAP + TILE_PAD + BORDER_H);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mosaic height grows with participant count', () => {
|
||||||
|
const h4 = strip._computeStripHeight('mosaic-sm', 4);
|
||||||
|
const h9 = strip._computeStripHeight('mosaic-sm', 9);
|
||||||
|
expect(h9).toBeGreaterThan(h4);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('_attachVideoStream() (Story 5.1)', () => {
|
describe('_attachVideoStream() (Story 5.1)', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user