diff --git a/packages/core/src/variables.ts b/packages/core/src/variables.ts index da4f382fb9..662be9df55 100644 --- a/packages/core/src/variables.ts +++ b/packages/core/src/variables.ts @@ -23,7 +23,9 @@ export { parseCompositionVariables, isCompositionVariable, isScalarVariableValue, + scanVariableUsage, } from "@hyperframes/parsers/composition"; +export type { VariableUsageScan } from "@hyperframes/parsers/composition"; export { getVariables, readDeclaredDefaults } from "./runtime/getVariables.js"; export { diff --git a/packages/parsers/src/composition.ts b/packages/parsers/src/composition.ts index e4cef33e6b..6ed6ab617c 100644 --- a/packages/parsers/src/composition.ts +++ b/packages/parsers/src/composition.ts @@ -15,3 +15,4 @@ export { isCompositionVariable, isScalarVariableValue, } from "./compositionVariables.js"; +export { scanVariableUsage, type VariableUsageScan } from "./variableUsage.js"; diff --git a/packages/parsers/src/index.ts b/packages/parsers/src/index.ts index b8d8ccd203..57a8f625b4 100644 --- a/packages/parsers/src/index.ts +++ b/packages/parsers/src/index.ts @@ -12,6 +12,7 @@ export { queryByAttr } from "./utils/cssSelector.js"; // path helpers live behind the ./asset-paths subpath to keep this entry // browser-safe. export { decodeUrlPathVariants } from "./utils/urlPath.js"; +export { scanVariableUsage, type VariableUsageScan } from "./variableUsage.js"; export { FONT_ALIAS_MAP, FONT_ALIAS_KEYS, diff --git a/packages/parsers/src/variableUsage.test.ts b/packages/parsers/src/variableUsage.test.ts new file mode 100644 index 0000000000..79a9d3241f --- /dev/null +++ b/packages/parsers/src/variableUsage.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from "vitest"; +import { scanVariableUsage } from "./variableUsage.js"; + +describe("scanVariableUsage", () => { + it("collects ids from direct destructuring with defaults", () => { + const scan = scanVariableUsage(` + const { title = "Untitled", accent } = __hyperframes.getVariables(); + document.querySelector("h1").textContent = title; + `); + expect(scan.usedIds).toEqual(["title", "accent"]); + expect(scan.scanIncomplete).toBe(false); + }); + + it("handles bare getVariables (sub-comp scoped shadow) and window-qualified calls", () => { + expect(scanVariableUsage(`const { a } = getVariables();`).usedIds).toEqual(["a"]); + expect(scanVariableUsage(`const { b } = window.__hyperframes.getVariables();`).usedIds).toEqual( + ["b"], + ); + }); + + it("collects ids from member access on the call and on an alias", () => { + const scan = scanVariableUsage(` + const x = __hyperframes.getVariables().headline; + const vars = __hyperframes.getVariables(); + el.style.color = vars.accent; + const size = vars["font-size"]; + const { title } = vars; + `); + expect(scan.usedIds).toEqual(["headline", "accent", "font-size", "title"]); + expect(scan.scanIncomplete).toBe(false); + }); + + it("collects string-literal destructuring keys", () => { + const scan = scanVariableUsage(`const { "kebab-id": kebab } = getVariables();`); + expect(scan.usedIds).toEqual(["kebab-id"]); + expect(scan.scanIncomplete).toBe(false); + }); + + it("flags dynamic access as incomplete without losing static ids", () => { + const scan = scanVariableUsage(` + const vars = getVariables(); + const known = vars.known; + const dynamic = vars[someKey]; + `); + expect(scan.usedIds).toEqual(["known"]); + expect(scan.scanIncomplete).toBe(true); + }); + + it("flags rest spreads, escaping values, and chained aliases", () => { + expect(scanVariableUsage(`const { a, ...rest } = getVariables();`).scanIncomplete).toBe(true); + expect(scanVariableUsage(`render(getVariables());`).scanIncomplete).toBe(true); + expect( + scanVariableUsage(`const vars = getVariables(); const v2 = vars; use(v2.x);`).scanIncomplete, + ).toBe(true); + }); + + it("flags unparseable scripts as incomplete", () => { + const scan = scanVariableUsage(`const { = broken`); + expect(scan.usedIds).toEqual([]); + expect(scan.scanIncomplete).toBe(true); + }); + + it("ignores unrelated code and same-named object keys", () => { + const scan = scanVariableUsage(` + const vars = getVariables(); + const config = { vars: 1, other: vars.real }; + gsap.timeline({ paused: true }); + `); + expect(scan.usedIds).toEqual(["real"]); + expect(scan.scanIncomplete).toBe(false); + }); + + it("returns empty for scripts that never touch variables", () => { + const scan = scanVariableUsage(`gsap.timeline({ paused: true }).to(".x", { opacity: 1 });`); + expect(scan.usedIds).toEqual([]); + expect(scan.scanIncomplete).toBe(false); + }); +}); diff --git a/packages/parsers/src/variableUsage.ts b/packages/parsers/src/variableUsage.ts new file mode 100644 index 0000000000..7798cbafbb --- /dev/null +++ b/packages/parsers/src/variableUsage.ts @@ -0,0 +1,172 @@ +/** + * Browser-safe static scan for composition-variable reads in script text. + * + * Compositions read variables by calling the runtime API — `getVariables()` + * bare (sub-comp scoped shadow) or via `__hyperframes.getVariables()` / + * `window.__hyperframes.getVariables()` — and there is no DOM-attribute + * binding to scan, so "which variables does this composition use" can only be + * derived from the scripts. This is a best-effort static analysis: the + * patterns agents actually write (destructuring, member access, a single + * alias variable) resolve to ids; anything opaque flips `scanIncomplete` + * so consumers can present usage as a lower bound instead of a fact. + * + * AST nodes are handled untyped (same convention as gsapParserAcorn.ts) — + * acorn's structural types don't survive acorn-walk's visitor signatures. + */ + +import * as acorn from "acorn"; +import * as acornWalk from "acorn-walk"; + +export interface VariableUsageScan { + /** Variable ids statically read by the script, in first-seen order. */ + usedIds: string[]; + /** + * True when the script accesses variables in a way the scan cannot resolve + * (computed keys, rest spreads, the values object escaping into a call…) or + * when the script fails to parse — usedIds is then a lower bound. + */ + scanIncomplete: boolean; +} + +interface Sink { + use(id: string): void; + incomplete(): void; +} + +// oxlint-disable no-explicit-any -- untyped acorn AST traversal, see header + +function isGetVariablesCallee(callee: any): boolean { + if (callee?.type === "Identifier") return callee.name === "getVariables"; + if (callee?.type === "MemberExpression" && !callee.computed) { + return callee.property?.type === "Identifier" && callee.property.name === "getVariables"; + } + return false; +} + +/** Collect ids from an ObjectPattern destructuring of the values object. */ +// Exhaustive AST-node classification — branchy by nature, same as gsapParserAcorn. +// fallow-ignore-next-line complexity +function collectFromObjectPattern(pattern: any, out: Sink): void { + for (const prop of pattern.properties ?? []) { + if (prop?.type === "RestElement") { + out.incomplete(); + continue; + } + if (prop?.type !== "Property") continue; + if (prop.computed === true) { + out.incomplete(); + } else if (prop.key?.type === "Identifier") { + out.use(String(prop.key.name)); + } else if (prop.key?.type === "Literal" && typeof prop.key.value === "string") { + out.use(prop.key.value); + } else { + out.incomplete(); + } + } +} + +/** Collect an id from a MemberExpression reading the values object. */ +function collectFromMemberAccess(member: any, out: Sink): void { + if (member.computed !== true && member.property?.type === "Identifier") { + out.use(String(member.property.name)); + } else if ( + member.computed === true && + member.property?.type === "Literal" && + typeof member.property.value === "string" + ) { + out.use(member.property.value); + } else { + out.incomplete(); + } +} + +/** + * Classify one read of the values object (a getVariables() call result or an + * alias holding it) by its immediate syntactic context. Returns the alias + * name when the value is bound to a plain variable (`const vars = …`). + */ +// fallow-ignore-next-line complexity +function classifyValueRead(parent: any, valueNode: any, out: Sink): string | null { + if (!parent || parent.type === "ExpressionStatement") { + // Bare statement — value unused, nothing read. + return null; + } + if (parent.type === "MemberExpression" && parent.object === valueNode) { + collectFromMemberAccess(parent, out); + return null; + } + if (parent.type === "VariableDeclarator" && parent.init === valueNode) { + if (parent.id?.type === "ObjectPattern") { + collectFromObjectPattern(parent.id, out); + return null; + } + if (parent.id?.type === "Identifier") return String(parent.id.name); + out.incomplete(); + return null; + } + // The values object escapes (argument, return, spread, assignment…) — + // reads beyond this point are invisible to the scan. + out.incomplete(); + return null; +} + +export function scanVariableUsage(scriptText: string): VariableUsageScan { + const usedIds: string[] = []; + const seen = new Set(); + let scanIncomplete = false; + + const sink: Sink = { + use(id: string) { + if (!seen.has(id)) { + seen.add(id); + usedIds.push(id); + } + }, + incomplete() { + scanIncomplete = true; + }, + }; + + let ast: any; + try { + ast = acorn.parse(scriptText, { ecmaVersion: "latest", sourceType: "script" }); + } catch { + return { usedIds: [], scanIncomplete: true }; + } + + const aliases = new Set(); + + // Pass 1: classify every getVariables() call by its parent context. + acornWalk.ancestor(ast, { + CallExpression(node: any, _: unknown, ancestors: any[]) { + if (!isGetVariablesCallee(node.callee)) return; + const parent = ancestors[ancestors.length - 2]; + const alias = classifyValueRead(parent, node, sink); + if (alias) aliases.add(alias); + }, + } as any); + + // Pass 2: classify every reference to an alias of the values object. + // Scope-naive by design: an unrelated same-named identifier can only make + // the scan report extra ids or flip scanIncomplete, never miss a read. + if (aliases.size > 0) { + acornWalk.ancestor(ast, { + // fallow-ignore-next-line complexity + Identifier(node: any, _: unknown, ancestors: any[]) { + if (!aliases.has(String(node.name))) return; + const parent = ancestors[ancestors.length - 2]; + if (!parent) return; + // Skip the declarator that introduced the alias and property-position + // identifiers that merely share the name. + if (parent.type === "VariableDeclarator" && parent.id === node) return; + if (parent.type === "MemberExpression" && parent.property === node) return; + if (parent.type === "Property" && parent.key === node && parent.computed !== true) return; + // Chained aliases (const v2 = vars) are not followed — flag instead + // of silently missing reads through the second name. + if (classifyValueRead(parent, node, sink)) sink.incomplete(); + }, + } as any); + } + + return { usedIds, scanIncomplete }; +} diff --git a/packages/sdk/src/adapters/types.ts b/packages/sdk/src/adapters/types.ts index 07ece0683b..f4a307fdc7 100644 --- a/packages/sdk/src/adapters/types.ts +++ b/packages/sdk/src/adapters/types.ts @@ -73,4 +73,13 @@ export interface PreviewAdapter { // Stage 8 prep: fired when the preview host changes selection (e.g. user clicks an element). // Not wired up in stage 7 — callers listen to the session's own selectionchange event instead. on(event: "selection", handler: (ids: string[]) => void): () => void; + + /** + * Optional: apply composition-variable values to the preview so it renders + * as `window.__hfVariables` injection would at render time (values must be + * visible to the runtime BEFORE composition scripts run — typically a + * preview reload with injection, not a live poke). Pass null to restore + * declared defaults. Values are ephemeral preview state, never persisted. + */ + setPreviewVariables?(values: Record | null): void; } diff --git a/packages/sdk/src/engine/mutate.ts b/packages/sdk/src/engine/mutate.ts index 1acc0c1e70..61dcce69a0 100644 --- a/packages/sdk/src/engine/mutate.ts +++ b/packages/sdk/src/engine/mutate.ts @@ -593,6 +593,7 @@ function handleSetTiming( } // Flush accumulated GSAP script changes as a single patch pair. + // fallow-ignore-next-line code-duplication if (origScript && currentScript && currentScript !== origScript) { setGsapScript(parsed.document, currentScript); const gsapResult = gsapScriptChange(origScript, currentScript); @@ -662,6 +663,7 @@ function handleRemoveElement(parsed: ParsedDocument, ids: HfId[]): MutationResul } } + // fallow-ignore-next-line code-duplication if (origScript && currentScript && currentScript !== origScript) { setGsapScript(parsed.document, currentScript); const gsapResult = gsapScriptChange(origScript, currentScript); @@ -1625,6 +1627,7 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult { case "removeElement": { const ids = targets(op.target); if (ids.length === 0) return canErr("E_TARGET_NOT_FOUND", "No target ids provided."); + // fallow-ignore-next-line code-duplication const missing = ids.filter((id) => resolveScoped(parsed.document, id) === null); if (missing.length > 0) return canErr( @@ -1660,6 +1663,7 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult { } case "reorderElements": { if (op.entries.length === 0) return CAN_OK; + // fallow-ignore-next-line code-duplication const missing = op.entries .map((e) => e.target) .filter((id) => resolveScoped(parsed.document, id) === null); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 0e61a5b0bb..8056835214 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -28,7 +28,9 @@ export type { CompositionVariable, CompositionVariableType, VariableValidationIssue, + VariableUsageScan, } from "@hyperframes/core/variables"; +export type { VariableUsageReport } from "./types.js"; export { UnsupportedOpError } from "./engine/mutate.js"; diff --git a/packages/sdk/src/session.ts b/packages/sdk/src/session.ts index 799556306f..fbac623e2a 100644 --- a/packages/sdk/src/session.ts +++ b/packages/sdk/src/session.ts @@ -27,6 +27,7 @@ import type { PersistErrorEvent, SelectionProxy, ElementHandle, + VariableUsageReport, } from "./types.js"; import { ORIGIN_APPLY_PATCHES, ORIGIN_LOCAL } from "./types.js"; import { buildRoots, flatElements, parsedAnimationIds } from "./document.js"; @@ -38,7 +39,11 @@ import { getGsapScript, resolveScoped } from "./engine/model.js"; import { extractGsapLabels } from "@hyperframes/core/gsap-parser-acorn"; import { stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler/html-document"; import { parseStartExpression } from "@hyperframes/core/runtime/start-expression"; -import { readDeclaredDefaults, validateVariables } from "@hyperframes/core/variables"; +import { + readDeclaredDefaults, + validateVariables, + scanVariableUsage, +} from "@hyperframes/core/variables"; import type { CompositionVariable, VariableValidationIssue } from "@hyperframes/core/variables"; import { readVariableDeclarations } from "./engine/variableModel.js"; import { serializeDocument } from "./engine/serialize.js"; @@ -70,6 +75,11 @@ export interface OpenCompositionOptions { // ─── Implementation ─────────────────────────────────────────────────────────── +/** Escape a string for literal use inside a RegExp. */ +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + class CompositionImpl implements Composition { private readonly parsed: ParsedDocument; private readonly persist: PersistAdapter | undefined; @@ -191,6 +201,75 @@ class CompositionImpl implements Composition { return validateVariables(values, this.getVariableDeclarations()); } + /** + * Script scans are content-keyed (same rationale as _gsapLabelCache): the + * panel recomputes usage on every preview reload, and unchanged script text + * is the common case — never pay a second acorn parse for identical input. + */ + private _variableUsageScanCache = new Map>(); + + // Scan/merge dispatcher — same complexity class as the suppressed + // variableUsage.ts classifiers it drives. + // fallow-ignore-next-line complexity + getVariableUsage(): VariableUsageReport { + const usedIds: string[] = []; + const seen = new Set(); + let scanIncomplete = false; + const freshCache = new Map>(); + // Inline scripts only — external src scripts aren't part of the document model. + for (const script of Array.from(this.parsed.document.querySelectorAll("script"))) { + if (script.getAttribute("src")) continue; + const text = script.textContent ?? ""; + // Direct global reads (window.__hfVariables / __hfVariablesByComp) are + // invisible to the getVariables() scanner — the report must degrade to + // a lower bound instead of confidently claiming declarations unused. + if (text.includes("__hfVariables")) scanIncomplete = true; + if (!text.includes("getVariables")) continue; // cheap pre-filter before an acorn parse + const scan = this._variableUsageScanCache.get(text) ?? scanVariableUsage(text); + freshCache.set(text, scan); + scanIncomplete = scanIncomplete || scan.scanIncomplete; + for (const id of scan.usedIds) { + if (!seen.has(id)) { + seen.add(id); + usedIds.push(id); + } + } + } + this._variableUsageScanCache = freshCache; + const declaredIds = this.getVariableDeclarations().map((d) => d.id); + // The CSS compat channel counts as usage: a variable consumed only via + // var(--id) in stylesheets or inline styles must not be badged unused + // (removing it also removes the --{id} root prop and breaks the binding). + const cssParts: string[] = []; + for (const styleEl of Array.from(this.parsed.document.querySelectorAll("style"))) { + cssParts.push(styleEl.textContent ?? ""); + } + for (const el of Array.from(this.parsed.document.querySelectorAll("[style]"))) { + cssParts.push(el.getAttribute("style") ?? ""); + } + const cssText = cssParts.join("\n"); + // Match var(--id) only at a custom-property-name boundary: the id must be + // followed by whitespace, a comma (fallback), or the closing paren — so id + // "foo" is NOT counted as used by an unrelated var(--foo-header). Ids are + // regex-escaped because a value read from disk may predate can()'s + // /^[A-Za-z_][A-Za-z0-9_-]*$/ enforcement and carry metacharacters. + const cssUsed = (id: string) => + new RegExp(`var\\(\\s*--${escapeRegExp(id)}[\\s,)]`).test(cssText); + const declaredSet = new Set(declaredIds); + return { + usedIds, + unusedDeclarations: declaredIds.filter((id) => !seen.has(id) && !cssUsed(id)), + undeclaredReads: usedIds.filter((id) => !declaredSet.has(id)), + scanIncomplete, + }; + } + + setPreviewVariables(values: Record | null): boolean { + if (!this.preview?.setPreviewVariables) return false; + this.preview.setPreviewVariables(values); + return true; + } + // ── WS-C: timing accessors + typed setHold ─────────────────────────────────── /** diff --git a/packages/sdk/src/session.variableusage.test.ts b/packages/sdk/src/session.variableusage.test.ts new file mode 100644 index 0000000000..fcdd0eeb0a --- /dev/null +++ b/packages/sdk/src/session.variableusage.test.ts @@ -0,0 +1,128 @@ +/** + * getVariableUsage (declaration ↔ script-scan cross-reference) and + * setPreviewVariables (preview adapter delegation). + */ + +import { describe, it, expect } from "vitest"; +import { openComposition } from "./session.js"; +import type { PreviewAdapter } from "./adapters/types.js"; + +const DECLS = JSON.stringify([ + { id: "title", type: "string", label: "Title", default: "Hello" }, + { id: "accent", type: "color", label: "Accent", default: "#00C3FF" }, + { id: "orphan", type: "string", label: "Never read", default: "x" }, +]); + +function doc(script: string, decls: string | null = DECLS): string { + const attr = decls ? ` data-composition-variables='${decls}'` : ""; + return ` + + +
+

Hello

+
+ + +`; +} + +describe("getVariableUsage", () => { + it("cross-references used, unused, and undeclared ids", async () => { + const comp = await openComposition( + doc(` + const { title, ghost } = __hyperframes.getVariables(); + document.querySelector("h1").textContent = title; + const vars = __hyperframes.getVariables(); + el.style.color = vars.accent; + `), + ); + const usage = comp.getVariableUsage(); + expect(usage.usedIds).toEqual(["title", "ghost", "accent"]); + expect(usage.unusedDeclarations).toEqual(["orphan"]); + expect(usage.undeclaredReads).toEqual(["ghost"]); + expect(usage.scanIncomplete).toBe(false); + }); + + it("reports all declarations unused when no script reads variables", async () => { + const comp = await openComposition(doc(`gsap.timeline({ paused: true });`)); + const usage = comp.getVariableUsage(); + expect(usage.usedIds).toEqual([]); + expect(usage.unusedDeclarations).toEqual(["title", "accent", "orphan"]); + expect(usage.scanIncomplete).toBe(false); + }); + + it("propagates scanIncomplete from opaque access", async () => { + const comp = await openComposition( + doc(`const vars = getVariables(); const v = vars[pickKey()];`), + ); + expect(comp.getVariableUsage().scanIncomplete).toBe(true); + }); + + it("counts a variable used only via var(--id) in a `, + ), + ); + const usage = comp.getVariableUsage(); + // accent is CSS-consumed, so it must NOT be badged unused. + expect(usage.unusedDeclarations).toEqual(["title", "orphan"]); + }); + + it("does not count var(--id) as usage of a prefix-extended custom property", async () => { + // `accent` must not be marked used by an unrelated `var(--accent-shadow)`. + const comp = await openComposition( + doc(`gsap.timeline({ paused: true });`).replace( + "", + ``, + ), + ); + const usage = comp.getVariableUsage(); + expect(usage.unusedDeclarations).toContain("accent"); + }); + + it("handles compositions with no declarations and no scripts", async () => { + const comp = await openComposition( + `

