Commit c2311673 by PLN (Algolia)

hydra: touch UX — tap-to-fullscreen modal, museum placard, hold-to-credit strip;…

hydra: touch UX — tap-to-fullscreen modal, museum placard, hold-to-credit strip; license is icons + a light Licence link
parent 067dd66a
......@@ -10,8 +10,17 @@ import CcIcon from './CcIcon';
* hydra-synth chunk is in), so autoplay is free — except under
* prefers-reduced-motion, where it waits for a click (the facade then also
* links to the sketch's origin for no-JS readers). The source rides in the
* page HTML, hidden behind <details>; the hover footer mirrors the sketch's
* own credit block — author and CC mention, as the author wrote them.
* page HTML, hidden behind <details>.
*
* The credit line is CC badge icons plus a light "Licence" link — the badge
* circles carry the BY/NC/SA reading, no repeated license prose.
*
* Pointers differ: a fine pointer freezes the sketch on click and reveals
* the credit strip on hover; touch opens the sketch fullscreen (same canvas,
* the figure just goes fixed — one hydra instance, no second WebGL context),
* where a tap freezes time and a press-and-hold reveals the strip. On touch
* the page also shows a museum-style placard under the work: title, author,
* license — the tableau caption, always visible without touching the piece.
*
* Two islands on one page (opening + closing) each boot their own
* hydra-synth instance; the last one booted owns the global generators,
......@@ -39,13 +48,19 @@ export default function HydraEmbed({ sketch, lang }) {
const [running, setRunning] = useState(false);
const [paused, setPaused] = useState(false);
const [failed, setFailed] = useState(false);
const [expanded, setExpanded] = useState(false);
const [holding, setHolding] = useState(false);
const canvasRef = useRef(null);
const stageRef = useRef(null);
const hydraRef = useRef(null);
const savedSpeedRef = useRef(1);
const coarseRef = useRef(false);
const holdTimerRef = useRef(null);
const suppressClickRef = useRef(false);
useEffect(() => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
coarseRef.current = window.matchMedia('(pointer: coarse)').matches;
setRunning(true);
}, []);
......@@ -75,6 +90,20 @@ export default function HydraEmbed({ sketch, lang }) {
};
}, [running, sketch.source]);
// Fullscreen modal is a plain dialog to the keyboard and locks page scroll.
useEffect(() => {
if (!expanded) return undefined;
const onKey = (e) => {
if (e.key === 'Escape') setExpanded(false);
};
window.addEventListener('keydown', onKey);
document.documentElement.style.overflow = 'hidden';
return () => {
window.removeEventListener('keydown', onKey);
document.documentElement.style.overflow = '';
};
}, [expanded]);
/** Click on a running sketch freezes time (speed → 0); click again resumes. */
const togglePause = () => {
const hydra = hydraRef.current;
......@@ -89,27 +118,108 @@ export default function HydraEmbed({ sketch, lang }) {
}
};
const hint = lang === 'en' ? 'Run the sketch' : 'Lancer le sketch';
const onCanvasClick = () => {
// A long-press just revealed the credit strip — its release click is not
// a pause command; swallow exactly one.
if (suppressClickRef.current) {
suppressClickRef.current = false;
return;
}
if (coarseRef.current && !expanded) {
setExpanded(true);
return;
}
togglePause();
};
/** Press-and-hold the fullscreen sketch to read its credits while holding. */
const startHold = () => {
if (!coarseRef.current || !expanded) return;
holdTimerRef.current = setTimeout(() => {
suppressClickRef.current = true;
setHolding(true);
}, 450);
};
const endHold = () => {
clearTimeout(holdTimerRef.current);
setHolding(false);
};
const t =
lang === 'en'
? {
run: 'Run the sketch',
freeze: 'Freeze the sketch',
resume: 'Resume the sketch',
fullscreen: 'View fullscreen',
close: 'Leave fullscreen',
}
: {
run: 'Lancer le sketch',
freeze: 'Figer le sketch',
resume: 'Relancer le sketch',
fullscreen: 'Voir en plein écran',
close: 'Quitter le plein écran',
};
const codeLabel = lang === 'en' ? 'sketch source' : 'code source';
const frozen = coarseRef.current && !expanded;
const canvasAria = paused ? t.resume : frozen ? t.fullscreen : t.freeze;
const licenseEl = sketch.license && (
<span className={styles.hydraLicense} title={sketch.license}>
<CcBadges license={sketch.license} />
{sketch.licenseUrl ? (
<a
href={sketch.licenseUrl}
target="_blank"
rel="license noopener noreferrer"
aria-label={sketch.license}
>
{lang === 'en' ? 'License' : 'Licence'}
</a>
) : (
<span aria-label={sketch.license}>{lang === 'en' ? 'License' : 'Licence'}</span>
)}
</span>
);
const origin = (
<a href={sketch.url} target="_blank" rel="noopener noreferrer">
hydra.ojack.xyz ↗
</a>
);
const figureClass = [
styles.hydra,
expanded && styles.hydraExpanded,
holding && styles.hydraHolding,
]
.filter(Boolean)
.join(' ');
return (
<figure className={styles.hydra}>
<figure className={figureClass}>
<div ref={stageRef} className={styles.hydraStage}>
{running ? (
<canvas
ref={canvasRef}
className={styles.hydraCanvas}
onClick={togglePause}
onClick={onCanvasClick}
onPointerDown={startHold}
onPointerUp={endHold}
onPointerCancel={endHold}
onPointerLeave={endHold}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
togglePause();
onCanvasClick();
}
}}
aria-label={paused ? (lang === 'en' ? 'Resume the sketch' : 'Relancer le sketch') : lang === 'en' ? 'Freeze the sketch' : 'Figer le sketch'}
title={paused ? (lang === 'en' ? 'Click to resume' : 'Cliquer pour relancer') : lang === 'en' ? 'Click to freeze' : 'Cliquer pour figer'}
aria-label={canvasAria}
title={canvasAria}
/>
) : (
<a
......@@ -125,34 +235,34 @@ export default function HydraEmbed({ sketch, lang }) {
<span className={styles.hydraPlay} aria-hidden="true">
▷
</span>
<span className={styles.hydraHint}>{failed ? sketch.url : hint}</span>
<span className={styles.hydraHint}>{failed ? sketch.url : t.run}</span>
</a>
)}
{expanded && (
<button
type="button"
className={styles.hydraClose}
onClick={() => setExpanded(false)}
aria-label={t.close}
>
×
</button>
)}
<footer className={styles.hydraFooter}>
{sketch.title && <span className={styles.hydraTitle}>{sketch.title}</span>}
{sketch.author && <span className={styles.hydraAuthor}>{sketch.author}</span>}
{sketch.license &&
(sketch.licenseUrl ? (
<a
href={sketch.licenseUrl}
target="_blank"
rel="license noopener noreferrer"
className={styles.hydraLicense}
>
<CcBadges license={sketch.license} />
{sketch.license}
</a>
) : (
<span className={styles.hydraLicense}>
<CcBadges license={sketch.license} />
{sketch.license}
</span>
))}
<a href={sketch.url} target="_blank" rel="noopener noreferrer">
hydra.ojack.xyz ↗
</a>
{licenseEl}
{origin}
</footer>
</div>
<figcaption className={styles.hydraPlacard}>
{sketch.title && <span className={styles.hydraTitle}>{sketch.title}</span>}
{sketch.author && <span className={styles.hydraAuthor}>{sketch.author}</span>}
<span className={styles.hydraPlacardMeta}>
{licenseEl}
{origin}
</span>
</figcaption>
<details className={styles.hydraCode}>
<summary>{codeLabel}</summary>
<pre>
......
......@@ -695,13 +695,51 @@
opacity: 1;
}
@media (hover: none) {
.hydraFooter {
/* Touch: the hover strip yields to the museum placard below the work; it
returns inside the fullscreen modal, only while the sketch is held. */
.hydra .hydraFooter {
display: none;
}
.hydra .hydraExpanded .hydraFooter {
display: flex;
opacity: 0;
}
.hydra .hydraExpanded.hydraHolding .hydraFooter {
opacity: 1;
}
}
.hydraTitle {
font-weight: 600;
}
/* Museum placard (touch only): the caption that sits under the painting —
title, author, license — always visible without touching the piece. */
.hydraPlacard {
display: none;
}
@media (hover: none) {
.hydra .hydraPlacard {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.2rem 1rem;
padding: 0.55rem 0.2rem 0;
font-size: 0.82rem;
/* On the page, not floating over the sketch — theme tokens are safe. */
color: var(--text-muted);
}
.hydra .hydraPlacard .hydraTitle {
font-style: italic;
}
.hydra .hydraExpanded .hydraPlacard {
display: none;
}
}
.hydraPlacardMeta {
display: inline-flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.3rem 0.9rem;
}
.hydraFooter a {
color: inherit;
text-decoration: underline;
......@@ -723,6 +761,58 @@
align-items: center;
gap: 0.4rem;
}
/* The full license name lives in the badge circles and the title attr; the
visible word stays a quiet link. */
.hydraLicense a,
.hydraPlacard a {
color: inherit;
text-decoration: underline;
text-underline-offset: 2px;
opacity: 0.85;
}
/* Fullscreen modal: the same figure goes fixed — one canvas, one hydra
instance, nothing reboots. Centered like a piece on a dark wall. */
.hydraExpanded {
position: fixed;
inset: 0;
z-index: 60;
margin: 0;
padding: 1rem;
background: rgba(0, 0, 0, 0.96);
display: flex;
flex-direction: column;
justify-content: center;
}
.hydraExpanded .hydraStage {
width: 100%;
max-width: calc((100vh - 2rem) * 16 / 9);
margin-inline: auto;
border-radius: 0;
}
.hydraExpanded .hydraCode {
display: none;
}
.hydraClose {
position: absolute;
top: 0.7rem;
right: 0.7rem;
z-index: 2;
width: 2.4rem;
height: 2.4rem;
display: grid;
place-items: center;
font-size: 1.4rem;
line-height: 1;
color: rgba(255, 255, 255, 0.85);
background: rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.35);
border-radius: 50%;
cursor: pointer;
}
.hydraClose:hover,
.hydraClose:focus-visible {
background: rgba(255, 255, 255, 0.12);
}
.hydraBadges {
display: inline-flex;
align-items: center;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment