feat(studio): track design-panel input usage across both inspector UIs#2467
Conversation
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at 843b03d85c73e40a40f95856839390ac75a666cb.
What this does
Adds per-input usage instrumentation to the Studio design panel across both the classic PropertyPanel and the flat PropertyPanelFlat UIs (behind STUDIO_FLAT_INSPECTOR_ENABLED). Every user-driven commit funnels through a single choke point — trackDesignInput({ ui, section, control, name }) at packages/studio/src/utils/designInputTracking.ts — and emits one batched studio:design_input event via the existing opt-out-aware trackStudioEvent helper. No user-facing behavior changes.
Architecture:
designInputTracking.ts— pure utility.trackDesignInputslugifies section+name, coalesces repeated fires of the same${ui}:${section}:${control}:${name}key within a 600 ms window (so a drag → many pointermove commits collapse to one event), and swallows any downstream telemetry throw so telemetry never breaks the edit path.DesignPanelInputContext.tsx—DesignPanelInputProvidercarries{ ui, section }through the render tree so commit sites only pass{ control, name }. Providers nest:PropertyPanelsetsuionce at the top; eachSectionsetssectionand inheritsuifrom the parent.useTrackDesignInput()returns a stable(control, name) => voidbound to the current context.- Primitive wiring — every commit-shaped primitive (
MetricField,SliderControl,SegmentedControl,SelectField,DetailField,TextAreaField,ColorField,FontFamilyField, and the flat equivalentsFlatRow/FlatSlider/FlatSegmentedRow/FlatToggle/FlatSelectRow) invokes the tracker on its commit path. Classic'sSectionauto-wraps in aDesignPanelInputProviderkeyed on the slugified section title.
Test surface: three new test files totalling +750 lines — utility (throttle, slugify, throw-safe), context (nested provider precedence, default fallback), and a 572-line propertyPanelInputCoverage.test.tsx that exercises each primitive under both UIs and enumerates the whole classic layout section + flat layout/header/footer sections end-to-end asserting payload.name !== "" && payload.name !== "unnamed" && payload.section !== "unknown".
Verification
- Choke-point discipline is clean. All 26 file edits either wire an existing primitive to the tracker OR add per-section
<DesignPanelInputProvider section="X">scoping. No inputs bypass the utility. ✓ - Throttle semantics correct.
performance.now()monotonic; per-keyMap<string, number>re-checked before every fire;__resetDesignInputThrottle()test seam is only public helper. Same-key within 600 ms → 1 event; distinct keys within window → both fire; same-key after window → 2 events. Pinned by the R4 test. ✓ - Change-guard semantics for continuous controls —
SliderControlusesinteractionChangedRef(set to true only on user-drivenonChange, reset incommitDraft) so an externalsetValue(...)prop update that flows throughcommitDraftdoesn't fire the tracker. Same pattern inTextAreaField.FlatSliderguards onstepped !== dragStartValueRef.current. ✓ - Non-continuous controls filter no-op commits. Classic
MetricField/DetailFieldwrapif (nextValue !== value) track(...); flatFlatRowrelies onCommitField's ownif (nextDraft !== valueRef.current) onCommit(nextDraft)guard, sotrackonly ever fires on a real change. Native<select onChange>inSelectFieldonly fires on change;SegmentedControlguards onoption.value !== value;FlatSegmentedRowguards on!option.active;FlatTogglefires only in theonChangehandler. ✓ - Throw-safety.
trackDesignInputwraps the whole body intry {} catch {}so a downstream telemetry throw is swallowed. Pinned by the "never throws even if the underlying tracker throws" test. ✓ - CI: 21 SUCCESS + 9 IN_PROGRESS + 7 SKIPPED + 1 NEUTRAL at read time. No FAILURE. ✓
Concerns — most-severe first
-
🟡 Flat motion section's
onUpdateMetafallback mis-attributes non-duration/non-position updates to "Speed".packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx:180-184:onUpdateMeta={(animationId, updates) => { if (updates.duration !== undefined) track("metric", "Length"); else if (updates.position !== undefined) track("metric", "Starts at"); else track("select", "Speed"); callbacks.onUpdateMeta(animationId, updates); }}
If
updatescarrieseasing,repeat,yoyo, or any other meta field beyond duration/position, we firetrack("select", "Speed")— the telemetry attributes usage to a control the user didn't touch. This poisons the "which controls are actually used" ranking that the whole PR exists to produce. Fix shape: switch on the specific key(s) inupdatesand either name each properly or explicitly bail on the unknown path (e.g.else return) rather than fall through to a placeholder name. Non-blocking but worth closing before this ships to real usage data. -
🟡 E2E coverage-completeness test only exercises
layoutfor classic andlayout/header/footerfor flat.propertyPanelInputCoverage.test.tsx:436-471(classic PropertyPanel) enumerates only the layout section's text inputs;:474-560(flat) enumerates layout + clicks the three header/footer buttons. Other sections (style, color-grading, motion, media, text, fill) rely on individual primitive tests but no end-to-end assertion that every input under those sections fires with a resolvedsectionname. If someone renders a subtree of inputs outside a<Section>wrapper (classic) or forgets a<DesignPanelInputProvider section="X">(flat), those inputs silently emitsection: "unknown"and the coverage test doesn't fire. Suggest extending the enumeration to each section (or, cheaper, adding a whole-panel test that assertsnew Set(tracked.section) === EXPECTED_SECTIONS). Non-blocking but the wiring's silent-failure mode is exactly what a coverage-completeness test should prevent. -
🟢 Classic
SliderControltracks on null-net interactions while other controls only track when the value actually changes.propertyPanelPrimitives.tsx:250-253—if (interactionChangedRef.current) { interactionChangedRef.current = false; track("slider", trackName); }fires whenever the user grabbed the slider and released it, even if they ended at the starting value. Defensible for "did the user try this control" analysis, but inconsistent with sibling controls that fire only on real changes. Pick one convention across the primitives and document it in the utility. -
🟢 Classic
MetricFieldhas double change-guard — its wrapper'sif (nextValue !== value) track(...)atpropertyPanelPrimitives.tsx:127guards on top ofCommitField's ownif (nextDraft !== valueRef.current) onCommit(nextDraft). FlatFlatRowatpropertyPanelFlatPrimitives.tsx:49-56relies only onCommitField's guard. Harmless redundancy but the asymmetry means the two UIs implement the same invariant differently — one small refactor away from "the guard lives inCommitFieldfull stop" (drop the wrapper's guard, sinceCommitField's onCommit is only ever called on real changes).
Verdict framing
Clean instrumentation PR with a well-designed single choke point, careful change-guarded commit sites, and a thoughtful coalescing window. The motion-section mis-attribution and the coverage-test scope gap are both about "the data this PR produces might not be trustworthy for the removal analysis it's built to enable," so worth closing before rollout — otherwise all fine. LGTM once those two 🟡 items are addressed.
vanceingalls
left a comment
There was a problem hiding this comment.
Additive to Rames's review at 843b03d8.
Right-vs-close: RIGHT direction — should ship after closing the parallel-branch gap below. The data-first order (instrument first, then remove inputs) is correct, and useTrackDesignInput()-bound-to-context + a single trackDesignInput choke-point with per-key 600 ms coalescing is exactly the architecture that keeps this cheap to extend as inputs are added.
Audited: designInputTracking.ts (end-to-end), DesignPanelInputContext.tsx, PropertyPanel.tsx classic header/body region, PropertyPanelFlat.tsx, PropertyPanelFlatHeader/Footer.tsx, propertyPanelPrimitives.tsx, propertyPanelFlatPrimitives.tsx, GsapAnimationSection.tsx, propertyPanelFlatMotionSection.tsx, InspectorHeaderActions.tsx (at HEAD), GestureRecordControl.tsx (at HEAD), all SliderControl/SegmentedControl consumers via code-search (7 files). Trusting: the two new test files at line-level (spot-verified key cases: throttle, throw-safety, nested-provider precedence, layout-section coverage assertions).
Position vs Rames: I concur on both 🟡 items and both 🟢s. The gaps below are additive.
🟡 blocker — classic-panel header + gesture-record button are the flat-panel siblings, and they're silent
The flat panel tracks 5 chrome buttons — PropertyPanelFlatHeader.tsx (Ungroup / Element visibility / Copy element info / Clear selection) + PropertyPanelFlatFooter.tsx (Ask agent, Gesture recording). Their classic-panel equivalents are:
packages/studio/src/components/editor/PropertyPanel.tsx:318-334— inline<button onClick={() => void onToggleElementHidden(...)}>— untracked.packages/studio/src/components/editor/InspectorHeaderActions.tsx:20-63— Ungroup / Copy element info / Clear selection buttons — untracked (nouseTrackDesignInputimport, no wrapper).packages/studio/src/components/editor/GestureRecordControl.tsx:36-50—GestureRecordPanelButtonrenders<button onClick={onToggleRecording}>— untracked. (Also lives in the classic panel body without any<DesignPanelInputProvider section="…">around it.)
Consequence: once data lands, dashboards for Ungroup / Visibility / Copy / Clear / Gesture-record will show as flat-only, and any comparability claim between the two UIs on those actions is definitionally wrong. That's the exact "flat gets instrumented, classic sibling silently skips" shape this PR should not ship — the whole point per the PR body is "it keeps the two UIs comparable during the flat rollout."
Fix pattern already exists in the PR — three edits:
- Add
useTrackDesignInput()insideInspectorHeaderActionsand wire the three buttons the same wayPropertyPanelFlatHeaderdoes (track("button", "Ungroup"); onUngroup?.();track("button", "Copy element info"); onCopy();track("button", "Clear selection"); onClear()). - Do the same for the inline visibility toggle in
PropertyPanel.tsx:318-334(track("toggle", "Element visibility")) and forGestureRecordPanelButton(track("button", "Gesture recording")). - Add
<DesignPanelInputProvider section="header">around the classic header row (PropertyPanel.tsx:309-344) andsection="footer"(or similar) around the gesture-record button — otherwise these emit assection: "unknown"and the R3 "coverage signal" marker becomes noise instead of a gap-detector.
🟡 important — Rames's Speed misattribution repeats in GsapAnimationSection.tsx:40-46 (classic panel)
The pattern Rames flagged at propertyPanelFlatMotionSection.tsx:181-184 is duplicated verbatim in the classic panel:
// GsapAnimationSection.tsx:40-46 (classic)
const updateMeta = (animationId, updates) => {
if (updates.duration !== undefined) track("metric", "Length");
else if (updates.position !== undefined) track("metric", "Starts at");
else track("select", "Speed");
onUpdateMeta(animationId, updates);
};Same silent mis-attribution: updates.ease / updates.repeat / any other meta key falls through to "Speed" in classic too. Fix in both files at once — the flat: true/flat: false branches share the bug because the pattern was copied. Under the extrapolation-as-blocker rule, one-shot fix both sites so the ranking stays trustworthy on both UIs.
🟢 nits
SelectFieldinpropertyPanelPrimitives.tsx:349-352is the odd control out — it firestrack("select", label)unconditionally inonChange, while every other classic control (SegmentedControl,MetricField,DetailField,ColorField,FontFamilyField) guards onnextValue !== value. Native<select>doesn't firechangewhen picking the same value, so in practice it's a no-op, but the primitive-level asymmetry means a future reviewer copying this pattern to a component whoseonChangeDOES fire on re-selection (e.g. custom combobox) inherits a silent double-count.slugifyDesignInput("---")returns"", which then becomes"unnamed"/"unknown". Not a bug — the R3 marker is the intended fallback — but callers passing a pre-slugified section (e.g.slugifyDesignInput(g.title)atPropertyPanelFlat.tsx:284) that resolves to""will silently emitsection: "unknown". Cheap defense:if (!slugifyDesignInput(g.title)) console.warn(...)in dev builds so the coverage guard isn't the only gate.
CI + envelope
Producer: integration tests is red on packages/producer/src/services/distributed/renderChunk.test.ts:204 — expect(encodeStageMs).toBeGreaterThan(0) observed 0. Unrelated to studio scope (this PR touches only packages/studio/**); appears to be a runner-timing flake on the encode-stage measurement in the byte-identical-retry test. Rerun should clear it; if not, worth a separate investigation, not a blocker on this PR's merits. Envelope clean (no AI-attribution trailers, expected on a Miguel-authored PR).
Verdict: COMMENT (post-merge: With fixes). RIGHT direction — the primitive + context design is the shape I'd want to see, and Rames's two 🟡s + my parallel-branch gap all resolve to trivial edits. Once the classic-panel header/gesture-record instrumentation lands and the Speed misattribution is fixed at both call sites, ship.
Reasoning: No correctness bug on the wired paths; the blocker-tier finding is a coverage gap where the classic-side chrome buttons parallel to the flat-side instrumented ones are silent, poisoning the exact "two UIs comparable" claim in the PR body.
— Via
… widen coverage guard
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at aff6f1c94856f742d4dddf4a77fc5dbb03c212eb — R1 (843b03d8) → R2 delta is +147 / -61 across 8 files.
R2 delta
Closes both 🟡 findings + Via's classic-chrome gap + my 🟢 SliderControl-convention question. Well-executed round.
1. 🟡 (mine) Motion-section meta mis-attribution → closed via shared trackAnimationMetaUpdate(track, updates) helper at gsapAnimationCallbacks.ts:38-63. Instead of a single else → "Speed" fallback, the helper iterates Object.keys(updates) and looks each up in an explicit ANIMATION_META_LABELS table (duration→Length, position→Starts at, ease→Speed, easeEach→Speed). Unknown keys fall through to track("select", key) — attributed by their own name rather than poisoning a placeholder control's usage count. Wired in both classic (GsapAnimationSection.tsx:56) and flat (propertyPanelFlatMotionSection.tsx:184). Bonus: also closes a bug my review missed — the R1 chain silently swallowed the keyframed easeEach key alongside all the other non-duration/non-position fields, and the R2 table catches it explicitly. ✓
2. 🟡 (Via) Classic chrome parity → closed in InspectorHeaderActions.tsx:22-101 + GestureRecordControl.tsx:33-42. Visibility toggle, Ungroup, Copy, Clear, and gesture-record button all now emit tracker events. PropertyPanel.tsx:308-341 wraps them in the matching section="header" / section="footer" DesignPanelInputProvider scopes, so the payloads mirror PropertyPanelFlatHeader/Footer exactly. Consolidating the visibility toggle into InspectorHeaderActions (removing the parallel branch in PropertyPanel.tsx) is a good structural side-effect — one place to update the header buttons and it keeps the file under the 600-line gate as Miguel noted. ✓
3. 🟡 (mine) Coverage-completeness scope → closed in propertyPanelInputCoverage.test.tsx:449-511. The classic test now:
- Fires every body text input across the whole panel (
host.querySelectorAll('input[type="text"]')) instead of just the layout section's inputs — so any section rendered without a<DesignPanelInputProvider section="X">wrapper surfaces immediately assection: "unknown". - Exercises header (Clear) + footer (Record gesture) chrome — the classic siblings of what the flat test already checks.
- Asserts
sections.has("header") && sections.has("footer") && sections.size > 2— a body section plus both chrome regions, i.e. ≥3 distinct sections — plus the existingsection !== "unknown" && !== ""andname !== "unnamed" && !== ""guards.
The mock-explanation comment (Copy skipped: clipboard unavailable in JSDOM; visibility store-gated on live selection) is honest about the two chrome buttons that aren't reached — both defensible mock constraints, not test gaps. ✓
4. 🟢 (mine) SliderControl convention → documented, not "fixed", in designInputTracking.ts:5-15. Added an explicit doc block:
- Discrete controls fire on real committed change
- Continuous controls (slider/scrub) fire once per interaction, because "net change at settle" is unreliable — mid-drag commits have already advanced the value, so
interactionChangedRefis the honest "did the user work this control" signal
Right call. Fixing SliderControl to gate on net-change would have silenced legitimate scrub events on the way back to the origin — a legitimate ergonomic usage of the slider. The MetricField double-guard is left as-is because it's the scrub path, same reasoning. ✓
Verification
- Helper table shape is closed under future meta fields.
Object.keys(updates)enumerates only the keys actually present in the payload — no wildcard fallthrough, no risk of a new field silently attributing to Speed. ✓ - Symmetric wiring across both UIs. Same helper used by classic (
GsapAnimationSection) and flat (propertyPanelFlatMotionSection) callsites — refactor collapses the two duplicateif/elsechains to a single call. ✓ - Chrome-tracker payloads match flat.
section="header"for Copy/Clear/Ungroup/visibility,section="footer"for gesture-record — same shape asPropertyPanelFlatHeader/Footer. Dashboard cross-UI join keys align. ✓ - Coverage guard would actually fire. A section rendered without a provider wrapper would emit
section: ""(default context) which the test asserts against. So the invariant is enforceable, not aspirational. ✓ - CI at read time: 30 SUCCESS + 2 Windows IN_PROGRESS + skips only. Producer: integration tests now SUCCESS — Via's flagged renderChunk flake settled on this run. ✓
Concerns
None. Both 🟡 items closed at the correct scope, Via's parallel-branch gap closed with better structural refactoring than a minimal patch would have produced, and the SliderControl convention documented rather than silently fixed-in-a-way-that-breaks-legitimate-fires.
Verdict framing
Clean R2 — every finding addressed at the right depth. LGTM from my side; standing on the two Windows shards.
vanceingalls
left a comment
There was a problem hiding this comment.
R2 at aff6f1c94856f742d4dddf4a77fc5dbb03c212eb (Δ from R1 843b03d8 = +147/-61 across 8 files). Additive to Rames's R2.
Right-vs-close: RIGHT — ship. Every finding closed at the right depth; my R1 parallel-branch gap and both of Rames's 🟡 items landed with structural refactors rather than minimal patches, and F4 is documented in the utility next to the code it governs.
Audited at head: gsapAnimationCallbacks.ts (helper end-to-end), GsapAnimationSection.tsx:53, propertyPanelFlatMotionSection.tsx:184, InspectorHeaderActions.tsx (full R2), GestureRecordControl.tsx (full R2), PropertyPanel.tsx (header + footer wrappers, 586 lines — under the 600 gate), DesignPanelInputContext.tsx (default value verified), PropertyPanelFlatHeader.tsx + PropertyPanelFlatFooter.tsx (label parity), propertyPanelInputCoverage.test.tsx (assertion mechanics), designInputTracking.ts (convention doc), AnimationCard.tsx (onUpdateMeta call sites — easeEach is a real key at :287/:289/:297/:298). All 4 R2 commits (386c2be5 → aff6f1c9) — envelope clean, no AI-attribution trailers.
F1 — Speed misattribution (both UIs) → ✅ closed
trackAnimationMetaUpdate(track, updates) in gsapAnimationCallbacks.ts:38-63 replaces the if/else if/else → "Speed" catch-all with a table lookup over Object.keys(updates). ANIMATION_META_LABELS maps duration→Length, position→Starts at, ease→Speed, easeEach→Speed; unknown keys fall through to track("select", key) — attributed by the actual key name, not poisoning Speed. Wired at GsapAnimationSection.tsx:53 and propertyPanelFlatMotionSection.tsx:184, collapsing the two duplicated if/else chains to one call. Verified: AnimationCard.tsx commits onUpdateMeta with exactly one key per call ({duration}, {position}, {[easeKey]: value} where easeKey is "ease" or "easeEach"), so iterating Object.keys cannot double-fire on realistic payloads. The easeEach → Speed entry is required — AnimationCard.tsx:281/297 sets easeKey = animation.keyframes ? "easeEach" : "ease", so on keyframed animations the R1 chain silently emitted "Speed" and the R2 table now attributes it explicitly.
F2 — Classic chrome parity → ✅ closed
InspectorHeaderActions.tsx now emits track("toggle", "Element visibility"), track("button", "Ungroup"), track("button", "Copy element info"), track("button", "Clear selection") — labels match PropertyPanelFlatHeader.tsx exactly, so cross-UI dashboards join. Inline visibility toggle in PropertyPanel.tsx is folded into InspectorHeaderActions (props: selectedElementId, selectedElementHidden, visibilityLabel, onToggleHidden), eliminating the parallel branch. GestureRecordControl.tsx emits track("button", "Gesture recording") matching PropertyPanelFlatFooter.tsx. Both regions wrapped in <DesignPanelInputProvider section="header"> / section="footer"> at PropertyPanel.tsx:308/333. No other classic-panel chrome affordance is untracked — the classic panel has no "Ask agent" equivalent, so there's no residual sibling gap. Payload shape parity verified label-by-label.
F3 — E2E coverage widening → ✅ closed, assertion is real (not aspirational)
propertyPanelInputCoverage.test.tsx:449-511 now fires host.querySelectorAll('input[type="text"]') across the whole panel (not just layout), plus Clear (header) and Record gesture (footer). The assertion is enforceable: DesignPanelInputContext.tsx:14-17 defaults section to "unknown", so any subtree rendered without a <DesignPanelInputProvider> wrapper emits payload.section === "unknown" and the expect(payload.section).not.toBe("unknown") assertion trips. sections.has("header") && sections.has("footer") && sections.size > 2 further asserts ≥3 distinct sections with both chrome regions present — silences the "silent-failure" mode I flagged at R1. The mock-explanation comment (Copy skipped — clipboard unavailable in JSDOM; visibility toggle store-gated on a live selection) is honest about the two chrome buttons the unit mock can't reach; both are defensible test-harness constraints, not coverage gaps.
F4 — Convention doc → ✅ closed
designInputTracking.ts:7-16 documents the fire convention verbatim: discrete controls fire on real committed value change (guarded by commit sites), continuous controls fire once per user interaction that produced commits (because mid-drag commits have already advanced the value, so net-change-at-settle is unreliable — interactionChangedRef is the honest "did the user work this control" signal). Right call to document rather than "fix" — gating SliderControl on net-change would silence legitimate scrub-back-to-origin as noise.
Extrapolation
The trackAnimationMetaUpdate extract-to-helper pattern (iterate real keys, lookup in a table, unknown-key falls back to its own name rather than a placeholder) generalizes cleanly to any future dispatch-shaped callback where a payload can carry multiple meta fields. No other such call sites exist in this PR — but if onUpdateProperty-style batch-update callbacks appear later (e.g. transform tuple {x, y, rotation} in one commit), reuse this shape rather than an if/else if chain. Worth noting because the "one control's usage count poisoned by another" bug family reproduces every time else → placeholder is used as a catch-all.
CI + envelope
CI at read time: 32 SUCCESS + 7 SKIPPED + 1 pending. No FAILURE. Producer flake I flagged at R1 (renderChunk.test.ts byte-identical-retry) settled on the R2 run — Rames noted the same. Envelope clean (Miguel-authored, no AI trailers). PR is MERGEABLE / BLOCKED — needs approval.
Verdict: APPROVE. Both 🟡 items and both 🟢s closed at the right scope; the classic-side chrome refactor consolidates the visibility toggle into InspectorHeaderActions for a cleaner structural outcome than a minimal patch would have produced. Concur with Rames's R2 assessment.
— Via
What
Adds per-input usage tracking to the Studio design (inspector) panel, covering both inspector UIs:
PropertyPanelsections, andPropertyPanelFlatredesign (behindSTUDIO_FLAT_INSPECTOR_ENABLED).Every user-driven input change emits a single
studio:design_inputevent tagged with{ ui, section, control, name }, through the existing opt-in Studio telemetry helper. No user-facing behavior changes — this is additive instrumentation only.Why
The design panel has a large and growing surface of inputs across many sections and now two parallel UIs, with no per-input signal for which controls are actually used. This data lets us rank inputs and identify controls that can be removed or simplified, and it keeps the two UIs comparable during the flat rollout.
Removing unused inputs is intentionally not part of this PR — it depends on real usage data this change produces.
How
trackDesignInputhelper wraps the existingtrackStudioEvent(opt-out aware, never throws). Both UIs funnel their inputs through it rather than instrumenting each leaf control.DesignPanelInputProvidercarries{ ui, section }; each panel setsuionce and each section sets its slug, so commit sites only pass{ control, name }. Providers nest and inherit.uifrom context, so they report correctly in either mode with no double-fire.nameandsection(no empty/unknownbuckets), so the data stays rankable as inputs are added.Commits are split by unit: tracking primitive, classic panel, flat panel.
Test plan
Full Studio test suite green (2591 passed):
bun run testinpackages/studio.New unit tests for the tracking helper (event shape, coalescing window, never-throw) and the section/UI context (nesting + inheritance).
New render-based coverage guard for both the classic and flat panels asserting no unnamed/unknown events.
Typecheck, lint, and format clean.
Unit tests added/updated
Manual testing performed
Documentation updated (if applicable)
Not covered / follow-up
STUDIO_FLAT_INSPECTOR_ENABLED(default off), so it does not emit until enabled.