Skip to content

Commit 1e4c12c

Browse files
Alvis1claude
andcommitted
Preview mode, consent-7 eval texts, SDF glyph redesigns
- Preview mode: right-click a node → Preview (per socket), or ⌘/Ctrl+click, routes that socket to the Output's Color for the 3D view only. It is a derived graph the preview pass alone sees — no history, no autosave, the code panel and export untouched. Everything else dims and a straight line runs to the Output. Node marks are data attributes, because React Flow's wrapper className write erases hand-added classes. Meta/Control left multiSelectionKeyCode, which is now Shift only. - Eval texts (consent-7): participant-facing sentences tightened and the Latvian corrected; node labels reviewed (LV) and two EN labels/descriptions aligned with the vec3 sockets and the settings-menu source picker. - Node Designer saves: redrawn SDF-family glyphs. - glyphPath.test: the shipped-path insert sweep samples its REFERENCE polyline at 150 chords. At 60, chord sag on sdfDeform's new S-curve read a correct split as 0.011 (measured true deviation 0.003–0.004, the 2-decimal quantization); explicit 30 s timeout for the sweep. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 3c61a07 commit 1e4c12c

26 files changed

Lines changed: 1173 additions & 346 deletions

‎CLAUDE.md‎

Lines changed: 4 additions & 1 deletion
Large diffs are not rendered by default.

‎EVAL_MODE_PLAN.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,3 +327,4 @@ say about itself.
327327
30. **Four experience questions before the SUS.** Blender Shader Editor, Unreal Engine Material Editor, other node-based editors (with a free-text "which software, and your level"), and shader programming (GLSL/HLSL) — one shared 5-point scale, **none → expert**, stored 0–4 with its label beside it so a reader never has to guess what 2 meant. They come BEFORE the SUS so that thinking about one's own expertise does not colour the SUS answers, and they are the covariate the score is read against: "easy to learn" means one thing from a Blender veteran and another from someone who has never seen a node graph. The four scales are required (they are quick, and a missing covariate weakens every comparison); the free text is optional, since a participant with no such experience has nothing to name. They ride `sus.json`'s `background` block rather than a new zip entry — one questionnaire, one file — with the disclosure line for `sus.json` updated to say so and `CONSENT_TEXT_VERSION` bumped to consent-5. `summary.csv` gains `exp_*` columns and the analysis table prints them as `blender/unreal/other/code`.
328328
31. **A third entry for professionals: `/evalpro`.** The ordinary study plus a block about the participant's working context, asked between the experience questions and the SUS. Opt-IN (`?pro=on`), unlike the two suppression flags — an absent parameter must never hand a student the professional block — and a separate entry for the same reason `/evalp` is one: which questionnaire someone gets must not depend on typing a query string correctly. It composes with the others, so `/evalpro/?task=T7-fire&costbar=off` is a professional in the no-cost-bar arm. The scales gate submit; the free-text questions never do. It rides `sus.json`'s `professional` block (one questionnaire, one file — no new zip entry), with the disclosure line and `CONSENT_TEXT_VERSION` (consent-6) moved together. **The questions themselves are a DRAFT** (`proQuestions.ts` — role, years in 3D/real-time, how often shaders are authored, how often GPU performance constrains the work, target platforms, whether the tool could be useful in their own work): the mechanism is finished, the content is one array for the researcher to review and replace. Two of them are chosen to speak to this study directly — the GPU-budget question is the cost-feedback arm's own covariate, and the last is the adoption signal a tool paper wants.
329329

330+
32. **consent-7 (2026-09-10): every participant-facing sentence tightened, Latvian corrected.** Owner review of the eval texts. The consent SUMMARY no longer says the study server is run "not by the university" and no longer carries the sentence about the optional email revealing the sender address — both by owner decision; the disclosure ("Where it goes") and the thank-you screen still state the email fact once each, beside the button it describes. The Latvian now uses the app's own term *ēnotājs* (with one "(shader)" gloss on first mention) instead of *šeiders*, which the rest of the UI never used; the disclosure's close button was keyed on `Back`, which lv.json renders as "Aizmugure" (the material side) — it is `Close` now; Brooke's "mark the centre point" instruction moved onto the SUS block it applies to (the experience strip above it runs none→expert, where a centre point means nothing); the finish dialog no longer claims the package is "sent by email" (it is uploaded; email is optional). `evalDisclosure.test.ts` now sweeps `EvalFinishModal`/`SusModal` and every questionnaire question/option label for a Latvian entry.

