Files
uberwald 9e80c2c028 Add re-order, spotlight/focus, and auto-position-snapshots features
- HTML5 drag-and-drop reordering of strip participants (per-GM flag)
- Shift+click toggles spotlight focus on a participant (gold ring indicator)
- Escape exits focus mode
- Auto-save strip position on drag end + every 30s with viewport validation
- Reset strip position button in Director's Board
- French locale strings for reset button
2026-05-27 11:44:24 +02:00

719 lines
19 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// All selectors MUST be scoped under .scrying-pool.
// Use --sp-* tokens only — no Foundry --color-* / --font-* / --border-* tokens allowed.
// Implemented in story 1.5.
// ============================================================
// CSS Custom Properties (State Tokens)
// ============================================================
:root {
--sp-state-active: hsl(140, 60%, 55%);
--sp-state-hidden: hsl(0, 0%, 50%);
--sp-state-self-muted: hsl(200, 60%, 55%);
--sp-state-cam-lost: hsl(30, 80%, 55%);
--sp-state-pending: hsl(50, 90%, 55%);
--sp-urgency-director: hsl(38, 90%, 55%);
--sp-state-color: hsl(140, 60%, 55%); // default, overridden per state
}
// ============================================================
// ScryingPoolStrip Layout
// ============================================================
// Outer Foundry Application window (has .scrying-pool-strip via defaultOptions.classes).
// Only visual appearance — sizing is controlled by JS setPosition().
// WARNING: do NOT add max-width or overflow here; the outer window also carries this class
// and would clip the expanded inner content.
.scrying-pool-strip {
background: rgba(0, 0, 0, 0.01);
border-radius: 8px;
transition: background 0.25s ease;
&:hover {
background: rgba(0, 0, 0, 0.3);
}
/* Override FoundryVTT v14's .application min-width: 200px — strip sizing is controlled by JS */
min-width: unset;
/* Ensure strip appears below Director's Board (z-index: 100) */
z-index: 50;
// Hide Foundry's default window header — replaced by a lightweight in-content button.
header.window-header { display: none; }
// Remove window-content padding so the strip fills the frame edge-to-edge.
.window-content {
padding: 0;
overflow: hidden;
}
}
// Inner template div (has BOTH .scrying-pool AND .scrying-pool-strip).
// Controls the expand/collapse behaviour; safe to use max-width + overflow here.
.scrying-pool.scrying-pool-strip {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
max-width: calc(var(--sp-widget-width, 83px) + 11px);
overflow: hidden;
transition: max-width 200ms ease-in-out;
// 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, var(--sp-widget-width, 83px));
}
.scrying-pool.scrying-pool-strip.sp-layout-mosaic-md .sp-strip__participants {
grid-template-columns: repeat(auto-fill, var(--sp-widget-width, 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: var(--sp-widget-width, 150px);
height: var(--sp-widget-width, 150px);
flex-direction: column;
align-items: center;
padding: 8px 4px 4px;
gap: 4px;
.sp-avatar__shell {
width: calc(var(--sp-widget-width, 150px) - 59px);
height: calc(var(--sp-widget-width, 150px) - 59px);
}
.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; }
}
}
// ── Small tile shell sizing (all S layouts) ─────────────────────────────────
// Base .sp-avatar__shell is 60×60 fixed — override to follow --sp-widget-width
.scrying-pool.scrying-pool-strip.sp-layout-vertical-sm,
.scrying-pool.scrying-pool-strip.sp-layout-horizontal-sm,
.scrying-pool.scrying-pool-strip.sp-layout-mosaic-sm {
.sp-participant-avatar .sp-avatar__shell {
width: calc(var(--sp-widget-width) - 12px);
height: calc(var(--sp-widget-width) - 12px);
}
}
// ── Toolbar: grip + toggle + DB + close on one line ─────────────────────────
// All chrome lives here; hidden at rest, revealed on strip hover.
.sp-strip__toolbar {
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
height: 24px;
flex-shrink: 0;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
.scrying-pool-strip:hover & {
opacity: 1;
pointer-events: auto;
}
}
// Drag grip — compact icon handle
.sp-strip__grip {
width: 20px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: grab;
color: var(--sp-text, hsl(0, 0%, 80%));
font-size: 10px;
flex-shrink: 0;
user-select: none;
&:active { cursor: grabbing; }
.scrying-pool-strip:hover & {
opacity: 0.45;
&:hover { opacity: 0.75; }
&:active { opacity: 1; }
}
}
// Toggle (expand/collapse)
.sp-strip__toggle {
height: 24px;
min-width: 22px;
width: 22px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
cursor: pointer;
color: var(--sp-text, hsl(0, 0%, 80%));
font-size: 10px;
flex-shrink: 0;
opacity: 0.6;
&:hover { opacity: 1; }
}
// Director's Board CTA button
.sp-strip__directors-board-cta {
width: 22px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background: none;
border: none;
cursor: pointer;
color: var(--sp-text-secondary, #7a8390);
font-size: 11px;
transition: background 0.15s, color 0.15s;
&:hover {
background: rgba(255, 255, 255, 0.06);
color: var(--sp-text-primary, #dde2e8);
}
}
// Spacer — pushes close button to the right
.sp-strip__toolbar-spacer {
flex: 1;
}
// Close button — in-toolbar, no longer absolutely positioned
.sp-strip__close-btn {
width: 20px;
height: 24px;
padding: 0;
line-height: 24px;
text-align: center;
font-size: 13px;
font-weight: 400;
background: transparent;
color: var(--sp-text, hsl(0, 0%, 80%));
border: none;
cursor: pointer;
flex-shrink: 0;
opacity: 0.45;
transition: opacity 0.2s, background 0.15s;
&:hover {
opacity: 1;
background: rgba(255, 255, 255, 0.1);
}
&:active { opacity: 0.75; }
}
.sp-strip__participants {
list-style: none;
margin: 0;
padding: 0;
width: 100%;
display: flex;
flex-direction: column;
gap: 4px;
}
.sp-strip__participant-item {
margin: 0; // override browser/Foundry default <li> margins
}
.sp-strip__first-tip {
font-size: 0.75rem;
color: var(--sp-text-muted, hsl(0, 0%, 60%));
padding: 4px 8px;
margin: 0;
opacity: 0;
transition: opacity 0.2s;
.scrying-pool-strip:hover & {
opacity: 1;
}
}
// ============================================================
// ParticipantAvatar (44×44px container, 32px rounded image)
// ============================================================
.sp-participant-avatar {
position: relative;
width: 83px;
height: var(--sp-widget-width, 83px);
display: flex;
align-items: center;
justify-content: flex-start;
background: none;
border: none;
cursor: pointer;
padding: 6px;
border-radius: 4px;
gap: 8px;
overflow: hidden;
&:focus-visible {
outline: 2px solid var(--sp-focus-ring, hsl(200, 80%, 60%));
outline-offset: 2px;
}
.is-expanded & {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: auto;
padding: 5px;
background: transparent;
&:hover {
background: hsl(220, 15%, 14%);
}
&::after { display: none; }
// Shell: sized to widget width, centered in the card
.sp-avatar__shell {
width: calc(var(--sp-widget-width) - 59px);
height: calc(var(--sp-widget-width) - 59px);
}
// No names displayed in vertical mode
.sp-avatar__name,
.sp-avatar__state-label {
display: none;
}
.sp-avatar__corner-badge {
bottom: 6px;
right: 6px;
z-index: 4;
width: 10px;
height: 10px;
}
}
}
// Border shell — no clip-path, no background. Serves only as the positioning
// parent for ::before (border), ::after (state ring), and .sp-avatar__shape (content).
// Border and ring use the SAME clip-path as the shape but are scaled up via
// negative inset, creating a genuine border/ring around the clipped shape.
.sp-avatar__shell {
position: relative;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
// Default sm size — overridden by layout-specific CSS
width: 60px;
height: 60px;
}
// Tile border — ::before with same clip-path as shape, scaled up by border-width.
// Hidden via opacity when --sp-tile-border-active is 0 (border-width = 0)
// so the border background doesn't bleed through transparent avatar areas.
.sp-avatar__shell::before {
content: '';
position: absolute;
inset: calc(var(--sp-tile-border-width, 0px) * -1);
background: var(--sp-tile-border-color, transparent);
clip-path: var(--sp-shape-clip, none);
opacity: var(--sp-tile-border-active, 1);
z-index: -3;
pointer-events: none;
}
// State ring — ::after with same clip-path, scaled up same as border.
// Uses inset box-shadow so the ring appears INSIDE the clipped area,
// overlaying either the border (via ::before behind) or the avatar content.
// At z-index 1 it renders above .sp-avatar__shape so the ring is visible
// on top of the avatar video/img.
// Gated by --sp-tile-border-active alongside the border ::before so that
// "None" border truly removes ALL colored rings.
.sp-avatar__shell::after {
content: '';
position: absolute;
inset: calc(var(--sp-tile-border-width, 0px) * -1);
background: transparent;
box-shadow: inset 0 0 0 2px transparent;
clip-path: var(--sp-shape-clip, none);
opacity: var(--sp-tile-border-active, 1);
z-index: 1;
pointer-events: none;
transition: box-shadow 200ms ease, opacity 200ms ease;
}
// Shape wrapper inside the shell — carries clip-path for shapes.
.sp-avatar__shape {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
clip-path: var(--sp-shape-clip, none);
}
// Inner content (img and video) fill the shape
.sp-avatar__shape .sp-avatar__img,
.sp-avatar__shape .sp-participant-video {
width: 100%;
height: 100%;
}
// Video container for WebRTC stream (full AV replacement mode)
// Regular flow child inside .sp-avatar__shell — contributes to shell sizing
.sp-participant-video {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.sp-avatar__img {
width: 60px;
height: 60px;
object-fit: cover;
flex-shrink: 0;
}
// Video element styling
.sp-participant-video__element {
width: 100%;
height: 100%;
object-fit: cover;
background: hsl(220, 15%, 18%);
.is-expanded & {
border-radius: 4px;
}
}
// Hide avatar image when video stream is active (has video element)
.sp-participant-video:not(:empty) ~ .sp-avatar__img {
display: none;
}
.sp-avatar__corner-badge {
position: absolute;
bottom: 2px;
right: 2px;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--sp-state-color);
font-size: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.sp-avatar__name {
font-size: 0.85rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--sp-text, hsl(0, 0%, 85%));
opacity: 0;
transition: opacity 0.2s;
.sp-participant-avatar:hover & {
opacity: 1;
}
}
.sp-avatar__state-label {
font-size: 0.7rem;
color: var(--sp-text-muted, hsl(0, 0%, 60%));
opacity: 0;
transition: opacity 0.2s;
.sp-participant-avatar:hover & {
opacity: 1;
}
}
// ============================================================
// StateRing variants (applied as class on .sp-participant-avatar)
// ============================================================
// State ring via ::after on the shell — uses inset box-shadow so the ring
// follows the same clip-path as the shape, overlaying the border's inner edge.
// When there's no border (width=0), the ring overlays the avatar content edge.
.sp-participant-avatar.sp-state-active,
.sp-participant-avatar.sp-state-self-muted {
--sp-state-color: var(--sp-state-active);
.sp-avatar__shell::after {
box-shadow: inset 0 0 0 2px var(--sp-state-color);
}
}
.sp-participant-avatar.sp-state-hidden,
.sp-participant-avatar.sp-state-cam-lost {
--sp-state-color: var(--sp-state-hidden);
}
.sp-participant-avatar.sp-state-pending {
--sp-state-color: var(--sp-state-pending);
.sp-avatar__shell::after {
box-shadow: inset 0 0 0 2px var(--sp-state-color);
}
}
// ============================================================
// Spotlight focus state — gold ring (uses urgency-director token)
// ============================================================
.sp-participant-avatar.sp-state-focused {
.sp-avatar__shell::after {
box-shadow: inset 0 0 0 2px var(--sp-urgency-director);
}
}
// ============================================================
// Drag-and-drop reorder feedback
// ============================================================
.sp-strip__participant-item.sp-dragging {
opacity: 0.3;
}
.sp-strip__participant-item.sp-drag-over {
box-shadow: inset 0 0 0 2px var(--sp-urgency-director);
border-radius: 4px;
}
// ============================================================
// StateRing animations — gated under no-preference (AC-16)
// ============================================================
@media (prefers-reduced-motion: no-preference) {
.sp-participant-avatar.sp-state-pending .sp-avatar__shell::after {
animation: sp-pulse 2s ease-in-out infinite;
}
@keyframes sp-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
// Revert flash (200ms amber, then restore)
.sp-participant-avatar.sp-state-revert .sp-avatar__shell::after {
animation: sp-revert-flash 200ms ease-out forwards;
}
@keyframes sp-revert-flash {
0% { box-shadow: inset 0 0 0 3px var(--sp-urgency-director); }
100% { box-shadow: inset 0 0 0 2px var(--sp-state-color); }
}
}
// ============================================================
// EmptyStatePanel (AC-11)
// ============================================================
.sp-strip__empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 8px;
gap: 8px;
color: var(--sp-text-muted, hsl(0, 0%, 60%));
}
.sp-empty__icon {
font-size: 1.5rem;
display: block;
}
.sp-empty__text {
font-size: 0.75rem;
text-align: center;
}
@media (prefers-reduced-motion: no-preference) {
.sp-empty__icon {
animation: sp-breathe 3s ease-in-out infinite;
}
@keyframes sp-breathe {
0%, 100% { opacity: 0.6; transform: scale(1); }
50% { opacity: 1.0; transform: scale(1.05); }
}
}
// ============================================================
// AV Tile overlays (applied to .camera-view[data-user-id="..."])
// ============================================================
.sp-lock-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: hsla(0, 0%, 0%, 0.45);
pointer-events: none;
z-index: 10;
&::before {
content: '\f023'; // fa-lock
font-family: 'Font Awesome 6 Free';
font-weight: 900;
font-size: 1.2rem;
color: hsl(0, 0%, 85%);
}
}
.camera-view.sp-state-hidden {
opacity: 0.55;
position: relative;
}
.sp-portrait-fallback {
position: absolute;
inset: 0;
background: var(--sp-bg, hsl(220, 15%, 18%)) center/cover no-repeat;
pointer-events: none;
}
// ============================================================
// Context menu
// ============================================================
.sp-context-menu {
background: var(--sp-bg, hsl(220, 15%, 15%));
border: 1px solid hsl(0, 0%, 30%);
border-radius: 4px;
padding: 4px 0;
min-width: 160px;
z-index: 1000;
box-shadow: 0 4px 12px hsla(0, 0%, 0%, 0.4);
.sp-context-menu__item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 12px;
background: none;
border: none;
cursor: pointer;
color: var(--sp-text, hsl(0, 0%, 85%));
font-size: 0.875rem;
text-align: left;
&:hover,
&:focus-visible {
background: hsla(200, 60%, 55%, 0.15);
}
}
}
// ============================================================
// ActionPopover (<dialog>)
// ============================================================
.sp-action-popover {
background: var(--sp-bg, hsl(220, 15%, 15%));
border: 1px solid hsl(0, 0%, 30%);
border-radius: 6px;
padding: 12px;
min-width: 160px;
box-shadow: 0 4px 16px hsla(0, 0%, 0%, 0.5);
color: var(--sp-text, hsl(0, 0%, 85%));
.sp-action-popover__cta {
display: block;
width: 100%;
padding: 8px 16px;
background: hsl(200, 60%, 40%);
border: none;
border-radius: 4px;
cursor: pointer;
color: hsl(0, 0%, 95%);
font-size: 0.875rem;
&:hover:not(:disabled) {
background: hsl(200, 60%, 50%);
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
}
}
// ============================================================
// Tile shape variants (applied as .sp-shape-* on .sp-participant-avatar)
// ============================================================
// Defines --sp-shape-clip which is consumed by:
// - .sp-avatar__shape (content clip)
// - .sp-avatar__shell::before (border)
// - .sp-avatar__shell::after (state ring)
// All three use the same clip-path, but ::before and ::after are scaled up
// via negative inset to create the border/ring around the clipped shape.
.sp-participant-avatar {
&.sp-shape-rounded {
--sp-shape-clip: inset(0 round 6px);
}
&.sp-shape-circle {
--sp-shape-clip: circle(50%);
}
&.sp-shape-hexagon {
--sp-shape-clip: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%);
}
&.sp-shape-octagon {
--sp-shape-clip: polygon(29% 0%, 71% 0%, 100% 29%, 100% 71%, 71% 100%, 29% 100%, 0% 71%, 0% 29%);
}
}