Fianlize deck strip and management
CI / ci (push) Failing after 6s

This commit is contained in:
2026-05-25 00:51:46 +02:00
parent 5dc9b3b8d4
commit 7b56d62563
12 changed files with 755 additions and 141 deletions
+9
View File
@@ -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",
+31 -2
View File
@@ -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
View File
@@ -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 });
}
}
} }
+34
View File
@@ -362,6 +362,22 @@ export class DirectorsBoard extends _AppBase {
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,
hasUndo: this._undoSnapshot !== null, hasUndo: this._undoSnapshot !== null,
@@ -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
View File
@@ -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 });
} }
} }
} }
+3 -10
View File
@@ -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;
} }
} }
+74
View File
@@ -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;
}
} }
+93 -9
View File
@@ -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
View File
@@ -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);
+20
View File
@@ -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"
+13 -8
View File
@@ -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>
+148 -35
View File
@@ -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)', () => {