diff --git a/lang/en.json b/lang/en.json index 41eb341..eac3156 100644 --- a/lang/en.json +++ b/lang/en.json @@ -49,6 +49,15 @@ "close": "Close", "empty": "No participants connected.", "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": { "savePreset": "Save Layout", "loadPreset": "Load Layout", diff --git a/module.js b/module.js index df082b2..843c840 100644 --- a/module.js +++ b/module.js @@ -94,6 +94,7 @@ Hooks.once("init", () => { config: false, type: Object, default: { _version: 1, matrix: {} }, + onChange: () => { stateStore?.init(); roleRenderer?.rerenderStrip(); }, }); 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", }); + // 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 // Note: ScenePresetManager is constructed in 'ready' hook after visibilityManager is available stateStore = new StateStore(adapter.settings); @@ -191,6 +211,15 @@ Hooks.once("init", () => { Hooks.once("ready", () => { 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) stateStore.init(); @@ -270,9 +299,9 @@ Hooks.once("ready", () => { // Story 4.2: Pass portraitFallbackHandler for custom portrait display roleRenderer = new RoleRenderer(stateStore, scryingPoolController, avTileAdapter, adapter, portraitFallbackHandler); 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) // Pass roleRenderer to access ScryingPoolStrip.stripOverlayLayer (created lazily) confirmationBar = new ConfirmationBar(adapter, visibilityManager, socketHandler, roleRenderer); diff --git a/src/ui/RoleRenderer.js b/src/ui/RoleRenderer.js index 08bc3d0..e33eace 100644 --- a/src/ui/RoleRenderer.js +++ b/src/ui/RoleRenderer.js @@ -115,7 +115,8 @@ export class RoleRenderer { this._stateStore, this._controller, this._avTileAdapter, - this._adapter + this._adapter, + { portraitFallbackHandler: this._portraitFallbackHandler } ); } this._strip.render(true); @@ -129,4 +130,14 @@ export class RoleRenderer { 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 }); + } + } } diff --git a/src/ui/gm/DirectorsBoard.js b/src/ui/gm/DirectorsBoard.js index c55bf1f..c0dea42 100644 --- a/src/ui/gm/DirectorsBoard.js +++ b/src/ui/gm/DirectorsBoard.js @@ -361,6 +361,22 @@ export class DirectorsBoard extends _AppBase { } catch (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 { ...base, @@ -376,6 +392,8 @@ export class DirectorsBoard extends _AppBase { presets: this._scenePresetManager?.list?.() ?? [], // A/V mode — reflects current world AV state (0 = disabled, 3 = audio+video) avModeEnabled: (game.webrtc?.settings?.world?.mode ?? 0) !== 0, + // Dock layout selector + dockLayouts, }; } @@ -420,6 +438,7 @@ export class DirectorsBoard extends _AppBase { case 'toggle-preset-panel': this._togglePresetPanel(); break; case 'toggle-av-mode': this._onToggleAVMode(); break; case 'open-av-config': this._onOpenAVConfig(); break; + case 'set-dock-layout': this._onSetDockLayout(btn.dataset.layout); break; case 'close': this.close(); break; } }; @@ -661,6 +680,21 @@ export class DirectorsBoard extends _AppBase { 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. */ diff --git a/src/ui/gm/ScryingPoolStrip.js b/src/ui/gm/ScryingPoolStrip.js index 39a5334..339b70b 100644 --- a/src/ui/gm/ScryingPoolStrip.js +++ b/src/ui/gm/ScryingPoolStrip.js @@ -1,5 +1,5 @@ // @ts-nocheck -/* global Application, directorsBoard */ +/* global directorsBoard */ import { generateOpId } from '../../utils/uuid.js'; import { StripOverlayLayer } from '../shared/StripOverlayLayer.js'; @@ -29,16 +29,24 @@ export function resolveTargetState(currentState) { * @param {object} controller * @param {object} adapter * @param {boolean} hasStreamAccess - Whether stream access is available for video replacement + * @param {object|null} [portraitFallbackHandler] - PortraitFallbackHandler for custom portrait lookup * @returns {Array} */ -export function buildParticipantList(userIds, stateStore, controller, adapter, hasStreamAccess = false) { +export function buildParticipantList(userIds, stateStore, controller, adapter, hasStreamAccess = false, portraitFallbackHandler = null) { return userIds.map(userId => { const user = adapter.users.get(userId) ?? { name: userId, avatar: null }; 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 { userId, name: user.name ?? userId, - avatarSrc: user.avatar ?? 'icons/svg/mystery-man.svg', + avatarSrc, state, stateLabel: _stateLabel(state), hasPendingOp: controller.hasPendingOp ? controller.hasPendingOp(userId) : false, @@ -67,42 +75,53 @@ function _stateLabel(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 = - typeof Application !== 'undefined' - ? Application - : class _FallbackApplication { - static get defaultOptions() { - return {}; - } - render() {} - close() {} - get rendered() { - return false; + typeof foundry !== 'undefined' && + foundry.applications?.api?.HandlebarsApplicationMixin && + foundry.applications?.api?.ApplicationV2 + ? foundry.applications.api.HandlebarsApplicationMixin( + foundry.applications.api.ApplicationV2 + ) + : class _FallbackApp { + static DEFAULT_OPTIONS = {}; + static PARTS = {}; + 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. - * Extends Foundry's Application base class. - * Uses Application (not ApplicationV2) for simplicity in FoundryVTT v14. + * Extends ApplicationV2 via HandlebarsApplicationMixin. */ export class ScryingPoolStrip extends _AppBase { - /** @inheritdoc */ - static get defaultOptions() { - const base = - typeof foundry !== 'undefined' && foundry.utils?.mergeObject - ? foundry.utils.mergeObject(super.defaultOptions, {}) - : super.defaultOptions ?? {}; - return Object.assign({}, base, { - id: 'scrying-pool-strip', + static DEFAULT_OPTIONS = { + id: 'scrying-pool-strip', + classes: ['scrying-pool-strip'], + window: { title: 'Scrying Pool', resizable: false }, + position: { width: 240 }, + }; + + static PARTS = { + strip: { template: 'modules/scrying-pool/templates/roster-strip.hbs', - popOut: true, - resizable: false, - title: 'Scrying Pool', - classes: ['scrying-pool-strip'], - }); - } + }, + }; /** * @param {object} stateStore @@ -112,16 +131,32 @@ export class ScryingPoolStrip extends _AppBase { * @param {object} [options] */ constructor(stateStore, controller, avTileAdapter, adapter, options = {}) { - super(options); + const { portraitFallbackHandler = null, ...appOptions } = options; + super(appOptions); this._stateStore = stateStore; this._controller = controller; this._avTileAdapter = avTileAdapter; this._adapter = adapter; - this._isExpanded = true; + this._portraitFallbackHandler = portraitFallbackHandler; /** @type {ActionPopover|null} */ this._activePopover = null; /** @type {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 */ - getData() { - const savedState = - typeof game !== 'undefined' - ? game.user?.getFlag?.('scrying-pool', 'stripState') - : null; - if (savedState?.expanded !== undefined) { - this._isExpanded = savedState.expanded; - } - + async _prepareContext(options) { const showFirstOpenTip = typeof game !== 'undefined' && !game.user?.getFlag?.('scrying-pool', 'firstStripOpen'); @@ -169,22 +196,43 @@ export class ScryingPoolStrip extends _AppBase { this._stateStore, this._controller, 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 { - participants, - isExpanded: this._isExpanded, - isEmpty: participants.length === 0, - showFirstOpenTip, + participants: visibleParticipants, + isExpanded, + showName, + dockLayout, + isEmpty: visibleParticipants.length === 0, + showFirstOpenTip: showFirstOpenTip && isGM, hasStreamAccess, + isGM, }; } /** @inheritdoc */ - activateListeners(html) { - super.activateListeners(html); - const el = html instanceof HTMLElement ? html : html?.[0]; + _onRender(context, options) { + super._onRender?.(context, options); + const el = this.element; if (!el) return; el.querySelectorAll('[data-action="open-popover"]').forEach(btn => { @@ -259,11 +307,70 @@ export class ScryingPoolStrip extends _AppBase { this._attachVideoStreams(el); } - // Sync the outer Application window width with the expanded/collapsed state. - // The LESS max-width only applies to the inner template div (.scrying-pool.scrying-pool-strip); - // the outer window must be explicitly resized so it doesn't clip the expanded content. + // Sync the outer Application window width with the selected dock layout. 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, top: this.position?.top, open: false, - expanded: this._isExpanded, }); } 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() { - this._isExpanded = !this._isExpanded; - if (typeof game !== 'undefined') { - game.user?.setFlag?.('scrying-pool', 'stripState', { - left: this.position?.left, - top: this.position?.top, - open: true, - expanded: this._isExpanded, - }); - } - this.render(true); + const raw = this._adapter.settings?.get?.('dockLayout') ?? 'vertical-sm'; + const canonicalSize = typeof raw === 'string' ? raw.split('-').pop() : 'sm'; + const sizeOverride = this._adapter.settings?.get?.('dockLayoutExpanded'); // '' | 'sm' | 'md' + const currentSize = (sizeOverride === 'sm' || sizeOverride === 'md') ? sizeOverride : canonicalSize; + const newSize = currentSize === 'md' ? 'sm' : 'md'; + this._adapter.settings?.set?.('dockLayoutExpanded', newSize).catch(err => { + console.error('[ScryingPool] Failed to toggle dock layout:', err); + }); + // Strip re-renders via the onChange callback registered in module.js } /** @@ -509,7 +617,7 @@ export class ScryingPoolStrip extends _AppBase { // Re-render to ensure DOM is up to date if (typeof this.render === 'function') { - this.render(false); + this.render({ force: false }); } } } diff --git a/src/ui/shared/ScryingPoolCameraViews.js b/src/ui/shared/ScryingPoolCameraViews.js index cf84aa3..b538e86 100644 --- a/src/ui/shared/ScryingPoolCameraViews.js +++ b/src/ui/shared/ScryingPoolCameraViews.js @@ -68,23 +68,16 @@ export class ScryingPoolCameraViews extends _getCameraViewsBase() { /** * Inject Scrying Pool visibility state into each user's camera tile context. - * Adds the 'sp-cam-hidden' CSS class when the SP state machine considers - * this user hidden, allowing the dock to visually reflect module state. + * Marks hidden users with `spHidden: true` rather than returning null, so + * Foundry's _configureRenderParts does not crash on a null part context. * @override * @param {string} id - User ID * @returns {object|undefined} */ _prepareUserContext(id) { const ctx = super._prepareUserContext(id); - if (!ctx) return ctx; - const spState = _stateStore?.getState?.(id) ?? 'active'; - const spHidden = spState === 'hidden'; - - if (spHidden) { - ctx.css = [ctx.css, 'sp-cam-hidden'].filter(Boolean).join(' '); - } - + if (ctx && spState === 'hidden') ctx.spHidden = true; return ctx; } } diff --git a/styles/components/_directors-board.less b/styles/components/_directors-board.less index 92614bc..6bbdf26 100644 --- a/styles/components/_directors-board.less +++ b/styles/components/_directors-board.less @@ -263,4 +263,78 @@ &: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; + } } diff --git a/styles/components/_roster-strip.less b/styles/components/_roster-strip.less index e7e2b54..e1d379d 100644 --- a/styles/components/_roster-strip.less +++ b/styles/components/_roster-strip.less @@ -43,13 +43,92 @@ display: flex; flex-direction: column; align-items: center; - max-width: 44px; + max-width: 83px; overflow: hidden; transition: max-width 200ms ease-in-out; &.is-expanded { 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) ──────────────── @@ -109,8 +188,8 @@ } .sp-strip__toggle { - width: 44px; - min-width: 44px; + width: 83px; + min-width: 83px; height: 28px; display: flex; align-items: center; @@ -164,6 +243,10 @@ gap: 4px; } +.sp-strip__participant-item { + margin: 0; // override browser/Foundry default
  • margins +} + .sp-strip__first-tip { font-size: 0.75rem; color: var(--sp-text-muted, hsl(0, 0%, 60%)); @@ -176,8 +259,8 @@ // ============================================================ .sp-participant-avatar { position: relative; - width: 44px; - height: 44px; + width: 83px; + height: 83px; display: flex; align-items: center; justify-content: flex-start; @@ -223,6 +306,7 @@ z-index: 1; width: 48px; height: 48px; + border-radius: 6px; // card view — rectangular } .sp-avatar__name { @@ -266,9 +350,9 @@ } .sp-avatar__img { - width: 32px; - height: 32px; - border-radius: 50%; + width: 60px; + height: 60px; + border-radius: 6px; object-fit: cover; flex-shrink: 0; } @@ -278,7 +362,7 @@ width: 100%; height: 100%; object-fit: cover; - border-radius: 50%; + border-radius: 6px; background: hsl(220, 15%, 18%); .is-expanded & { diff --git a/styles/scrying-pool.css b/styles/scrying-pool.css index 93fba19..495c218 100644 --- a/styles/scrying-pool.css +++ b/styles/scrying-pool.css @@ -494,13 +494,76 @@ display: flex; flex-direction: column; align-items: center; - max-width: 44px; + max-width: 83px; overflow: hidden; transition: max-width 200ms ease-in-out; } .scrying-pool.scrying-pool-strip.is-expanded { 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 { width: 100%; height: 16px; @@ -557,8 +620,8 @@ border-bottom: 1px solid rgba(255, 255, 255, 0.06); } .sp-strip__toggle { - width: 44px; - min-width: 44px; + width: 83px; + min-width: 83px; height: 28px; display: flex; align-items: center; @@ -614,6 +677,9 @@ flex-direction: column; gap: 4px; } +.sp-strip__participant-item { + margin: 0; +} .sp-strip__first-tip { font-size: 0.75rem; color: var(--sp-text-muted, hsl(0, 0%, 60%)); @@ -622,8 +688,8 @@ } .sp-participant-avatar { position: relative; - width: 44px; - height: 44px; + width: 83px; + height: 83px; display: flex; align-items: center; justify-content: flex-start; @@ -666,6 +732,7 @@ z-index: 1; width: 48px; height: 48px; + border-radius: 6px; } .is-expanded .sp-participant-avatar .sp-avatar__name { position: absolute; @@ -701,9 +768,9 @@ z-index: 1; } .sp-avatar__img { - width: 32px; - height: 32px; - border-radius: 50%; + width: 60px; + height: 60px; + border-radius: 6px; object-fit: cover; flex-shrink: 0; } @@ -711,7 +778,7 @@ width: 100%; height: 100%; object-fit: cover; - border-radius: 50%; + border-radius: 6px; background: hsl(220, 15%, 18%); } .is-expanded .sp-participant-video__element { @@ -1171,6 +1238,73 @@ .scrying-pool.directors-board .directors-board__footer-btn--av-config:hover { 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 { background: linear-gradient(160deg, hsl(215, 28%, 13%) 0%, hsl(215, 25%, 10%) 100%); border: 1px solid var(--sp-border); diff --git a/templates/directors-board.hbs b/templates/directors-board.hbs index e65f165..ca756a9 100644 --- a/templates/directors-board.hbs +++ b/templates/directors-board.hbs @@ -58,6 +58,26 @@ data-tooltip="{{localize "scrying-pool.directorsBoard.shortcuts.openPanel"}}">? + {{!-- Dock layout selector --}} + +
  • diff --git a/tests/unit/ui/gm/ScryingPoolStrip.test.js b/tests/unit/ui/gm/ScryingPoolStrip.test.js index 15c08e1..597e24e 100644 --- a/tests/unit/ui/gm/ScryingPoolStrip.test.js +++ b/tests/unit/ui/gm/ScryingPoolStrip.test.js @@ -1,15 +1,9 @@ // @ts-nocheck 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(() => { - vi.stubGlobal('Application', class { - static get defaultOptions() { return {}; } - constructor() { this.position = { left: 0, top: 0 }; this.rendered = false; } - render() {} - close() {} - activateListeners() {} - }); vi.stubGlobal('foundry', { utils: { mergeObject: (base, override) => Object.assign({}, base, override), @@ -182,89 +176,208 @@ describe('ScryingPoolStrip', () => { strip = new ScryingPoolStrip(stateStore, controller, avTileAdapter, adapter); }); - describe('defaultOptions', () => { + describe('DEFAULT_OPTIONS', () => { 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', () => { - expect(ScryingPoolStrip.defaultOptions.template).toContain('roster-strip.hbs'); + expect(ScryingPoolStrip.PARTS.strip.template).toContain('roster-strip.hbs'); }); it('is not resizable', () => { - expect(ScryingPoolStrip.defaultOptions.resizable).toBe(false); + expect(ScryingPoolStrip.DEFAULT_OPTIONS.window?.resizable).toBe(false); }); - it('popOut is true', () => { - expect(ScryingPoolStrip.defaultOptions.popOut).toBe(true); + it('has title set', () => { + expect(ScryingPoolStrip.DEFAULT_OPTIONS.window?.title).toBe('Scrying Pool'); }); }); - describe('getData()', () => { - it('returns participants array', () => { - const data = strip.getData(); + describe('_prepareContext()', () => { + it('returns participants array', async () => { + const data = await strip._prepareContext({}); expect(Array.isArray(data.participants)).toBe(true); }); - it('returns isExpanded property', () => { - const data = strip.getData(); + it('returns isExpanded property', async () => { + const data = await strip._prepareContext({}); 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([]); - const data = strip.getData(); + const data = await strip._prepareContext({}); expect(data.isEmpty).toBe(true); }); - it('returns isEmpty false when participants exist', () => { - const data = strip.getData(); + it('returns isEmpty false when participants exist', async () => { + const data = await strip._prepareContext({}); 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 = { getMediaStreamForUser: vi.fn(), }; - const data = strip.getData(); + const data = await strip._prepareContext({}); 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; - const data = strip.getData(); + const data = await strip._prepareContext({}); 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 = {}; - const data = strip.getData(); + const data = await strip._prepareContext({}); 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.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('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.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 expect(data.participants.map(p => p.userId)).not.toContain('u1'); expect(data.participants.map(p => p.userId)).toContain('u2'); }); - it('includes all users when settings is unavailable (defaults to true)', () => { + it('includes all users when settings is unavailable (defaults to true)', async () => { // no adapter.settings — fallback to true adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }]); - const data = strip.getData(); + const data = await strip._prepareContext({}); 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)', () => {