Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 206 additions & 0 deletions app/diag-hdr/page.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
"use client";

// app/diag-hdr/page.jsx
// ─────────────────────────────────────────────────────────────────────────────
// INSTRUMENT, not a screen. Answers one question: is there a version of a
// touch bloom that reads as a torch behind vellum rather than a LinkedIn
// gain-map advert (boss's bar, 2026-08-21)?
//
// It reports what the device actually supports AND lets you feel each
// candidate, because the bar is aesthetic and no support matrix can settle it.
//
// Gain maps themselves are the wrong tool and are deliberately not tried here:
// ISO 21496-1 attaches a brightening map to an IMAGE asset. You cannot apply
// one to a button — you would be compositing a picture under a thumb. The
// CSS-native route to light above SDR white is the rec2100 colour spaces plus
// dynamic-range-limit, which is what these swatches use.
//
// Nothing here is imported by the app. Glows remain on the never-list
// (lib/tokens.js) unless and until this changes someone's mind.
// ─────────────────────────────────────────────────────────────────────────────

import { useEffect, useState } from "react";

const PROBES = [
["color(display-p3 …)", "color", "color(display-p3 1 1 1)"],
["color(rec2100-hlg …)", "color", "color(rec2100-hlg 1 1 1)"],
["color(rec2100-pq …)", "color", "color(rec2100-pq 1 1 1)"],
["dynamic-range-limit: no-limit", "dynamic-range-limit", "no-limit"],
["dynamic-range-limit: constrained", "dynamic-range-limit", "constrained"],
];

// Each swatch is one hypothesis about the material. The control is first so
// the eye has the current app press to compare against, not a memory of it.
const SWATCHES = [
{ id: "control", name: "Control — what ships today",
note: "scale(0.99) + press tint. No light at all." },
{ id: "sdr", name: "SDR bloom",
note: "An ordinary radial highlight. Tests the SHAPE before HDR is involved — if this already reads wrong, brightness will not save it." },
{ id: "hlg-low", name: "HDR bloom — restrained",
note: "rec2100-hlg just above diffuse white." },
{ id: "hlg-high", name: "HDR bloom — assertive",
note: "The LinkedIn end of the dial. Included so the bad option is on screen next to the good one." },
{ id: "vellum", name: "Torch behind vellum",
note: "The light sits UNDER a translucent surface rather than on top of it. Backlit, not emissive — the one that matches the brief." },
];

export default function DiagHdrPage() {
const [env, setEnv] = useState(null);

/* eslint-disable react-hooks/set-state-in-effect -- mount-once capability
read, exactly as /diag-safe-area does it: these values only exist in a
browser, and the whole point of the page is to report them. */
useEffect(() => {
const supports = (prop, val) => {
try { return String(CSS?.supports?.(prop, val)); } catch { return "threw"; }
};
const mq = (q) => {
try { return String(window.matchMedia(q).matches); } catch { return "threw"; }
};
setEnv({
probes: PROBES.map(([label, prop, val]) => [label, supports(prop, val)]),
dynamicRange: mq("(dynamic-range: high)"),
videoDynamicRange: mq("(video-dynamic-range: high)"),
p3: mq("(color-gamut: p3)"),
rec2020: mq("(color-gamut: rec2020)"),
ua: navigator.userAgent.slice(0, 120),
});
}, []);

// The bloom follows the contact point — "around the area of thumb" is the
// whole idea, and a halo pinned to an element's centre tests something else.
const track = (e) => {
const r = e.currentTarget.getBoundingClientRect();
e.currentTarget.style.setProperty("--x", `${e.clientX - r.left}px`);
e.currentTarget.style.setProperty("--y", `${e.clientY - r.top}px`);
};

return (
<div style={{ background: "#F2E9E3", color: "#241C19", minHeight: "100%", fontFamily: "system-ui, sans-serif" }}>
<style dangerouslySetInnerHTML={{ __html: CSS_TEXT }} />
<div style={{ maxWidth: 560, margin: "0 auto", padding: "40px 20px 64px" }}>
<h1 style={{ fontSize: 22, margin: "0 0 6px" }}>HDR touch-bloom probe</h1>
<p style={{ fontSize: 13, color: "#6A5B54", lineHeight: 1.6, margin: "0 0 26px" }}>
Press and hold each panel. The bloom follows your finger. Judge it in
the room you actually train in — this is a material question, not a
numbers one.
</p>

{SWATCHES.map((s) => (
<div key={s.id} style={{ marginBottom: 22 }}>
<div
className={`probe probe-${s.id}`}
onPointerDown={track}
onPointerMove={track}
>
<span className="probe-label">{s.name}</span>
</div>
<p style={{ fontSize: 12, color: "#6A5B54", lineHeight: 1.55, margin: "8px 2px 0" }}>{s.note}</p>
</div>
))}

<h2 style={{ fontSize: 15, margin: "34px 0 10px" }}>What this device reports</h2>
{!env ? (
<p style={{ fontSize: 13, color: "#6A5B54" }}>Reading…</p>
) : (
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
<tbody>
{[
...env.probes,
["media (dynamic-range: high)", env.dynamicRange],
["media (video-dynamic-range: high)", env.videoDynamicRange],
["media (color-gamut: p3)", env.p3],
["media (color-gamut: rec2020)", env.rec2020],
["userAgent", env.ua],
].map(([k, v]) => (
<tr key={k} style={{ borderBottom: "1px solid #E0D2C9" }}>
<td style={{ padding: "7px 0", color: "#6A5B54" }}>{k}</td>
<td style={{ padding: "7px 0", textAlign: "right", fontFamily: "ui-monospace, monospace" }}>{v}</td>
</tr>
))}
</tbody>
</table>
)}

<p style={{ fontSize: 12, color: "#6A5B54", lineHeight: 1.6, marginTop: 24 }}>
If the rec2100 rows read <code>false</code>, the HDR panels are
falling back to ordinary colour and only the SDR and vellum panels
mean anything. Worth knowing before judging them.
</p>
</div>
</div>
);
}

