seat tray: tray.js, SCSS, FTs, Jasmine specs
- new apps.epic.static tray.js: IIFE with drag-open/click-close/wobble behaviour; document-level pointermove+mouseup listeners; reset() for Jasmine afterEach; try/catch around setPointerCapture for synthetic events - _room.scss: #id_tray_wrap fixed-right flex container; #id_tray_handle + #id_tray_grip (box-shadow frame, transparent inner window, border-radius clip); #id_tray_btn grab cursor; #id_tray bevel box-shadows, margin-left gap, height removed (align-items:stretch handles it); tray-wobble keyframes - _applets.scss + _game-kit.scss: z-index raised (312-318) for primacy over tray (310) - room.html: #id_tray_wrap + children markup; tray.js script tag - FTs test_room_tray: 5 tests (T1-T5); _simulate_drag via execute_script pointer events (replaces unreliable ActionChains drag); wobble asserts on #id_tray_wrap not btn - static_src/tests/TraySpec.js + SpecRunner.html: Jasmine unit tests for all tray.js branches Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -93,7 +93,7 @@
|
||||
position: fixed;
|
||||
bottom: 4.2rem;
|
||||
right: 0.5rem;
|
||||
z-index: 202;
|
||||
z-index: 314;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
position: fixed;
|
||||
bottom: 6.6rem;
|
||||
right: 1rem;
|
||||
z-index: 201;
|
||||
z-index: 312;
|
||||
}
|
||||
|
||||
// In landscape: shift gear btn and applet menus left of the footer right sidebar
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
top: auto;
|
||||
}
|
||||
|
||||
z-index: 305;
|
||||
z-index: 318;
|
||||
font-size: 1.75rem;
|
||||
cursor: pointer;
|
||||
color: rgba(var(--secUser), 1);
|
||||
@@ -42,14 +42,14 @@
|
||||
border: none;
|
||||
border-top: 0.1rem solid rgba(var(--terUser), 0.3);
|
||||
background: rgba(var(--priUser), 0.97);
|
||||
z-index: 204;
|
||||
z-index: 316;
|
||||
overflow: hidden;
|
||||
|
||||
@media (orientation: landscape) and (max-width: 1440px) {
|
||||
$sidebar-w: 4rem;
|
||||
// left: $sidebar-w;
|
||||
right: $sidebar-w;
|
||||
z-index: 301;
|
||||
z-index: 316;
|
||||
}
|
||||
// Closed state
|
||||
max-height: 0;
|
||||
@@ -112,6 +112,7 @@
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
padding: 0 0.125rem;
|
||||
color: rgba(var(--terUser), 1);
|
||||
}
|
||||
|
||||
.kit-bag-placeholder {
|
||||
@@ -281,11 +282,11 @@
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
.fan-card-number { font-size: 0.65rem; opacity: 0.5; }
|
||||
.fan-card-name-group { font-size: 0.65rem; opacity: 0.5; margin: 0; text-transform: uppercase; letter-spacing: 0.08em; }
|
||||
.fan-card-name { font-size: 0.95rem; font-weight: bold; margin: 0; }
|
||||
.fan-card-arcana { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.1em; opacity: 0.6; }
|
||||
.fan-card-correspondence { font-size: 0.6rem; opacity: 0.45; font-style: italic; }
|
||||
.fan-card-number { font-size: 0.65rem; }
|
||||
.fan-card-name-group { font-size: 0.65rem; margin: 0; text-transform: uppercase; letter-spacing: 0.08em; color: rgba(var(--secUser), 1); }
|
||||
.fan-card-name { font-size: 0.95rem; font-weight: bold; margin: 0; color: rgba(var(--terUser), 1); }
|
||||
.fan-card-arcana { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.1em; color: rgba(var(--secUser), 1); }
|
||||
.fan-card-correspondence { font-size: 0.6rem; font-style: italic; color: rgba(var(--secUser), 0.5); }
|
||||
}
|
||||
|
||||
.fan-nav {
|
||||
|
||||
@@ -17,7 +17,7 @@ $gate-line: 2px;
|
||||
position: fixed;
|
||||
bottom: 6.6rem;
|
||||
right: 0.5rem;
|
||||
z-index: 202;
|
||||
z-index: 314;
|
||||
background-color: rgba(var(--priUser), 0.95);
|
||||
border: 0.15rem solid rgba(var(--secUser), 1);
|
||||
box-shadow:
|
||||
@@ -747,3 +747,138 @@ $inv-strip: 30px; // visible height of each stacked card after the first
|
||||
.fan-card-arcana { font-size: 0.35rem; }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Seat tray ──────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Structure:
|
||||
// #id_tray_wrap — fixed right edge, flex row, slides on :has(.open)
|
||||
// #id_tray_handle — $handle-exposed wide; contains grip + button
|
||||
// #id_tray_grip — position:absolute; ::before/::after = concentric rects
|
||||
// #id_tray_btn — circle button (z-index:1, paints above grip)
|
||||
// #id_tray — 280px panel; covers grip's rightward extension when open
|
||||
//
|
||||
// Closed: wrap translateX($tray-w) → only button circle visible at right edge.
|
||||
// Open: translateX(0) → full tray panel slides in; grip rects visible as handle.
|
||||
|
||||
$tray-w: 280px;
|
||||
$handle-rect-w: 10000px;
|
||||
$handle-rect-h: 72px;
|
||||
$handle-exposed: 48px;
|
||||
$handle-r: 1rem;
|
||||
|
||||
#id_tray_wrap {
|
||||
position: fixed;
|
||||
// left set by JS: closed = vw - handle; open = 0
|
||||
// top/bottom set by JS from nav/footer measurements
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 310;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
transition: left 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&.tray-dragging { transition: none; }
|
||||
&.wobble { animation: tray-wobble 0.45s ease; }
|
||||
}
|
||||
|
||||
#id_tray_handle {
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
width: $handle-exposed;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#id_tray_grip {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: calc(#{$handle-exposed} / 2 - 0.125rem);
|
||||
transform: translateY(-50%);
|
||||
width: $handle-rect-w;
|
||||
height: $handle-rect-h;
|
||||
pointer-events: none;
|
||||
// Border + overflow:hidden on the grip itself clips ::before's shadow with correct radius
|
||||
border-radius: $handle-r;
|
||||
border: 0.15rem solid rgba(var(--secUser), 1);
|
||||
overflow: hidden;
|
||||
|
||||
// Inset inner window: box-shadow spills outward to fill the opaque frame area,
|
||||
// clipped to grip's rounded edge by overflow:hidden. background:transparent = see-through hole.
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0.4rem;
|
||||
border-radius: calc(#{$handle-r} - 0.35rem);
|
||||
border: 0.15rem solid rgba(var(--secUser), 1);
|
||||
background: transparent;
|
||||
box-shadow: 0 0 0 200px rgba(var(--priUser), 1);
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: none;
|
||||
}
|
||||
}
|
||||
|
||||
#id_tray_btn {
|
||||
pointer-events: auto;
|
||||
position: relative;
|
||||
z-index: 1; // above #id_tray_grip
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(var(--priUser), 1);
|
||||
border: 0.15rem solid rgba(var(--secUser), 1);
|
||||
cursor: grab;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&.active {
|
||||
color: rgba(var(--quaUser), 1);
|
||||
border-color: rgba(var(--quaUser), 1);
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 1.75rem;
|
||||
color: rgba(var(--secUser), 1);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:active { cursor: grabbing; }
|
||||
&.open { cursor: pointer; }
|
||||
}
|
||||
|
||||
@keyframes tray-wobble {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
20% { transform: translateX(-8px); }
|
||||
40% { transform: translateX(6px); }
|
||||
60% { transform: translateX(-5px); }
|
||||
80% { transform: translateX(3px); }
|
||||
}
|
||||
|
||||
#id_tray {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-left: 0.5rem; // small gap so tray appears slightly off-screen on drag start
|
||||
pointer-events: auto;
|
||||
position: relative;
|
||||
z-index: 1; // above #id_tray_grip pseudo-elements
|
||||
background: rgba(var(--secUser), 1);
|
||||
border-left:2.5rem solid rgba(var(--terUser), 1);
|
||||
border-top: 2.5rem solid rgba(var(--terUser), 1);
|
||||
border-bottom: 2.5rem solid rgba(var(--terUser), 1);
|
||||
box-shadow:
|
||||
-0.25rem 0 0.5rem rgba(0, 0, 0, 0.75),
|
||||
inset 0 0 0 0.12rem rgba(255, 255, 255, 0.12), // bright bevel ring at wall edge
|
||||
inset 0.6rem 0 1.5rem -0.5rem rgba(0, 0, 0, 0.45), // left wall depth
|
||||
inset 0 0.6rem 1.5rem -0.5rem rgba(0, 0, 0, 0.3), // top wall depth
|
||||
inset 0 -0.6rem 1.5rem -0.5rem rgba(0, 0, 0, 0.3) // bottom wall depth
|
||||
;
|
||||
overflow-y: auto;
|
||||
// scrollbar-width: thin;
|
||||
// scrollbar-color: rgba(var(--terUser), 0.3) transparent;
|
||||
}
|
||||
|
||||
@@ -20,9 +20,11 @@
|
||||
<!-- spec files -->
|
||||
<script src="Spec.js"></script>
|
||||
<script src="RoleSelectSpec.js"></script>
|
||||
<script src="TraySpec.js"></script>
|
||||
<!-- src files -->
|
||||
<script src="/static/apps/dashboard/dashboard.js"></script>
|
||||
<script src="/static/apps/epic/role-select.js"></script>
|
||||
<script src="/static/apps/epic/tray.js"></script>
|
||||
<!-- Jasmine env config (optional) -->
|
||||
<script src="lib/jasmine-6.0.1/boot1.js"></script>
|
||||
|
||||
|
||||
206
src/static_src/tests/TraySpec.js
Normal file
206
src/static_src/tests/TraySpec.js
Normal file
@@ -0,0 +1,206 @@
|
||||
// ── TraySpec.js ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Unit specs for tray.js — the per-seat, per-room slide-out panel anchored
|
||||
// to the right edge of the viewport.
|
||||
//
|
||||
// DOM contract assumed by the module:
|
||||
// #id_tray_wrap — outermost container; JS sets style.left for positioning
|
||||
// #id_tray_btn — the drawer-handle button
|
||||
// #id_tray — the tray panel (hidden by default)
|
||||
//
|
||||
// Public API under test:
|
||||
// Tray.init() — compute bounds, apply vertical bounds, attach listeners
|
||||
// Tray.open() — reveal tray, animate wrap to minLeft
|
||||
// Tray.close() — hide tray, animate wrap to maxLeft
|
||||
// Tray.isOpen() — state predicate
|
||||
// Tray.reset() — restore initial state (for afterEach)
|
||||
//
|
||||
// Drag model: tray follows pointer in real-time; position persists on release.
|
||||
// Any leftward drag opens the tray.
|
||||
// Drag > 10px suppresses the subsequent click event.
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Tray", () => {
|
||||
let btn, tray, wrap;
|
||||
|
||||
beforeEach(() => {
|
||||
wrap = document.createElement("div");
|
||||
wrap.id = "id_tray_wrap";
|
||||
|
||||
btn = document.createElement("button");
|
||||
btn.id = "id_tray_btn";
|
||||
|
||||
tray = document.createElement("div");
|
||||
tray.id = "id_tray";
|
||||
tray.style.display = "none";
|
||||
|
||||
wrap.appendChild(btn);
|
||||
document.body.appendChild(wrap);
|
||||
document.body.appendChild(tray);
|
||||
|
||||
Tray.init();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Tray.reset();
|
||||
wrap.remove();
|
||||
tray.remove();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------- //
|
||||
// open() //
|
||||
// ---------------------------------------------------------------------- //
|
||||
|
||||
describe("open()", () => {
|
||||
it("makes #id_tray visible", () => {
|
||||
Tray.open();
|
||||
expect(tray.style.display).not.toBe("none");
|
||||
});
|
||||
|
||||
it("adds .open to #id_tray_btn", () => {
|
||||
Tray.open();
|
||||
expect(btn.classList.contains("open")).toBe(true);
|
||||
});
|
||||
|
||||
it("sets wrap left to minLeft (0)", () => {
|
||||
Tray.open();
|
||||
expect(wrap.style.left).toBe("0px");
|
||||
});
|
||||
|
||||
it("calling open() twice does not duplicate .open", () => {
|
||||
Tray.open();
|
||||
Tray.open();
|
||||
const openCount = btn.className.split(" ").filter(c => c === "open").length;
|
||||
expect(openCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------- //
|
||||
// close() //
|
||||
// ---------------------------------------------------------------------- //
|
||||
|
||||
describe("close()", () => {
|
||||
beforeEach(() => Tray.open());
|
||||
|
||||
it("hides #id_tray", () => {
|
||||
Tray.close();
|
||||
expect(tray.style.display).toBe("none");
|
||||
});
|
||||
|
||||
it("removes .open from #id_tray_btn", () => {
|
||||
Tray.close();
|
||||
expect(btn.classList.contains("open")).toBe(false);
|
||||
});
|
||||
|
||||
it("sets wrap left to maxLeft", () => {
|
||||
Tray.close();
|
||||
expect(parseInt(wrap.style.left, 10)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("does not throw if already closed", () => {
|
||||
Tray.close();
|
||||
expect(() => Tray.close()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------- //
|
||||
// isOpen() //
|
||||
// ---------------------------------------------------------------------- //
|
||||
|
||||
describe("isOpen()", () => {
|
||||
it("returns false by default", () => {
|
||||
expect(Tray.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true after open()", () => {
|
||||
Tray.open();
|
||||
expect(Tray.isOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false after close()", () => {
|
||||
Tray.open();
|
||||
Tray.close();
|
||||
expect(Tray.isOpen()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------- //
|
||||
// Click when closed — wobble wrap, do not open //
|
||||
// ---------------------------------------------------------------------- //
|
||||
|
||||
describe("clicking btn when closed", () => {
|
||||
it("adds .wobble to wrap", () => {
|
||||
btn.click();
|
||||
expect(wrap.classList.contains("wobble")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not open the tray", () => {
|
||||
btn.click();
|
||||
expect(Tray.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it("removes .wobble once animationend fires on wrap", () => {
|
||||
btn.click();
|
||||
wrap.dispatchEvent(new Event("animationend"));
|
||||
expect(wrap.classList.contains("wobble")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------- //
|
||||
// Click when open — close, no wobble //
|
||||
// ---------------------------------------------------------------------- //
|
||||
|
||||
describe("clicking btn when open", () => {
|
||||
beforeEach(() => Tray.open());
|
||||
|
||||
it("closes the tray", () => {
|
||||
btn.click();
|
||||
expect(Tray.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it("does not add .wobble", () => {
|
||||
btn.click();
|
||||
expect(wrap.classList.contains("wobble")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------- //
|
||||
// Drag interaction — continuous positioning //
|
||||
// ---------------------------------------------------------------------- //
|
||||
|
||||
describe("drag interaction", () => {
|
||||
function simulateDrag(deltaX) {
|
||||
const startX = 800;
|
||||
btn.dispatchEvent(new PointerEvent("pointerdown", { clientX: startX, bubbles: true }));
|
||||
btn.dispatchEvent(new PointerEvent("pointermove", { clientX: startX + deltaX, bubbles: true }));
|
||||
btn.dispatchEvent(new PointerEvent("pointerup", { clientX: startX + deltaX, bubbles: true }));
|
||||
}
|
||||
|
||||
it("dragging left opens the tray", () => {
|
||||
simulateDrag(-60);
|
||||
expect(Tray.isOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it("any leftward drag opens the tray", () => {
|
||||
simulateDrag(-20);
|
||||
expect(Tray.isOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it("dragging right does not open the tray", () => {
|
||||
simulateDrag(100);
|
||||
expect(Tray.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it("drag > 10px suppresses the subsequent click", () => {
|
||||
simulateDrag(-60);
|
||||
btn.click(); // should be swallowed — tray stays open
|
||||
expect(Tray.isOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it("does not add .wobble during drag", () => {
|
||||
simulateDrag(-60);
|
||||
expect(wrap.classList.contains("wobble")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user