[ui] THE SCREEN click-to-enlarge
Click the sky, get the sky: ~2.5x, centred, over a dimmed backdrop. Esc or a click closes it. Enlarges the real element rather than mirroring it. I built the mirror first and it rendered black — THE SCREEN is a WebGL canvas, and drawImage from a WebGL context without preserveDrawingBuffer returns nothing. Asking LANE-SCREEN to enable that flag would cost them a buffer copy every frame for a feature used occasionally. Since that means touching another lane's element, it snapshots the style attribute on open and restores it verbatim on close — including the case where SCREEN later adds inline styles of their own, which is tested. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
e24100c26e
commit
5d6afdac97
80
fktry/src/ui/screenview.test.ts
Normal file
80
fktry/src/ui/screenview.test.ts
Normal file
@ -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
|
||||||
|
? '<canvas id="screen" style="position:fixed;top:10px"></canvas>'
|
||||||
|
: '<canvas id="screen"></canvas>';
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
82
fktry/src/ui/screenview.ts
Normal file
82
fktry/src/ui/screenview.ts
Normal file
@ -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 };
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user