x

`, + ); + expect(comp.getVariableUsage()).toEqual({ + usedIds: [], + unusedDeclarations: [], + undeclaredReads: [], + scanIncomplete: false, + }); + }); +}); + +describe("setPreviewVariables", () => { + function makeAdapter(calls: Array | null>): PreviewAdapter { + return { + elementAtPoint: () => null, + applyDraft: () => {}, + commitPreview: () => {}, + cancelPreview: () => {}, + select: () => {}, + on: () => () => {}, + setPreviewVariables: (values) => calls.push(values), + }; + } + + it("delegates to the preview adapter and reports handling", async () => { + const calls: Array | null> = []; + const comp = await openComposition(doc(""), { preview: makeAdapter(calls) }); + expect(comp.setPreviewVariables({ title: "Custom" })).toBe(true); + expect(comp.setPreviewVariables(null)).toBe(true); + expect(calls).toEqual([{ title: "Custom" }, null]); + }); + + it("returns false without an adapter or without adapter support", async () => { + const noAdapter = await openComposition(doc("")); + expect(noAdapter.setPreviewVariables({ a: 1 })).toBe(false); + + const bare = makeAdapter([]); + delete bare.setPreviewVariables; + const unsupported = await openComposition(doc(""), { preview: bare }); + expect(unsupported.setPreviewVariables({ a: 1 })).toBe(false); + }); +}); diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index b6d63752fc..790cd1366b 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -1,5 +1,24 @@ import type { CompositionVariable, VariableValidationIssue } from "@hyperframes/core/variables"; +/** + * Cross-referenced variable usage for a whole composition: the per-script + * static scans merged and compared against the declared schema. + */ +export interface VariableUsageReport { + /** Variable ids read by composition scripts (static analysis, first-seen order). */ + usedIds: string[]; + /** Declared ids never read by any script. */ + unusedDeclarations: string[]; + /** Ids read by scripts but missing from data-composition-variables. */ + undeclaredReads: string[]; + /** + * True when any script accesses variables opaquely (computed keys, escaping + * values object…) — usedIds is then a lower bound and unusedDeclarations + * may be false positives. + */ + scanIncomplete: boolean; +} + // ─── Document model ─────────────────────────────────────────────────────────── /** Full DOM-level view of one editable element. Built by the SDK adaptation layer. */ @@ -448,6 +467,18 @@ export interface Composition { * `--strict-variables`). Read-only — does not dispatch. */ validateVariableValues(values: Record): VariableValidationIssue[]; + /** + * Cross-reference the declared schema against a static scan of every inline + * composition script (getVariables() reads). Read-only — does not dispatch. + */ + getVariableUsage(): VariableUsageReport; + /** + * Apply variable values to the preview surface (ephemeral — never written + * to the document; use setVariableValue to persist a default). Pass null to + * restore declared defaults. No-op when the preview adapter doesn't + * implement setPreviewVariables; returns whether the adapter handled it. + */ + setPreviewVariables(values: Record | null): boolean; /** * Read enter/exit times and GSAP labels for every timed element (WS-C). * Derives enterAt/exitAt using the same data-duration vs data-end preference