// Plain CSS rather than inline styles: dynamic-range-limit and the rec2100
// colour spaces are exactly the sort of thing a style-object serialiser drops
// silently, and a silently-dropped property would make this instrument lie.
const CSS_TEXT = `
.probe {
position: relative;
height: 128px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent;
overflow: hidden;
background: #FBF6F2;
box-shadow: 0 1px 2px rgba(36,28,25,0.10), 0 6px 18px rgba(36,28,25,0.06);
transition: transform 380ms cubic-bezier(0.22,1,0.36,1);
--x: 50%; --y: 50%;
}
.probe:active { transform: scale(0.99); transition-duration: 90ms; }
.probe-label { font-size: 14px; font-weight: 500; color: #241C19; position: relative; z-index: 2; }

/* The light itself — a child layer so the panel's own surface stays matte. */
.probe::before {
content: "";
position: absolute;
inset: 0;
opacity: 0;
transition: opacity 380ms cubic-bezier(0.22,1,0.36,1);
pointer-events: none;
z-index: 1;
}
.probe:active::before { opacity: 1; transition-duration: 90ms; }

/* Control: no light, tint only — the app's current treatment. */
.probe-control:active { background: #F3E9E2; }

.probe-sdr::before {
background: radial-gradient(180px circle at var(--x) var(--y),
rgba(255,255,255,0.92), rgba(255,255,255,0) 70%);
}

.probe-hlg-low::before {
dynamic-range-limit: no-limit;
background: radial-gradient(180px circle at var(--x) var(--y),
color(rec2100-hlg 0.80 0.78 0.74), transparent 70%);
}

.probe-hlg-high::before {
dynamic-range-limit: no-limit;
background: radial-gradient(200px circle at var(--x) var(--y),
color(rec2100-hlg 1 1 1), transparent 72%);
}

/* Torch behind vellum: the light is UNDER a translucent sheet, so what you
see is a surface being lit rather than a surface emitting. */
.probe-vellum { background: transparent; }
.probe-vellum::before {
dynamic-range-limit: no-limit;
background: radial-gradient(200px circle at var(--x) var(--y),
color(rec2100-hlg 0.92 0.90 0.86), transparent 74%);
}
.probe-vellum::after {
content: "";
position: absolute;
inset: 0;
z-index: 1;
background: rgba(251,246,242,0.86);
}
`;
54 changes: 54 additions & 0 deletions app/diag-safe-area/page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,56 @@ export default function SafeAreaDiagPage() {
dpr: String(window.devicePixelRatio),
inner: `${window.innerWidth} x ${window.innerHeight}`,
screen: `${window.screen.width} x ${window.screen.height}`,
// WHICH RUNG OF THE HEIGHT LADDER IS ACTUALLY LIVE.
// .forge-page climbs 100vh → 100dvh (browser only) → stretch, the last
// behind an @supports. stretch is late-Safari, so on an older OS the
// query simply fails and the shell silently falls back to 100vh — which
// reports the whole SCREEN while the viewport already excludes the
// status bar. That is a shell taller than the space it has, and no
// amount of reading the stylesheet reveals which branch a device took.
supportsStretch: String(
typeof CSS !== "undefined" && CSS.supports?.("min-height", "stretch"),
),
supportsFillAvailable: String(
typeof CSS !== "undefined" && CSS.supports?.("min-height", "-webkit-fill-available"),
),
// The consequence, measured rather than inferred: the shell's real box
// against the viewport it is meant to fill. Any positive overflow here
// IS the scroll the session screen shows.
// THE HEIGHT LADDER, MEASURED. The shell resolves against body, and
// body is height:100vh — so whatever 100vh reports here is what every
// rung below it inherits, stretch included. Printing all four units
// side by side against innerHeight is the one measurement that says
// which of them is the viewport and which is the screen.
//
// Units are composed at runtime rather than written as style literals:
// this file is an instrument, not a screen, and it should not read as a
// new viewport-height owner to tests/viewport-contract.test.js. (The
// first draft of this very comment spelled the literal out and tripped
// the ratchet, which is the ratchet working.)
rungs: (() => {
const probe = document.createElement("div");
probe.style.position = "absolute";
probe.style.visibility = "hidden";
probe.style.pointerEvents = "none";
probe.style.top = "0";
document.body.appendChild(probe);
const measure = (value) => {
probe.style.height = value;
return Math.round(probe.getBoundingClientRect().height);
};
const out = ["vh", "svh", "lvh", "dvh"]
.map((u) => `${u}:${measure(`100${u}`)}`)
.join(" ");
probe.remove();
return out;
})(),
shell: (() => {
const el = document.querySelector(".forge-page");
if (!el) return "no .forge-page on this route";
const h = Math.round(el.getBoundingClientRect().height);
return `${h} vs innerHeight ${window.innerHeight} (overflow ${h - window.innerHeight})`;
})(),
ua: navigator.userAgent.slice(0, 120),
});
}, []);
Expand Down Expand Up @@ -151,6 +201,10 @@ export default function SafeAreaDiagPage() {
["devicePixelRatio", env.dpr],
["innerWidth x innerHeight", env.inner],
["screen", env.screen],
["100vh / svh / lvh / dvh", env.rungs],
["supports min-height: stretch", env.supportsStretch],
["supports -webkit-fill-available", env.supportsFillAvailable],
[".forge-page height", env.shell],
].map(([k, v]) => (
<tr key={k} style={{ borderBottom: "1px solid #E0D2C9" }}>
<td style={{ padding: "7px 0", color: "#6A5B54" }}>{k}</td>
Expand Down
13 changes: 10 additions & 3 deletions components/SessionHost.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,9 @@ export default function SessionHost() {
setReadinessReason(draft.readinessReason);
if (draft.travel === true) setTravelState(true);
setBlockIdx(resumeBlockIdx);
setSetNum(Math.min(setsOnCurrent + 1, session.blocks[resumeBlockIdx].sets));
// Unclamped for the same reason as handleJumpToBlock: resuming onto a
// finished block should present it as finished.
setSetNum(setsOnCurrent + 1);
setPhase("A");
setFlow("session");
};
Expand Down Expand Up @@ -308,7 +310,7 @@ export default function SessionHost() {
const REACH_EARLIEST_SET = 3;
const canReach =
readiness === "fresh" && !reachSpent && !reachArmed && !activeDeload && !travel &&
!isSS && isHeadline && setNum >= blockSets && setNum >= REACH_EARLIEST_SET &&
!isSS && isHeadline && setNum === blockSets && setNum >= REACH_EARLIEST_SET &&
getLoadType(activeEx) !== "bodyweight" && Number.isFinite(reachWeight);

// Plain handlers, like commitLog/handleLog below — the React Compiler
Expand Down Expand Up @@ -489,7 +491,12 @@ export default function SessionHost() {
? Math.max(0, ...Object.values(saved.exercises).map(ex => (ex.sets || []).length))
: 0;
setBlockIdx(targetIdx);
setSetNum(Math.min(pairs + 1, targetBlock.sets));
// NOT clamped to targetBlock.sets. Clamping put you back on the final set
// of a finished block, which reads as "one still to do" while the overview
// calls the same block Done. Landing past the end lets the screen say what
// is true; adding a further set is an explicit choice there (boss ruling,
// 2026-08-21 — flipping back is for revisiting the numbers you put up).
setSetNum(pairs + 1);
setPhase("A");
setAwaitRpe(false);
setSsRoundDone(false);
Expand Down
40 changes: 32 additions & 8 deletions components/SessionScreen.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,17 @@ export function SessionScreen({session,block,blockIdx,totalBlocks,setNum,phase,i
const partnerEx=isSS?(phase==="A"?resolvedExB:resolvedExA):null;
const vidEx =isSS?(phase==="A"?resolvedExA:resolvedExB):resolvedEx;
const progress =((blockIdx+(setNum-1)/block.sets)/totalBlocks)*100;
// Revisiting a finished block. setNum runs one past the prescribed count
// (SessionHost no longer clamps), so this is the block's own truth rather
// than a flag threaded through the flow. The set pips already read correctly
// from it — i<setNum-1 fills them all.
const blockDone = setNum > block.sets;
// Adding a further set is deliberate, never the default: you came back here
// to look at what you lifted. Keyed so it resets on any block/set change.
const [addKey,setAddKey]=useState(null);
const thisKey=`${block.id}|${setNum}`;
if(addKey&&addKey!==thisKey) setAddKey(null);
const adding=addKey===thisKey;
// Display face fence: never below 28px. Long names wrap rather than
// shrinking under the fence.
const nameFz =Math.min(42,Math.max(28,340/(activeEx?.name?.length||10)));
Expand Down Expand Up @@ -570,7 +581,9 @@ export function SessionScreen({session,block,blockIdx,totalBlocks,setNum,phase,i
<div style={{padding:"18px 20px 0"}}>
{/* Kicker — the room's live state: set count + slot. Never a sentence. */}
<div style={{fontSize:13,color:T.ink2,marginBottom:8}}>
Set <span style={{fontFamily:T.measured}}>{setNum}</span> of <span style={{fontFamily:T.measured}}>{block.sets}</span> · {block.label}{isSS?` ${phase}`:""}
{blockDone
? <>All <span style={{fontFamily:T.measured}}>{block.sets}</span> {isSS?"rounds":"sets"} logged · {block.label}</>
: <>Set <span style={{fontFamily:T.measured}}>{setNum}</span> of <span style={{fontFamily:T.measured}}>{block.sets}</span> · {block.label}{isSS?` ${phase}`:""}</>}
</div>
<div style={{display:"flex",alignItems:"flex-start",justifyContent:"space-between",gap:12}}>
<div onClick={resolveVid(activeEx?.name, activeEx?.vid) ? ()=>setShowVid(true) : undefined}
Expand Down Expand Up @@ -829,7 +842,7 @@ export function SessionScreen({session,block,blockIdx,totalBlocks,setNum,phase,i
</div>
)}
<div style={{margin:"12px 20px 0",display:"flex",gap:12,alignItems:"center"}}>
{showRestHint&&(
{showRestHint&&!(blockDone&&!adding)&&(
<button
onClick={()=>{if(restActive){setRestActive(false);setRestRemain(block.rest);}else{setRestRemain(block.rest);setRestActive(true);}}}
aria-label={restActive?`Resting, ${restStr} left — tap to skip`:"Start rest timer"}
Expand All @@ -843,12 +856,23 @@ export function SessionScreen({session,block,blockIdx,totalBlocks,setNum,phase,i
{restActive?restStr:`${Math.round(block.rest/60)}:00`}
</button>
)}
<button className="forge-press" onClick={()=>{haptic.tap();onLog();}}
style={{flex:1,height:56,background:T.commit,border:"none",borderRadius:T.r,cursor:"pointer",
display:"flex",alignItems:"center",justifyContent:"center",
fontFamily:T.text,fontSize:17,fontWeight:500,color:T.commitInk,boxShadow:T.elevStrong}}>
{isSS?(phase==="A"?"Log A — into B":"Log B — round done"):"Log set"}
</button>
{blockDone&&!adding?(
/* Revisiting: the ledger above is the point, so the thumb zone
offers a quiet way onward rather than a primed commit. */
<button className="forge-press" onClick={()=>{haptic.tap();setAddKey(thisKey);}}
style={{flex:1,height:56,background:"transparent",border:`1px solid ${T.rule}`,borderRadius:T.r,cursor:"pointer",
display:"flex",alignItems:"center",justifyContent:"center",
fontFamily:T.text,fontSize:15,fontWeight:500,color:T.ink2}}>
Add another set
</button>
):(
<button className="forge-press" onClick={()=>{haptic.tap();onLog();}}
style={{flex:1,height:56,background:T.commit,border:"none",borderRadius:T.r,cursor:"pointer",
display:"flex",alignItems:"center",justifyContent:"center",
fontFamily:T.text,fontSize:17,fontWeight:500,color:T.commitInk,boxShadow:T.elevStrong}}>
{isSS?(phase==="A"?"Log A — into B":"Log B — round done"):(blockDone?`Log set ${setNum}`:"Log set")}
</button>
)}
</div>
{showRestHint&&restActive&&(
<div style={{padding:"0 20px"}}>
Expand Down
Loading