diff --git a/fktry/src/ui/screenview.test.ts b/fktry/src/ui/screenview.test.ts new file mode 100644 index 0000000..250f1c5 --- /dev/null +++ b/fktry/src/ui/screenview.test.ts @@ -0,0 +1,80 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from 'vitest'; +import { createScreenView } from './screenview'; + +function mount(withInlineStyle = false) { + document.body.innerHTML = withInlineStyle + ? '' + : ''; + const source = document.getElementById('screen') as HTMLCanvasElement; + const view = createScreenView(source); + document.body.append(view.el); + return { view, source }; +} + +const click = (el: Element) => el.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + +describe('THE SCREEN click-to-enlarge', () => { + it('starts closed and leaves THE SCREEN alone', () => { + const { view, source } = mount(); + expect(view.isOpen()).toBe(false); + expect(source.getAttribute('style')).toBeNull(); + }); + + it('enlarges THE SCREEN and dims the backdrop when clicked', () => { + const { view, source } = mount(); + click(source); + expect(view.isOpen()).toBe(true); + expect(view.el.classList.contains('is-open')).toBe(true); + const style = source.getAttribute('style')!; + expect(style).toContain('position:fixed'); + expect(style).toContain('z-index:41'); // above the backdrop + }); + + it('restores THE SCREEN exactly as it found it', () => { + // Another lane owns this element; we must hand it back untouched. + const { view, source } = mount(); + click(source); + view.close(); + expect(source.getAttribute('style')).toBeNull(); + expect(view.isOpen()).toBe(false); + }); + + it('preserves inline styles LANE-SCREEN may add later', () => { + const { view, source } = mount(true); + const before = source.getAttribute('style'); + click(source); + expect(source.getAttribute('style')).toContain('top:10px'); // ours appended, not replacing + view.close(); + expect(source.getAttribute('style')).toBe(before); + }); + + it('closes when the enlarged SCREEN is clicked again', () => { + const { view, source } = mount(); + click(source); + click(source); + expect(view.isOpen()).toBe(false); + expect(source.getAttribute('style')).toBeNull(); + }); + + it('closes on a click on the dimmed backdrop', () => { + const { view, source } = mount(); + click(source); + click(view.el); + expect(view.isOpen()).toBe(false); + }); + + it('close() is idempotent and does not clobber the style on a second call', () => { + const { view, source } = mount(true); + const before = source.getAttribute('style'); + view.close(); + view.close(); + expect(source.getAttribute('style')).toBe(before); + }); + + it('survives having no SCREEN to enlarge', () => { + document.body.innerHTML = ''; + const view = createScreenView(null); + expect(() => { view.toggle(); view.close(); }).not.toThrow(); + }); +}); diff --git a/fktry/src/ui/screenview.ts b/fktry/src/ui/screenview.ts new file mode 100644 index 0000000..b9ab498 --- /dev/null +++ b/fktry/src/ui/screenview.ts @@ -0,0 +1,82 @@ +/** + * LANE-UI — THE SCREEN click-to-enlarge (order 8). The sky is the score; let people look. + * + * Clicking THE SCREEN toggles a centered ~2.5× view over a dimmed backdrop. Esc or a + * click closes it. + * + * We enlarge the real `#screen` element with inline styles rather than mirroring it into + * our own canvas: THE SCREEN is a WebGL canvas, and `drawImage` from a WebGL context + * without `preserveDrawingBuffer` returns black (verified — the mirror rendered empty). + * Asking LANE-SCREEN to enable that flag would cost them a frame buffer copy every frame + * for a feature used occasionally, so the transform is both cheaper and what the order + * specified. + * + * Touching another lane's element is done carefully: we snapshot its `style` attribute on + * open and restore it verbatim on close, so THE SCREEN's own positioning stays SCREEN's + * to change. + */ +import { cls, el } from './dom'; + +export interface ScreenView { + /** The dimming backdrop, mounted in #ui. */ + el: HTMLElement; + toggle(): void; + close(): void; + isOpen(): boolean; +} + +/** Inline styles applied to #screen while enlarged. z-index clears the backdrop (40). */ +const ENLARGED = [ + 'position:fixed', + 'top:50%', + 'left:50%', + 'transform:translate(-50%,-50%)', + 'width:min(800px, calc(100vw - 80px))', + 'height:auto', + 'z-index:41', + 'cursor:zoom-out', + 'image-rendering:pixelated', // it's a dying video file — let the blocks show +].join(';'); + +export function createScreenView(source: HTMLElement | null): ScreenView { + const backdrop = el('div'); + backdrop.id = 'fk-screenview'; + + let open = false; + /** The element's own style attribute before we touched it. */ + let savedStyle: string | null = null; + + function apply() { + if (!source) return; + savedStyle = source.getAttribute('style'); + source.setAttribute('style', `${savedStyle ? savedStyle + ';' : ''}${ENLARGED}`); + } + + function restore() { + if (!source) return; + if (savedStyle === null) source.removeAttribute('style'); + else source.setAttribute('style', savedStyle); + savedStyle = null; + } + + function close() { + if (!open) return; + open = false; + cls(backdrop, 'is-open', false); + restore(); + } + + function toggle() { + if (open) return close(); + open = true; + cls(backdrop, 'is-open', true); + apply(); + } + + // A click on the dimmed backdrop closes. So does a click on the enlarged SCREEN, which + // sits above the backdrop — that's the same toggle listener below. + backdrop.addEventListener('pointerdown', close); + source?.addEventListener('pointerdown', toggle); + + return { el: backdrop, toggle, close, isOpen: () => open }; +}