‎src/components/Layout/Toolbar.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -550,7 +550,7 @@ export function Toolbar() {
550550
}}
551551
title={
552552
isEvalMode()
553-
? t('Finish the session — answer a short questionnaire, then submit your shader and session data to the researcher', language)
553+
? t('Finish the session: a short questionnaire, then your shader and session data are submitted', language)
554554
: `${t('Download the shader — .js with the FastShaders project embedded (drag it back in to continue); becomes a .zip with the image and 3D-model files alongside when the graph embeds images or a custom preview mesh is loaded', language)}. ${
555555
previewMesh && !exportIncludeMesh
556556
? t('The 3D model is currently excluded from the export — right-click to change.', language)
@@ -1005,7 +1005,7 @@ export function Toolbar() {
10051005
{isEvalMode() && (
10061006
<span
10071007
className="toolbar__eval-badge"
1008-
title={t('Evaluation session — interactions are being recorded for the study', language)}
1008+
title={t('Study session — your actions in the editor are being recorded', language)}
10091009
>
10101010
EVAL
10111011
</span>

‎src/components/NodeEditor/NodeEditor.tsx‎

Lines changed: 141 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import {
2828
type Activation,
2929
} from './labelPeek';
3030
import { useLongPress } from '@/hooks/useLongPress';
31+
import { PreviewRoute } from './PreviewRoute';
32+
import { previewableOutputs, PREVIEW_KEEP_SELECTOR } from '@/utils/nodePreview';
3133
import { nodeTypes, edgeTypes } from './flowTypes';
3234
import { CONNECTION_RADIUS } from './nodes/connectionReveal';
3335
import { clearSocketTapTooltip } from './handles/TypedHandle';
@@ -81,7 +83,7 @@ import {
8183
readStoredViewport, writeStoredViewport, VIEWPORT_MIN_ZOOM, VIEWPORT_MAX_ZOOM,
8284
type StoredViewport,
8385
} from '@/utils/viewportMemory';
84-
import { anyOutputDormant } from '@/utils/outputMaterials';
86+
import { anyOutputDormant, findDefaultOutput } from '@/utils/outputMaterials';
8587
import { CostBar } from '@/components/Layout/CostBar';
8688
import { PreviewLink } from '@/components/Layout/PreviewLink';
8789
import { getCostScale, canvasInkColor } from '@/utils/colorUtils';
@@ -212,14 +214,17 @@ const NODE_MENU_TYPES: Record<string, ContextMenuType> = {
212214
};
213215
/**
214216
* Modifiers that make a click ADD to the selection instead of replacing it.
215-
* Shift is the one users reach for; Cmd/Ctrl (React Flow's platform default)
216-
* stays in so existing habits keep working. An ARRAY means "any of these", not
217-
* a combination — a '+' inside one entry would be the combination form.
218-
* Module-scope so the array identity is stable: useKeyPress memoizes on it, so
219-
* a fresh array per render would re-bind its key listeners every frame of a
220-
* drag (the same reason the objects above are hoisted).
217+
* Shift ONLY. Cmd/Ctrl (React Flow's platform default) were in this list until
218+
* 2026-09-10, when ⌘/Ctrl+click became PREVIEW MODE (see onNodeClick): one
219+
* modifier cannot both extend the selection and route a node to the 3D view,
220+
* and Shift+click still adds to the selection, so nothing is lost. An ARRAY
221+
* means "any of these", not a combination — a '+' inside one entry would be
222+
* the combination form. Module-scope so the array identity is stable:
223+
* useKeyPress memoizes on it, so a fresh array per render would re-bind its
224+
* key listeners every frame of a drag (the same reason the objects above are
225+
* hoisted).
221226
*/
222-
const MULTI_SELECT_KEYS = ['Shift', 'Meta', 'Control'];
227+
const MULTI_SELECT_KEYS = ['Shift'];
223228

224229
// The edge-snap radius (CONNECTION_RADIUS) lives in connectionReveal.ts —
225230
// shared with the drag-reveal system so hidden sockets appear at exactly
@@ -727,6 +732,103 @@ export function NodeEditor() {
727732
// detached by then and removing a class from it is a no-op.
728733
return () => el?.classList.remove('fs-menu-active');
729734
}, [menuNodeId]);
735+
736+
/**
737+
* PREVIEW MODE (utils/nodePreview.ts): one node's output stands in for the
738+
* Output's Color channel on the 3D view. Entered from the node's right-click
739+
* menu (menuShared's NodeActions) or by ⌘/Ctrl+click (onNodeClick below);
740+
* left by a press anywhere else, by Escape, by ⌘/Ctrl+clicking the node
741+
* again, or by the graph being replaced. The mode is STORE state because the
742+
* sync engine must emit the rerouted `previewCode`; everything visual here
743+
* is imperative — the `fs-menu-active` idiom above — because looking at a
744+
* node is not an edit and must not push through history or the autosave.
745+
* The canvas carries `fs-previewing` (everything else dims, NodeBase.css),
746+
* the two ends carry `data-fs-preview="src"` / `"dst"`, and PreviewRoute
747+
* draws the straight line between them.
748+
*
749+
* The marks are ATTRIBUTES, not classes — the one way this differs from
750+
* `fs-menu-active` above, and it is measured, not taste. React Flow's node
751+
* wrapper renders its `className` from props (`selected`, `dragging`, …),
752+
* and React writes the whole string whenever that prop changes, which
753+
* erases anything added by hand: a ⌘/Ctrl+click both selects the node and
754+
* starts the preview, and the `selected` write landed AFTER this effect's
755+
* `classList.add` (MutationObserver, old value carrying the class, new
756+
* value without it) — so the previewed node dimmed with the rest. A drag
757+
* of the previewed node would do the same on `dragging`. An attribute the
758+
* wrapper never renders is left alone by every re-render.
759+
*/
760+
const previewSrcId = useAppStore((s) => s.nodePreview?.nodeId ?? null);
761+
// The plain Output the route lands on — the same node the rerouted module
762+
// feeds (findDefaultOutput inside previewGraph). Null while not previewing,
763+
// so the scan runs only for the mode's own notifications.
764+
const previewDstId = useAppStore((s) =>
765+
s.nodePreview ? findDefaultOutput(s.nodes)?.id ?? null : null,
766+
);
767+
useEffect(() => {
768+
if (!previewSrcId) return;
769+
const mark = (id: string | null, role: 'src' | 'dst'): (() => void) => {
770+
if (!id) return () => {};
771+
let el: Element | null = null;
772+
try {
773+
// Node ids come out of .fastshader files — escaped for the reason the
774+
// menu-active effect gives.
775+
el = document.querySelector(`.react-flow__node[data-id="${CSS.escape(id)}"]`);
776+
} catch {
777+
return () => {};
778+
}
779+
el?.setAttribute('data-fs-preview', role);
780+
return () => el?.removeAttribute('data-fs-preview');
781+
};
782+
const unmarkSrc = mark(previewSrcId, 'src');
783+
const unmarkDst = mark(previewDstId, 'dst');
784+
return () => {
785+
unmarkSrc();
786+
unmarkDst();
787+
};
788+
}, [previewSrcId, previewDstId]);
789+
// A press ANYWHERE but the previewed node ends the mode. POINTERDOWN in the
790+
// CAPTURE phase, for the reasons ContextMenu's outside-press closer gives:
791+
// React Flow stops click propagation for its own gestures, and a press that
792+
// becomes a drag never produces a click while being unambiguously "I am
793+
// doing something else now". Exempt: the node itself (scrubbing its own
794+
// number boxes is part of looking at it), the menus a right-click on it
795+
// opens (portalled popovers included) and the 3D pane — orbiting the model
796+
// IS looking at the preview — all via PREVIEW_KEEP_SELECTOR; and the MIDDLE
797+
// button, since panning around the canvas is not a click. A press on
798+
// ANOTHER node ends this preview first, so ⌘/Ctrl+click and a Preview row
799+
// on that node then start theirs from a clean state.
800+
useEffect(() => {
801+
if (!previewSrcId) return;
802+
const onPointerDown = (e: PointerEvent) => {
803+
if (e.button === 1) return;
804+
const target = e.target as Element | null;
805+
if (!target) return;
806+
if (target.closest?.(PREVIEW_KEEP_SELECTOR)) return;
807+
let srcEl: Element | null = null;
808+
try {
809+
srcEl = document.querySelector(`.react-flow__node[data-id="${CSS.escape(previewSrcId)}"]`);
810+
} catch {
811+
srcEl = null;
812+
}
813+
if (srcEl?.contains(target)) return;
814+
useAppStore.getState().setNodePreview(null);
815+
};
816+
document.addEventListener('pointerdown', onPointerDown, true);
817+
return () => document.removeEventListener('pointerdown', onPointerDown, true);
818+
}, [previewSrcId]);
819+
// The graph being REPLACED ends it outright — an import or NEW. The sync
820+
// engine also drops a target whose node is gone, but a re-import of the
821+
// same file keeps the same ids, and a preview surviving "load this shader"
822+
// would show the loaded document through the previous one's routing.
823+
useEffect(() => {
824+
const clear = () => useAppStore.getState().setNodePreview(null);
825+
window.addEventListener('fs:graph-imported', clear);
826+
window.addEventListener('fs:graph-new', clear);
827+
return () => {
828+
window.removeEventListener('fs:graph-imported', clear);
829+
window.removeEventListener('fs:graph-new', clear);
830+
};
831+
}, []);
730832
/**
731833
* The node whose socket labels are held open by a double-click / double-tap
732834
* (see labelPeek.ts). Local state: nothing outside this component opens it,
@@ -757,6 +859,26 @@ export function NodeEditor() {
757859
* carries a long-press beside its own double-click handler).
758860
*/
759861
const onNodeClick = useCallback((e: React.MouseEvent, node: AppNode) => {
862+
// ⌘/Ctrl+click → PREVIEW MODE, toggling off on the node already previewed
863+
// (the outside-press closer has already ended any OTHER node's preview by
864+
// the time this click lands). Above the double-click pairing, so a
865+
// modified click never counts toward a label peek. A node's own widgets
866+
// keep their click (PEEK_EXEMPT_SELECTOR — the Sound node's arm light is
867+
// a real button), and a node with no output has nothing to route, so
868+
// there the click falls through to ordinary selection. Ctrl+click is the
869+
// right-click on macOS and opens the menu instead — the Preview row
870+
// covers it there; ⌘ is the key on that platform.
871+
if (e.metaKey || e.ctrlKey) {
872+
lastActivationRef.current = null;
873+
const el = e.target as Element | null;
874+
if (el?.closest?.(PEEK_EXEMPT_SELECTOR)) return;
875+
const outs = previewableOutputs(node);
876+
if (outs.length === 0) return;
877+
const store = useAppStore.getState();
878+
const same = store.nodePreview?.nodeId === node.id;
879+
store.setNodePreview(same ? null : { nodeId: node.id, handleId: outs[0].id });
880+
return;
881+
}
760882
const t = Date.now();
761883
const prev = lastActivationRef.current;
762884
lastActivationRef.current = { id: node.id, t };
@@ -1045,7 +1167,12 @@ export function NodeEditor() {
10451167
// …and puts a held-open set of socket labels away. Below draw mode, so
10461168
// one Escape does one thing; it does not return, because Escape is a
10471169
// dismissal key and anything else listening for it still gets its turn.
1048-
if (e.key === 'Escape') setPeekNodeId(null);
1170+
if (e.key === 'Escape') {
1171+
setPeekNodeId(null);
1172+
// …and leaves Preview mode, for the same reason and with the same
1173+
// no-return: a dismissal key does every dismissal it can.
1174+
useAppStore.getState().setNodePreview(null);
1175+
}
10491176

10501177
const mod = e.metaKey || e.ctrlKey;
10511178
// Normalized so Caps Lock (which reports 'C' rather than 'c') doesn't
@@ -3281,7 +3408,7 @@ export function NodeEditor() {
32813408
return (
32823409
<div className="node-editor" style={canvasCssVars}>
32833410
<div
3284-
className={`node-editor__canvas${drawToolActive ? ' fs-draw-active' : ''}${drawToolActive && drawEraser ? ' fs-erase-active' : ''}${connecting ? ' fs-connecting' : ''}`}
3411+
className={`node-editor__canvas${drawToolActive ? ' fs-draw-active' : ''}${drawToolActive && drawEraser ? ' fs-erase-active' : ''}${connecting ? ' fs-connecting' : ''}${previewSrcId ? ' fs-previewing' : ''}`}
32853412
ref={canvasRef}
32863413
// HTML5 drag wandering off the canvas (into the code editor / assets
32873414
// bar) must tear down the live previews — dragover stops firing here,
@@ -3378,7 +3505,7 @@ export function NodeEditor() {
33783505
nodesDraggable={!drawToolActive}
33793506
elementsSelectable={!drawToolActive}
33803507
selectionMode={SelectionMode.Partial}
3381-
// Shift+click adds to the selection (Cmd/Ctrl still do too).
3508+
// Shift+click adds to the selection (⌘/Ctrl+click is Preview mode).
33823509
multiSelectionKeyCode={MULTI_SELECT_KEYS}
33833510
// Shift MUST be released from its DEFAULT job (hold-to-marquee) for
33843511
// the line above to work at all: while selectionKeyCode is held, the
@@ -3433,6 +3560,9 @@ export function NodeEditor() {
34333560
cards, above the canvas bg) and is clipped by the pane, so it
34343561
tucks behind the code/preview frames. */}
34353562
<PreviewLink />
3563+
{/* Preview mode's straight route line — ABOVE the nodes (z 5, before
3564+
the panels in DOM order so the canvas bar still wins). */}
3565+
<PreviewRoute />
34363566
<DrawingLayer livePathRef={livePathRef} />
34373567
{/* Bottom-LEFT canvas bar: undo/redo + draw tools + view controls
34383568
in one pill (replaces the old toolbar history group, the RF
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/* Preview mode's straight route line (PreviewRoute.tsx). A child of
2+
`.react-flow`, absolutely positioned over the whole pane, ABOVE the node
3+
layer: React Flow's renderer is z 4 and its panels z 5, and this is
4+
mounted before the panels in DOM order, so the canvas bar and the cost pill
5+
still paint over it. `pointer-events: none` keeps the canvas interactive. */
6+
.preview-route {
7+
position: absolute;
8+
inset: 0;
9+
width: 100%;
10+
height: 100%;
11+
pointer-events: none;
12+
overflow: visible;
13+
z-index: 5;
14+
transition: opacity 160ms ease;
15+
}
16+
17+
/* Solid, in the app's focus blue — the colour of every "this is the thing you
18+
are acting on" indicator on the canvas (drag-connect's socket ring, the
19+
keyboard focus ring). A STRAIGHT line, deliberately unlike every wire, so it
20+
reads as a route the preview takes rather than a connection that exists.
21+
Hard offset shadow, no blur: the app-wide idiom. */
22+
.preview-route__line {
23+
stroke: var(--border-focus);
24+
stroke-width: 2.5;
25+
stroke-linecap: round;
26+
filter: drop-shadow(1px 1px 0 var(--node-cost-text-shadow, rgba(0, 0, 0, 0.35)));
27+
}
28+
29+
.preview-route__dot {
30+
fill: var(--border-focus);
31+
}

0 commit comments

Comments
 (0)