diff --git a/unpublishedScripts/marketplace/camera-move/Eye-Camera.svg b/unpublishedScripts/marketplace/camera-move/Eye-Camera.svg
new file mode 100644
index 0000000000..c58700823d
--- /dev/null
+++ b/unpublishedScripts/marketplace/camera-move/Eye-Camera.svg
@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 viewBox="0 -270 2622 1198" enable-background="new 0 0 2622 1198" xml:space="preserve">
+<g transform="scale(1,.4546)">
+	<path d="M2591,599c0,0-573.0756,558.7166-1280,558.7166S31,599,31,599S604.0755,40.2834,1311,40.2834S2591,599,2591,599z"/>
+	<g>
+		<path fill="#FFFFFF" d="M1507.3644,831.6261c8.1908-85.9868,6.8108-166.8081,5.306-206.6722l-92.7485,160.6452l0.1365,0.2027
+			c-0.0315,0.0212-0.1725,0.1157-0.4128,0.2759l-0.3179,0.5509h-0.5111c-11.4204,7.5681-99.7214,65.3458-204.2842,113.1478
+			c-65.7413,30.0543-125.0349,50.3848-176.2341,60.427c-33.0687,6.4862-62.8685,8.665-89.223,6.5692
+			c93.1363,91.6643,220.923,148.226,361.9247,148.226c38.7316,0,76.463-4.2765,112.7566-12.3666
+			c19.0804-22.5304,35.3513-53.2766,48.5972-91.9321C1488.853,962.5517,1500.6323,902.3027,1507.3644,831.6261z"/>
+		<path fill="#FFFFFF" d="M1202.6725,411.3712h0.5111c11.4207-7.5681,99.7214-65.3457,204.2842-113.1477
+			c65.7412-30.0542,125.0348-50.3848,176.234-60.4269c33.0687-6.4863,62.8685-8.6651,89.223-6.5692
+			C1579.7885,139.563,1452.0017,83.0015,1311,83.0015c-38.7316,0-76.463,4.2764-112.7566,12.3666
+			c-19.0803,22.5305-35.3513,53.2766-48.5973,91.932c-16.499,48.1481-28.2784,108.3973-35.0105,179.0738
+			c-8.1908,85.9868-6.8108,166.8082-5.306,206.6722l92.7484-160.6451l-0.1364-0.2027c0.0314-0.0211,0.1724-0.1157,0.4127-0.2759
+			L1202.6725,411.3712z"/>
+		<path fill="#FFFFFF" d="M1586.8657,253.4136c-49.9471,9.7853-108.0142,29.7088-172.5878,59.2168
+			c-78.5621,35.9-147.8654,77.5058-181.6364,98.741h185.4969l0.1073-0.2195c0.0342,0.0167,0.1865,0.0915,0.4454,0.2195h0.6364
+			l0.2554,0.4426c12.2645,6.1064,106.4518,53.6884,200.1307,120.3414c58.8986,41.9065,106.1522,83.091,140.4484,122.4097
+			c22.2083,25.4601,39.0232,50.2394,50.3865,74.1677c10.7344-41.452,16.45-84.9244,16.45-129.7325
+			c0-134.8296-51.7194-257.5746-136.3927-349.4958C1661.5886,244.2816,1626.8857,245.5726,1586.8657,253.4136z"/>
+		<path fill="#FFFFFF" d="M1748.2195,665.1135c-33.4479-38.3628-79.7356-78.6885-137.5773-119.857
+			c-70.3712-50.0869-141.0546-89.3023-176.3302-107.9312l92.7485,160.6452l0.2438-0.0168c0.0027,0.0379,0.014,0.2072,0.0326,0.4955
+			l0.318,0.551l-0.2555,0.4425c0.844,13.6745,6.7305,119.0341-4.1532,233.4891c-6.8429,71.9608-18.8829,133.4758-35.7858,182.8367
+			c-10.9449,31.963-23.9971,58.9147-39.0378,80.7199c168.875-46.5455,303.0535-176.7669,355.1321-343.2732
+			C1793.5869,725.4113,1775.0889,695.9305,1748.2195,665.1135z"/>
+		<path fill="#FFFFFF" d="M1035.1344,944.5864c49.947-9.7854,108.014-29.7087,172.5876-59.2168
+			c78.5623-35.9,147.8655-77.5058,181.6365-98.741h-185.4971l-0.1073,0.2195c-0.0342-0.0167-0.1865-0.0915-0.4454-0.2195h-0.6364
+			l-0.2554-0.4426c-12.2645-6.1064-106.4518-53.6884-200.1309-120.3412c-58.8984-41.9065-106.152-83.091-140.4482-122.4097
+			c-22.2083-25.46-39.0232-50.2393-50.3865-74.1676C800.7169,510.7194,795.0015,554.192,795.0015,599
+			c0,134.8295,51.7193,257.5745,136.3928,349.4958C960.4114,953.7184,995.1143,952.4273,1035.1344,944.5864z"/>
+		<path fill="#FFFFFF" d="M873.7805,532.8865c33.4479,38.3627,79.7355,78.6885,137.5771,119.857
+			c70.3715,50.0868,141.0547,89.3022,176.3304,107.9312l-92.7487-160.645l-0.2438,0.0167c-0.0026-0.0378-0.014-0.2072-0.0326-0.4954
+			l-0.318-0.551l0.2555-0.4426c-0.8439-13.6744-6.7303-119.034,4.1532-233.4892c6.8429-71.9607,18.8829-133.4758,35.7858-182.8366
+			c10.9451-31.963,23.9971-58.9148,39.0378-80.7198c-168.8749,46.5456-303.0535,176.7669-355.132,343.2733
+			C828.413,472.5887,846.9111,502.0695,873.7805,532.8865z"/>
+	</g>
+</g>
+</svg>
diff --git a/unpublishedScripts/marketplace/camera-move/_debug.js b/unpublishedScripts/marketplace/camera-move/_debug.js
new file mode 100644
index 0000000000..1b90acffcf
--- /dev/null
+++ b/unpublishedScripts/marketplace/camera-move/_debug.js
@@ -0,0 +1,99 @@
+_debug = {
+    handleUncaughtException: function onerror(message, fileName, lineNumber, colNumber, err) {
+        var output = _utils.normalizeStackTrace(err || { message: message });
+        console.error('window.onerror: ' + output, err);
+        var errorNode = document.querySelector('#errors'),
+            textNode = errorNode && errorNode.querySelector('.output');
+        if (textNode) textNode.innerText = output;
+        if (errorNode) errorNode.style.display = 'block';
+    },
+    loadScriptNodes: function loadScriptNodes(selector) {
+        // scripts are loaded this way to ensure refreshing the client script refreshes dependencies too
+        [].forEach.call(document.querySelectorAll(selector), function(script) {
+            script.parentNode.removeChild(script);
+            if (script.src) {
+                script.src += location.search;
+            }
+            script.type = 'application/javascript';
+            document.write(script.outerHTML);
+        });
+    },
+    // TESTING MOCK (allows the UI to be tested using a normal web browser, outside of Interface
+    openEventBridgeMock: function openEventBridgeMock(onEventBridgeOpened) {
+        // emulate EventBridge's API
+        EventBridge = {
+            emitWebEvent: signal(function emitWebEvent(message){}),
+            scriptEventReceived: signal(function scriptEventReceived(message){}),
+        };
+        EventBridge.emitWebEvent.connect(onEmitWebEvent);
+        onEventBridgeOpened(EventBridge);
+        setTimeout(function() {
+            assert(!bridgedSettings.onUnhandledMessage);
+            bridgedSettings.onUnhandledMessage = function(msg) {
+                return true;
+            };
+            // manually trigger bootstrapping responses
+            $('.slider .control').parent().css('visibility','visible');
+            bridgedSettings.handleExtraParams({uuid: PARAMS.uuid, ns: PARAMS.ns, extraParams: {
+                mock: true,
+                appVersion: 'browsermock',
+                toggleKey: { text: 'SPACE', isShifted: true },
+            } });
+            bridgedSettings.setValue('ui-show-advanced-options', true);
+            if (/fps/.test(location.hash)) setTimeout(function() { $('#fps').each(function(){ this.scrollIntoView(); }); }, 100);
+        },1);
+
+        function log(msg) {
+            console.log.apply(console, ['[mock] ' + msg].concat([].slice.call(arguments,1)));
+        }
+
+        var updatedValues = {};
+        // generate mock data in response to outgoing web events
+        function onEmitWebEvent(message) {
+            try { var obj = JSON.parse(message); } catch(e) {}
+            if (!obj) {
+                // message isn't JSON or doesn't expect a reply so just log it and bail early
+                log('consuming non-callback web event', message);
+                return;
+            }
+            switch(obj.method) {
+                case 'valueUpdated': {
+                    updatedValues[obj.params[0]] = obj.params[1];
+                } break;
+                case 'Settings.getValue': {
+                    var key = obj.params[0];
+                    var node = jquerySettings.findNodeByKey(key, true);
+                    var type = node && (node.dataset.type || node.getAttribute('type'));
+                    switch(type) {
+                        case 'checkbox': {
+                            obj.result = /tooltip/i.test(key) || PARAMS.tooltiptest ? true : Math.random() > .5;
+                        } break;
+                        case 'radio-group': {
+                            var radios = $(node).find('input[type=radio]').toArray();
+                            while(Math.random() < .9) { radios.push(radios.shift()); }
+                            obj.result = radios[0].value;
+                        } break;
+                        case 'number': {
+                            var step = node.step || 1, precision = (1/step).toString().length - 1;
+                            var magnitude = node.max || (precision >=1 ? Math.pow(10, precision-1) : 10);
+                            obj.result = parseFloat((Math.random() * magnitude).toFixed(precision||1));
+                        } break;
+                        default: {
+                            log('unhandled node type for making dummy data: ' + [key, node && node.type, type, node && node.getAttribute('type')] + ' @ ' + (node && node.id));
+                            obj.result = updatedValues[key] || false;
+                        } break;
+                    }
+                    log('mock getValue data %c%s = %c%s', 'color:blue',
+                        JSON.stringify(key), 'color:green', JSON.stringify(obj.result));
+                } break;
+                default: {
+                    log('ignoring outbound method call', obj);
+                } break;
+            }
+            setTimeout(function() {
+                EventBridge.scriptEventReceived(JSON.stringify(obj));
+            }, 100);
+        }
+    },
+};
+
diff --git a/unpublishedScripts/marketplace/camera-move/app-camera-move.js b/unpublishedScripts/marketplace/camera-move/app-camera-move.js
new file mode 100644
index 0000000000..25c11e5a93
--- /dev/null
+++ b/unpublishedScripts/marketplace/camera-move/app-camera-move.js
@@ -0,0 +1,874 @@
+//  app-camera-move.js
+//
+//  Created by Timothy Dedischew on 05/05/2017.
+//  Copyright 2017 High Fidelity, Inc.
+//
+//  Distributed under the Apache License, Version 2.0.
+//  See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html
+//
+//  This Client script sets up the Camera Control Tablet App, which can be used to configure and
+//  drive your avatar with easing/smoothing movement constraints for a less jittery filming experience.
+
+/* eslint-disable comma-dangle */
+"use strict";
+
+var VERSION = '0.0.0d',
+    NAMESPACE = 'app-camera-move',
+    APP_HTML_URL = Script.resolvePath('app.html'),
+    BUTTON_CONFIG = {
+        text: '\nCam Drive',
+        icon: Script.resolvePath('Eye-Camera.svg'),
+    },
+    DEFAULT_TOGGLE_KEY = '{ "text": "SPACE" }';
+
+var MINIMAL_CURSOR_SCALE = 0.5,
+    FILENAME = Script.resolvePath(''),
+    WANT_DEBUG = (
+        false || (FILENAME.match(/[&#?]debug[=](\w+)/)||[])[1] ||
+            Settings.getValue(NAMESPACE + '/debug')
+    ),
+    EPSILON = 1e-6,
+    DEG_TO_RAD = Math.PI / 180.0;
+
+WANT_DEBUG = 1;
+
+function log() {
+    print( NAMESPACE + ' | ' + [].slice.call(arguments).join(' ') );
+}
+
+var require = Script.require,
+    debugPrint = function(){},
+    _debugChannel = NAMESPACE + '.stats',
+    overlayDebugOutput = function(){};
+
+if (WANT_DEBUG) {
+    _instrumentDebugValues();
+}
+
+var _utils = require('./modules/_utils.js'),
+    assert = _utils.assert,
+    CustomSettingsApp = require('./modules/custom-settings-app/CustomSettingsApp.js'),
+    movementUtils = require('./modules/movement-utils.js?'+ +new Date),
+    configUtils = require('./modules/config-utils.js');
+
+Object.assign = Object.assign || _utils.assign;
+
+var cameraControls, eventMapper, cameraConfig, applicationConfig;
+
+var DEFAULTS = {
+    'namespace': NAMESPACE,
+    'debug': WANT_DEBUG,
+    'camera-move-enabled': false,
+    'thread-update-mode': movementUtils.CameraControls.SCRIPT_UPDATE,
+    'fps': 90,
+    'drive-mode': movementUtils.DriveModes.MOTOR,
+    'use-head': true,
+    'stay-grounded': true,
+    'prevent-roll': true,
+    'constant-delta-time': false,
+    'minimal-cursor': false,
+    'normalize-inputs': false,
+    'enable-mouse-smooth': true,
+    'translation-max-velocity': 5.50,
+    'translation-ease-in': 1.25,
+    'translation-ease-out': 5.50,
+    'rotation-max-velocity': 90.00,
+    'rotation-ease-in': 1.00,
+    'rotation-ease-out': 5.50,
+    'rotation-x-speed': 45,
+    'rotation-y-speed': 60,
+    'rotation-z-speed': 1,
+    'rotation-mouse-multiplier': 1.0,
+    'rotation-keyboard-multiplier': 1.0,
+
+    'ui-enable-tooltips': true,
+    'ui-show-advanced-options': false,
+};
+
+// map setting names to/from corresponding Menu and API properties
+var APPLICATION_SETTINGS = {
+    'Avatar/pitchSpeed': 'pitchSpeed' in MyAvatar && {
+        object: [ MyAvatar, 'pitchSpeed' ]
+    },
+    'Avatar/yawSpeed': 'yawSpeed' in MyAvatar && {
+        object: [ MyAvatar, 'yawSpeed' ]
+    },
+    'Avatar/Enable Avatar Collisions': {
+        menu: 'Avatar > Enable Avatar Collisions',
+        object: [ MyAvatar, 'collisionsEnabled' ],
+    },
+    'Avatar/Draw Mesh': {
+        menu: 'Developer > Draw Mesh',
+        // object: [ MyAvatar, 'shouldRenderLocally' ], // shouldRenderLocally seems to be broken...
+        object: [ MyAvatar, 'getEnableMeshVisible', 'setEnableMeshVisible' ],
+    },
+    'Avatar/useSnapTurn': {
+        object: [ MyAvatar, 'getSnapTurn', 'setSnapTurn' ],
+    },
+    'Avatar/lookAtSnappingEnabled': 'lookAtSnappingEnabled' in MyAvatar && {
+        menu: 'Developer > Enable LookAt Snapping',
+        object: [ MyAvatar, 'lookAtSnappingEnabled' ]
+    },
+    'camera-move-enabled': {
+        get: function() {
+            return cameraControls && cameraControls.enabled;
+        },
+        set: function(nv) {
+            cameraControls.setEnabled(!!nv);
+        },
+    },
+};
+
+
+var DEBUG_INFO = {
+    // these values are also sent to the tablet app after EventBridge initialization
+    appVersion: VERSION,
+    utilsVersion: _utils.version,
+    movementVersion: movementUtils.version,
+    configVersion: configUtils.version,
+    clientScript: Script.resolvePath(''),
+    MyAvatar: {
+        supportsPitchSpeed: 'pitchSpeed' in MyAvatar,
+        supportsYawSpeed: 'yawSpeed' in MyAvatar,
+        supportsLookAtSnappingEnabled: 'lookAtSnappingEnabled' in MyAvatar,
+        supportsDensity: 'density' in MyAvatar,
+    },
+    Reticle: {
+        supportsScale: 'scale' in Reticle,
+    },
+    protocolVersion: location.protocolVersion,
+};
+
+var globalState = {
+    // cached values from the last animation frame
+    previousValues: {
+        reset: function() {
+            this.pitchYawRoll = Vec3.ZERO;
+            this.thrust = Vec3.ZERO;
+        },
+    },
+
+    // batch updates to MyAvatar/Camera properties (submitting together seems to help reduce timeslice jitter)
+    pendingChanges: _utils.DeferredUpdater.createGroup({
+        Camera: Camera,
+        MyAvatar: MyAvatar,
+    }, { dedupe: false }),
+
+    // current input controls' effective velocities
+    currentVelocities: new movementUtils.VelocityTracker({
+        translation: Vec3.ZERO,
+        step_translation: Vec3.ZERO,
+        rotation: Vec3.ZERO,
+        step_rotation: Vec3.ZERO,
+        zoom: Vec3.ZERO,
+    }),
+};
+
+function main() {
+    log('initializing...', VERSION);
+
+    var tablet = Tablet.getTablet('com.highfidelity.interface.tablet.system'),
+        button = tablet.addButton(BUTTON_CONFIG);
+
+    Script.scriptEnding.connect(function() {
+        tablet.removeButton(button);
+        button = null;
+    });
+
+    // track both runtime state (applicationConfig) and settings state (cameraConfig)
+    // (this is necessary because Interface does not yet consistently keep config Menus, APIs and Settings in sync)
+    applicationConfig = new configUtils.ApplicationConfig({
+        namespace: DEFAULTS.namespace,
+        config: APPLICATION_SETTINGS,
+    });
+    cameraConfig = new configUtils.SettingsConfig({
+        namespace: DEFAULTS.namespace,
+        defaultValues: DEFAULTS,
+    });
+
+    var toggleKey = JSON.parse(DEFAULT_TOGGLE_KEY);
+    if (cameraConfig.getValue('toggle-key')) {
+        try { toggleKey = JSON.parse(cameraConfig.getValue('toggle-key')); } catch(e) {}
+    }
+    // set up a monitor to observe configuration changes between the two sources
+    var MONITOR_INTERVAL_MS = 1000;
+    _startConfigationMonitor(applicationConfig, cameraConfig, MONITOR_INTERVAL_MS);
+
+    // ----------------------------------------------------------------------------
+    // set up the tablet webview app
+    log('APP_HTML_URL', APP_HTML_URL);
+    var settingsApp = new CustomSettingsApp({
+        namespace: cameraConfig.namespace,
+        uuid: cameraConfig.uuid,
+        settingsAPI: cameraConfig,
+        url: APP_HTML_URL,
+        tablet: tablet,
+        extraParams: Object.assign({
+            toggleKey: toggleKey,
+        }, _utils.getSystemMetadata(), DEBUG_INFO),
+        debug: WANT_DEBUG > 1,
+    });
+    Script.scriptEnding.connect(settingsApp, 'cleanup');
+    settingsApp.valueUpdated.connect(function(key, value, oldValue, origin) {
+        log('[settingsApp.valueUpdated @ ' + origin + ']', key + ' = ' + JSON.stringify(value) + ' (was: ' + JSON.stringify(oldValue) + ')');
+        if (/tablet/i.test(origin)) {
+            log('cameraConfig applying immediate setting', key, value);
+            // apply relevant settings immediately when changed from the app UI
+            applicationConfig.applyValue(key, value, origin);
+        }
+    });
+
+    settingsApp.onUnhandledMessage = function(msg) {
+        switch(msg.method) {
+            case 'window.close': {
+                this.toggle(false);
+            } break;
+            case 'reloadClientScript':  {
+                log('reloadClientScript...');
+                _utils.reloadClientScript(FILENAME);
+            } break;
+            case 'resetSensors': {
+                Menu.triggerOption('Reset Sensors');
+                Script.setTimeout(function() {
+                    MyAvatar.bodyPitch = 0;
+                    MyAvatar.bodyRoll = 0;
+                    MyAvatar.orientation = Quat.cancelOutRollAndPitch(MyAvatar.orientation);
+                }, 500);
+            } break;
+            case 'reset': {
+                //if (!Window.confirm('Reset all camera move settings to system defaults?')) {
+                //    return;
+                //}
+                var novalue = Uuid.generate();
+                var resetValues = {};
+                Object.keys(DEFAULTS).reduce(function(out, key) {
+                    var resolved = cameraConfig.resolve(key);
+                    out[resolved] = resolved in out ? out[resolved] : DEFAULTS[key];
+                    return out;
+                }, resetValues);
+                Object.keys(applicationConfig.config).reduce(function(out, key) {
+                    var resolved = applicationConfig.resolve(key);
+                    out[resolved] = resolved in out ? out[resolved] : applicationConfig.getValue(key);
+                    return out;
+                }, resetValues);
+                log('resetValues', JSON.stringify(resetValues, 0, 2));
+                for (var p in resetValues) {
+                    var value = resetValues[p];
+                    applicationConfig.applyValue(p, value, 'reset');
+                    cameraConfig.setValue(p, value);
+                }
+            } break;
+            case 'overlayWebWindow': {
+                _overlayWebWindow(msg.options);
+            } break;
+            default: {
+                log('onUnhandledMessage', JSON.stringify(msg,0,2));
+            } break;
+        }
+    };
+
+    // ----------------------------------------------------------------------------
+    // set up the keyboard/mouse/controller/meta input state manager
+    eventMapper = new movementUtils.MovementEventMapper({
+        namespace: DEFAULTS.namespace,
+        mouseSmooth: cameraConfig.getValue('enable-mouse-smooth'),
+        xexcludeNames: [ 'Keyboard.C', 'Keyboard.E', 'Actions.TranslateY' ],
+        mouseMultiplier: cameraConfig.getValue('rotation-mouse-multiplier'),
+        keyboardMultiplier: cameraConfig.getValue('rotation-keyboard-multiplier'),
+        eventFilter: function eventFilter(from, event, defaultFilter) {
+            var result = defaultFilter(from, event),
+                driveKeyName = event.driveKeyName;
+            if (!result || !driveKeyName) {
+                if (from === 'Keyboard.RightMouseButton') {
+                    settingsApp.syncValue('Keyboard.RightMouseButton', event.actionValue, 'eventFilter');
+                }
+                return 0;
+            }
+            if (cameraConfig.getValue('normalize-inputs')) {
+                result = _utils.sign(result);
+            }
+            if (from === 'Actions.Pitch') {
+                result *= cameraConfig.getFloat('rotation-x-speed');
+            }
+            if (from === 'Actions.Yaw') {
+                result *= cameraConfig.getFloat('rotation-y-speed');
+            }
+            return result;
+        },
+    });
+    Script.scriptEnding.connect(eventMapper, 'disable');
+    applicationConfig.register({
+        'enable-mouse-smooth': { object: [ eventMapper.options, 'mouseSmooth' ] },
+        'rotation-keyboard-multiplier': { object: [ eventMapper.options, 'keyboardMultiplier' ] },
+        'rotation-mouse-multiplier': { object: [ eventMapper.options, 'mouseMultiplier' ] },
+    });
+
+    // ----------------------------------------------------------------------------
+    // set up the top-level camera controls manager / animator
+    cameraControls = new movementUtils.CameraControls({
+        namespace: DEFAULTS.namespace,
+        update: update,
+        threadMode: cameraConfig.getValue('thread-update-mode'),
+        fps: cameraConfig.getValue('fps'),
+        getRuntimeSeconds: _utils.getRuntimeSeconds,
+    });
+    Script.scriptEnding.connect(cameraControls, 'disable');
+    applicationConfig.register({
+        'thread-update-mode': { object: [ cameraControls, 'threadMode' ] },
+        'fps': { object: [ cameraControls, 'fps' ] },
+    });
+
+    // ----------------------------------------------------------------------------
+    // set up SPACEBAR for toggling camera movement mode
+    var spacebar = new _utils.KeyListener(Object.assign(toggleKey, {
+        onKeyPressEvent: function(event) {
+            cameraControls.setEnabled(!cameraControls.enabled);
+        },
+    }));
+    Script.scriptEnding.connect(spacebar, 'disconnect');
+
+    // ----------------------------------------------------------------------------
+    // set up ESC for reset drive key states
+    Script.scriptEnding.connect(new _utils.KeyListener({
+        text: 'ESC',
+        onKeyPressEvent: function(event) {
+            if (cameraControls.enabled) {
+                log('ESC pressed -- resetting drive keys values:', JSON.stringify({
+                    virtualDriveKeys: eventMapper.states,
+                    movementState: eventMapper.getState(),
+                }, 0, 2));
+                eventMapper.states.reset();
+            }
+        },
+    }), 'disconnect');
+
+    // set up the tablet button to toggle the app UI display
+    button.clicked.connect(settingsApp, function(enable) {
+        Object.assign(this.extraParams, _utils.getSystemMetadata());
+        this.toggle(enable);
+    });
+
+    settingsApp.isActiveChanged.connect(function(isActive) {
+        updateButtonText();
+    });
+
+    cameraControls.modeChanged.connect(onCameraModeChanged);
+
+    var fpsTimeout = 0;
+    function updateButtonText() {
+        var lines = [
+            settingsApp.isActive ? '(app open)' : '',
+            cameraControls.enabled ? (update.momentaryFPS||0).toFixed(2) + 'fps' : BUTTON_CONFIG.text.trim()
+        ];
+        button && button.editProperties({ text: lines.join('\n') });
+    }
+
+    cameraControls.enabledChanged.connect(function(enabled) {
+        log('enabledChanged', enabled);
+        button && button.editProperties({ isActive: enabled });
+        if (enabled) {
+            onCameraControlsEnabled();
+            fpsTimeout = Script.setInterval(updateButtonText, 1000);
+        } else {
+            if (fpsTimeout) {
+                Script.clearInterval(fpsTimeout);
+                fpsTimeout = 0;
+            }
+            eventMapper.disable();
+            _resetMyAvatarMotor({ MyAvatar: MyAvatar });
+            updateButtonText();
+        }
+        cameraConfig.getValue('debug') && Overlays.editOverlay(overlayDebugOutput.overlayID, { visible: enabled });
+    });
+
+    var resetIfChanged = [
+        'minimal-cursor', 'drive-mode', 'fps', 'thread-update-mode',
+        'rotation-mouse-multiplier', 'rotation-keyboard-multiplier',
+        'enable-mouse-smooth', 'constant-delta-time',
+    ].filter(Boolean).map(_utils.bind(cameraConfig, 'resolve'));
+
+    cameraConfig.valueUpdated.connect(function(key, value, oldValue, origin) {
+        var triggerReset = !!~resetIfChanged.indexOf(key);
+        log('[cameraConfig.valueUpdated @ ' + origin + ']',
+            key + ' = ' + JSON.stringify(value), '(was:' + JSON.stringify(oldValue) + ')',
+            'triggerReset: ' + triggerReset);
+
+        if (/tablet/i.test(origin)) {
+            log('cameraConfig applying immediate setting', key, value);
+            // apply relevant settings immediately when changed from the app UI
+            applicationConfig.applyValue(key, value, origin);
+            log(JSON.stringify(cameraConfig.getValue(key)));
+        }
+        0&&debugPrint('//cameraConfig.valueUpdated', JSON.stringify({
+            key: key,
+            cameraConfig: cameraConfig.getValue(key),
+            applicationConfig: applicationConfig.getValue(key),
+            value: value,
+            triggerReset: triggerReset,
+        },0,2));
+
+        if (triggerReset) {
+            log('KEYBOARD multiplier', eventMapper.options.keyboardMultiplier);
+            cameraControls.reset();
+        }
+    });
+
+    if (cameraConfig.getValue('camera-move-enabled')) {
+        cameraControls.enable();
+    }
+
+    log('DEFAULTS', JSON.stringify(DEFAULTS, 0, 2));
+} // main()
+
+function onCameraControlsEnabled() {
+    log('onCameraControlsEnabled!!!! ----------------------------------------------');
+    globalState.previousValues.reset();
+    globalState.currentVelocities.reset();
+    globalState.pendingChanges.reset();
+    eventMapper.enable();
+    if (cameraConfig.getValue('minimal-cursor')) {
+        Reticle.scale = MINIMAL_CURSOR_SCALE;
+    }
+    log('cameraConfig', JSON.stringify({
+        cameraConfig: getCameraMovementSettings(cameraConfig),
+        //DEFAULTS: DEFAULTS
+    }));
+}
+
+// reset values based on the selected Camera.mode (to help keep the visual display/orientation more reasonable)
+function onCameraModeChanged(mode, oldMode) {
+    globalState.pendingChanges.reset();
+    globalState.previousValues.reset();
+    eventMapper.reset();
+    var preventRoll = cameraConfig.getValue('prevent-roll');
+    var avatarOrientation = cameraConfig.getValue('use-head') ? MyAvatar.headOrientation : MyAvatar.orientation;
+    if (preventRoll) {
+        avatarOrientation = Quat.cancelOutRollAndPitch(avatarOrientation);
+    }
+    switch (Camera.mode) {
+        case 'mirror':
+        case 'entity':
+        case 'independent':
+            globalState.currentVelocities.reset();
+            break;
+        default:
+            Camera.position = MyAvatar.position;
+            Camera.orientation = avatarOrientation;
+            break;
+    }
+    MyAvatar.orientation = avatarOrientation;
+    if (preventRoll) {
+        MyAvatar.headPitch = MyAvatar.headRoll = 0;
+    }
+}
+
+// consolidate and normalize cameraConfig settings
+function getCameraMovementSettings(cameraConfig) {
+    return {
+        epsilon: EPSILON,
+        debug: cameraConfig.getValue('debug'),
+        driveMode: cameraConfig.getValue('drive-mode'),
+        threadMode: cameraConfig.getValue('thread-update-mode'),
+        useHead: cameraConfig.getValue('use-head'),
+        stayGrounded: cameraConfig.getValue('stay-grounded'),
+        preventRoll: cameraConfig.getValue('prevent-roll'),
+        useConstantDeltaTime: cameraConfig.getValue('constant-delta-time'),
+
+        mouseSmooth: cameraConfig.getValue('enable-mouse-smooth'),
+        mouseMultiplier: cameraConfig.getValue('rotation-mouse-multiplier'),
+        keyboardMultiplier: cameraConfig.getValue('rotation-keyboard-multiplier'),
+
+        rotation: _getEasingGroup(cameraConfig, 'rotation'),
+        translation: _getEasingGroup(cameraConfig, 'translation'),
+        zoom: _getEasingGroup(cameraConfig, 'zoom'),
+    };
+
+    // extract an easing group (translation, rotation, or zoom) from cameraConfig
+    function _getEasingGroup(cameraConfig, group) {
+        var multiplier = 1.0;
+        if (group === 'zoom') {
+            // BoomIn / TranslateCameraZ support is only partially plumbed -- for now use scaled translation easings
+            group = 'translation';
+            multiplier = 0.01;
+        } else if (group === 'rotation') {
+            // degrees -> radians
+            //multiplier = DEG_TO_RAD;
+        }
+
+        return {
+            easeIn: cameraConfig.getFloat(group + '-ease-in'),
+            easeOut: cameraConfig.getFloat(group + '-ease-out'),
+            maxVelocity: multiplier * cameraConfig.getFloat(group + '-max-velocity'),
+            speed: Vec3.multiply(multiplier, {
+                x: cameraConfig.getFloat(group + '-x-speed'),
+                y: cameraConfig.getFloat(group + '-y-speed'),
+                z: cameraConfig.getFloat(group + '-z-speed')
+            }),
+        };
+    }
+}
+
+var DEFAULT_MOTOR_TIMESCALE = 1e6; // a large value that matches Interface's default
+var EASED_MOTOR_TIMESCALE = 0.01;  // a small value to make Interface quickly apply MyAvatar.motorVelocity
+var EASED_MOTOR_THRESHOLD = 0.1;   // above this speed (m/s) EASED_MOTOR_TIMESCALE is used
+var ACCELERATION_MULTIPLIERS = { translation: 1, rotation: 1, zoom: 1 };
+var STAYGROUNDED_PITCH_THRESHOLD = 45.0; // degrees; ground level is maintained when pitch is within this threshold
+var MIN_DELTA_TIME = 0.0001; // to avoid math overflow, never consider dt less than this value
+
+update.frameCount = 0;
+update.endTime = _utils.getRuntimeSeconds();
+
+function update(dt) {
+    update.frameCount++;
+    var startTime = _utils.getRuntimeSeconds();
+    var settings = getCameraMovementSettings(cameraConfig);
+
+    var collisions = applicationConfig.getValue('Avatar/Enable Avatar Collisions'),
+        independentCamera = Camera.mode === 'independent',
+        headPitch = MyAvatar.headPitch;
+
+    var actualDeltaTime = Math.max(MIN_DELTA_TIME, (startTime - update.endTime)),
+        deltaTime;
+
+    if (settings.useConstantDeltaTime) {
+        deltaTime = settings.threadMode === movementUtils.CameraControls.ANIMATION_FRAME ?
+            (1 / cameraControls.fps) : (1 / 90);
+    } else if (settings.threadMode === movementUtils.CameraControls.SCRIPT_UPDATE) {
+        deltaTime = dt;
+    } else {
+        deltaTime = actualDeltaTime;
+    }
+
+    var orientationProperty = settings.useHead ? 'headOrientation' : 'orientation',
+        currentOrientation = independentCamera ? Camera.orientation : MyAvatar[orientationProperty],
+        currentPosition = MyAvatar.position;
+
+    var previousValues = globalState.previousValues,
+        pendingChanges = globalState.pendingChanges,
+        currentVelocities = globalState.currentVelocities;
+
+    var movementState = eventMapper.getState({ update: deltaTime }),
+        targetState = movementUtils.applyEasing(deltaTime, 'easeIn', settings, movementState, ACCELERATION_MULTIPLIERS),
+        dragState = movementUtils.applyEasing(deltaTime, 'easeOut', settings, currentVelocities, ACCELERATION_MULTIPLIERS);
+
+    currentVelocities.integrate(targetState, currentVelocities, dragState, settings);
+
+    var currentSpeed = Vec3.length(currentVelocities.translation),
+        targetSpeed = Vec3.length(movementState.translation),
+        verticalHold = movementState.isGrounded && settings.stayGrounded && Math.abs(headPitch) < STAYGROUNDED_PITCH_THRESHOLD;
+
+    var deltaOrientation = Quat.fromVec3Degrees(Vec3.multiply(deltaTime, currentVelocities.rotation)),
+        targetOrientation = Quat.normalize(Quat.multiply(currentOrientation, deltaOrientation));
+
+    var targetVelocity = Vec3.multiplyQbyV(targetOrientation, currentVelocities.translation);
+
+    if (verticalHold) {
+        targetVelocity.y = 0;
+    }
+
+    var deltaPosition = Vec3.multiply(deltaTime, targetVelocity);
+
+    _resetMyAvatarMotor(pendingChanges);
+
+    if (!independentCamera) {
+        var DriveModes = movementUtils.DriveModes;
+        switch(settings.driveMode) {
+            case DriveModes.MOTOR: {
+                if (currentSpeed > EPSILON || targetSpeed > EPSILON) {
+                    var motorTimescale = (currentSpeed > EASED_MOTOR_THRESHOLD ? EASED_MOTOR_TIMESCALE : DEFAULT_MOTOR_TIMESCALE);
+                    var motorPitch = Quat.fromPitchYawRollDegrees(headPitch, 180, 0),
+                        motorVelocity = Vec3.multiplyQbyV(motorPitch, currentVelocities.translation);
+                    if (verticalHold) {
+                        motorVelocity.y = 0;
+                    }
+                    Object.assign(pendingChanges.MyAvatar, {
+                        motorVelocity: motorVelocity,
+                        motorTimescale: motorTimescale,
+                    });
+                }
+                break;
+            }
+            case DriveModes.THRUST: {
+                var thrustVector = currentVelocities.translation,
+                    maxThrust = settings.translation.maxVelocity,
+                    thrust;
+                if (targetSpeed > EPSILON) {
+                    thrust = movementUtils.calculateThrust(maxThrust * 5, thrustVector, previousValues.thrust);
+                } else if (currentSpeed > 1 && Vec3.length(previousValues.thrust) > 1) {
+                    thrust = Vec3.multiply(-currentSpeed / 10.0, thrustVector);
+                } else {
+                    thrust = Vec3.ZERO;
+                }
+                if (thrust) {
+                    thrust = Vec3.multiplyQbyV(MyAvatar[orientationProperty], thrust);
+                    if (verticalHold) {
+                        thrust.y = 0;
+                    }
+                }
+                previousValues.thrust = pendingChanges.MyAvatar.setThrust = thrust;
+                break;
+            }
+            case DriveModes.JITTER_TEST:
+            case DriveModes.POSITION: {
+                pendingChanges.MyAvatar.position = Vec3.sum(currentPosition, deltaPosition);
+                break;
+            }
+            default: {
+                throw new Error('unknown driveMode: ' + settings.driveMode);
+                break;
+            }
+        }
+    }
+
+    var finalOrientation;
+    switch (Camera.mode) {
+        case 'mirror': // fall through
+        case 'independent':
+            targetOrientation = settings.preventRoll ? Quat.cancelOutRoll(targetOrientation) : targetOrientation;
+            var boomVector = Vec3.multiply(-currentVelocities.zoom.z, Quat.getFront(targetOrientation)),
+                deltaCameraPosition = Vec3.sum(boomVector, deltaPosition);
+            Object.assign(pendingChanges.Camera, {
+                position: Vec3.sum(Camera.position, deltaCameraPosition),
+                orientation: targetOrientation,
+            });
+            break;
+        case 'entity':
+            finalOrientation = targetOrientation;
+            break;
+        default: // 'first person', 'third person'
+            finalOrientation = targetOrientation;
+            break;
+    }
+
+    if (settings.driveMode === movementUtils.DriveModes.JITTER_TEST) {
+        finalOrientation = Quat.multiply(MyAvatar[orientationProperty], Quat.fromPitchYawRollDegrees(0, 60 * deltaTime, 0));
+        // Quat.fromPitchYawRollDegrees(0, _utils.getRuntimeSeconds() * 60, 0)
+    }
+
+    if (finalOrientation) {
+        if (settings.preventRoll) {
+            finalOrientation = Quat.cancelOutRoll(finalOrientation);
+        }
+        previousValues.finalOrientation = pendingChanges.MyAvatar[orientationProperty] = Quat.normalize(finalOrientation);
+    }
+
+    if (!movementState.mouseSmooth && movementState.isRightMouseButton) {
+        // directly apply mouse pitch and yaw when mouse smoothing is disabled
+        _applyDirectPitchYaw(deltaTime, movementState, settings);
+    }
+
+    var endTime = _utils.getRuntimeSeconds();
+    var cycleTime = endTime - update.endTime;
+    update.endTime = endTime;
+
+    var submitted = pendingChanges.submit();
+
+    update.momentaryFPS = 1 / actualDeltaTime;
+
+    if (settings.debug && update.frameCount % 120  === 0) {
+        Messages.sendLocalMessage(_debugChannel, JSON.stringify({
+            threadMode: cameraControls.threadMode,
+            driveMode: settings.driveMode,
+            orientationProperty: orientationProperty,
+            isGrounded: movementState.isGrounded,
+            targetAnimationFPS: cameraControls.threadMode === movementUtils.CameraControls.ANIMATION_FRAME ? cameraControls.fps : undefined,
+            actualFPS: 1 / actualDeltaTime,
+            effectiveAnimationFPS: 1 / deltaTime,
+            seconds: {
+                startTime: startTime,
+                endTime: endTime,
+            },
+            milliseconds: {
+                actualDeltaTime: actualDeltaTime * 1000,
+                deltaTime: deltaTime * 1000,
+                cycleTime: cycleTime * 1000,
+                calculationTime: (endTime - startTime) * 1000,
+            },
+            finalOrientation: finalOrientation,
+            thrust: thrust,
+            maxVelocity: settings.translation,
+            targetVelocity: targetVelocity,
+            currentSpeed: currentSpeed,
+            targetSpeed: targetSpeed,
+        }, 0, 2));
+    }
+}
+
+if (0) {
+    Script.update.connect(gc);
+    Script.scriptEnding.connect(function() {
+        Script.update.disconnect(gc);
+    });
+}
+
+function _applyDirectPitchYaw(deltaTime, movementState, settings) {
+    var orientationProperty = settings.useHead ? 'headOrientation' : 'orientation',
+        rotation = movementState.rotation,
+        speed = Vec3.multiply(-DEG_TO_RAD / 2.0, settings.rotation.speed);
+
+    var previousValues = globalState.previousValues,
+        pendingChanges = globalState.pendingChanges,
+        currentVelocities = globalState.currentVelocities;
+
+    var previous = previousValues.pitchYawRoll,
+        target = Vec3.multiply(deltaTime, Vec3.multiplyVbyV(rotation, speed)),
+        pitchYawRoll = Vec3.mix(previous, target, 0.5),
+        orientation = Quat.fromVec3Degrees(pitchYawRoll);
+
+    previousValues.pitchYawRoll = pitchYawRoll;
+
+    if (pendingChanges.MyAvatar.headOrientation || pendingChanges.MyAvatar.orientation) {
+        var newOrientation = Quat.multiply(MyAvatar[orientationProperty], orientation);
+        delete pendingChanges.MyAvatar.headOrientation;
+        delete pendingChanges.MyAvatar.orientation;
+        if (settings.preventRoll) {
+            newOrientation = Quat.cancelOutRoll(newOrientation);
+        }
+        MyAvatar[orientationProperty] = newOrientation;
+    } else if (pendingChanges.Camera.orientation) {
+        var cameraOrientation = Quat.multiply(Camera.orientation, orientation);
+        if (settings.preventRoll) {
+            cameraOrientation = Quat.cancelOutRoll(cameraOrientation);
+        }
+        Camera.orientation = cameraOrientation;
+    }
+    currentVelocities.rotation = Vec3.ZERO;
+}
+
+// ----------------------------------------------------------------------------
+function _startConfigationMonitor(applicationConfig, cameraConfig, interval) {
+    // monitor and sync Application state -> Settings values
+    return Script.setInterval(function monitor() {
+        var settingNames = Object.keys(applicationConfig.config);
+        settingNames.forEach(function(key) {
+            applicationConfig.resyncValue(key); // align Menus <=> APIs
+            var value = cameraConfig.getValue(key),
+                appValue = applicationConfig.getValue(key);
+            if (appValue !== undefined && String(appValue) !== String(value)) {
+                log('applicationConfig -> cameraConfig',
+                    key, [typeof appValue, appValue], '(was:'+[typeof value, value]+')');
+                cameraConfig.setValue(key, appValue); // align Application <=> Settings
+            }
+        });
+    }, interval);
+}
+
+// ----------------------------------------------------------------------------
+
+function _resetMyAvatarMotor(targetObject) {
+    if (MyAvatar.motorTimescale !== DEFAULT_MOTOR_TIMESCALE) {
+        targetObject.MyAvatar.motorTimescale = DEFAULT_MOTOR_TIMESCALE;
+    }
+    if (MyAvatar.motorReferenceFrame !== 'avatar') {
+        targetObject.MyAvatar.motorReferenceFrame = 'avatar';
+    }
+    if (Vec3.length(MyAvatar.motorVelocity)) {
+        targetObject.MyAvatar.motorVelocity = Vec3.ZERO;
+    }
+}
+
+// ----------------------------------------------------------------------------
+function _fixedPrecisionStringifiyFilter(key, value, object) {
+    if (typeof value === 'object' && value && 'w' in value) {
+        return Quat.safeEulerAngles(value);
+    } else if (typeof value === 'number') {
+        return value.toFixed(4)*1;
+    }
+    return value;
+}
+function _createOverlayDebugOutput(options) {
+    options = require('./modules/_utils.js').assign({
+        x: 0, y: 0, width: 500, height: 800, visible: false
+    }, options || {});
+    options.lineHeight = options.lineHeight || Math.round(options.height / 36);
+    options.font = options.font || { size: Math.floor(options.height / 36) };
+    overlayDebugOutput.overlayID = Overlays.addOverlay('text', options);
+
+    Messages.subscribe(_debugChannel);
+    Messages.messageReceived.connect(onMessageReceived);
+
+    Script.scriptEnding.connect(function() {
+        Overlays.deleteOverlay(overlayDebugOutput.overlayID);
+        Messages.unsubscribe(debugChannel);
+        Messages.messageReceived.disconnect(onMessageReceived);
+    });
+    function overlayDebugOutput(output) {
+        var text = JSON.stringify(output, _fixedPrecisionStringifiyFilter, 2);
+        if (text !== overlayDebugOutput.lastText) {
+            overlayDebugOutput.lastText = text;
+            Overlays.editOverlay(overlayDebugOutput.overlayID, { text: text });
+        }
+    }
+    function onMessageReceived(channel, message, ssend, local) {
+        if (!local || channel !== _debugChannel) {
+            return;
+        }
+        overlayDebugOutput(JSON.parse(message));
+    }
+
+    return overlayDebugOutput;
+}
+
+
+// ----------------------------------------------------------------------------
+_patchCameraModeSetting();
+function _patchCameraModeSetting() {
+    // FIXME: looks like the Camera API suffered a regression where setting Camera.mode = 'first person' or 'third person'
+    //  no longer works; the only reliable way to set it now seems to be jury-rigging the Menu items...
+    Camera.$setModeString = Camera.$setModeString || function(mode) {
+        // 'independent' => "Independent Mode", 'first person' => 'First Person', etc.
+        var cameraMenuItem = (mode+'')
+            .replace(/^(independent|entity)$/, '$1 mode')
+            .replace(/\b[a-z]/g, function(ch) {
+                return ch.toUpperCase();
+            });
+
+        log('working around Camera.mode bug by enabling the menuItem:', cameraMenuItem);
+        Menu.setIsOptionChecked(cameraMenuItem, true);
+    };
+}
+// ----------------------------------------------------------------------------
+
+function _instrumentDebugValues() {
+    debugPrint = log;
+    var cacheBuster = '?' + new Date().getTime().toString(36);
+    require = Script.require(Script.resolvePath('./modules/_utils.js') + cacheBuster).makeDebugRequire(Script.resolvePath('.'));
+    APP_HTML_URL += cacheBuster;
+    overlayDebugOutput = _createOverlayDebugOutput({
+        lineHeight: 10,
+        font: { size: 10 },
+        width: 250, height: 800 });
+    // auto-disable camera move mode when debugging
+    Script.scriptEnding.connect(function() {
+        cameraConfig && cameraConfig.setValue('camera-move-enabled', false);
+    });
+}
+
+// Show fatal (unhandled) exceptions in a BSOD popup
+Script.unhandledException.connect(function onUnhandledException(error) {
+    log('UNHANDLED EXCEPTION!!', error, error && error.stack);
+    try { cameraControls.disable(); } catch(e) {}
+    Script.unhandledException.disconnect(onUnhandledException);
+    if (WANT_DEBUG) {
+        // show blue screen of death with the error details
+        _utils.BSOD({
+            error: error,
+            buttons: [ 'Abort', 'Retry', 'Fail' ],
+            debugInfo: DEBUG_INFO,
+        }, function(error, button) {
+            log('BSOD.result', error, button);
+            if (button === 'Abort') {
+                Script.stop();
+            } else if (button === 'Retry') {
+                _utils.reloadClientScript(FILENAME);
+            }
+        });
+    } else {
+        // use a simple alert to display just the error message
+        Window.alert('app-camera-move error: ' + error.message);
+        Script.stop();
+    }
+});
+// ----------------------------------------------------------------------------
+
+main();
diff --git a/unpublishedScripts/marketplace/camera-move/app.html b/unpublishedScripts/marketplace/camera-move/app.html
new file mode 100644
index 0000000000..c27e179df5
--- /dev/null
+++ b/unpublishedScripts/marketplace/camera-move/app.html
@@ -0,0 +1,1076 @@
+<!doctype html>
+<html class="tablet-ui">
+  <head>
+    <title>Camera Move</title>
+    <script> VERSION = '0.0.1'; </script>
+
+    <!-- hide the page content until fully loaded -->
+    <style>
+      body { background-color: #393939 }
+      .content { display: none }
+    </style>
+
+    <!-- bring in jQuery and jQuery UI -->
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/2.1.4/jquery.min.js"></script>
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.js"></script>
+    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/themes/base/jquery-ui.css" />
+
+    <!--script src="https://d3js.org/d3-ease.v1.min.js"></script-->
+
+    <script src="https://cdn.jsdelivr.net/jquery.tooltipster/4.2.5/js/tooltipster.bundle.min.js"></script>
+    <link rel="stylesheet" href="https://cdn.jsdelivr.net/g/jquery.tooltipster@4.2.5(css/tooltipster.bundle.min.css+css/plugins/tooltipster/sideTip/themes/tooltipster-sideTip-noir.min.css)">
+
+    <script src='./_debug.js'></script>
+
+    <!-- load our local scripts -->
+    <script type='require' src='./modules/_utils.js'></script>
+    <script type='require' src='./modules/custom-settings-app/browser/BridgedSettings.js'></script>
+    <script type='require' src='./modules/custom-settings-app/browser/JQuerySettings.js'></script>
+    <script type='require'>{
+        signal = _utils.signal;
+        assert = _utils.assert;
+        Object.assign = Object.assign || _utils.assign;
+        browserUtils = new _utils.BrowserUtils(window);
+    }</script>
+    <script type='require' src='./app.js'></script>
+
+    <script>
+      _debug.loadScriptNodes('script[type=require]');
+    </script>
+
+    <script>;
+{
+    // Qt web views only show the first parameter passed to console; patch if needed so all parameters show up
+    console = browserUtils.makeConsoleWorkRight(console);
+
+    // display unhandled exceptions in an error div
+    if (/debug/.test(location) || window.qt) {
+        window.onerror = _debug.handleUncaughtException;
+    }
+
+    // process querystring parameters from main client script
+    var PARAMS = browserUtils.extendWithQueryParams({
+        namespace: location.pathname.split('/').pop(),
+        uuid: undefined,
+        debug: false,
+        tooltiptest: false,
+    }, location.href);
+
+    log.prefix = 'html.' + PARAMS.namespace + ' | ';
+    function log(msg) {
+        console.info.apply(console, [log.prefix + msg].concat([].slice.call(arguments,1)));
+    }
+    function _debugPrint(msg) {
+        console.debug.apply(console, [log.prefix + msg].concat([].slice.call(arguments,1)));
+    }
+    var debugPrint = PARAMS.debug ? _debugPrint : function() {};
+
+    $(document).ready(function() {
+        defineCustomWidgets();
+        initializeDOM();
+        viewportUpdated.connect(preconfigureLESS.onViewportUpdated);
+
+        // event bridge intialization
+        log('document.ready...');
+        if (typeof QWebChannel === 'function') {
+            // Qt/Interface within an embedded web view
+            browserUtils.openEventBridge(onEventBridgeOpened);
+        } else {
+            // Testing in Desktop Chrome
+            _debug.openEventBridgeMock(onEventBridgeOpened);
+        }
+    });
+
+    function onEventBridgeOpened(eventBridge) {
+        EventBridge = eventBridge;
+        log('openEventBridge.opened', EventBridge);
+
+        bridgedSettings = new BridgedSettings({
+            eventBridge: EventBridge,
+            namespace: PARAMS.namespace,
+            uuid: PARAMS.uuid,
+            debug: PARAMS.debug,
+        });
+        window.addEventListener('unload', bridgedSettings.cleanup.bind(bridgedSettings));
+        bridgedSettings.callbackError.connect(function onCallbackError(err, msg)  {
+            console.error(err.stack);
+            throw err;
+        });
+
+        jquerySettings = new JQuerySettings({
+            namespace: PARAMS.namespace,
+            uuid: PARAMS.uuid,
+            debug: PARAMS.debug,
+        });
+
+        debugPrint('>>> SENDING ACK');
+        // let Client script know we are ready
+        EventBridge.emitWebEvent(location.href);
+
+        // keep Interface in sync when DOM changes
+        jquerySettings.valueUpdated.connect(function(key, value, oldValue, origin) {
+            logValueUpdate('jquerySettings.valueUpdated', key, value, oldValue, origin);
+            bridgedSettings.syncValue(key, value, origin);
+        });
+
+        // keep DOM in sync when Interface changes
+        bridgedSettings.valueUpdated.connect(function(key, value, oldValue, origin) {
+            logValueUpdate('bridgedSettings.valueUpdated', key, value, oldValue, origin);
+            jquerySettings.setValue(key, value);
+        });
+
+        setupUI();
+
+        setTimeout(function() {
+            // wait a tic before showing so initial async settings have time to queue/arrive
+            $('section').show();
+        }, 150);
+    }
+}</script>
+  </head>
+
+  <body class="settings-app">
+    <div id="errors" style="display:none"><pre class='output'></pre><button onclick=location.reload()>adsfsadf</button></div>
+    <div class="content">
+      <header class="title">
+        <div class="inner-title">
+          <h1>Camera Move</h1>
+          <div class="bool row">
+            <input class="setting" type="checkbox" id="camera-move-enabled" />
+            <label for="camera-move-enabled">Enabled</label>
+          </div>
+          <div id="appVersion"><span class='output'>...</span></div>
+        </div>
+      </header>
+
+      <div class='scrollable'>
+        <!-- generate debug line rows -->
+        <pre style='display:none;font-size:8px'>
+          <script id='delme'>for (var i=0; !(i >= 100); i++) document.write('<span class=content></span>'); </script>
+        </pre>
+        <script>with({ parent: '', node: document.getElementById('delme') }) node.parentNode.removeChild(node);</script>
+
+        <section>
+          <h2>Translation</h2>
+          <div class="number row">
+            <label>Max Linear Velocity<span class="unit">m/s</span></label>
+            <input class="setting" data-type="number" step=".1" id="translation-max-velocity" />
+          </div>
+          <div class="slider row">
+            <label>Ease In Coefficient</label>
+            <div class="control"></div>
+            <input class="setting" data-type="number" id="translation-ease-in" />
+          </div>
+          <div class="slider row">
+            <label>Ease Out Coefficient</label>
+            <div class="control"></div>
+            <input class="setting" data-type="number" id="translation-ease-out"  />
+          </div>
+        </section>
+
+        <hr />
+
+        <section>
+          <h2>Rotation</h2>
+          <div class="number row">
+            <label>Max Angular Velocity<span class="unit">deg/s</span></label>
+            <input class="setting" data-type="number" step=".1" id="rotation-max-velocity" />
+          </div>
+          <div class="slider row">
+            <label>Ease In Coefficient</label>
+            <div class="control"></div>
+            <input class="setting" data-type="number" id="rotation-ease-in" />
+          </div>
+          <div class="slider row">
+            <label>Ease Out Coefficient</label>
+            <div class="control"></div>
+            <input class="setting" data-type="number" id="rotation-ease-out"  />
+          </div>
+        </section>
+
+        <hr />
+
+        <section>
+          <h2>Options</h2>
+          <div class="bool row">
+            <input class="setting" id="enable-lookat-snapping" name="Avatar/lookAtSnappingEnabled" type="checkbox" />
+            <label for="enable-lookat-snapping">Avatars snap look at camera</label>
+          </div>
+          <div class="bool row">
+            <input class="setting" id="use-snap-turn" name="Avatar/useSnapTurn" type="checkbox" />
+            <label for="use-snap-turn">Enable snap turn in HMD</label>
+          </div>
+          <div class="bool row">
+            <input class="setting" id="enable-mouse-smooth" type="checkbox"  />
+            <label for="enable-mouse-smooth">Enable smooth mouselook</label>
+          </div>
+          <div class="bool row">
+            <input class="setting" id="minimal-cursor" type="checkbox" />
+            <label for="minimal-cursor">Enable minimal cursor</label>
+          </div>
+        </section>
+
+        <!-- advanced-options -->
+        <div style='display: none' id='advanced-options'>
+          <br />
+          <div class='content'>
+            <hr />
+            <section>
+              <h2>Rotation Speeds</h2>
+              <div class="column">
+                <div class="number row">
+                  <label for='rotation-x-speed'>Pitch speed<span class="unit">deg/s</span></label>
+                  <input class="setting" data-type="number" step="1" id="rotation-x-speed" />
+                </div>
+                <div class="number row">
+                  <label for='rotation-y-speed'>Yaw speed<span class="unit">deg/s</span></label>
+                  <input class="setting" data-type="number" step="1" id="rotation-y-speed" />
+                </div>
+              </div>
+            </section>
+
+            <hr />
+
+            <section>
+              <h2>input scaling</h2>
+              <div class="column">
+                <div class="number row">
+                  <label for='rotation-keyboard-multiplier'>Keyboard multiplier</label>
+                  <input class="setting" data-type="number" step=".1" id="rotation-keyboard-multiplier" />
+                </div>
+                <div class="number row">
+                  <label for='rotation-mouse-multiplier'>Mouse multiplier</label>
+                  <input class="setting" data-type="number" step=".1" id="rotation-mouse-multiplier" />
+                </div>
+              </div>
+            </section>
+
+            <hr />
+
+            <section>
+              <h2>Advanced Options</h2>
+              <div class="bool row">
+                <input class="setting" id="stay-grounded" type="checkbox" />
+                <label for="stay-grounded">Stay on ground</label>
+              </div>
+              <div class="bool row">
+                <input class="setting" id="use-head" type="checkbox" />
+                <label for="use-head">Apply rotations to Avatar Head</label>
+              </div>
+              <div class="bool row">
+                <input class="setting" id="prevent-roll" type="checkbox" />
+                <label for="prevent-roll">Prevent Roll</label>
+              </div>
+              <div class="bool row">
+                <input class="setting" id="constant-delta-time" type="checkbox" />
+                <label for="constant-delta-time">Use constant &Delta; time for frame updates</label>
+              </div>
+              <div class="bool row">
+                <input class="setting" id="normalize-inputs" type="checkbox" />
+                <label for="normalize-inputs">Normalize mouse movement</label>
+              </div>
+              <div class="bool row">
+                <input class="setting" id="collisions-enabled" name="Avatar/Enable Avatar Collisions" type="checkbox" />
+                <label for="collisions-enabled">Enable Avatar Collisions</label>
+              </div>
+              <div class="bool row">
+                <input class="setting" id="draw-mesh" name="Avatar/Draw Mesh" type="checkbox" />
+                <label for="draw-mesh">Draw My Avatar</label>
+              </div>
+              <div class="bool row">
+                <input class="setting" id="ui-enable-tooltips" type="checkbox" />
+                <label for="ui-enable-tooltips">Enable UI tooltips</label>
+              </div>
+              <br />
+            </section>
+
+            <hr />
+
+            <section>
+              <h2>Update Mode</h2>
+              <div class='column'>
+                <div class="radio row" id="drive-mode" >
+                  <div>
+                    <input class="setting" name="drive-mode" id="motor" type="radio" />
+                    <label for="motor">Scripted Motor Control</label>
+                  </div>
+                  <div>
+                    <input class="setting" name="drive-mode" id="position" type="radio" />
+                    <label for="position">Absolute Avatar position</label>
+                  </div>
+                  <div>
+                    <input class="setting" name="drive-mode" id="thrust" type="radio" />
+                    <label for="thrust">Thrust/force vectors</label>
+                  </div>
+                  <div>
+                    <input class="setting" name="drive-mode" id="jitter-test" type="radio" />
+                    <label for="jitter-test">Debug Jitter Testing</label>
+                  </div>
+                </div>
+                <!--/section>
+                    <hr />
+                    <section>
+                      <h2>Script update mode:</h2-->
+                <div class="radio row" id="thread-update-mode" >
+                  <div>
+                    <input class="setting" name="thread-update-mode" id="update" type="radio" />
+                    <label for="update"><code>Script.update</code></label>
+                  </div>
+                  <div class='row tooltip-target' for="requestAnimationFrame" data-tooltip-side='left'>
+                    <label for="requestAnimationFrame"><code>requestAnimationFrame</code></label>
+                    <input class="setting" name="thread-update-mode" id="requestAnimationFrame" type="radio" />
+                    <br />&nbsp; <small>fps:</small> <input class="setting" data-type="number" step="1" id="fps" />
+                  </div>
+                  <div>
+                    <input class="setting" name="thread-update-mode" id="setImmediate" type="radio" />
+                    <label for="setImmediate"><code>setImmediate</code></label>
+                  </div>
+                  <div>
+                    <input class="setting" name="thread-update-mode" id="nextTick" type="radio" />
+                    <label for="nextTick"><code>nextTick</code></label>
+                  </div>
+                </div>
+              </div>
+            </section>
+
+            <hr />
+
+            <section id='debug-menu'>
+              <h2>debug menu</h2>
+              <div>
+                <div style='float:left'>
+                  <button id='reset-sensors'>Reset Avatar</button>
+                  <button class='localhost-only' id='page-reload'>window.reload</button>
+                  <button class='localhost-only' id='script-reload'>script.reload</button>
+                </div>
+                <div style='float:right'>
+                  <button id='copy-json'>Export JSON</button>
+                  <button id='paste-json'>Import JSON</button>
+                </div>
+              </div>
+            </section>
+
+            <hr />
+            <div style='height: 32px'>&nbsp;</div>
+          </div>
+        </div><!-- /advanced-options -->
+      </div><!-- /scrollable -->
+
+      <footer>
+        <div style='float:left'>
+          <button id='reset-to-defaults' title='Replace applicable settings with System Defaults'>Reset</button>
+        </div>
+        <center>
+          <div id='toggleKey'>keybinding: &nbsp; <span class='binding'>&hellip;</span></div>
+        </center>
+        <div style='float:right'>
+          <button id='toggle-advanced-options'>Advanced<span class='chevron'></span></button>
+        </div>
+        <br />
+      </footer>
+    </div>
+
+    <!-- tooltips -->
+    <div id="tooltips" style="display:none">
+      <section>
+        <div for="translation-max-velocity">
+          avatar walking/flying speed limit (ie: the value eased-into)
+        </div>
+        <div for="translation-ease-in">
+          <ul>
+            <li>lower values gently ramp-into moving</li>
+            <li>higher values rapidly accelerate to top speed</li>
+          </ul>
+        </div>
+        <div for="translation-ease-out">
+          <ul>
+            <li>lower values bring movement to a rolling stop</li>
+            <li>higher values stop movements more immediately</li>
+          </ul>
+        </div>
+        <div for="rotation-max-velocity">
+          look up/down (pitch) and turn left/right (yaw) speed limit
+        </div>
+      </section>
+
+      <section>
+        <div for="rotation-ease-in">
+          <ul>
+            <li>lower values gently start turning or looking up/down</li>
+            <li>higher values quickly turn and look around</li>
+          </ul>
+        </div>
+        <div for="rotation-ease-out">
+          <ul>
+            <li>lower values bring turning/looking to a rolling stop</li>
+            <li>higher values stop turning/looking more immediately</li>
+          </ul>
+        </div>
+      </section>
+
+      <section>
+        <div for="enable-lookat-snapping">
+          uncheck this to prevent avatars from automatically making eye contact with the camera
+        </div>
+        <div for="enable-mouse-smooth">
+          uncheck this to bypass smoothing for right-mouse-button drag controls
+        </div>
+        <div for="use-snap-turn">
+          toggles <b>Settings &gt; Avatar &gt; Snap turn while in HMD</b>
+        </div>
+        <div for="minimal-cursor">
+          use half-sized mouse cursor
+        </div>
+      </section>
+
+      <section>
+        <div for="stay-grounded">
+          prevents your Avatar from gaining altitude when looking up/down and moving
+        </div>
+        <div for="use-head">
+          update <b>MyAvatar.headOrientation</b> (instead of body orientation)
+        </div>
+        <div for="prevent-roll">
+          keep upright by applying <b>Quat.cancelOutRoll</b> to resulting rotations
+        </div>
+        <div for="constant-delta-time">
+          ignore actual time between frames and instead calculate using <b>(1s / requestAnimationFrame.fps)</b> or <b>(1s / 90fps)</b>
+        </div>
+        <div for="normalize-inputs">
+          convert variable mouse movements into unit values <b>&plus;1, 0, &minus;1</b>
+        </div>
+        <div for="collisions-enabled">
+          toggles <b>Avatar &gt; Enable Avatar Collisions</b>
+        </div>
+        <div for="draw-mesh">
+          toggles <b>Developer &gt; Avatar &gt; Draw Mesh</b>
+        </div>
+        <div for="ui-enable-tooltips">
+          toggle display of tooltips like this one
+        </div>
+      </section>
+
+      <section>
+        <div for="rotation-x-speed">degrees per up/down controller inputs</div>
+        <div for="rotation-y-speed">degrees per left/right controller inputs</div>
+        <div for="rotation-keyboard-multiplier">prescale raw controller inputs by this amount</div>
+        <div for="rotation-mouse-multiplier">prescale raw controller inputs by this amount</div>
+      </section>
+
+      <section>
+        <div for="motor">
+          ~ <b>MyAvatar.motorVelocity</b>
+        </div>
+        <div for="position">
+          ~ <b>MyAvatar.position</b>
+        </div>
+        <div for="thrust">
+          ~ <b>MyAvatar.setThrust</b>
+        </div>
+        <div for="jitter-test">
+          <b>Diagnostic test:</b> Applies a constant rotation to reveal system-level jitter and update interference
+        </div>
+      </section>
+
+      <section>
+        <div for="update">
+          Script.update events fire at rightly ~60 times per second
+        </div>
+        <div for="requestAnimationFrame">
+          <b>requestAnimationFrame</b> emulation attempts to schedule frame updates in a way that maintains the given frame rate
+        </div>
+        <div for="setImmediate">
+          <b>setImmediate</b> emulation schedules frame updates "next in line" (ie: after pending tasks)
+        </div>
+        <div for="nextTick">
+          <b>nextTick</b> emulation queues frame updates "first in line" (ie: before other tasks)
+        </div>
+      </section>
+
+      <section>
+        <div for="copy-json">
+          exports current settings as JSON
+        </div>
+        <div for="paste-json">
+          replace current settings with previously-exported JSON
+        </div>
+        <div for="reset-sensors">
+          trigger <b>MyAvatar &gt; Reset Sensors</b> and then reset <b>bodyPitch</b> and <b>bodyYaw</b>
+        </div>
+        <div for="toggle-advanced-options">
+          show / hide advanced settings
+        </div>
+      </section>
+    </div><!-- /tooltips -->
+
+    <style id='tablet-ui-less' type="text/less">
+// Embedded LESS stylesheets are used as a structured way to manage page styling.
+// The template rules below get compiled at page load time by less.js.
+//   see: app.js:preconfigureLESS() and http://lesscss.org/usage/ for more info
+// ----------------------------------------------------------------------------
+// tablet-ui.less
+.tablet-ui {
+    .mixins > .load-hifi-font(@custom-font-family);
+    .mixins > .load-hifi-font(@input-font-family);
+
+    input, textarea {
+        font-family: '@{input-font-family}', sans-serif;
+    }
+
+    .mixin-tablet-mode() {
+        width: 100%;
+        margin-left: 0;
+        margin-right: 0;
+        overflow-x: hidden;
+    }
+    .tablet-mode { .mixin-tablet-mode() !important; }
+
+    body {
+        color: @color-text;
+        background-color: @color-bg;
+
+        font-family: '@{custom-font-family}';
+        font-size: 15px;
+        text-rendering: optimizeLegibility;
+        -webkit-touch-callout: none;
+        -webkit-user-select: none;
+        user-select: none;
+        overflow: hidden;
+        overflow-x: hidden !important;
+        padding: 0;
+        margin: 0;
+
+        height: @client-height * 1px;
+        //margin-top: 6px;
+        margin-left: -5px;
+        overflow: hidden;
+    }
+
+    p { margin: 2px 0; }
+
+    header { z-index: 2; }
+
+    section { padding: 0 24px; }
+
+    hr {
+        border: none;
+        background: #404040 url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAYAAACp8Z5+AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsQAAA7EAZUrDhsAAAAjSURBVBhXY1RVVf3PgARYjIyMoEwIYHRwcEBRwQSloYCBAQCwjgPMiI7W2QAAAABJRU5ErkJggg==) repeat-x top left;
+        padding: 1px;
+        margin: 0px;
+        width: 100%;
+        //position: absolute;
+    }
+
+    .title {
+        position: relative;
+        padding: 6px 0 6px 10px;
+        text-align: left;
+        clear: both;
+
+        h1, label {
+            font-weight: normal;
+            //margin: 16px 0;
+            display: inline-block;
+        }
+    }
+
+    #errors .content { padding: 1px 3px; background-color: black; color:blue; display:block; }
+    #errors span:nth-child(1).content:before { content: 'hash:@{hash}\000a'; }
+
+    h1 { font-size: 18px; }
+
+    h2 {
+        margin-left: -12px;
+        font-size: 14px;
+        color: #ddd;
+    }
+
+    input[type=text], input[data-type=number], textarea {
+        margin: 0;
+        padding: 0 0 0 12px;
+        color: @color-text;
+        background-color: @color-bg-darker;
+        border: none;
+        font-size: 15px;
+        &:disabled {
+            background-color: lighten(@color-bg-darker, 25%);
+            color: darken(@color-text, 25%);
+        }
+    }
+}
+
+.mixins() {
+    .load-hifi-font(@font-family) {
+        @font-face {
+            font-family: '@{font-family}';
+            src: url('../../../../resources/fonts/@{font-family}.ttf'),  /* Windows production */
+            url('../../../../fonts/@{font-family}.ttf'),  /* OSX production */
+            url('../../../../interface/resources/fonts/@{font-family}.ttf'),  /* Development, running script in /HiFi/examples */
+            url('https://cdn.rawgit.com/highfidelity/hifi/9fa900ba/interface/resources/fonts/@{font-family}.ttf'); /* fallback to rawgit/github */
+        }
+        //@import (css) url("https://fonts.googleapis.com/css?family=@{custom-font-family}");
+    }
+}
+
+.scrollable {
+    top: @header-height * 1px + 1;
+    padding-top: 0;
+    padding-right: 5px;
+    left: 0;
+    position: relative;
+    //width: @client-width * 1px;
+    overflow-x: hidden;
+    height: @inner-height * 1px - @footer-height - @header-height;
+    overflow-y: scroll;
+    &::-webkit-scrollbar { width: 12px; height: 0px; }
+    &::-webkit-scrollbar-button { width: 0px; height: 0px;}
+    &::-webkit-scrollbar-thumb { background: darken(@color-highlight, 5%); border-radius: 4px; }
+    &::-webkit-scrollbar-thumb:hover { background: lighten(@color-highlight, 5%); border-radius: 4px;}
+    //&::-webkit-scrollbar-thumb:active { background: #fff; border: 1px dotted black;}
+    &::-webkit-scrollbar-track:disabled { background: @color-bg; border: 0px none #ffffff; border-radius: 0px;}
+    &::-webkit-scrollbar-track { background: #666666; border: 0px none #ffffff; border-radius: 0px;}
+    &::-webkit-scrollbar-track:hover { background: #666666;}
+    &::-webkit-scrollbar-track:active { xborder: 2px dotted #666; background: #555;}
+    &::-webkit-scrollbar-corner { background: transparent;}
+}
+</style>
+<style id='jquery-ui-overrides-less' type='text/less'>
+// ----------------------------------------------------------------------------
+// jquery-ui-overrides.less
+.tablet-ui {
+    .tooltipster-base {
+        @tooltip-bg-color: darken(#624888, 5%);
+        @tooltip-text-color: contrast(@tooltip-bg-color);
+        .tooltipster-box {
+            background-color: @tooltip-bg-color;
+            .tooltipster-content {
+                ul {
+                    margin: 0; padding: 0;
+                    li {
+                        list-style-position: outside;
+                        text-indent: -4px;
+                        margin-left: 14px;
+                    }
+                }
+                color: @tooltip-text-color;
+                font-weight: normal;
+                font-size: 12.5px;
+                overflow-y: hidden;
+                padding: 3px 6px 2px 6px;
+            }
+        }
+        .arrow-border(@side) {
+            &.tooltipster-@{side} {
+                .tooltipster-arrow {
+                    .tooltipster-arrow-background { border-@{side}-color: @tooltip-bg-color; }
+                    .tooltipster-arrow-border { border-@{side}-color: black; }
+                }
+            }
+        }
+        .arrow-border(top);
+        .arrow-border(right);
+        .arrow-border(left);
+        .arrow-border(bottom);
+    }
+    .radio {
+        label.ui-state-active { color: white !important; }
+        .ui-checkboxradio-label .ui-icon { border-radius: .5em; }
+    }
+
+    .ui-checkboxradio-label {
+        padding: 3px 0;
+        &:hover {
+            .ui-icon-background { background-color: white; }
+            &.ui-state-active .ui-icon-background {
+                border-color: white;
+                background-color: @color-highlight;
+            }
+        }
+        .ui-checkboxradio-icon-space {
+            width: 8px;
+            display: inline-block;
+        }
+        &.ui-visual-focus { box-shadow: inherit; }
+        .ui-icon {
+            background-color: @color-bg-icon;
+            box-shadow: none;
+            border-radius: .25em;
+        }
+        &.ui-state-active {
+            .ui-icon-background {
+                background-color: @color-highlight;
+                padding: 2px;
+                border: solid 2px @color-bg-icon;
+                width: 8px;
+                height: 8px;
+            }
+        }
+    }
+    .ui-slider-range { background-color: @color-highlight; }
+
+    .ui-spinner {
+        border: none;
+        background-color: transparent;
+        margin: 4px 2px;
+        padding: 0;
+        .ui-button {
+            border-left: solid 1px rgba(158, 158, 158, 0.151);
+            background-color: inherit;
+        }
+        input.ui-spinner-input {
+            margin: 0 !important;
+            xwidth: inherit !important;
+            &::-webkit-outer-spin-button,
+            &::-webkit-inner-spin-button {
+                -webkit-appearance: none;
+            }
+        }
+    }
+
+    .ui-slider-horizontal {
+        background-color: @color-bg-darker;
+        border-color: @color-bg-darker !important;
+        .ui-slider-handle {
+            top: -.5em;
+            width: 1.4em;
+            height: 1.4em;
+            border-radius: 100%;
+            outline: none;
+            background-color: #757575;
+            border-color: @color-bg-darker;
+            &:focus  { border-color: white; }
+            // custom inner slider handle circle
+            .inner-ui-slider-handle {
+                border-radius: 100%;
+                padding: 0px;
+                right: 2px;
+                bottom: 2px;
+                position: absolute;
+                left: 2px;
+                top: 2px;
+                border: solid black 2px;
+            }
+        }
+    }
+}
+</style>
+<style id='CustomSettingsApp-less' type='text/less'>
+// ----------------------------------------------------------------------------
+// CustomSettingsApp.less
+.tablet-ui .settings-app {
+    @subtle-border: solid 1px rgba(158, 158, 158, 0.151);
+
+    br { clear: both; }
+
+    .content { display: block; }
+
+    #overlay {
+        opacity: .15;
+        background-color: white;
+        position: absolute;
+        top: 0;
+        left: 0;
+        height: 100%;
+        width: 100%;
+        z-index: 9999999;
+    }
+    &:not(.active) #overlay { display: block; }
+    &.active #overlay { display: none; }
+
+    section {
+        clear: both;
+        h2 {
+            text-transform: uppercase;
+            margin-bottom: 10px;
+        }
+    }
+
+    .content footer {
+        bottom: 0;
+        top: auto;
+        text-align: center;
+        height: @footer-height * 1px;
+        border-bottom: none;
+        box-shadow: none;
+        center {
+            //line-height: @footer-height * 1px;
+            vertical-align: middle;
+            font-size: .8em;
+            width: @client-width * .5px;
+            position: absolute;
+            display: block;
+            margin-left: 24%;
+            left: 0;
+            div { display: inline-block; }
+        }
+        button {
+            margin-top: 3px;
+        }
+    }
+    header, footer {
+        border: solid 1px black;
+        height: @header-height * 1px;
+        margin: 0;
+        position: fixed;
+        left: 0;
+        padding: 0;
+        top: 0;
+        width: 100%;
+        background-color: lighten(@color-bg,10%);
+        box-shadow: 2px 2px 10px rgba(0,0,0,.5);
+        z-index: 10;
+        white-space: nowrap;
+        > div {
+            position: relative;
+            padding: 0 6px;
+        }
+        .ui-icon { background-color: lighten(@color-bg-icon, 10%); }
+        .ui-checkboxradio-label {
+            &.ui-state-active {
+                .ui-icon { border-color: lighten(@color-bg-icon, 10%); }
+                &:hover .ui-icon { border-color: inherit; }
+            }
+        }
+        .row {
+            margin-top: 2px;
+            margin-left: 10px;
+            font-size: .9em;
+            display: inline-block !important;
+            &.bool {
+                margin-top: inherit !important;
+                margin-bottom: inherit !important;
+            }
+        }
+    }
+
+    // row styles in two column mode
+    .column {
+        margin-left: -16px;
+        margin-right: -16px;
+        .row {
+            width: 40%;
+            &:nth-child(1) { margin-right: 5%; }
+            display: inline-block;
+            &.radio {
+                padding-top: .5em;
+                padding-bottom: .5em;
+            }
+        }
+    }
+
+    .focused() { outline: dotted 1px @color-highlight; }
+
+    // general row styles
+    .row {
+        white-space: nowrap;
+        margin-top: .3em;
+        margin-bottom: .3em;
+
+        &:focus, div:focus { .focused }
+        &.disabled input.setting { color: #000; }
+        &.invalid input.setting { border: solid 1px mix(red, @color-highlight, 55%); }
+        input {
+            outline: none !important;
+            font-size: 1.1em;
+            margin: 8px;
+            padding: 8px;
+            border: solid 1px transparent;
+            &:focus { border: solid 1px @color-highlight !important; }
+        }
+        &.slider {
+            label { display: block; clear: both }
+            .ui-spinner {
+                position: relative;
+                width: 100px;
+                input { width: 80% }
+            }
+            .control  {
+                float: left;
+                clear: left;
+                width: 75%;
+                margin: 13px;
+                top: 6px;
+                height: 6px;
+            }
+        }
+        &.bool {
+            input[type=checkbox] { display: none; }
+            label.ui-widget { padding-left: 0px; }
+            display: table-row;
+            margin-top: .6em;
+            margin-bottom: .6em;
+        }
+
+        &.radio {
+            vertical-align: top;
+            input[type=radio] { display: inline-block; }
+            label.ui-visual-focus { .focused; }
+            border: @subtle-border;
+            border-right: @subtle-border;
+            border-radius: .5em;
+            padding-left: 12px;
+            padding-right: 12px;
+        }
+
+        &.radio, &.bool {
+            label.ui-button {
+                text-align: left;
+                background: inherit;
+                border: inherit;
+                color: inherit;
+                .ui-icon { background-image: none; }
+            }
+            //.ui-checkboxradio-checked { color: white }
+        }
+        &.number {
+            label {
+                display: block;
+                width: 100%;
+                margin-right: 26px;
+            }
+            input { width: 10em; }
+        }
+        .unit {
+            margin-left: 6px;
+            font-weight: lighter;
+            font-size: .8em;
+            font-style: italic;
+        }
+    }
+    code {
+        font-family: monospace;
+        font-size: .8em;
+        font-weight: bold;
+        //color: mix(@color-text, magenta, 90%);
+    }
+    kbd {
+        display: inline-block;
+        margin: 0 .1em;
+        padding: .1em .6em;
+        font-family: Arial,"Helvetica Neue",Helvetica,sans-serif;
+        font-size: .85em;
+        font-weight: bold;
+        line-height: 1.1em;
+        color: #242729;
+        text-shadow: 0 1px 0 darken(#FFF, 25%);
+        background-color: darken(#e1e3e5, 25%);
+        border: 1px solid darken(#adb3b9, 25%);
+        border-radius: 3px;
+        box-shadow: 0 1px 0 rgba(12,13,14,0.2),0 0 0 2px darken(#FFF,25%) inset;
+        white-space: nowrap;
+    }
+}
+</style>
+<style id='app-less' type='text/less'>
+// ----------------------------------------------------------------------------
+// app.less
+.tablet-ui .settings-app {
+    &.camera-move-enabled header {
+        border-bottom: solid 1px @color-highlight;
+        label { color: white !important; font-weight: bold; }
+    }
+
+    button {
+        font-weight: bold;
+        font-family: arial;
+        font-size: 12px;
+        //padding: 6px 8px;
+        cursor: pointer;
+        background-color: @color-primary-button;
+        color: white;
+        border-color: transparent;
+        margin: 2px;
+        text-transform: uppercase;
+        &:hover { background-color: lighten(@color-primary-button,10%); }
+        border: solid 1px transparent;
+        &:focus { border: dotted 1px tint(@color-highlight) !important; }
+    }
+    .localhost-only {
+        //display: none;
+        &when (@debug), (@localhost) {
+            display: inline-block;
+        }
+    }
+    #advanced-options {
+        .content { background-color: #333; }
+        display: none;
+    }
+    #toggle-advanced-options {
+        background-color: darken(@color-alt-button, 5%);
+        .chevron {
+            width: 2em;
+            display: inline-block;
+            &:after { content: ' \25B7'; }
+        }
+        &:hover { background-color: lighten(@color-alt-button, 5%); }
+    }
+    #reset-to-defaults {
+        background-color: darken(@color-caution-button, 5%);
+        &:hover { background-color: lighten(@color-caution-button, 5%); }
+    }
+}
+.ui-show-advanced-options {
+    #advanced-options { display: block !important; }
+    #toggle-advanced-options .chevron:after { content: ' \25BC' !important; }
+}
+
+#appVersion {
+    z-index: 1;
+    float: right;
+}
+#toggleKey { line-height: @footer-height * 1px; }
+
+// #overlay when(not(@interface-mode)) {
+//     height: 16px !important;
+// }
+
+#errors {
+    position: fixed;
+    left: 0;
+    top: 0;
+    height: auto;
+    width: 100%;
+    font-size: .8em;
+    background-color: rgb(95,0,0);
+    color: white;
+    overflow: auto;
+    margin: 0;
+    padding: 8px;
+    z-index: 9999999;
+}
+
+footer center > div { display: none; }
+#tooltips { display: none }
+
+.tablet-ui .settings-app .row[for=requestAnimationFrame] {
+    display: inherit !important;
+    margin-top: 0;
+    margin-bottom: 0;
+    .ui-spinner { margin: 0; }
+    .ui-checkboxradio-label { margin: 0; padding: 0;}
+    #fps {
+        padding: 3px;
+        height: .7em;
+        font-size: .9em;
+        width: 50px;
+    }
+}
+</style>
+<script> preconfigureLESS(); </script>
+<script data-env-'development' xx-data-env='production' src="https://cdnjs.cloudflare.com/ajax/libs/less.js/2.7.2/less.js"></script>
+
+<!-- JSON DUMP TEMPLATE -->
+<script type='text/html' id='POPUP'>
+  <html>
+    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/styles/ir-black.min.css" />
+    <xx-script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/highlight.min.js"></xx-script>
+    <body style='margin:0;overflow:hidden;'>
+      <pre><code class="json" style='height:100%'>JSON</code></pre>
+      <xx-script>hljs.initHighlighting();</xx-script>
+    </body>
+  </html>
+</script>
+
+<div id='overlay'></div>
+</body>
+</html>
diff --git a/unpublishedScripts/marketplace/camera-move/app.js b/unpublishedScripts/marketplace/camera-move/app.js
new file mode 100644
index 0000000000..366b6b7117
--- /dev/null
+++ b/unpublishedScripts/marketplace/camera-move/app.js
@@ -0,0 +1,895 @@
+// app.js -- jquery support functions
+
+/* eslint-env commonjs, browser */
+
+// ----------------------------------------------------------------------------
+function defineCustomWidgets() {
+    $.widget('ui.hifiCheckboxRadio', $.ui.checkboxradio, {
+        _create: function() {
+            this._super();
+            this.element[0].value = this.element[0].id;
+            debugPrint('ui.hifiCheckboxRadio._create', this.element[0].type, this.element[0].id, this.element[0].value);
+        },
+    });//$.fn.hifiCheckboxRadio = $.fn.checkboxradio;
+    $.widget('ui.hifiControlGroup', $.ui.controlgroup, {
+        _create: function(x) {
+            debugPrint('ui.hifiControlGroup._create', this.element[0])
+            var tmp = this.options.items.checkboxradio;
+            delete this.options.items.checkboxradio;
+            this.options.items.hifiCheckboxRadio = tmp;
+            this._super();
+
+            Object.defineProperty(this.element[0], 'value', {
+                enumerable: true,
+                get: function() { assert(false, 'attempt to access hifiControlGroup.element[0].value...' +[this.id,this.name]); },
+                set: function(nv) { assert(false, 'attempt to set hifiControlGroup.element[0].value...' +[this.id,this.name]); },
+            });
+        },
+    });
+    $.widget('ui.hifiSpinner', $.ui.spinner, {
+        _create: function() {
+            debugPrint('ui.hifiSpinner._create', this.element[0])
+            this.previous = null;
+            this._super();
+        },
+        _spin: function( step, event ) {
+            if (event.type === 'mousewheel') {
+                if (!event.shiftKey) {
+                    step *= ('1e'+Math.max(1,this._precision()))/10;
+                }
+                if (event.ctrlKey) {
+                    step *= 10;
+                }
+            }
+            return this._super( step, event );
+        },
+        _stop: function( event, ui ) {
+            try {
+                return this._super(event, ui);
+            } finally {
+                if (/mouse/.test(event && event.type)) {
+                    var value = this.element.val();
+                    if (value != "" && !isNaN(value) && this.previous !== null && this.previous !== value) {
+                        debugPrint(this.element[0].id, 'spinner.changed', event.type, JSON.stringify({
+                            previous: isNaN(this.previous) ? this.previous+'' : this.previous,
+                            val: isNaN(value) ? value+'' : value,
+                        }));
+                        this.element.change();
+                    }
+                    this.previous = value;
+                }
+            }
+        },
+        _format: function(n) {
+            var precision = this._precision()
+            return parseFloat(n).toFixed(precision);
+        },
+        _events: {
+            mousewheel: function(event, delta) {
+                if (document.activeElement !== this.element[0])
+                    return;
+                // fix broken mousewheel on Chrome / webkit
+                delta = delta === undefined ? event.originalEvent.deltaY : delta;
+                $.ui.spinner.prototype._events.mousewheel.call(this, event, delta);
+            }
+        }
+    });
+    $.widget('ui.hifiSlider', $.ui.slider, {
+        _create: function() {
+            this._super();
+            // add the inner circle and border (per design specs) to jquery-ui's existing slider handle
+            this.element.find('.ui-slider-handle')
+                .html('<div class="inner-ui-slider-handle"></div>');
+        },
+    });
+}
+
+// JSON export / import helpers proto module
+JQuerySettings.$json = (function() {
+    return {
+        setPath: setPath,
+        rollupPaths: rollupPaths,
+        encodeNodes: encodeNodes,
+        exportAll: exportAll,
+        showSettings: showSettings,
+        applyJSON: applyJSON,
+        promptJSON: promptJSON,
+        popupJSON, popupJSON,
+    };
+
+    function encodeNodes(resolver, elements) {
+        return elements.toArray().reduce((function(out, input, i) {
+            log('input['+i+']', input.id);
+            var id = input.type === 'radio' ? $(input).closest(':ui-hifiControlGroup').prop('id') : input.id;
+            var key = resolver.getKey(id);
+            log('toJSON', id, key, input.id);
+            setPath(out, key.split('/'), resolver.getValue(key));
+            return out;
+        }).bind(this), {});
+    }
+
+    function setPath(obj, path, value) {
+        var key = path.pop();
+        obj = path.reduce(function(obj, subkey) {
+            return obj[subkey] = obj[subkey] || {};
+        }, obj);
+        debugPrint('setPath', key, Object.keys(obj));
+        obj[key] = value;
+    }
+
+    function rollupPaths(obj, output, path) {
+        path = path || [];
+        output = output || {};
+        log('rollupPaths', Object.keys(obj||{}), Object.keys(output), path);
+        for (var p in obj) {
+            path.push(p);
+            var value = obj[p];
+            if (value && typeof value === 'object') {
+                rollupPaths(obj[p], output, path);
+            } else {
+                output[path.join('/')] = value;
+            }
+            path.pop();
+        }
+        return output;
+    }
+
+    function exportAll(resolver, name) {
+        return {
+            version: VERSION,
+            name: name || undefined,
+            settings: encodeNodes(resolver, $('input')),
+            _metadata: { timestamp: new Date(), PARAMS: PARAMS, url: location.href, }
+        };
+    };
+
+    function showSettings(resolver, saveName) {
+        JQuerySettings.$json.popupJSON(saveName || '(current settings)', Object.assign(JQuerySettings.$json.exportAll(resolver, saveName), {
+            extraParams: bridgedSettings.extraParams,
+        }));
+    };
+
+    function popupJSON(title, tmp) {
+        var HTML = POPUP.innerHTML
+            .replace(/\bxx-script\b/g, 'script')
+            .replace('JSON', JSON.stringify(tmp, 0, 2).replace(/\n/g, '<br />'));
+        if (0) {
+            bridgedSettings.sendEvent({
+                method: 'overlayWebWindow',
+                options: {
+                    title: 'app-camera-move-export' + (title ? '::'+title : ''),
+                    content: HTML,
+                },
+            });
+        } else {
+            // make the browser address bar less ugly by putting spaces and friedly name as a "URL footer"
+            var footer = '<\!-- #' + HTML.substr(0,256).replace(/./g,' ') + (title || 'Camera Move Settings');
+            window.open("data:text/html;escape," + encodeURIComponent(HTML) + footer,"app-camera-move-export");
+        }
+    }
+
+    function applyJSON(resolver, name, tmp) {
+        assert('version' in tmp && 'settings' in tmp, 'invalid settings record: ' + JSON.stringify(tmp))
+        var settings = rollupPaths(tmp.settings);
+        for(var p in settings) {
+            var key = resolver.getId(p, true);
+            if (!key) {
+                log('$applySettings -- skipping unregistered Settings key: ', p);
+            } else {
+                resolver.setValue(p, settings[p], name+'.settings.'+p);
+            }
+        }
+    };
+
+    function promptJSON() {
+        var json = window.prompt('(paste JSON here)', '');
+        if (!json) return;
+        try {
+            json = JSON.parse(json);
+        } catch(e) {
+            throw new Error('Could not parse pasted JSON: ' + e + '\n\n' + (json||'').replace(/</g,'&lt;'));
+        }
+        return json;
+    }
+})(this);
+
+// ----------------------------------------------------------------------------
+// manage jquery-tooltipster hover tooltips
+TooltipManager = (function(global) {
+    function TooltipManager(options) {
+        this.options = options;
+        assert(options.elements, 'TooltipManager constructor expects options.elements');
+        assert(options.tooltips, 'TooltipManager constructor expects options.tooltips');
+        var TOOLTIPS = this.TOOLTIPS = {};
+        $(options.tooltips).find('[for]').each(function() {
+            var id = $(this).attr('for');
+            TOOLTIPS[id] = $(this).html();
+        });
+
+        var _self = this;
+        options.elements.each(function() {
+            var element = $($(this).closest('.tooltip-target').get(0) || this),
+                input = element.is('input, button') ? element : element.find('input, button'),
+                parent = element.is('.row, button') ? element : element.parent(),
+                id = element.attr('for') || input.prop('id'),
+                tip = TOOLTIPS[id] || element.prop('title');
+
+            var tooltipSide = element.data('tooltipSide');
+
+            log('binding tooltip', tooltipSide, element[0].nodeName, id || element[0], tip);
+            if(!tip)
+                return log('missing tooltip: ' + (id || this.id || this.name || this.nodeName));
+            if(element.is('.tooltipstered')) {
+                console.info('already tooltipstered!?', this.id, this.name, id);
+                return;
+            }
+            var instance = element.tooltipster({
+                theme: ['tooltipster-noir'],
+                side: tooltipSide || (
+                    input.is('button') ? 'top' :
+                        input.closest('.slider').get(0) || input.closest('.column').get(0) ? ['top','bottom'] :
+                        ['right','top','bottom', 'left']
+                ),
+                content: tip,
+                updateAnimation: 'scale',
+                trigger: !options.testmode ? 'hover' : 'click',
+                distance: element.is('.slider.row') ? -20 : undefined,//element
+                delay: [500, 1000],
+                contentAsHTML: true,
+                interactive: options.testmode,
+                minWidth: options.viewport && options.viewport.min.x,
+                maxWidth: options.viewport && options.viewport.max.w,
+            }).tooltipster('instance');
+
+            instance.on('close', function(event) {
+                if (options.keepopen === element) {
+                    debugPrint(event.type, 'canceling close keepopen === element', id);
+                    event.stop();
+                    options.keepopen = null;
+                }
+            });
+            instance.on('before', function(event) {
+                debugPrint(event.type, 'before', event);
+                !options.testmode && _self.closeAll();
+                !options.enabled && event.stop();
+                return;
+            });
+            parent.find(':focusable, input, [tabindex], button, .control')
+                .add(parent)
+                .add(input.closest(':focusable, input, [tabindex]'))
+                .on({
+                    click: function(evt) {
+                        if (input.is('button')) return setTimeout(instance.close.bind(instance,null),50);
+                        options.keepopen = element; 0&&instance.open();
+                    },
+                    focus: instance.open.bind(instance, null),
+                    blur: function(evt) { instance.close(); _self.openFocusedTooltip(); },
+                });
+        });
+        Object.assign(this, {
+            openFocusedTooltip: function() {
+                if (!this.options.enabled)
+                    return;
+                setTimeout(function() {
+                    if (!document.activeElement || document.activeElement === document.body ||
+                        !$(document.activeElement).closest('section')) {
+                        return;
+                    }
+                    var tip = $([])
+                        .add($(document.activeElement))
+                        .add($(document.activeElement).find('.tooltipstered'))
+                        .add($(document.activeElement).closest('.tooltipstered'))
+                        .filter('.tooltipstered');
+                    if (tip.is('.tooltipstered')) {
+                        log('opening focused tooltip', tip.length, tip[0].id);
+                        tip.tooltipster('open');
+                    }
+                },1);
+            },
+            rapidClose: function(instance, reopen) {
+                if (!instance.status().open) {
+                    return;
+                }
+                instance.elementTooltip() && $(instance.elementTooltip()).hide();
+                instance.close(function() { reopen && instance.open(); });
+            },
+            openAll: function() {
+                $('.tooltipstered').tooltipster('open');
+            },
+            closeAll: function() {
+                $.tooltipster.instances().forEach(function(instance) {
+                    this.rapidClose(instance);
+                }.bind(this));
+            },
+            updateViewport: function(viewport) {
+                var options = {
+                    minWidth: viewport.min.x,
+                    maxWidth: viewport.max.x,
+                };
+                $.tooltipster.setDefaults(options);
+                log('updating tooltipster options', JSON.stringify(options, 0, 2));
+                $.tooltipster.instances().forEach(function(instance) {
+                    instance.option('minWidth', options.minWidth);
+                    instance.option('maxWidth', options.maxWidth);
+                    this.rapidClose(instance, instance.status().open);
+                }.bind(this));
+            },
+            enable: function() {
+                this.options.enabled = true;
+                if (this.options.testmode)
+                    this.openAll();
+            },
+            disable: function() {
+                this.options.enabled = false;
+                this.closeAll();
+            },
+        });
+    }
+    return TooltipManager;
+})(this);
+
+function setupUI() {
+    $(window).trigger('resize'); // populate viewport
+
+    $('#debug-menu button, footer button').button();
+
+    var $json = JQuerySettings.$json;
+
+    var buttonHandlers = {
+        'page-reload': function() {
+            log('triggering location.reload');
+            location.reload();
+        },
+        'script-reload': function() {
+            log('triggering script.reload');
+            bridgedSettings.sendEvent({ method: 'reloadClientScript' });
+        },
+        'reset-sensors': function() {
+            log('resetting avatar orientation');
+            bridgedSettings.sendEvent({ method: 'resetSensors' });
+        },
+        'reset-to-defaults': function() {
+            tooltipManager.closeAll();
+            document.activeElement && document.activeElement.blur();
+            document.body.focus();
+            setTimeout(function() {
+                bridgedSettings.sendEvent({ method: 'reset' });
+            },1);
+        },
+        'copy-json': $json.showSettings.bind($json, jquerySettings, null),
+        'paste-json': function() {
+            $json.applyJSON(
+                jquerySettings,
+                'pasted',
+                $json.promptJSON()
+            );
+        },
+        'toggle-advanced-options': function(evt) {
+            var on = $('body').hasClass('ui-show-advanced-options');
+            bridgedSettings.setValue('ui-show-advanced-options', !on);
+            evt.stopPropagation();
+            evt.preventDefault();
+            $(this).tooltipster('instance').content((on ? 'show' : 'hide') + ' advanced options');
+            if (!on) {
+                $('.scrollable').delay(100).animate({
+                    scrollTop: innerHeight - $('header').innerHeight() - 24
+                }, 1500);
+            }
+        },
+        'appVersion': function(evt) { evt.shiftKey && $json.showSettings(jquerySettings); },
+        'errors': function() {
+            $(this).find('.output').text('').end().hide();
+        },
+    };
+    Object.keys(buttonHandlers).forEach(function(p) {
+        $('#' + p).css('cursor', 'pointer').on('click', buttonHandlers[p]);
+    });
+
+    // ----------------------------------------------------------------
+    tooltipManager = new TooltipManager({
+        enabled: false,
+        testmode: PARAMS.tooltiptest,
+        viewport: PARAMS.viewport,
+        tooltips: $('#tooltips'),
+        elements: $($.unique($('input.setting, button').map(function() {
+            var input = $(this),
+                button = input.is('button') && input,
+                row = input.closest('.row').is('.slider') && input.closest('.row'),
+                label = input.is('[type=checkbox],[type=radio]') && input.parent().find('label');
+
+            return (button || label || row || input).get(0);
+        }))),
+    });
+
+    $( "input[data-type=number]" ).each(function() {
+        // set up default numeric precisions
+        var step = $(this).prop('step') || .01;
+        var precision = ((1 / step).toString().length - 1);
+        $(this).prop('step', step || Math.pow(10, -precision));
+    });
+
+    $('input.setting').each(function() {
+        // set up the bidirectional mapping between DOM and Settings
+        jquerySettings.registerNode(this);
+    }).on('change', function(evt) {
+        // set up change detection
+        var input = $(this),
+            key = assert(jquerySettings.getKey(this.id)),
+            type = this.dataset.type || input.prop('type'),
+            valid = (this.value !== '' && (type !== 'number' || isFinite(this.value)));
+
+        debugPrint('input.setting.change', key, evt, this);
+        $(this).closest('.row').toggleClass('invalid', !valid);
+
+        if (!valid) {
+            debugPrint('input.setting.change: ignoring invalid value ' + this.value + ' for ' + key);
+            return;
+        }
+
+        jquerySettings.resyncValue(key, 'input.setting.change');
+
+        // radio buttons are codependent (ie: TCOBO selected at a time)
+        //   so if under a control group, trigger a display refresh
+        $(evt.target).closest(':ui-hifiControlGroup').find(':ui-hifiCheckboxRadio').hifiCheckboxRadio('refresh');
+    });
+
+    if (PARAMS.debug || true) {
+        // support hitting 'r' key to refresh during development
+        $(window).on('keypress.debug', function(evt) {
+            if (!$(evt.target).is('input')) {
+                var ch = String.fromCharCode(evt.keyCode);
+                if (evt.originalEvent.keyIdentifier === 'U+0057')
+                    ch = 'w';
+                switch(ch) {
+                    case 'r': {
+                        log('... "r" reloading', evt.keyCode);
+                        location.reload()
+                    } break;
+                    case 'w': {
+                        if (evt.ctrlKey) {
+                            log('ctrl-w detected; closing via client script');
+                            bridgedSettings.sendEvent({ method: 'window.close' });
+                        }
+                    } break;
+                }
+            }
+        });
+    }
+
+    // numeric fields
+    $( ".number.row input.setting" )
+        .hifiSpinner({
+            disabled: true,
+            create: function() {
+                var input = $(this),
+                    id = assert(this.id, '.number input.setting without id attribute: ' + this),
+                    key = assert(jquerySettings.getKey(this.id));
+
+                var options = input.hifiSpinner('instance').options;
+                options.min = options.min || 0.0;
+
+                bridgedSettings.getValueAsync(key, function(err, result) {
+                    input.hifiSpinner('enable');
+                    jquerySettings.setValue(key, result);
+                });
+            },
+        });
+
+    // radio button groups
+    $( ".radio.row" )
+        .hifiControlGroup({
+            direction: 'horizontal',
+            disabled: true,
+            create: function() {
+                var group = $(this), id = this.id;
+                this.setAttribute('type', this.type = this.dataset.type = 'radio-group');
+                var key = assert(jquerySettings.getKey(id));
+                bridgedSettings.getValueAsync(key, function(err, result) {
+                    debugPrint('> GOT RADIO', key, id, result);
+                    group.hifiControlGroup('enable');
+                    jquerySettings.setValue(key, result);
+                    group.change();
+                });
+            },
+        });
+
+    // checkbox fields
+    $( ".bool.row input.setting" )
+        .hifiCheckboxRadio({ disabled: true })
+        .each(function() {
+            var key = assert(jquerySettings.getKey(this.id)),
+                input = $(this);
+
+            bridgedSettings.getValueAsync(key, function(err, result) {
+                input.hifiCheckboxRadio('enable');
+                jquerySettings.setValue(key, result);
+            });
+        });
+
+    // slider + numeric field sets
+    $( ".slider.row .control" ).each(function(ent) {
+        var element = $(this),
+            input = element.parent().find('input'),
+            id = input.prop('id'),
+            key = assert(jquerySettings.getKey(id));
+
+        var commonOptions = {
+            disabled: true,
+            min: parseFloat(input.prop('min') || 0),
+            max: parseFloat(input.prop('max') || 10),
+            step: parseFloat(input.prop('step') || 0.01),
+        };
+        debugPrint('commonOptions', commonOptions);
+
+        // see: https://api.jqueryui.com/slider/ for more options
+        var slider = element.hifiSlider(Object.assign({
+            orientation: "horizontal",
+            range: "min",
+            animate: 'fast',
+            value: 0.0,
+            start: function(event, ui) { this.$startValue = ui.value; },
+            slide: function(event, ui) { input.hifiSpinner('value', ui.value); },
+            stop: function(event, ui) {
+                if (ui.value != this.$startValue) {
+                    jquerySettings.setValue(key, ui.value, 'slider.stop');
+                }
+            },
+        }, commonOptions));
+
+        // setup chrome up/down arrow steps and change event for propagating input field -> slider
+        input.on('change', function() {
+            element.hifiSlider("value", this.value);
+        }).hifiSpinner(Object.assign({}, commonOptions, { max: Infinity }));
+
+        bridgedSettings.getValueAsync(key, function(err, result) {
+            input.hifiSpinner('enable');
+            element.hifiSlider('enable');
+            jquerySettings.setValue(key, result);
+        });
+    });
+
+    // make all other numeric fields into custom jquery spinners
+    $('input[data-type=number]:not(:ui-hifiSpinner)').hifiSpinner({
+        disabled: true,
+        create: function() {
+            var input = $(this),
+                id = assert(this.id, '.number input.setting without id attribute: ' + this),
+                key = assert(jquerySettings.getKey(this.id));
+
+            log('======================================', id, key);
+
+            var options = input.hifiSpinner('instance').options;
+            options.min = options.min || 0.0;
+
+            bridgedSettings.getValueAsync(key, function(err, result) {
+                jquerySettings.setValue(key, result);
+            });
+        },
+    });
+
+
+    // ----------------------------------------------------------------------------
+    // allow spacebar to toggle checkbox / radiobutton fields
+    $('[type=checkbox]:ui-hifiCheckboxRadio').parent().prop('tabindex',0)
+        .on('keydown.toggle', function spaceToggle(evt) {
+            if (evt.keyCode === 32) {
+                var input = $(this).find(':ui-hifiCheckboxRadio'),
+                    id = input.prop('id'),
+                    key = assert(jquerySettings.getKey(id));
+                log('spaceToggle', evt.target+'', id, key);
+                if (!input.is('[type=radio]') || !input.prop('checked')) {
+                    input.prop('checked', !input.prop('checked'));
+                    input.change();
+                }
+                evt.preventDefault();
+                evt.stopPropagation();
+            }
+        });
+
+    // when user presses ENTER on a number field, blur it to provide visual feedback
+    $('input[data-type=number]').on('keydown.enter', function(evt) {
+        if (evt.keyCode === 13 && $(document.activeElement).is('input')) {
+            tooltipManager.closeAll();
+            document.activeElement.blur();
+            var nexts = $('[tabindex],input').not('[tabindex=-1],.ui-slider-handle').toArray();
+            if (~nexts.indexOf(this)) {
+                var nextActive = nexts[nexts.indexOf(this)+1];
+                $(nextActive).focus();
+            }
+        }
+    });
+
+    // by default webkit spacebar behaves like PageDown; this disables that to avoid confusion
+    window.onkeydown = function(evt) {
+        if (evt.keyCode === 32 && document.activeElement !== document.body) {
+            log('snarfing spacebar event', document.activeElement);
+            return evt.preventDefault(), evt.stopPropagation(), false;
+        }
+    };
+
+    // select-all text when an input field is first focused
+    $('input').not('input[type=radio],input[type=checkbox]').on('focus', function (e) {
+        var dt = (new Date - this.blurredAt);
+        if (!(dt < 5)) { // debounce
+            this.blurredAt = +new Date;
+            //log('FOCUS', dt, e.target === document.activeElement, this === document.activeElement);
+            $(this).one('mouseup.selectit', function () {
+                $(this).select();
+                return false;
+            }).select();
+        }
+    }).on('blur', function(e) {
+        this.blurredAt = new Date;
+        //log('BLUR', e.target === document.activeElement, this === document.activeElement);
+    });
+
+    // monitor specific settings for live changes
+    jquerySettings.registerSetting({ type: 'placeholder' }, '.extraParams');
+    jquerySettings.registerSetting({ type: 'placeholder' }, 'ui-show-advanced-options');
+    jquerySettings.registerSetting({ type: 'placeholder' }, 'Keyboard.RightMouseButton');
+    monitorSettings({
+        // advanced options toggle
+        'ui-show-advanced-options': function(value) {
+            function handle(err, result) {
+                log('***************************** ui-show-advanced-options updated', result+'');
+                $('body').toggleClass('ui-show-advanced-options', !!result);
+            }
+            if (value !== undefined) {
+                handle(null, value);
+            } else {
+                bridgedSettings.getValueAsync('ui-show-advanced-options', handle);
+            }
+        },
+        // UI tooltips checkbox
+        'ui-enable-tooltips': function(value) {
+            if (value) {
+                tooltipManager.enable();
+                tooltipManager.openFocusedTooltip();
+            } else {
+                tooltipManager.disable();
+            }
+        },
+
+        // enable/disable fps field based on thread update mode
+        'thread-update-mode': function(value) {
+            var enabled = value === 'requestAnimationFrame',
+                fps = $('#fps');
+
+            log('onThreadModeChanged', value, enabled);
+            fps.hifiSpinner(enabled ? 'enable' : 'disable');
+            fps.closest('.tooltip-target').toggleClass('disabled', !enabled);
+        },
+
+        // apply CSS to body based on whether camera move mode is currently enabled
+        'camera-move-enabled': function(value) {
+            $('body').toggleClass('camera-move-enabled', value);
+        },
+
+        // update keybinding and appVersion whenever extraParams arrives
+        '.extraParams': function(value, other) {
+            value = bridgedSettings.extraParams;
+            //log('.extraParams', value, other, arguments);
+            if (value.mode) {
+                $('body').toggleClass('tablet-mode', value.mode.tablet);
+                $('body').toggleClass('hmd-mode', value.mode.hmd);
+                $('body').toggleClass('desktop-mode', value.mode.desktop);
+                $('body').toggleClass('toolbar-mode', value.mode.toolbar);
+                //setupTabletModeScrolling(value.mode.tablet);
+            }
+            var versionDisplay = [
+                value.appVersion || '(unknown appVersion)',
+                PARAMS.debug && '(debug)',
+                value.mode && value.mode.tablet ? '(tablet)' : '',
+            ].filter(Boolean).join(' | ');
+            $('#appVersion').find('.output').text(versionDisplay).end().fadeIn();
+
+            if (value.toggleKey) {
+                function getKeysHTML(binding) {
+                    return [ 'Control', 'Meta', 'Alt', 'Super', 'Menu', 'Shifted' ]
+                        .map(function(flag) { return binding['is' + flag] && flag; })
+                        .concat(binding.text || ('(#' + binding.key + ')'))
+                        .filter(Boolean)
+                        .map(function(key) { return '<kbd>' + key.replace('Shifted','Shift') + '</kbd>'; })
+                        .join('-');
+                }
+                $('#toggleKey').find('.binding').empty()
+                    .append(getKeysHTML(value.toggleKey)).end().fadeIn();
+            }
+        },
+
+        // gray out / ungray out page content if user is mouse looking around in Interface
+        // (otherwise the cursor still interacts with web content...)
+        'Keyboard.RightMouseButton': function(localValue, key, value) {
+            log('... Keyboard.RightMouseButton:' + value);
+            window.active = !value;
+        },
+    });
+    $('input').css('-webkit-user-select', 'none');
+} // setupUI
+
+// helper for instrumenting local jquery onchange handlers
+function monitorSettings(options) {
+    return Object.keys(options).reduce(function(out, id) {
+        var key = bridgedSettings.resolve(id);
+        assert(function assertion(){ return typeof key === 'string' }, 'monitorSettings -- received invalid key type')
+        function onChange(varargs) {
+            debugPrint('onChange', id, typeof varargs === 'string' ? varargs : typeof varargs);
+            var args = [].slice.call(arguments);
+            options[id].apply(this, [ jquerySettings.getValue(id) ].concat(args));
+        }
+        if (bridgedSettings.pendingRequestCount()) {
+            bridgedSettings.pendingRequestsFinished.connect(function once() {
+                bridgedSettings.pendingRequestsFinished.disconnect(once);
+                onChange('pendingRequestsFinished');
+            });
+        } else {
+            onChange('initialization');
+        }
+        function _onValueUpdated(_key) {  _key === key && onChange.apply(this, arguments); }
+        bridgedSettings.valueUpdated.connect(bridgedSettings, _onValueUpdated);
+        jquerySettings.valueUpdated.connect(jquerySettings, _onValueUpdated);
+        return out;
+    }, {});
+}
+
+function logValueUpdate(hint, key, value, oldValue, origin) {
+    if (0 === key.indexOf('.')) {
+        return;
+    }
+    oldValue = JSON.stringify(oldValue), value = JSON.stringify(value);
+    _debugPrint('[ ' + hint +' @ ' + origin + '] ' + key + ' = ' + value + ' (was: ' + oldValue + ')');
+}
+
+function initializeDOM() {
+    // DOM initialization
+    window.viewportUpdated = signal(function viewportUpdated(viewport) {});
+    function triggerViewportUpdate() {
+        var viewport = {
+            geometry: { x: innerWidth, y: innerHeight },
+            min: { x: window.innerWidth / 3, y: 32 },
+            max: { x: window.innerWidth * 7/8, y: window.innerHeight * 7/8 },
+        };
+        viewportUpdated(viewport, triggerViewportUpdate.lastViewport);
+        triggerViewportUpdate.lastViewport = viewport;
+    }
+    viewportUpdated.connect(PARAMS, function(viewport, oldViewport) {
+        log('viewportUpdated', viewport);
+        Object.assign(PARAMS, {
+            viewport: Object.assign(PARAMS.viewport||{}, viewport),
+        });
+        tooltipManager.updateViewport(viewport);
+    });
+    document.onselectstart = document.ondragstart =
+        document.body.ondragstart = document.body.onselectstart = function(){ return false; };
+
+    document.body.oncontextmenu = document.oncontextmenu = document.body.ontouchstart = function(evt) {
+        evt.stopPropagation();
+        evt.preventDefault();
+        return false;
+    };
+
+    $('.scrollable').on('mousemove.unselect', function() {
+        if (!$(document.activeElement).is('input')) {
+            if (document.selection) {
+                log('snarfing mousemove.unselect.selection');
+                document.selection.empty()
+            } else {
+                window.getSelection().removeAllRanges()
+            }
+        }
+    }).on('selectstart.unselect', function(evt) {
+        if (!$(document.activeElement).is('input')) {
+            log('snarfing selectstart');
+            evt.stopPropagation();
+            evt.preventDefault();
+            return false;
+        }
+    });
+
+    Object.defineProperty(window, 'active', {
+        get: function() { return window._active; },
+        set: function(nv) {
+            nv = !!nv;
+            window._active = nv;
+            log('window.active == ' + nv);
+            if (!nv) {
+                document.activeElement && document.activeElement.blur();
+                document.body.focus();
+                $('body').toggleClass('active', nv);
+            } else {
+                $('body').toggleClass('active', nv);
+            }
+        },
+    });
+    window.active = true;
+
+    function checkAnim(evt) {
+        if (!checkAnim.disabled) {
+            if ($('.scrollable').is(':animated')) {
+                $('.scrollable').stop();
+                log(evt.type, 'stop animation');
+            }
+        }
+    }
+    $(window).on({
+        resize: function resize() {
+            window.clearTimeout(resize.to);
+            resize.to = window.setTimeout(triggerViewportUpdate, 100);
+        },
+        mousedown: checkAnim,
+        mouseup: checkAnim,
+        scroll: checkAnim,
+        wheel: checkAnim,
+        blur: function() {
+            log('** BLUR ** ');
+            document.body.focus();
+            document.activeElement && document.activeElement.blur();
+            tooltipManager.disable();
+            //tooltipManager.closeAll();
+        },
+        focus: function() {
+            log('** FOCUS **');
+            bridgedSettings.getValue('ui-enable-tooltips') && tooltipManager.enable();
+        },
+    });
+}
+
+function preconfigureLESS() {
+    window.lessOriginalNodes = $('style[type="text/less"]').remove();
+    window.lessGlobalVars = Object.assign({
+        debug: !!PARAMS.debug,
+        localhost: 1||/^\b(?:localhost|127[.])/.test(location),
+        hash: '',
+    }, {
+        'header-height': 48,
+        'footer-height': 32,
+        'custom-font-family': 'Raleway-Regular',
+        'input-font-family': 'FiraSans-Regular',
+        'color-highlight': '#009bd5',
+        'color-text': '#afafaf',
+        'color-bg': '#393939',
+        'color-bg-darker': '#252525',
+        'color-bg-icon': '#545454',
+        'color-primary-button': 'darkblue',
+        'color-alt-button': 'green',
+        'color-caution-button': 'darkred',
+    });
+
+    window.less = {
+        //poll: 1000,
+        //watch: true,
+        globalVars: lessGlobalVars,
+    };
+
+    preconfigureLESS.onViewportUpdated = function onViewportUpdated(viewport) {
+        if (onViewportUpdated.to) {
+            clearTimeout(onViewportUpdated.to);
+        } else if (lessGlobalVars.hash) {
+            onViewportUpdated.to = setTimeout(onViewportUpdated, 500); // debounce
+            return;
+        }
+        delete lessGlobalVars.hash;
+        Object.assign(lessGlobalVars, {
+            'interface-mode': /highfidelity/i.test(navigator.userAgent),
+            'inner-width': window.innerWidth,
+            'inner-height': window.innerHeight,
+            'client-width': document.body.clientWidth || window.innerWidth,
+            'client-height': document.body.clientHeight || window.innerHeight,
+            'hash': '',
+        });
+        lessGlobalVars.hash = JSON.stringify(JSON.stringify(lessGlobalVars,0,2)).replace(/\\n/g , '\\000a');
+        var hash = JSON.stringify(lessGlobalVars, 0, 2);
+        log('onViewportUpdated', JSON.parse(onViewportUpdated.lastHash||'{}')['inner-width'], JSON.parse(hash)['inner-width']);
+        if (onViewportUpdated.lastHash !== hash) {
+            //log('updating lessVars', 'less.modifyVars:' + typeof less.modifyVars, JSON.stringify(lessGlobalVars, 0, 2));
+            PARAMS.debug && $('#errors').show().html("<pre>").children(0).text(hash);
+            // LESS needs some help to recompile inline styles, so a fresh copy of the source nodes is swapped-in
+            var newNodes = lessOriginalNodes.clone().appendTo(document.body);
+            less.modifyVars && less.modifyVars(true, lessGlobalVars);
+            var oldNodes = onViewportUpdated.lastNodes;
+            oldNodes && oldNodes.remove();
+            onViewportUpdated.lastNodes = newNodes;
+        }
+        onViewportUpdated.lastHash = hash;
+    };
+}
diff --git a/unpublishedScripts/marketplace/camera-move/modules/EnumMeta.js b/unpublishedScripts/marketplace/camera-move/modules/EnumMeta.js
new file mode 100644
index 0000000000..f59f134344
--- /dev/null
+++ b/unpublishedScripts/marketplace/camera-move/modules/EnumMeta.js
@@ -0,0 +1,239 @@
+// EnumMeta.js -- helper module that maps related enum values to names and ids
+//
+
+/* eslint-env commonjs */
+/* global DriveKeys */
+
+var VERSION = '0.0.1';
+var WANT_DEBUG = false;
+
+function _debugPrint() {
+    print('EnumMeta | ' + [].slice.call(arguments).join(' '));
+}
+
+var debugPrint = WANT_DEBUG ? _debugPrint : function(){};
+
+try {
+    /* global process */
+    if (process.title === 'node') {
+        _defineNodeJSMocks();
+    }
+} catch (e) { /* eslint-disable-line empty-block */ }
+
+_debugPrint(VERSION);
+
+// FIXME: C++ emits this action event, but doesn't expose it yet to scripting
+//   (ie: as Actions.ZOOM or Actions.TranslateCameraZ)
+var ACTION_TRANSLATE_CAMERA_Z = {
+    actionName: 'TranslateCameraZ',
+    actionID: 12,
+    driveKeyName: 'ZOOM'
+};
+
+module.exports = {
+    version: VERSION,
+    DriveKeyNames: invertKeys(DriveKeys),
+    Controller: {
+        Hardware: Object.keys(Controller.Hardware).reduce(function(names, prop) {
+            names[prop+'Names'] = invertKeys(Controller.Hardware[prop]);
+            return names;
+        }, {}),
+        ActionNames: _getActionNames(),
+        StandardNames: invertKeys(Controller.Standard)
+    },
+    getDriveKeyNameFromActionName: getDriveKeyNameFromActionName,
+    getActionNameFromDriveKeyName: getActionNameFromDriveKeyName,
+    eventKeyText2KeyboardName: eventKeyText2KeyboardName,
+    keyboardName2eventKeyText: keyboardName2eventKeyText,
+
+    ACTION_TRANSLATE_CAMERA_Z: ACTION_TRANSLATE_CAMERA_Z,
+    INVALID_ACTION_ID: Controller.findAction('INVALID_ACTION_ID_FOO'),
+};
+
+_debugPrint('///'+VERSION, Object.keys(module.exports));
+
+var actionsMapping = {}, driveKeyMapping = {};
+
+initializeMappings(actionsMapping, driveKeyMapping);
+
+function invertKeys(object) {
+    if (!object) {
+        return object;
+    }
+    return Object.keys(object).reduce(function(out, key) {
+        out[object[key]] = key;
+        return out;
+    }, {});
+}
+
+function _getActionNames() {
+    var ActionNames = invertKeys(Controller.Hardware.Actions);
+    ActionNames[ACTION_TRANSLATE_CAMERA_Z.actionID] = ACTION_TRANSLATE_CAMERA_Z.actionName;
+    function mapActionName(actionName) {
+        var actionKey = Controller.Hardware.Actions[actionName],
+            actionID = Controller.findAction(actionName),
+            similarName = eventKeyText2KeyboardName(actionName),
+            existingName = ActionNames[actionID];
+
+        var keyName = actionName;
+
+        if (actionID === module.exports.INVALID_ACTION_ID) {
+            _debugPrint('actionID === INVALID_ACTION_ID', actionName);
+        }
+        switch (actionName) {
+            case 'StepTranslateX': actionName = 'StepTranslate'; break;
+            case 'StepTranslateY': actionName = 'StepTranslate'; break;
+            case 'StepTranslateZ': actionName = 'StepTranslate'; break;
+            case 'ACTION1': actionName = 'PrimaryAction'; break;
+            case 'ACTION2': actionName = 'SecondaryAction'; break;
+        }
+        debugPrint(keyName, actionName, actionKey, actionID);
+
+        similarName = similarName.replace('Lateral','Strafe').replace(/^(?:Longitudinal|Vertical)/, '');
+        if (actionID in ActionNames) {
+            // check if overlap is just BoomIn <=> BOOM_IN
+            if (similarName !== existingName && actionName !== existingName) {
+                throw new Error('assumption failed: overlapping actionID:'+JSON.stringify({
+                    actionID: actionID,
+                    actionKey: actionKey,
+                    actionName: actionName,
+                    similarName: similarName,
+                    keyName: keyName,
+                    existingName: existingName
+                },0,2));
+            }
+        } else {
+            ActionNames[actionID] = actionName;
+            ActionNames[actionKey] = keyName;
+        }
+    }
+    // first map non-legacy (eg: Up and not VERTICAL_UP) actions
+    Object.keys(Controller.Hardware.Actions).filter(function(name) {
+        return /[a-z]/.test(name);
+    }).sort().reverse().forEach(mapActionName);
+    // now legacy actions
+    Object.keys(Controller.Hardware.Actions).filter(function(name) {
+        return !/[a-z]/.test(name);
+    }).sort().reverse().forEach(mapActionName);
+
+    return ActionNames;
+}
+
+// attempts to brute-force translate an Action name into a DriveKey name
+//   eg: _translateActionName('TranslateX') === 'TRANSLATE_X'
+//   eg: _translateActionName('Yaw') === 'YAW'
+function _translateActionName(name, _index) {
+    name = name || '';
+    var key = name;
+    var re = new RegExp('[A-Z][a-z0-9]+', 'g');
+    key = key.replace(re, function(Word) {
+        return Word.toUpperCase()+'_';
+    })
+        .replace(/_$/, '');
+
+    if (key in DriveKeys) {
+        debugPrint('getDriveKeyFromEventName', _index, name, key, DriveKeys[key]);
+        return key;
+    }
+}
+
+function getActionNameFromDriveKeyName(driveKeyName) {
+    return driveKeyMapping[driveKeyName];
+}
+// maps an action lookup value to a DriveKey name
+//  eg: actionName: 'Yaw' === 'YAW'
+//      actionKey:  Controller.Actions.Yaw => 'YAW'
+//      actionID:   Controller.findAction('Yaw') => 'YAW'
+function getDriveKeyNameFromActionName(lookupValue) {
+    if (lookupValue === ACTION_TRANSLATE_CAMERA_Z.actionName ||
+        lookupValue === ACTION_TRANSLATE_CAMERA_Z.actionID) {
+        return ACTION_TRANSLATE_CAMERA_Z.driveKeyName;
+    }
+    if (typeof lookupValue === 'string') {
+        lookupValue = Controller.findAction(lookupValue);
+    }
+    return actionsMapping[lookupValue];
+}
+
+// maps a Controller.key*Event event.text -> Controller.Hardware.Keyboard[name]
+//   eg: ('Page Up') === 'PgUp'
+//   eg: ('LEFT') === 'Left'
+function eventKeyText2KeyboardName(text) {
+    if (eventKeyText2KeyboardName[text]) {
+        // use memoized value
+        return eventKeyText2KeyboardName[text];
+    }
+    var keyboardName = (text||'').toUpperCase().split(/[ _]/).map(function(WORD) {
+        return WORD.replace(/([A-Z])(\w*)/g, function(_, A, b) {
+            return (A.toUpperCase() + b.toLowerCase());
+        });
+    }).join('').replace('Page','Pg');
+    return eventKeyText2KeyboardName[text] = eventKeyText2KeyboardName[keyboardName] = keyboardName;
+}
+
+// maps a Controller.Hardware.Keyboard[name] -> Controller.key*Event event.text
+//   eg: ('PgUp') === 'PAGE UP'
+//   eg: ('Shift') === 'SHIFT'
+function keyboardName2eventKeyText(keyName) {
+    if (keyboardName2eventKeyText[keyName]) {
+        // use memoized value
+        return keyboardName2eventKeyText[keyName];
+    }
+    var text = keyName.replace('Pg', 'Page');
+    var caseWords = text.match(/[A-Z][a-z0-9]+/g) || [ text ];
+    var eventText = caseWords.map(function(str) {
+        return str.toUpperCase();
+    }).join('_');
+    return keyboardName2eventKeyText[keyName] = eventText;
+}
+
+function initializeMappings(actionMap, driveKeyMap) {
+    _debugPrint('creating mapping');
+    var ref = ACTION_TRANSLATE_CAMERA_Z;
+    actionMap[ref.actionName] = actionMap[ref.actionID] = ref.driveKeyName;
+    actionMap.BoomIn = 'ZOOM';
+    actionMap.BoomOut = 'ZOOM';
+
+    Controller.getActionNames().sort().reduce(
+        function(out, name, index, arr) {
+            var actionKey = arr[index];
+            var actionID = Controller.findAction(name);
+            var value = actionID in out ? out[actionID] : _translateActionName(name, index);
+            if (value !== undefined) {
+                var prefix = (actionID in out ? '+++' : '---');
+                debugPrint(prefix + ' Action2DriveKeyName['+name+'('+actionID+')] = ' + value);
+                driveKeyMap[value] = driveKeyMap[value] || name;
+            }
+            out[name] = out[actionID] = out[actionKey] = value;
+            return out;
+        }, actionMap);
+}
+
+// ----------------------------------------------------------------------------
+// mocks for litmus testing using Node.js command line tools
+function _defineNodeJSMocks() {
+    /* eslint-disable no-global-assign */
+    DriveKeys = {
+        TRANSLATE_X: 12345
+    };
+    Controller = {
+        getActionNames: function() {
+            return Object.keys(this.Hardware.Actions);
+        },
+        findAction: function(name) {
+            return this.Hardware.Actions[name] || 4095;
+        },
+        Hardware: {
+            Actions: {
+                TranslateX: 54321
+            },
+            Application: {
+                Grounded: 1
+            },
+            Keyboard: {
+                A: 65
+            }
+        }
+    };
+    /* eslint-enable no-global-assign */
+}
diff --git a/unpublishedScripts/marketplace/camera-move/modules/_utils.js b/unpublishedScripts/marketplace/camera-move/modules/_utils.js
new file mode 100644
index 0000000000..5d04912e69
--- /dev/null
+++ b/unpublishedScripts/marketplace/camera-move/modules/_utils.js
@@ -0,0 +1,520 @@
+// _utils.js -- misc. helper classes/functions
+
+"use strict";
+/* eslint-env commonjs, hifi */
+
+//var HIRES_CLOCK = (typeof Window === 'object' && Window && Window.performance) && Window.performance.now;
+var USE_HIRES_CLOCK = typeof HIRES_CLOCK === 'function';
+
+var exports = {
+    version: '0.0.1b' + (USE_HIRES_CLOCK ? '-hires' : ''),
+    bind: bind,
+    signal: signal,
+    assign: assign,
+    sign: sign,
+    assert: assert,
+    getSystemMetadata: getSystemMetadata,
+    makeDebugRequire: makeDebugRequire,
+    DeferredUpdater: DeferredUpdater,
+    KeyListener: KeyListener,
+    getRuntimeSeconds: getRuntimeSeconds,
+    createAnimationStepper: createAnimationStepper,
+    reloadClientScript: reloadClientScript,
+
+    normalizeStackTrace: normalizeStackTrace,
+    BrowserUtils: BrowserUtils,
+    BSOD: BSOD, // exception reporter
+};
+try {
+    module.exports = exports; // Interface / Node.js
+} catch(e) {
+    this._utils = assign(this._utils || {}, exports); // browser
+}
+
+// ----------------------------------------------------------------------------
+function makeDebugRequire(relativeTo) {
+    return function boundDebugRequire(id) {
+        return debugRequire(id, relativeTo);
+    };
+}
+function debugRequire(id, relativeTo) {
+    // hack-around for use during local development / testing that forces every require to re-fetch the script from the server
+    var modulePath = Script._requireResolve(id, relativeTo) + '?' + new Date().getTime().toString(36);
+    print('========== DEBUGREQUIRE:' + modulePath);
+    Script.require.cache[modulePath] = Script.require.cache[id] = undefined;
+    Script.require.__qt_data__[modulePath] = Script.require.__qt_data__[id] = true;
+    return Script.require(modulePath);
+}
+
+function assert(truthy, message) {
+    message = message || 'Assertion Failed:';
+
+    if (typeof truthy === 'function' && truthy.name === 'assertion') {
+        message += ' ' + JSON.stringify((truthy+'').replace(/^[^{]+\{|\}$|^\s*|\s*$/g, ''));
+        try {
+            truthy = truthy();
+        } catch (e) {
+            message += '(exception: ' + e +')';
+        }
+    }
+    if (!truthy) {
+        message += ' (! '+truthy+')';
+        throw new Error(message);
+    }
+    return truthy;
+}
+
+
+// ----------------------------------------------------------------------------
+function sign(x) {
+    x = +x;
+    if (x === 0 || isNaN(x)) {
+        return Number(x);
+    }
+    return x > 0 ? 1 : -1;
+}
+// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign#Polyfill
+/* eslint-disable */
+function assign(target, varArgs) { // .length of function is 2
+    'use strict';
+    if (target == null) { // TypeError if undefined or null
+        throw new TypeError('Cannot convert undefined or null to object');
+    }
+
+    var to = Object(target);
+
+    for (var index = 1; index < arguments.length; index++) {
+        var nextSource = arguments[index];
+
+        if (nextSource != null) { // Skip over if undefined or null
+            for (var nextKey in nextSource) {
+                // Avoid bugs when hasOwnProperty is shadowed
+                if (Object.prototype.hasOwnProperty.call(nextSource, nextKey)) {
+                    to[nextKey] = nextSource[nextKey];
+                }
+            }
+        }
+    }
+    return to;
+}
+/* eslint-enable */
+// //https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign#Polyfill
+
+// ----------------------------------------------------------------------------
+// @function - bind a function to a `this` context
+// @param {Object} - the `this` context
+// @param {Function|String} - function or method name
+bind.debug = true;
+function bind(thiz, method) {
+    var methodName = typeof method === 'string' ? method : method.name;
+    method = thiz[method] || method;
+    if (bind.debug && methodName) {
+        methodName = methodName.replace(/[^A-Za-z0-9_$]/g, '_');
+        var debug = {};
+        debug[methodName] = method;
+        return eval('1,function bound'+methodName+'() { return debug.'+methodName+'.apply(thiz, arguments); }');
+    }
+    return function() {
+        return method.apply(thiz, arguments);
+    };
+}
+
+// @function - Qt signal polyfill
+function signal(template) {
+    var callbacks = [];
+    return Object.defineProperties(function() {
+        var args = [].slice.call(arguments);
+        callbacks.forEach(function(obj) {
+            obj.handler.apply(obj.scope, args);
+        });
+    }, {
+        connect: { value: function(scope, handler) {
+            callbacks.push({scope: scope, handler: scope[handler] || handler || scope});
+        }},
+        disconnect: { value: function(scope, handler) {
+            var match = {scope: scope, handler: scope[handler] || handler || scope};
+            callbacks = callbacks.filter(function(obj) {
+                return !(obj.scope === match.scope && obj.handler === match.handler);
+            });
+        }}
+    });
+}
+// ----------------------------------------------------------------------------
+function normalizeStackTrace(err, options) {
+    options = options || {};
+    // * Chromium:   "  at <anonymous> (file://.../filename.js:45:65)"
+    // * Interface:  "  at <anonymous() file://.../filename.js:45"
+    // * normalized: "  at <anonymous> filename.js:45"
+    var output = err.stack ? err.stack.replace(
+            /((?:https?|file):[/][/].*?):(\d+)(?::\d+)?([)]|\s|$)/g,
+        function(_, url, lineNumber, suffix) {
+            var fileref = url.split(/[?#]/)[0].split('/').pop();
+            if (Array.isArray(options.wrapFilesWith)) {
+                fileref = options.wrapFilesWith[0] + fileref + options.wrapFilesWith[1];
+            }
+            if (Array.isArray(options.wrapLineNumbersWith)) {
+                lineNumber = options.wrapLineNumbersWith[0] + lineNumber + options.wrapLineNumbersWith[1];
+            }
+            return fileref + ':' + lineNumber + suffix;
+        }
+    ).replace(/[(]([-\w.%:]+[.](?:html|js))[)]/g, '$1') : err.message;
+    return '    '+output;
+}
+
+// utilities specific to use from web browsers / embedded Interface web windows
+function BrowserUtils(global) {
+    global = global || (1,eval)('this');
+    return {
+        global: global,
+        console: global.console,
+        log: function(msg) {
+            this.console.log.apply(this.console, ['browserUtils | ' + msg].concat([].slice.call(arguments, 1)));
+        },
+        makeConsoleWorkRight: function(console, forcePatching) {
+            if (console.$patched || !(forcePatching || global.qt)) {
+                return console;
+            }
+            var patched = ['log','debug','info','warn','error'].reduce(function(output, method) {
+                output[method] = function() {
+                    return console[method]([].slice.call(arguments).join(' '));
+                };
+                return output;
+            }, { $patched: console });
+            for (var p in console) {
+                if (typeof console[p] === 'function' && !(p in patched)) {
+                    patched[p] = console[p].bind(console);
+                }
+            }
+            patched.__proto__ = console; // let scope chain find constants and other non-function values
+            return patched;
+        },
+        parseQueryParams: function(querystring) {
+            return this.extendWithQueryParams({}, querystring);
+        },
+        extendWithQueryParams: function(obj, querystring) {
+            querystring = querystring || global.location.href;
+            querystring.replace(/\b(\w+)=([^&?#]+)/g, function(_, key, value) {
+                value = unescape(value);
+                obj[key] = value;
+            });
+            return obj;
+        },
+        // openEventBridge handles the cluster of scenarios Interface has imposed on webviews for making EventBridge connections
+        openEventBridge: function openEventBridge(callback) {
+            this.log('openEventBridge |', 'typeof global.EventBridge == ' + typeof global.EventBridge);
+            try {
+                global.EventBridge.scriptEventReceived.connect.exists;
+                //this.log('openEventBridge| EventBridge already exists... -- invoking callback', 'typeof EventBridge == ' + typeof global.EventBridge);
+                return callback(global.EventBridge);
+            } catch(e) {
+                this.log('EventBridge does not yet exist -- attempting to instrument via qt.webChannelTransport');
+                var QWebChannel = assert(global.QWebChannel, 'expected global.QWebChannel to exist'),
+                    qt = assert(global.qt, 'expected global.qt to exist'),
+                    webChannelTransport = assert(qt.webChannelTransport, 'expected global.qt.webChannelTransport to exist');
+                new QWebChannel(qt.webChannelTransport, bind(this, function (channel) {
+                    global.EventBridge = channel.objects.eventBridgeWrapper.eventBridge;
+                    global.EventBridge.$WebChannel = channel;
+                    this.log('openEventBridge opened -- invoking callback', 'typeof EventBridge === ' + typeof global.EventBridge);
+                    callback(global.EventBridge);
+                }));
+            }
+        },
+    };
+}
+// ----------------------------------------------------------------------------
+// queue pending updates so the exact order of application can be varied
+// (currently Interface exists sporadic jitter that seems to depend on whether
+//  Camera or MyAvatar gets updated first)
+function DeferredUpdater(target, options) {
+    options = options || {};
+    // define _meta as a non-enumerable instance property (so it doesn't show up in for(var p in ...) loops)
+    Object.defineProperty(this, '_meta', { value: {
+        target: target,
+        lastValue: {},
+        dedupe: options.dedupe,
+    }});
+}
+DeferredUpdater.prototype = {
+    reset: function() {
+        var self = this;
+        Object.keys(this).forEach(function(property) {
+            delete self[property];
+        });
+        this._meta.lastValue = {};
+    },
+    submit: function() {
+        var meta = this._meta,
+            target = meta.target,
+            lastValue = meta.lastValue,
+            dedupe = meta.dedupe,
+            self = this,
+            submitted = {};
+
+        self.submit = getRuntimeSeconds();
+        Object.keys(self).forEach(function(property) {
+            var newValue = self[property];
+            if (0 && dedupe) {
+                var stringified = JSON.stringify(newValue);
+                var last = lastValue[property];
+                if (stringified === last) {
+                    return;
+                }
+                lastValue[property] = stringified;
+            }
+            if (0) {
+                var tmp = lastValue['_'+property];
+                if (typeof tmp === 'object') {
+                    if ('w' in tmp) {
+                        newValue = Quat.normalize(Quat.slerp(tmp, newValue, 0.95));
+                    } else if ('z' in tmp) {
+                        newValue = Vec3.mix(tmp, newValue, 0.95);
+                    }
+                } else if (typeof tmp === 'number') {
+                    newValue = (newValue + tmp)/2.0;
+                }
+                lastValue['_'+property] = newValue;
+            }
+            submitted[property] = newValue;
+            if (typeof target[property] === 'function') {
+                target[property](newValue);
+            } else {
+                target[property] = newValue;
+            }
+            delete self[property];
+        });
+        return submitted;
+    }
+};
+// create a group of deferred updaters eg: DeferredUpdater.createGroup({ MyAvatar: MyAvatar, Camera: Camera })
+DeferredUpdater.createGroup = function(items, options) {
+    var result = {
+        __proto__: {
+            reset: function() {
+                Object.keys(this).forEach(bind(this, function(item) {
+                    this[item].reset();
+                }));
+            },
+            submit: function() {
+                var submitted = {};
+                Object.keys(this).forEach(bind(this, function(item) {
+                    submitted[item] = this[item].submit();
+                }));
+                return submitted;
+            }
+        }
+    };
+    Object.keys(items).forEach(function(item) {
+        result[item] = new DeferredUpdater(items[item], options);
+    });
+    return result;
+};
+
+// ----------------------------------------------------------------------------
+
+// monotonic session runtime (in seconds)
+getRuntimeSeconds.EPOCH = getRuntimeSeconds(0);
+function getRuntimeSeconds(since) {
+    since = since === undefined ? getRuntimeSeconds.EPOCH : since;
+    var now = USE_HIRES_CLOCK ? HIRES_CLOCK() : +new Date;
+    return ((now / 1000.0) - since);
+}
+
+
+function createAnimationStepper(options) {
+    options = options || {};
+    var fps = options.fps || 30,
+        waitMs = 1000 / fps,
+        getTime = options.getRuntimeSeconds || getRuntimeSeconds,
+        lastUpdateTime = -1e-6,
+        timeout = 0;
+
+    requestAnimationFrame.fps = fps;
+    requestAnimationFrame.reset = function() {
+        if (timeout) {
+            Script.clearTimeout(timeout);
+            timeout = 0;
+        }
+    };
+
+    function requestAnimationFrame(update) {
+        requestAnimationFrame.reset();
+        timeout = Script.setTimeout(function() {
+            timeout = 0;
+            update(getTime(lastUpdateTime));
+            lastUpdateTime = getTime();
+        }, waitMs );
+    }
+
+    return requestAnimationFrame;
+}
+
+// ----------------------------------------------------------------------------
+// KeyListener provides a scoped wrapper where options.onKeyPressEvent only gets
+//   called when the specified event.text matches the input options
+// example: var listener = new KeyListener({ text: 'SPACE', isShifted: false, onKeyPressEvent: function(event) { ... } });
+//          Script.scriptEnding.connect(listener, 'disconnect');
+function KeyListener(options) {
+    assert(typeof options === 'object' && 'text' in options && 'onKeyPressEvent' in options);
+
+    this._options = options;
+    assign(this, {
+        modifiers: this._getEventModifiers(options, true)
+    }, options);
+    log('created KeyListener', JSON.stringify(this, 0, 2));
+    this.connect();
+}
+KeyListener.prototype = {
+    _getEventModifiers: function(event, trueOnly) {
+        return [ 'Control', 'Meta', 'Alt', 'Super', 'Menu', 'Shifted' ].map(function(mod) {
+            var isMod = 'is' + mod,
+                value = event[isMod],
+                found = (trueOnly ? value : typeof value === 'boolean');
+            return found && isMod + ' = ' + value;
+        }).filter(Boolean).sort().join('|');
+    },
+    handleEvent: function(event, target) {
+        if (event.text === this.text) {
+            var modifiers = this._getEventModifiers(event, true);
+            if (modifiers !== this.modifiers) {
+                return log('KeyListener -- different modifiers, disregarding keystroke', JSON.stringify({
+                    expected: this.modifiers,
+                    received: modifiers,
+                },0,2));
+            }
+            return this[target](event);
+        }
+    },
+    connect: function() {
+        return this.$bindEvents(true);
+    },
+    disconnect: function() {
+        return this.$bindEvents(false);
+    },
+    $onKeyPressEvent: function(event) {
+        return this.handleEvent(event, 'onKeyPressEvent');
+    },
+    $onKeyReleaseEvent: function(event) {
+        return this.handleEvent(event, 'onKeyReleaseEvent');
+    },
+    $bindEvents: function(connect) {
+        if (this.onKeyPressEvent) {
+            Controller.keyPressEvent[connect ? 'connect' : 'disconnect'](this, '$onKeyPressEvent');
+        }
+        if (this.onKeyReleaseEvent) {
+            Controller.keyReleaseEvent[connect ? 'connect' : 'disconnect'](this, '$onKeyReleaseEvent');
+        }
+        Controller[(connect ? 'capture' : 'release') + 'KeyEvents'](this._options);
+    }
+};
+
+
+// helper to show a verbose exception report in a BSOD-like poup window, in some cases enabling users to
+// report specfic feedback without having to manually scan through their local debug logs
+
+// creates an OverlayWebWindow using inline HTML content
+function _overlayWebWindow(options) {
+    options = Object.assign({
+        title: '_overlayWebWindow',
+        width: Overlays.width() * 2 / 3,
+        height: Overlays.height() * 2 / 3,
+        content: '(empty)',
+    }, options||{}, {
+        source: 'about:blank',
+    });
+    var window = new OverlayWebWindow(options);
+    window.options = options;
+    options.content && window.setURL('data:text/html;text,' + encodeURIComponent(options.content));
+    return window;
+}
+
+function BSOD(options, callback) {
+    var buttonHTML = Array.isArray(options.buttons) && [
+        '<div onclick="EventBridge.emitWebEvent(arguments[0].target.innerText)">',
+        options.buttons.map(function(innerText) {
+            return '<button>' + innerText + '</button>';
+        }).join(',&nbsp;'),
+        '</div>'].join('\n');
+
+    var HTML = [
+        '<style>body { background:#0000aa; color:#ffffff; font-family:courier; font-size:8pt; margin:10px; }</style>',
+        buttonHTML,
+        '<pre style=whitespace:pre-wrap>',
+        '<strong>' + options.error + '</strong>',
+        _utils.normalizeStackTrace(options.error, {
+            wrapLineNumbersWith: ['<b style=color:lime>','</b>'],
+            wrapFilesWith: ['<b style=color:#f99>','</b>'],
+        }),
+        '</pre>',
+        '<hr />DEBUG INFO:<br />',
+        '<pre>' + JSON.stringify(Object.assign({ date: new Date }, options.debugInfo), 0, 2) + '</pre>',
+    ].filter(Boolean).join('\n');
+
+    var popup = _overlayWebWindow({
+        title: options.title || 'PC LOAD LETTER',
+        content: HTML,
+    });
+    popup.webEventReceived.connect(function(message) {
+        log('popup.webEventReceived', message);
+        try {
+            callback(null, message);
+        } finally {
+            popup.close();
+        }
+    });
+    return popup;
+}
+
+function getSystemMetadata() {
+    var tablet = Tablet.getTablet('com.highfidelity.interface.tablet.system');
+    return {
+        mode: {
+            hmd: HMD.active,
+            desktop: !HMD.active,
+            toolbar: Uuid.isNull(HMD.tabletID),
+            tablet: !Uuid.isNull(HMD.tabletID),
+        },
+        tablet: {
+            toolbarMode: tablet.toolbarMode,
+            desktopScale: Settings.getValue('desktopTabletScale'),
+            hmdScale: Settings.getValue('hmdTabletScale'),
+        },
+        avatar: {
+            pitchSpeed: MyAvatar.pitchSpeed,
+            yawSpeed: MyAvatar.yawSpeed,
+            density: MyAvatar.density,
+            scale: MyAvatar.scale,
+        },
+        overlays: {
+            width: Overlays.width(),
+            height: Overlays.height(),
+        },
+        window: {
+            width: Window.innerWidth,
+            height: Window.innerHeight,
+        },
+        desktop: {
+            width: Desktop.width,
+            height: Desktop.height,
+        },
+    };
+}
+
+function reloadClientScript(filename) {
+    log('reloading', filename);
+    var result = ScriptDiscoveryService.stopScript(filename, true);
+    log('...stopScript', filename, result);
+    if (!result) {
+        var matches = ScriptDiscoveryService.getRunning().filter(function(script) {
+            //log(script.path, script.url);
+            return 0 === script.path.indexOf(filename);
+        });
+        log('...matches', JSON.stringify(matches,0,2));
+        var path = matches[0] && matches[0].path;
+        if (path) {
+            log('...stopScript', path);
+            result = ScriptDiscoveryService.stopScript(path, true);
+            log('///stopScript', result);
+        }
+    }
+    return result;
+}
diff --git a/unpublishedScripts/marketplace/camera-move/modules/config-utils.js b/unpublishedScripts/marketplace/camera-move/modules/config-utils.js
new file mode 100644
index 0000000000..0f19884388
--- /dev/null
+++ b/unpublishedScripts/marketplace/camera-move/modules/config-utils.js
@@ -0,0 +1,265 @@
+// config-utils.js -- helpers for coordinating Application runtime vs. Settings configuration
+//
+//   * ApplicationConfig -- provides a way to configure and monitor Menu items and API values.
+//   * SettingsConfig -- provides a similar way to configure and monitor scoped Settings values.
+//   * ... together they provide a way to manage overlapping values and keep them in sync.
+
+"use strict";
+/* eslint-env commonjs */
+
+module.exports = {
+    version: '0.0.1a',
+    ApplicationConfig: ApplicationConfig,
+    SettingsConfig: SettingsConfig
+};
+
+var _utils = require('./_utils.js'),
+    assert = _utils.assert;
+Object.assign = Object.assign || _utils.assign;
+
+function _debugPrint() {
+    print('config-utils | ' + [].slice.call(arguments).join(' '));
+}
+
+var debugPrint = function() {};
+
+// ----------------------------------------------------------------------------
+// grouped Application-specific configuration values using runtime state / API props
+//
+// options.config[] supports the following item formats:
+//   'settingsName': { menu: 'Menu > MenuItem'}, // assumes MenuItem is a checkbox / checkable value
+//   'settingsName': { object: [ MyAvatar, 'property' ] },
+//   'settingsName': { object: [ MyAvatar, 'getterMethod', 'setterMethod' ] },
+//   'settingsName': { menu: 'Menu > MenuItem', object: [ MyAvatar, 'property' ] },
+
+function ApplicationConfig(options) {
+    options = options || {};
+    assert('namespace' in options && 'config' in options);
+    if (options.debug) {
+        debugPrint = _debugPrint;
+        debugPrint('debugPrinting enabled');
+    }
+    this.namespace = options.namespace;
+    this.valueUpdated = _utils.signal(function valueUpdated(key, newValue, oldValue, origin){});
+
+    // process shorthand notations into fully-qualfied ApplicationConfigItem instances
+    this.config = {};
+    this.register(options.config);
+}
+ApplicationConfig.prototype = {
+    resolve: function(key) {
+        assert(typeof key === 'string', 'ApplicationConfig.resolve error: key is not a string: ' + key);
+        if (0 !== key.indexOf('.') && !~key.indexOf('/')) {
+            key = [ this.namespace, key ].join('/');
+        }
+        if (key in this.config) {
+            return key;
+        }
+        log('ApplicationConfig -- could not resolve key: ' + key);
+        return undefined;
+    },
+    registerItem: function(settingName, item) {
+        item._settingName = settingName;
+        item.settingName = ~settingName.indexOf('/') ? settingName : [ this.namespace, settingName ].join('/');
+        return this.config[item.settingName] = this.config[settingName] = new ApplicationConfigItem(item);
+    },
+    register: function(items) {
+        for (var p in items) {
+            var item = items[p];
+            if (item) {
+                this.registerItem(p, item)
+            }
+        }
+    },
+    _getItem: function(key) {
+        return this.config[this.resolve(key)];
+    },
+    getValue: function(key, defaultValue) {
+        var item = this._getItem(key);
+        if (!item) {
+            return defaultValue;
+        }
+        return item.get();
+    },
+    setValue: function setValue(key, value) {
+        key = this.resolve(key);
+        var lastValue = this.getValue(key, value);
+        var ret = this._getItem(key).set(value);
+        if (lastValue !== value) {
+            this.valueUpdated(key, value, lastValue, 'ApplicationConfig.setValue');
+        }
+        return ret;
+    },
+    // sync dual-source (ie: Menu + API) items
+    resyncValue: function(key) {
+        var item = this._getItem(key);
+        return item && item.resync();
+    },
+    // sync Settings values -> Application state
+    applyValue: function applyValue(key, value, origin) {
+        if (this.resolve(key)) {
+            var appValue = this.getValue(key, value);
+            log('applyValue', key, value, origin ? '['+origin+']' : '', appValue);
+            if (appValue !== value) {
+                this.setValue(key, value);
+                log('applied new setting', key, value, '(was:'+appValue+')');
+            }
+            return true;
+        }
+    }
+};
+
+// ApplicationConfigItem represents a single API/Menu item accessor
+function ApplicationConfigItem(item) {
+    Object.assign(this, item, { _item: item });
+    Object.assign(this, {
+        _menu: this._parseMenuConfig(this.menu),
+        _object: this._parseObjectConfig(this.object)
+    });
+    if(0)debugPrint('>>>>>' + this);
+}
+ApplicationConfigItem.prototype = {
+    authority: 'object', // when values conflict, this determines which source is considered the truth
+    resync: function resync() {
+        var authoritativeValue = this.get();
+
+        if (this._menu && this._menu.get() !== authoritativeValue) {
+            _debugPrint(this.settingName, this._menu.menuItem,
+                        '... menu value ('+this._menu.get()+') out of sync;',
+                        'setting to authoritativeValue ('+authoritativeValue+')');
+            this._menu.set(authoritativeValue);
+        }
+        if (this._object && this._object.get() !== authoritativeValue) {
+            _debugPrint(this.settingName, this._object.getter || this._object.property,
+                        '... object value ('+this._object.get()+') out of sync;',
+                        'setting to authoritativeValue ('+authoritativeValue+')');
+            this._object.set(authoritativeValue);
+        }
+    },
+    toString: function() {
+        return '[ApplicationConfigItem ' + [
+            'setting:' + JSON.stringify(this.settingName),
+            this.authority !== ApplicationConfigItem.prototype.authority && 'authority:' + JSON.stringify(this.authority),
+            this._object && 'object:' + JSON.stringify(this._object.property || this._object.getter),
+            this._menu && 'menu:' + JSON.stringify(this._menu.menu)
+            // 'value:' + this.get(),
+        ].filter(Boolean).join(' ') + ']';
+    },
+    get: function get() {
+        return this.authority === 'menu' ? this._menu.get() : this._object.get();
+    },
+    set: function set(nv) {
+        this._object && this._object.set(nv);
+        this._menu && this._menu.set(nv);
+        return nv;
+    },
+    _raiseError: function(errorMessage) {
+        if (this.debug) {
+            throw new Error(errorMessage);
+        } else {
+            _debugPrint('ERROR: ' + errorMessage);
+        }
+    },
+    _parseObjectConfig: function(parts) {
+        if (!Array.isArray(parts) || parts.length < 2) {
+            return null;
+        }
+        var object = parts[0], getter = parts[1], setter = parts[2];
+        if (typeof object[getter] === 'function' && typeof object[setter] === 'function') {
+            // [ API, 'getter', 'setter' ]
+            return {
+                object: object, getter: getter, setter: setter,
+                get: function getObjectValue() {
+                    return this.object[this.getter]();
+                },
+                set: function setObjectValue(nv) {
+                    return this.object[this.setter](nv), nv;
+                }
+            };
+        } else if (getter in object) {
+            // [ API, 'property' ]
+            return {
+                object: object, property: getter,
+                get: function() {
+                    //log('======> get API, property', object, getter, this.object[this.property]);
+                    return this.object[this.property];
+                },
+                set: function(nv) {
+                    //log('======> set API, property', object, getter, this.object[this.property], nv);
+                    return this.object[this.property] = nv;
+                }
+            };
+        }
+
+        this._raiseError('{ object: [ Object, getterOrPropertyName, setterName ] } -- invalid params or does not exist: ' +
+                         [ this.settingName, this.object, getter, setter ].join(' | '));
+    },
+    _parseMenuConfig: function(menu) {
+        if (!menu || typeof menu !== 'string') {
+            return null;
+        }
+        var parts = menu.split(/\s*>\s*/), menuItemName = parts.pop(), menuName = parts.join(' > ');
+        if (menuItemName && Menu.menuItemExists(menuName, menuItemName)) {
+            return {
+                menu: menu, menuName: menuName, menuItemName: menuItemName,
+                get: function() {
+                    return Menu.isOptionChecked(this.menuItemName);
+                },
+                set: function(nv) {
+                    return Menu.setIsOptionChecked(this.menuItemName, nv), nv;
+                }
+            };
+        }
+        this._raiseError('{ menu: "Menu > Item" } structure -- invalid params or does not exist: ' +
+                         [ this.settingName, this.menu, menuName, menuItemName ].join(' | '));
+    }
+}; // ApplicationConfigItem.prototype
+
+// ----------------------------------------------------------------------------
+// grouped configuration using the Settings.* API  abstraction
+function SettingsConfig(options) {
+    options = options || {};
+    assert('namespace' in options);
+    this.namespace = options.namespace;
+    this.defaultValues = {};
+    this.valueUpdated = _utils.signal(function valueUpdated(key, newValue, oldValue, origin){});
+    if (options.defaultValues) {
+        Object.keys(options.defaultValues)
+            .forEach(_utils.bind(this, function(key) {
+                var fullSettingsKey = this.resolve(key);
+                this.defaultValues[fullSettingsKey] = options.defaultValues[key];
+            }));
+    }
+}
+SettingsConfig.prototype = {
+    resolve: function(key) {
+        assert(typeof key === 'string', 'SettingsConfig.resolve error: key is not a string: ' + key);
+        if (0 !== key.indexOf('.') && !~key.indexOf('/')) {
+            return [ this.namespace, key ].join('/');
+        } else {
+            return key;
+        }
+    },
+    getValue: function(key, defaultValue) {
+        key = this.resolve(key);
+        defaultValue = defaultValue === undefined ? this.defaultValues[key] : defaultValue;
+        return Settings.getValue(key, defaultValue);
+    },
+    setValue: function setValue(key, value) {
+        key = this.resolve(key);
+        var lastValue = this.getValue(key);
+        var ret = Settings.setValue(key, value);
+        if (lastValue !== value) {
+            this.valueUpdated(key, value, lastValue, 'SettingsConfig.setValue');
+        }
+        return ret;
+    },
+    getFloat: function getFloat(key, defaultValue) {
+        key = this.resolve(key);
+        defaultValue = defaultValue === undefined ? this.defaultValues[key] : defaultValue;
+        var value = parseFloat(this.getValue(key, defaultValue));
+        return isFinite(value) ? value : isFinite(defaultValue) ? defaultValue : 0.0;
+    }
+};
+
+// ----------------------------------------------------------------------------
diff --git a/unpublishedScripts/marketplace/camera-move/modules/custom-settings-app/CustomSettingsApp.js b/unpublishedScripts/marketplace/camera-move/modules/custom-settings-app/CustomSettingsApp.js
new file mode 100644
index 0000000000..549d60b887
--- /dev/null
+++ b/unpublishedScripts/marketplace/camera-move/modules/custom-settings-app/CustomSettingsApp.js
@@ -0,0 +1,339 @@
+// CustomSettingsApp.js -- manages Settings between a Client script and connected "settings" tablet WebView page
+// see html.bridgedSettings.js for the webView side
+
+// example:
+//   var button = tablet.addButton({ text: 'My Settings' });
+//   var mySettingsApp = new CustomSettingsApp({
+//       namespace: 'mySettingsGroup',
+//       url: Script.resolvePath('myapp.html'),
+//       uuid: button.uuid,
+//       tablet: tablet
+//   });
+//
+//   // settings are automatically sync'd from the web page back to Interface; to be notified when that happens, use:
+//   myAppSettings.settingUpdated.connect(function(name, value, origin) {
+//      print('setting updated from web page', name, value, origin);
+//   });
+//
+//   // settings are also automatically sync'd from Interface back to the web page; to manually sync a value, use:
+//   myAppSettings.syncValue(fullSettingsKey, value);
+
+/* eslint-env commonjs */
+"use strict";
+
+CustomSettingsApp.version = '0.0.0';
+module.exports = CustomSettingsApp;
+
+var _utils = require('../_utils.js');
+Object.assign = Object.assign || _utils.assign;
+
+function assert(truthy, message) {
+    return _utils.assert.call(this, truthy, 'CustomSettingsApp | ' + message);
+}
+
+function _debugPrint() {
+    print('CustomSettingsApp | ' + [].slice.call(arguments).join(' '));
+}
+var debugPrint = function() {};
+
+function CustomSettingsApp(options) {
+    assert('url' in options, 'expected options.url');
+    if (options.debug) {
+        debugPrint = _debugPrint;
+    }
+
+    this.url = options.url;
+    this.namespace = options.namespace || 'BridgedSettings';
+    this.uuid = options.uuid || Uuid.generate();
+    this.recheckInterval = options.recheckInterval || 1000;
+
+    this.settingsScreenVisible = false;
+    this.isActive = false;
+
+    this.extraParams = Object.assign(options.extraParams || {}, {
+        customSettingsVersion: CustomSettingsApp.version+'',
+        protocolVersion: location.protocolVersion && location.protocolVersion()
+    });
+
+    var params = {
+        namespace: this.namespace,
+        uuid: this.uuid,
+        debug: options.debug || undefined
+    };
+
+    // encode PARAMS into '?key=value&...'
+    var query = Object.keys(params).map(function encodeValue(key) {
+        var value = encodeURIComponent(params[key] === undefined ? '' : params[key]);
+        return [ key, value ].join('=');
+    }).join('&');
+    this.url += '?&' + query;
+
+    this.isActiveChanged = _utils.signal(function(isActive) {});
+    this.valueUpdated = _utils.signal(function(key, value, oldValue, origin) {});
+
+    this.settingsAPI = options.settingsAPI || Settings;
+
+    // keep track of accessed settings so they can be kept in sync when changed on this side
+    this._activeSettings = {
+        sent: {},
+        received: {},
+        remote: {},
+        get: function(key) {
+            return {
+                sent: this.sent[key],
+                received: this.received[key],
+                remote: this.remote[key]
+            };
+        }
+    };
+
+    if (options.tablet) {
+        this._initialize(options.tablet);
+    }
+}
+
+CustomSettingsApp.prototype = {
+    tablet: null,
+    resolve: function(key) {
+        if (0 === key.indexOf('.') || ~key.indexOf('/')) {
+            // key is already qualified under a group; return as-is
+            return key;
+        }
+        // nest under the current namespace
+        return [ this.namespace, key ].join('/');
+    },
+    sendEvent: function(msg) {
+        assert(this.tablet, '!this.tablet');
+        msg.ns = msg.ns || this.namespace;
+        msg.uuid = msg.uuid || this.uuid;
+        this.tablet.emitScriptEvent(JSON.stringify(msg));
+    },
+    getValue: function(key, defaultValue) {
+        key = this.resolve(key);
+        return key in this._activeSettings.remote ? this._activeSettings.remote[key] : defaultValue;
+    },
+    setValue: function(key, value) {
+        key = this.resolve(key);
+        var current = this.getValue(key);
+        if (current !== value) {
+            return this.syncValue(key, value, 'CustomSettingsApp.setValue');
+        }
+        return false;
+    },
+    syncValue: function(key, value, origin) {
+        key = this.resolve(key);
+        var oldValue = this._activeSettings.remote[key];
+        assert(value !== null, 'CustomSettingsApp.syncValue value is null');
+        this.sendEvent({ id: 'valueUpdated', params: [key, value, oldValue, origin] });
+        this._activeSettings.sent[key] = value;
+        this._activeSettings.remote[key] = value;
+        this.valueUpdated(key, value, oldValue,  (origin ? origin+':' : '') + 'CustomSettingsApp.syncValue');
+    },
+    onScreenChanged: function onScreenChanged(type, url) {
+        this.settingsScreenVisible = (url === this.url);
+        debugPrint('===> onScreenChanged', type, url, 'settingsScreenVisible: ' + this.settingsScreenVisible);
+        if (this.isActive && !this.settingsScreenVisible) {
+            this.isActiveChanged(this.isActive = false);
+        }
+    },
+
+    _setValue: function(key, value, oldValue, origin) {
+        var current = this.settingsAPI.getValue(key),
+            lastRemoteValue = this._activeSettings.remote[key];
+        debugPrint('.setValue(' + JSON.stringify({key: key, value: value, current: current, lastRemoteValue: lastRemoteValue })+')');
+        this._activeSettings.received[key] = value;
+        this._activeSettings.remote[key] = value;
+        var result;
+        if (lastRemoteValue !== value) {
+            this.valueUpdated(key, value, lastRemoteValue, 'CustomSettingsApp.tablet');
+        }
+        if (current !== value) {
+            result = this.settingsAPI.setValue(key, value);
+        }
+        return result;
+    },
+
+    _handleValidatedMessage: function(obj, msg) {
+        var tablet = this.tablet;
+        if (!tablet) {
+            throw new Error('_handleValidatedMessage called when not connected to tablet...');
+        }
+        var params = Array.isArray(obj.params) ? obj.params : [obj.params];
+        var parts = (obj.method||'').split('.'), api = parts[0], method = parts[1];
+        if (api === 'valueUpdated') {
+            obj.result = this._setValue.apply(this, params);
+        } else if (api === 'Settings' && method && params[0]) {
+            var key = this.resolve(params[0]), value = params[1];
+            debugPrint('>>>>', method, key, value);
+            switch (method) {
+                case 'getValue': {
+                    obj.result = this.settingsAPI.getValue(key, value);
+                    this._activeSettings.sent[key] = obj.result;
+                    this._activeSettings.remote[key] = obj.result;
+                    break;
+                }
+            case 'setValue': {
+                obj.result = this._setValue(key, value, params[2], params[3]);
+                    break;
+                }
+                default: {
+                    obj.error = 'unmapped Settings method: ' + method;
+                    throw new Error(obj.error);
+                }
+            }
+        } else {
+            if (this.onUnhandledMessage) {
+                this.onUnhandledMessage(obj, msg);
+            } else {
+                obj.error = 'unmapped method call: ' + msg;
+            }
+        }
+        if (obj.id) {
+            // if message has an id, reply with the same message obj which now has a .result or .error field
+            // note: a small delay is needed because of an apparent race condition between ScriptEngine and Tablet WebViews
+            Script.setTimeout(_utils.bind(this, function() {
+                debugPrint(obj.id, '########', JSON.stringify(obj));
+                this.sendEvent(obj);
+            }), 100);
+        } else if (obj.error) {
+            throw new Error(obj.error);
+        }
+    },
+    onWebEventReceived: function onWebEventReceived(msg) {
+        var tablet = this.tablet;
+        if (!tablet) {
+            throw new Error('onWebEventReceived called when not connected to tablet...');
+        }
+        if (msg === this.url) {
+            this.isActiveChanged(this.isActive = true);
+            // reply to initial HTML page ACK with any extraParams that were specified
+            this.sendEvent({ id: 'extraParams', extraParams: this.extraParams });
+            return;
+        }
+        try {
+            var obj = JSON.parse(msg);
+            var validSender = obj.ns === this.namespace && obj.uuid === this.uuid;
+            if (validSender) {
+                this._handleValidatedMessage(obj, msg);
+            } else {
+                debugPrint('skipping', JSON.stringify([obj.ns, obj.uuid]), JSON.stringify(this), msg);
+            }
+        } catch (e) {
+            _debugPrint('rpc error:', e, msg);
+        }
+    },
+
+    _initialize: function(tablet) {
+        if (this.tablet) {
+            throw new Error('CustomSettingsApp._initialize called but this.tablet already has a value');
+        }
+        this.tablet = tablet;
+        tablet.webEventReceived.connect(this, 'onWebEventReceived');
+        tablet.screenChanged.connect(this, 'onScreenChanged');
+
+        this.onAPIValueUpdated = function(key, newValue, oldValue, origin) {
+            if (this._activeSettings.remote[key] !== newValue) {
+                _debugPrint(
+                    '[onAPIValueUpdated @ ' + origin + ']',
+                    key + ' = ' + JSON.stringify(newValue), '(was: ' + JSON.stringify(oldValue) +')',
+                    JSON.stringify(this._activeSettings.get(key),0,2));
+                this.syncValue(key, newValue, (origin ? origin+':' : '') + 'CustomSettingsApp.onAPIValueUpdated');
+            }
+        };
+        this.isActiveChanged.connect(this, function(isActive) {
+            debugPrint('============= CustomSettingsApp... isActiveChanged', JSON.stringify({
+                isActive: isActive,
+                interval: this.interval || 0,
+                recheckInterval: this.recheckInterval
+            },0,2));
+            this._activeSettings.remote = {}; // reset assumptions about remote values
+            isActive ? this.$startMonitor() : this.$stopMonitor();
+        });
+
+        debugPrint('CustomSettingsApp...initialized', this.namespace);
+    },
+
+    $syncSettings: function() {
+        for (var p in this._activeSettings.sent) {
+            var value = this.settingsAPI.getValue(p),
+                lastValue = this._activeSettings.remote[p];
+            if (value !== undefined && value !== null && value !== '' && value !== lastValue) {
+                _debugPrint('CustomSettingsApp... detected external settings change', JSON.stringify({
+                    key: p,
+                    lastValueSent: (this._activeSettings.sent[p]+''),
+                    lastValueAssumed: (this._activeSettings.remote[p]+''),
+                    lastValueReceived: (this._activeSettings.received[p]+''),
+                    newValue: (value+'')
+                }));
+                this.syncValue(p, value, 'Settings');
+                this.valueUpdated(p, value, lastValue, 'CustomSettingsApp.$syncSettings');
+            }
+        }
+    },
+    $startMonitor: function() {
+        if (!(this.recheckInterval > 0)) { // expressed this way handles -1, NaN, null, undefined etc.
+            _debugPrint('$startMonitor -- recheckInterval <= 0; not starting settings monitor thread');
+            return false;
+        }
+        if (this.interval) {
+            this.$stopMonitor();
+        }
+        if (this.settingsAPI.valueUpdated) {
+            _debugPrint('settingsAPI supports valueUpdated -- binding to detect settings changes', this.settingsAPI);
+            this.settingsAPI.valueUpdated.connect(this, 'onAPIValueUpdated');
+        }
+        this.interval = Script.setInterval(_utils.bind(this, '$syncSettings'), this.recheckInterval);
+        _debugPrint('STARTED MONITORING THREAD');
+    },
+    $stopMonitor: function() {
+        if (this.interval) {
+            Script.clearInterval(this.interval);
+            this.interval = 0;
+            if (this.settingsAPI.valueUpdated) {
+                this.settingsAPI.valueUpdated.disconnect(this, 'onAPIValueUpdated');
+            }
+            _debugPrint('stopped monitoring thread');
+            return true;
+        }
+    },
+
+    cleanup: function() {
+        if (!this.tablet) {
+            return _debugPrint('CustomSettingsApp...cleanup called when not initialized');
+        }
+        var tablet = this.tablet;
+        tablet.webEventReceived.disconnect(this, 'onWebEventReceived');
+        tablet.screenChanged.disconnect(this, 'onScreenChanged');
+        this.$stopMonitor();
+        if (this.isActive) {
+            try {
+                this.isActiveChanged(this.isActive = false);
+            } catch (e) {
+                _debugPrint('ERROR: cleanup error during isActiveChanged(false)', e);
+            }
+        }
+        this.toggle(false);
+        this.settingsScreenVisible = false;
+        this.tablet = null;
+        debugPrint('cleanup completed', this.namespace);
+    },
+
+    toggle: function(show) {
+        if (!this.tablet) {
+            return _debugPrint('CustomSettingsApp...cleanup called when not initialized');
+        }
+        if (typeof show !== 'boolean') {
+            show = !this.settingsScreenVisible;
+        }
+
+        if (this.settingsScreenVisible && !show) {
+            this.tablet.gotoHomeScreen();
+        } else if (!this.settingsScreenVisible && show) {
+            Script.setTimeout(_utils.bind(this, function() {
+                // Interface sometimes crashes if not for adding a small timeout here :(
+                this.tablet.gotoWebScreen(this.url);
+            }), 1);
+        }
+    }
+};
+
diff --git a/unpublishedScripts/marketplace/camera-move/modules/custom-settings-app/browser/BridgedSettings.js b/unpublishedScripts/marketplace/camera-move/modules/custom-settings-app/browser/BridgedSettings.js
new file mode 100644
index 0000000000..169b722228
--- /dev/null
+++ b/unpublishedScripts/marketplace/camera-move/modules/custom-settings-app/browser/BridgedSettings.js
@@ -0,0 +1,207 @@
+// BridgedSettings.js -- HTML-side implementation of bridged/async Settings
+// see ../CustomSettingsApp.js for the corresponding Interface script
+
+/* eslint-env commonjs, browser */
+(function(global) {
+    "use strict";
+
+    BridgedSettings.version = '0.0.2';
+
+    try {
+        module.exports = BridgedSettings;
+    } catch (e) {
+        global.BridgedSettings = BridgedSettings;
+    }
+
+    var _utils = global._utils || (global.require && global.require('../_utils.js'));
+
+    if (!_utils || !_utils.signal) {
+        throw new Error('html.BridgedSettings.js -- expected _utils to be available on the global object (ie: window._utils)');
+    }
+    var signal = _utils.signal,
+        assert = _utils.assert;
+
+    function log() {
+        console.info('bridgedSettings | ' + [].slice.call(arguments).join(' ')); // eslint-disable-line no-console
+    }
+    log('version', BridgedSettings.version);
+
+    var debugPrint = function() {}; // = log
+
+    function BridgedSettings(options) {
+        options = options || {};
+        this.eventBridge = options.eventBridge || global.EventBridge;
+        this.namespace = options.namespace || 'BridgedSettings';
+        this.uuid = options.uuid || undefined;
+        this.valueUpdated = signal(function valueUpdated(key, newValue, oldValue, origin){});
+        this.callbackError = signal(function callbackError(error, message){});
+        this.pendingRequestsFinished = signal(function pendingRequestsFinished(){});
+        this.extraParams = {};
+
+        // keep track of accessed settings so they can be kept in sync when changed on this side
+        this._activeSettings = {
+            sent: {},
+            received: {},
+            remote: {},
+        };
+
+        this.debug = options.debug;
+        this.log = log.bind({}, this.namespace + ' |');
+        this.debugPrint = function() { return this.debug && this.log.apply(this, arguments); };
+
+        this.log('connecting to EventBridge.scriptEventReceived');
+        this._boundScriptEventReceived = this.onScriptEventReceived.bind(this);
+        this.eventBridge.scriptEventReceived.connect(this._boundScriptEventReceived);
+
+        this.callbacks = Object.defineProperties(options.callbacks || {}, {
+            extraParams: { value: this.handleExtraParams },
+            valueUpdated: { value: this.handleValueUpdated },
+        });
+    }
+
+    BridgedSettings.prototype = {
+        _callbackId: 1,
+        resolve: function(key) {
+            if (0 !== key.indexOf('.') && !~key.indexOf('/')) {
+                return [ this.namespace, key ].join('/');
+            } else {
+                return key;
+            }
+        },
+        handleValueUpdated: function(msg) {
+            // client script notified us that a value was updated on that side
+            var key = this.resolve(msg.params[0]),
+                value = msg.params[1],
+                oldValue = msg.params[2],
+                origin = msg.params[3];
+                log('callbacks.valueUpdated', key, value, oldValue, origin);
+            this._activeSettings.received[key] = this._activeSettings.remote[key] = value;
+            this.valueUpdated(key, value, oldValue, (origin?origin+':':'') + 'callbacks.valueUpdated');
+        },
+        handleExtraParams: function(msg) {
+            // client script sent us extraParams
+            var extraParams = msg.extraParams;
+            Object.assign(this.extraParams, extraParams);
+            this.debugPrint('received .extraParams', JSON.stringify(extraParams,0,2));
+            var key = '.extraParams';
+            this._activeSettings.received[key] = this._activeSettings.remote[key] = extraParams;
+            this.valueUpdated(key, this.extraParams, this.extraParams, 'html.bridgedSettings.handleExtraParams');
+        },
+        cleanup: function() {
+            try {
+                this.eventBridge.scriptEventReceived.disconnect(this._boundScriptEventReceived);
+            } catch(e) {
+                this.log('error disconnecting from scriptEventReceived:', e);
+            }
+        },
+        pendingRequestCount: function() {
+            return Object.keys(this.callbacks).length;
+        },
+        onScriptEventReceived: function(_msg) {
+            var error;
+            this.debugPrint(this.namespace, '_onScriptEventReceived......' + _msg);
+            try {
+                var msg = JSON.parse(_msg);
+                if (msg.ns === this.namespace && msg.uuid === this.uuid) {
+                    this.debugPrint('_onScriptEventReceived', msg);
+                    var callback = this.callbacks[msg.id],
+                        handled = false,
+                        debug = this.debug;
+                    if (callback) {
+                        try {
+                            callback.call(this, msg);
+                            handled = true;
+                        } catch (e) {
+                            error = e;
+                            this.log('CALLBACK ERROR', this.namespace, msg.id, '_onScriptEventReceived', e);
+                            this.callbackError(error, msg);
+                            if (debug) {
+                                throw error;
+                            }
+                        }
+                    }
+                    if (!handled) {
+                        if (this.onUnhandledMessage) {
+                            return this.onUnhandledMessage(msg, _msg);
+                        } else {
+                            error = new Error('unhandled message: ' + _msg);
+                        }
+                    }
+                }
+            } catch (e) { error = e; }
+            if (this.debug && error) {
+                throw error;
+            }
+            return error;
+        },
+        sendEvent: function(msg) {
+            msg.ns = msg.ns || this.namespace;
+            msg.uuid = msg.uuid || this.uuid;
+            this.eventBridge.emitWebEvent(JSON.stringify(msg));
+        },
+        getValue: function(key, defaultValue) {
+            key = this.resolve(key);
+            return key in this._activeSettings.remote ? this._activeSettings.remote[key] : defaultValue;
+        },
+        setValue: function(key, value) {
+            key = this.resolve(key);
+            var current = this.getValue(key);
+            if (current !== value) {
+                return this.syncValue(key, value, 'setValue');
+            }
+            return false;
+        },
+        syncValue: function(key, value, origin) {
+            key = this.resolve(key);
+            var oldValue = this._activeSettings.remote[key];
+            this.sendEvent({ method: 'valueUpdated', params: [key, value, oldValue, origin] });
+            this._activeSettings.sent[key] = this._activeSettings.remote[key] = value;
+            this.valueUpdated(key, value, oldValue, (origin ? origin+':' : '') + 'html.bridgedSettings.syncValue');
+        },
+        getValueAsync: function(key, defaultValue, callback) {
+            key = this.resolve(key);
+            if (typeof defaultValue === 'function') {
+                callback = defaultValue;
+                defaultValue = undefined;
+            }
+            var params = defaultValue !== undefined ? [ key, defaultValue ] : [ key ],
+                event = { method: 'Settings.getValue', params: params };
+
+            this.debugPrint('< getValueAsync...', key, params);
+            if (callback) {
+                event.id = this._callbackId++;
+                this.callbacks[event.id] = function(obj) {
+                    try {
+                        callback(obj.error, obj.result);
+                        if (!obj.error) {
+                            this._activeSettings.received[key] = this._activeSettings.remote[key] = obj.result;
+                        }
+                    } finally {
+                        delete this.callbacks[event.id];
+                    }
+                    this.pendingRequestCount() === 0 && this.pendingRequestsFinished();
+                };
+            }
+            this.sendEvent(event);
+        },
+        setValueAsync: function(key, value, callback) {
+            key = this.resolve(key);
+            this.log('< setValueAsync', key, value);
+            var params = [ key, value ],
+                event = { method: 'Settings.setValue', params: params };
+            if (callback) {
+                event.id = this._callbackId++;
+                this.callbacks[event.id] = function(obj) {
+                    try {
+                        callback(obj.error, obj.result);
+                    } finally {
+                        delete this.callbacks[event.id];
+                    }
+                };
+            }
+            this._activeSettings.sent[key] = this._activeSettings.remote[key] = value;
+            this.sendEvent(event);
+        }
+    };
+
+})(this);
diff --git a/unpublishedScripts/marketplace/camera-move/modules/custom-settings-app/browser/JQuerySettings.js b/unpublishedScripts/marketplace/camera-move/modules/custom-settings-app/browser/JQuerySettings.js
new file mode 100644
index 0000000000..bd31f0abd7
--- /dev/null
+++ b/unpublishedScripts/marketplace/camera-move/modules/custom-settings-app/browser/JQuerySettings.js
@@ -0,0 +1,231 @@
+// JQuerySettings.js -- HTML-side helper class for managing settings-linked jQuery UI elements
+
+/* eslint-env commonjs, browser */
+(function(global) {
+    "use strict";
+
+    JQuerySettings.version = '0.0.0';
+
+    try {
+        module.exports = JQuerySettings;
+    } catch (e) {
+        global.JQuerySettings= JQuerySettings
+    }
+
+    var _utils = global._utils || (global.require && global.require('../_utils.js'));
+
+    if (!_utils || !_utils.signal) {
+        throw new Error('html.BridgedSettings.js -- expected _utils to be available on the global object (ie: window._utils)');
+    }
+    var signal = _utils.signal,
+        assert = _utils.assert;
+
+    function log() {
+        console.info('jquerySettings | ' + [].slice.call(arguments).join(' ')); // eslint-disable-line no-console
+    }
+    log('version', JQuerySettings.version);
+
+    var debugPrint = function() {}; // = log
+
+    function JQuerySettings(options) {
+        assert('namespace' in options);
+        Object.assign(this, {
+            id2Setting: {},   // DOM id -> qualified Settings key
+            Setting2id: {},   // qualified Settings key -> DOM id
+            _activeSettings: {
+                received: {}, // from DOM elements
+                sent: {},     // to DOM elements
+                remote: {},   // MRU values
+            },
+
+        }, options);
+        this.valueUpdated = signal(function valueUpdated(key, value, oldValue, origin){});
+    }
+    JQuerySettings.prototype = {
+        resolve: function(key) {
+            if (0 !== key.indexOf('.') && !~key.indexOf('/')) {
+                return [ this.namespace, key ].join('/');
+            } else {
+                return key;
+            }
+        },
+        registerSetting: function(id, key) {
+            this.id2Setting[id] = key;
+            this.Setting2id[key] = id;
+        },
+        registerNode: function(node) {
+            var name = node.name || node.id,
+                key = this.resolve(name);
+            this.registerSetting(node.id, key);
+            if (node.type === 'radio') {
+                // for radio buttons also map the overall radio-group to the key
+                this.registerSetting(name, key);
+            }
+        },
+        // lookup the DOM id for a given Settings key
+        getId: function(key, missingOk) {
+            key = this.resolve(key);
+            return assert(this.Setting2id[key] || missingOk || true, 'jquerySettings.getId: !Setting2id['+key+']');
+        },
+        // lookup the Settings key for a given DOM id
+        getKey: function(id, missingOk) {
+            return assert(this.id2Setting[id] || missingOk || true, 'jquerySettings.getKey: !id2Setting['+id+']');
+        },
+        // lookup the DOM node for a given Settings key
+        findNodeByKey: function(key, missingOk) {
+            key = this.resolve(key);
+            var id = this.getId(key, missingOk);
+            var node = document.getElementById(id);
+            if (typeof node !== 'object') {
+                log('jquerySettings.getNodeByKey -- node not found:', 'key=='+key, 'id=='+id);
+            }
+            return node;
+        },
+        _notifyValueUpdated: function(key, value, oldValue, origin) {
+            this._activeSettings.sent[key] = value;
+            this._activeSettings.remote[key] = value;
+            this.valueUpdated(this.resolve(key), value, oldValue, origin);
+        },
+        getValue: function(key, defaultValue) {
+            key = this.resolve(key);
+            var node = this.findNodeByKey(key);
+            if (node) {
+                var value = this.__getNodeValue(node);
+                this._activeSettings.remote[key] = value;
+                this._activeSettings.received[key] = value;
+                return value;
+            }
+            return defaultValue;
+        },
+        setValue: function(key, value, origin) {
+            key = this.resolve(key);
+            var lastValue = this.getValue(key, value);
+            if (lastValue !== value || origin) {
+                var node = assert(this.findNodeByKey(key), 'jquerySettings.setValue -- node not found: ' + key);
+                var ret = this.__setNodeValue(node, value);
+                if (origin) {
+                    this._notifyValueUpdated(key, value, lastValue, origin || 'jquerySettings.setValue');
+                }
+                return ret;
+            }
+        },
+        resyncValue: function(key, origin) {
+            var sentValue = key in this._activeSettings.sent ? this._activeSettings.sent[key] : null,
+                receivedValue = key in this._activeSettings.received ? this._activeSettings.received[key] : null,
+                currentValue = this.getValue(key, null);
+
+            log('resyncValue', JSON.stringify({
+                key: key, current: currentValue, sent: sentValue, received: receivedValue,
+                'current !== received': [typeof currentValue, currentValue+'', typeof receivedValue, receivedValue+'', currentValue !== receivedValue],
+                'current !== sent': [currentValue+'', sentValue+'', currentValue !== sentValue],
+            }));
+
+            if (currentValue !== receivedValue || currentValue !== sentValue) {
+                this._notifyValueUpdated(key, currentValue, sentValue, (origin?origin+':':'')+'jquerySettings.resyncValue');
+            }
+        },
+
+        domAccessors: {
+            'default': {
+                get: function() { return this.value; },
+                set: function(nv) { $(this).val(nv); },
+            },
+            'checkbox': {
+                get: function() { return this.checked; },
+                set: function(nv) { $(this).prop('checked', nv); },
+            },
+            'number': {
+                get: function() { return parseFloat(this.value); },
+                set: function(nv) {
+                    var step = this.step || 1, precision = (1/step).toString(this).length - 1;
+                    var value = parseFloat(newValue).toFixed(precision);
+                    if (isNaN(value)) {
+                        log('domAccessors.number.set', id, 'ignoring NaN value:', value+'');
+                        return;
+                    }
+                    $(this).val(value);
+                },
+            },
+            'radio': {
+                get: function() { assert(false, 'use radio-group to get current selected radio value for ' + this.id); },
+                set: function(nv) {},
+            },
+            'radio-group': {
+                get: function() {
+                    var checked = $(this).closest(':ui-hifiControlGroup').find('input:checked');
+                    debugPrint('_getthisValue.radio-group checked item: ' + checked[0], checked.val());
+                    return checked.val();
+                },
+                set: function(nv) {
+
+                },
+            },
+        },
+
+        __getNodeValue: function(node) {
+            assert(node && typeof node === 'object', '__getNodeValue expects a DOM node');
+            node = node.jquery ? node.get(0) : node;
+            var type = node ? (node.dataset.type || node.type) : undefined;
+            var value;
+            assert(type in this.domAccessors, 'DOM value accessor not defined for node type: ' + type);
+            debugPrint('__getNodeValue', type);
+            return this.domAccessors[type].get.call(node);
+
+            // switch(node.type) {
+            //     case 'checkbox': value = node.checked; break;
+            //     case 'number': value = parseFloat(node.value); break;
+            //     case 'radio':
+            //     case 'radio-group':
+            //         var checked = $(node).closest(':ui-hifiControlGroup').find('input:checked');
+            //         debugPrint('_getNodeValue.radio-group checked item: ' + checked[0], checked.val());
+            //         value = checked.val();
+            //         break;
+            //     default: value = node.value;
+            // }
+            // debugPrint('_getNodeValue', node, node.id, node.type, node.type !== 'radio-group' && node.value, value);
+            // return value;
+        },
+
+        __setNodeValue: function(node, newValue) {
+            if (node && node.jquery) {
+                node = node[0];
+            }
+            var id = node.id,
+                key = this.getKey(node.id),
+                type = node.dataset.type || node.type,
+                value = newValue,
+                element = $(node);
+
+            debugPrint('__setNodeValue', '('+type+') #' + key + '=' + newValue);
+
+            switch(type) {
+                case 'radio':
+                    assert(false, 'radio buttons should be set through their radio-group parent');
+                    break;
+                case 'checkbox':
+                    element.prop('checked', newValue);
+                    element.is(':ui-hifiCheckboxRadio') && element.hifiCheckboxRadio('refresh');
+                    break;
+                case 'radio-group':
+                    var input = element.find('input[type=radio]#' + newValue);
+                    assert(input[0], 'ERROR: ' + key + ': could not find "input[type=radio]#' + newValue + '" to set new radio-group value of: ' + newValue);
+                    input.prop('checked', true);
+                    input.closest(':ui-hifiControlGroup').find(':ui-hifiCheckboxRadio').hifiCheckboxRadio('refresh');
+                    break;
+                case 'number':
+                    var step = node.step || 1, precision = (1/step).toString().length - 1;
+                    value = parseFloat(newValue).toFixed(precision);
+                    if (isNaN(value)) {
+                        log(id, 'ignoring NaN value');
+                        break;
+                    }
+                    element.val(value);
+                    element.closest('.row').find(':ui-hifiSlider').hifiSlider('value', parseFloat(value));
+                    break;
+                default:
+                    element.val(newValue);
+            }
+            return value;
+        }
+    };
+})(this);
diff --git a/unpublishedScripts/marketplace/camera-move/modules/movement-utils.js b/unpublishedScripts/marketplace/camera-move/modules/movement-utils.js
new file mode 100644
index 0000000000..b887f4a268
--- /dev/null
+++ b/unpublishedScripts/marketplace/camera-move/modules/movement-utils.js
@@ -0,0 +1,841 @@
+// movement-utils.js -- helper classes that help manage related Controller.*Event and input API bindings for movement controls
+
+/* eslint-disable comma-dangle */
+/* global require: true */
+/* eslint-env commonjs */
+"use strict";
+
+module.exports = {
+    version: '0.0.2c-0104',
+
+    CameraControls: CameraControls,
+    MovementEventMapper: MovementEventMapper,
+    MovementMapping: MovementMapping,
+    VelocityTracker: VelocityTracker,
+    VirtualDriveKeys: VirtualDriveKeys,
+
+    applyEasing: applyEasing,
+    calculateThrust: calculateThrust,
+    vec3damp: vec3damp,
+    vec3eclamp: vec3eclamp,
+
+    DriveModes: {
+        JITTER_TEST: 'jitter-test',
+        POSITION: 'position', // ~ MyAvatar.position
+        MOTOR: 'motor',       // ~ MyAvatar.motorVelocity
+        THRUST: 'thrust',     // ~ MyAvatar.setThrust
+    },
+};
+
+var MAPPING_TEMPLATE = require('./movement-utils.mapping.json' + (Script.resolvePath('').match(/[?#].*$/)||[''])[0]);
+
+var WANT_DEBUG = false;
+
+function log() {
+    print('movement-utils | ' + [].slice.call(arguments).join(' '));
+}
+
+var debugPrint = function() {};
+
+log(module.exports.version);
+
+var _utils = require('./_utils.js'),
+    assert = _utils.assert;
+
+if (1||WANT_DEBUG) {
+    require = _utils.makeDebugRequire(Script.resolvePath('.'));
+    _utils = require('./_utils.js'); // re-require in debug mode
+    if (WANT_DEBUG) debugPrint = log;
+}
+
+Object.assign = Object.assign || _utils.assign;
+
+var enumMeta = require('./EnumMeta.js');
+assert(enumMeta.version >= '0.0.1', 'enumMeta >= 0.0.1 expected but got: ' + enumMeta.version);
+
+MovementEventMapper.CAPTURE_DRIVE_KEYS = 'drive-keys';
+MovementEventMapper.CAPTURE_ACTION_EVENTS = 'action-events';
+//MovementEventMapper.CAPTURE_KEYBOARD_EVENTS = 'key-events';
+
+function MovementEventMapper(options) {
+    assert('namespace' in options, '.namespace expected ' + Object.keys(options) );
+    this.namespace = options.namespace;
+    this.enabled = false;
+
+    this.options = Object.assign({
+        namespace: this.namespace,
+        captureMode: MovementEventMapper.CAPTURE_ACTION_EVENTS,
+        excludeNames: null,
+        mouseSmooth: true,
+        keyboardMultiplier: 1.0,
+        mouseMultiplier: 1.0,
+        eventFilter: null,
+        controllerMapping: MAPPING_TEMPLATE,
+    }, options);
+
+    this.isShifted = false;
+    this.isGrounded = false;
+    this.isRightMouseButton = false;
+    this.rightMouseButtonReleased = undefined;
+
+    this.inputMapping = new MovementMapping(this.options);
+    this.inputMapping.virtualActionEvent.connect(this, 'onVirtualActionEvent');
+}
+MovementEventMapper.prototype = {
+    defaultEventFilter: function(from, event) {
+        return event.actionValue;
+    },
+    getState: function(options) {
+        var state = this.states ? this.states.getDriveKeys(options) : { };
+
+        state.enabled = this.enabled;
+
+        state.mouseSmooth = this.options.mouseSmooth;
+        state.captureMode = this.options.captureMode;
+        state.mouseMultiplier = this.options.mouseMultiplier;
+        state.keyboardMultiplier = this.options.keyboardMultiplier;
+
+        state.isGrounded = this.isGrounded;
+        state.isShifted = this.isShifted;
+        state.isRightMouseButton = this.isRightMouseButton;
+        state.rightMouseButtonReleased = this.rightMouseButtonReleased;
+
+        return state;
+    },
+    updateOptions: function(options) {
+        var changed = 0;
+        for (var p in this.options) {
+            if (p in options) {
+                log('MovementEventMapper updating options.'+p, this.options[p] + ' -> ' + options[p]);
+                this.options[p] = options[p];
+                changed++;
+            }
+        }
+        for (var p in options) {
+            if (!(p in this.options)) {
+                var value = options[p];
+                log('MovementEventMapper warning: ignoring option:', p, (value +'').substr(0, 40)+'...');
+            }
+        }
+        return changed;
+    },
+    applyOptions: function(options, applyNow) {
+        var changed = this.updateOptions(options || {});
+        if (changed && applyNow) {
+            this.reset();
+        }
+    },
+    reset: function() {
+        log(this.constructor.name, 'reset', JSON.stringify(Object.assign({}, this.options, { controllerMapping: undefined }),0,2));
+        var enabled = this.enabled;
+        enabled && this.disable();
+        enabled && this.enable();
+    },
+    disable: function() {
+        this.inputMapping.disable();
+        this.bindEvents(false);
+        this.enabled = false;
+    },
+    enable: function() {
+        if (!this.enabled) {
+            this.enabled = true;
+            this.states = new VirtualDriveKeys({
+                eventFilter: this.options.eventFilter && _utils.bind(this, this.options.eventFilter)
+            });
+            this.bindEvents(true);
+            this.inputMapping.updateOptions(this.options);
+            this.inputMapping.enable();
+        }
+    },
+    bindEvents: function bindEvents(capture) {
+        assert(function assertion() {
+            return captureMode === MovementEventMapper.CAPTURE_ACTION_EVENTS ||
+                captureMode === MovementEventMapper.CAPTURE_DRIVE_KEYS;
+        });
+        log('bindEvents....', capture, this.options.captureMode);
+        var exclude = Array.isArray(this.options.excludeNames) && this.options.excludeNames;
+
+        if (!capture || this.options.captureMode === MovementEventMapper.CAPTURE_ACTION_EVENTS) {
+            var tmp = capture ? 'captureActionEvents' : 'releaseActionEvents';
+            log('bindEvents -- ', tmp.toUpperCase());
+            Controller[tmp]();
+        }
+        if (!capture || this.options.captureMode === MovementEventMapper.CAPTURE_DRIVE_KEYS) {
+            var tmp = capture ? 'disableDriveKey' : 'enableDriveKey';
+            log('bindEvents -- ', tmp.toUpperCase());
+            for (var p in DriveKeys) {
+                if (capture && (exclude && ~exclude.indexOf(p))) {
+                    log(tmp.toUpperCase(), 'excluding DriveKey===' + p);
+                } else {
+                    MyAvatar[tmp](DriveKeys[p]);
+                }
+            }
+        }
+        try {
+            Controller.actionEvent[capture ? 'connect' : 'disconnect'](this, 'onActionEvent');
+        } catch (e) { /* eslint-disable-line empty-block */ }
+
+        if (!capture || !/person/i.test(Camera.mode)) {
+            Controller[capture ? 'captureWheelEvents' : 'releaseWheelEvents']();
+            try {
+                Controller.wheelEvent[capture ? 'connect' : 'disconnect'](this, 'onWheelEvent');
+            } catch (e) { /* eslint-disable-line empty-block */ }
+        }
+    },
+    onWheelEvent: function onWheelEvent(event) {
+        var actionID = enumMeta.ACTION_TRANSLATE_CAMERA_Z,
+            actionValue = -event.delta;
+        return this.onActionEvent(actionID, actionValue, event);
+    },
+    onActionEvent: function(actionID, actionValue, extra) {
+        var actionName = enumMeta.Controller.ActionNames[actionID],
+            driveKeyName = enumMeta.getDriveKeyNameFromActionName(actionName),
+            prefix = (actionValue > 0 ? '+' : actionValue < 0 ? '-' : ' ');
+
+        var event = {
+            id: prefix + actionName,
+            actionName: actionName,
+            driveKey: DriveKeys[driveKeyName],
+            driveKeyName: driveKeyName,
+            actionValue: actionValue,
+            extra: extra
+        };
+        if(0)debugPrint('onActionEvent', actionID, actionName, driveKeyName);
+        this.states.handleActionEvent('Actions.' + actionName, event);
+    },
+    onVirtualActionEvent: function(from, event) {
+        if (from === 'Application.Grounded') {
+            this.isGrounded = !!event.applicationValue;
+        } else if (from === 'Keyboard.Shift') {
+            this.isShifted = !!event.value;
+        } else if (from === 'Keyboard.RightMouseButton') {
+            this.isRightMouseButton = !!event.value;
+            this.rightMouseButtonReleased = !event.value ? new Date : undefined;
+        }
+        this.states.handleActionEvent(from, event);
+    }
+}; // MovementEventMapper.prototype
+
+// ----------------------------------------------------------------------------
+// helper JS class to track drive keys -> translation / rotation influences
+function VirtualDriveKeys(options) {
+    options = options || {};
+    Object.defineProperties(this, {
+        $pendingReset: { value: {} },
+        $eventFilter: { value: options.eventFilter },
+        $valueUpdated: { value: _utils.signal(function valueUpdated(action, newValue, oldValue){}) }
+    });
+}
+VirtualDriveKeys.prototype = {
+    update: function update(dt) {
+        Object.keys(this.$pendingReset).forEach(_utils.bind(this, function(i) {
+            var reset = this.$pendingReset[i];
+            if (reset.event.driveKey in this) {
+                this.setValue(reset.event, 0);
+            }
+        }));
+    },
+    getValue: function(driveKey, defaultValue) {
+        return driveKey in this ? this[driveKey] : defaultValue;
+    },
+    handleActionEvent: function(from, event) {
+        var value = event.actionValue;
+        if (this.$eventFilter) {
+            value = this.$eventFilter(from, event, function(from, event) {
+                return event.actionValue;
+            });
+        }
+        if (event.driveKeyName) {
+            return this.setValue(event, value);
+        }
+        return false;
+    },
+    setValue: function(event, value) {
+        var driveKeyName = event.driveKeyName,
+            driveKey = DriveKeys[driveKeyName],
+            id = event.id,
+            previous = this[driveKey],
+            autoReset = (driveKeyName === 'ZOOM');
+
+        if(0)debugPrint('setValue', 'id:'+id, 'driveKey:' + driveKey, 'driveKeyName:'+driveKeyName, 'actionName:'+event.actionName, 'previous:'+previous, 'value:'+value);
+
+        this[driveKey] = value;
+
+        if (previous !== value) {
+            this.$valueUpdated(event, value, previous);
+        }
+        if (value === 0.0) {
+            delete this.$pendingReset[id];
+        } else if (autoReset) {
+            this.$pendingReset[id] = { event: event, value: value };
+        }
+    },
+    reset: function() {
+        Object.keys(this).forEach(_utils.bind(this, function(p) {
+            this[p] = 0.0;
+            if(0)debugPrint('actionStates.$pendingReset reset', enumMeta.DriveKeyNames[p]);
+        }));
+        Object.keys(this.$pendingReset).forEach(_utils.bind(this, function(p) {
+            delete this.$pendingReset[p];
+        }));
+    },
+    toJSON: function() {
+        var obj = {};
+        for (var key in this) {
+            if (enumMeta.DriveKeyNames[key]) {
+                obj[enumMeta.DriveKeyNames[key]] = this[key];
+            }
+        }
+        return obj;
+    },
+    getDriveKeys: function(options) {
+        options = options || {};
+        try {
+            return {
+                translation: {
+                    x: this.getValue(DriveKeys.TRANSLATE_X) || 0,
+                    y: this.getValue(DriveKeys.TRANSLATE_Y) || 0,
+                    z: this.getValue(DriveKeys.TRANSLATE_Z) || 0
+                },
+                step_translation: {
+                    x: 'STEP_TRANSLATE_X' in DriveKeys && this.getValue(DriveKeys.STEP_TRANSLATE_X) || 0,
+                    y: 'STEP_TRANSLATE_Y' in DriveKeys && this.getValue(DriveKeys.STEP_TRANSLATE_Y) || 0,
+                    z: 'STEP_TRANSLATE_Z' in DriveKeys && this.getValue(DriveKeys.STEP_TRANSLATE_Z) || 0
+                },
+                rotation: {
+                    x: this.getValue(DriveKeys.PITCH) || 0,
+                    y: this.getValue(DriveKeys.YAW) || 0,
+                    z: 'ROLL' in DriveKeys && this.getValue(DriveKeys.ROLL) || 0
+                },
+                step_rotation: {
+                    x: 'STEP_PITCH' in DriveKeys && this.getValue(DriveKeys.STEP_PITCH) || 0,
+                    y: 'STEP_YAW' in DriveKeys && this.getValue(DriveKeys.STEP_YAW) || 0,
+                    z: 'STEP_ROLL' in DriveKeys && this.getValue(DriveKeys.STEP_ROLL) || 0
+                },
+                zoom: Vec3.multiply(this.getValue(DriveKeys.ZOOM) || 0, Vec3.ONE)
+            };
+        } finally {
+            options.update && this.update(options.update);
+        }
+    }
+};
+
+// ----------------------------------------------------------------------------
+// MovementMapping
+
+function MovementMapping(options) {
+    options = options || {};
+    assert('namespace' in options && 'controllerMapping' in options);
+    this.namespace = options.namespace;
+    this.enabled = false;
+    this.options = {
+        keyboardMultiplier: 1.0,
+        mouseMultiplier: 1.0,
+        mouseSmooth: true,
+        captureMode: MovementEventMapper.CAPTURE_ACTION_EVENTS,
+        excludeNames: null,
+        controllerMapping: MAPPING_TEMPLATE,
+    };
+    this.updateOptions(options);
+    this.virtualActionEvent = _utils.signal(function virtualActionEvent(from, event) {});
+}
+MovementMapping.prototype = {
+    enable: function() {
+        this.enabled = true;
+        if (this.mapping) {
+            this.mapping.disable();
+        }
+        this.mapping = this._createMapping();
+        log('ENABLE CONTROLLER MAPPING', this.mapping.name);
+        this.mapping.enable();
+    },
+    disable: function() {
+        this.enabled = false;
+        if (this.mapping) {
+            log('DISABLE CONTROLLER MAPPING', this.mapping.name);
+            this.mapping.disable();
+        }
+    },
+    reset: function() {
+        var enabled = this.enabled;
+        enabled && this.disable();
+        this.mapping = this._createMapping();
+        enabled && this.enable();
+    },
+    updateOptions: function(options) {
+        var changed = 0;
+        for (var p in this.options) {
+            if (p in options) {
+                log('MovementMapping updating options.'+p, this.options[p] + ' -> ' + options[p]);
+                this.options[p] = options[p];
+                changed++;
+            }
+        }
+        for (var p in options) {
+            if (!(p in this.options)) {
+                var value = options[p];
+                log('MovementMapping warning: ignoring option:', p, (value +'').substr(0, 40)+'...');
+            }
+        }
+        return changed;
+    },
+    applyOptions: function(options) {
+        var changed = this.updateOptions(options || {});
+        if (changed && applyNow) {
+            this.reset();
+        }
+    },
+    onShiftKey: function onShiftKey(value, key) {
+        var event = {
+            type: value ? 'keypress' : 'keyrelease',
+            keyboardKey: key,
+            keyboardText: 'SHIFT',
+            keyboardValue: value,
+            actionName: 'Shift',
+            actionValue: !!value,
+            value: !!value,
+            at: +new Date
+        };
+        this.virtualActionEvent('Keyboard.Shift', event);
+    },
+    onRightMouseButton: function onRightMouseButton(value, key) {
+        var event = {
+            type: value ? 'mousepress' : 'mouserelease',
+            keyboardKey: key,
+            keyboardValue: value,
+            actionName: 'RightMouseButton',
+            actionValue: !!value,
+            value: !!value,
+            at: +new Date
+        };
+        this.virtualActionEvent('Keyboard.RightMouseButton', event);
+    },
+    onApplicationEvent: function _onApplicationEvent(key, name, value) {
+        var event = {
+            type: 'application',
+            actionName: 'Application.' + name,
+            applicationKey: key,
+            applicationName: name,
+            applicationValue: value,
+            actionValue: !!value,
+            value: !!value
+        };
+        this.virtualActionEvent('Application.' + name, event);
+    },
+    _createMapping: function() {
+        this._mapping = this._getTemplate();
+        var mappingJSON = JSON.stringify(this._mapping, 0, 2);
+        var mapping = Controller.parseMapping(mappingJSON);
+        log(mappingJSON);
+        mapping.name = mapping.name || this._mapping.name;
+
+        mapping.from(Controller.Hardware.Keyboard.Shift).peek().to(_utils.bind(this, 'onShiftKey'));
+        mapping.from(Controller.Hardware.Keyboard.RightMouseButton).peek().to(_utils.bind(this, 'onRightMouseButton'));
+
+        var boundApplicationHandler = _utils.bind(this, 'onApplicationEvent');
+        Object.keys(Controller.Hardware.Application).forEach(function(name) {
+            var key = Controller.Hardware.Application[name];
+            debugPrint('observing Controller.Hardware.Application.'+ name, key);
+            mapping.from(key).to(function(value) {
+                boundApplicationHandler(key, name, value);
+            });
+        });
+
+        return mapping;
+    },
+    _getTemplate: function() {
+        assert(this.options.controllerMapping, 'MovementMapping._getTemplate -- !this.options.controllerMapping');
+        var template = JSON.parse(JSON.stringify(this.options.controllerMapping)); // make a local copy
+        template.name = this.namespace;
+        template.channels = template.channels.filter(function(item) {
+            // ignore any "JSON comment" or other bindings without a from spec
+            return item.from && item.from.makeAxis;
+        });
+        var exclude = Array.isArray(this.options.excludeNames) ? this.options.excludeNames : [];
+        if (!this.options.mouseSmooth) {
+            exclude.push('Keyboard.RightMouseButton');
+        }
+
+        log('EXCLUSIONS:' + exclude);
+
+        template.channels = template.channels.filter(_utils.bind(this, function(item, i) {
+            debugPrint('channel['+i+']', item.from && item.from.makeAxis, item.to, JSON.stringify(item.filters) || '');
+            //var hasFilters = Array.isArray(item.filters) && !item.filters[1];
+            item.filters = Array.isArray(item.filters) ? item.filters :
+                typeof item.filters === 'string' ? [ { type: item.filters }] : [ item.filters ];
+
+            if (/Mouse/.test(item.from && item.from.makeAxis)) {
+                item.filters.push({ type: 'scale', scale: this.options.mouseMultiplier });
+                log('applied mouse multiplier:', item.from.makeAxis, item.when, item.to, this.options.mouseMultiplier);
+            } else if (/Keyboard/.test(item.from && item.from.makeAxis)) {
+                item.filters.push({ type: 'scale', scale: this.options.keyboardMultiplier });
+                log('applied keyboard multiplier:', item.from.makeAxis, item.when, item.to, this.options.keyboardMultiplier);
+            }
+            item.filters = item.filters.filter(Boolean);
+            if (~exclude.indexOf(item.to)) {
+                log('EXCLUDING item.to === ' + item.to);
+                return false;
+            }
+            var when = Array.isArray(item.when) ? item.when : [item.when];
+            when = when.filter(shouldInclude);
+            function shouldIncludeWhen(p, i) {
+                if (~exclude.indexOf(when)) {
+                    log('EXCLUDING item.when === ' + when);
+                    return false;
+                }
+                return true;
+            }
+            item.when = when.length > 1 ? when : when[0];
+
+            if (item.from && Array.isArray(item.from.makeAxis)) {
+                var makeAxis = item.from.makeAxis;
+                function shouldInclude(p, i) {
+                    if (~exclude.indexOf(p)) {
+                        log('EXCLUDING from.makeAxis[][' + i + '] === ' + p);
+                        return false;
+                    }
+                    return true;
+                }
+                item.from.makeAxis = makeAxis.map(function(axis) {
+                    if (Array.isArray(axis))
+                        return axis.filter(shouldInclude);
+                    else
+                        return shouldInclude(axis, -1) && axis;
+                }).filter(Boolean);
+            }
+            return true;
+        }));
+        debugPrint(JSON.stringify(template,0,2));
+        return template;
+    }
+}; // MovementMapping.prototype
+
+// ----------------------------------------------------------------------------
+function calculateThrust(maxVelocity, targetVelocity, previousThrust) {
+    var THRUST_FALLOFF = 0.1; // round to ZERO if component is below this threshold
+    // Note: MyAvatar.setThrust might need an update to account for the recent avatar density changes...
+    // For now, this discovered scaling factor seems to accomodate a similar easing effect to the other movement models.
+    var magicScalingFactor = 12.0 * (maxVelocity + 120) / 16 - Math.sqrt( maxVelocity / 8 );
+
+    var targetThrust = Vec3.multiply(magicScalingFactor, targetVelocity);
+    targetThrust = vec3eclamp(targetThrust, THRUST_FALLOFF, maxVelocity);
+    if (Vec3.length(MyAvatar.velocity) > maxVelocity) {
+        targetThrust = Vec3.multiply(0.5, targetThrust);
+    }
+    return targetThrust;
+}
+
+// ----------------------------------------------------------------------------
+// clamp components and magnitude to maxVelocity, rounding to Vec3.ZERO if below epsilon
+function vec3eclamp(velocity, epsilon, maxVelocity) {
+    velocity = {
+        x: Math.max(-maxVelocity, Math.min(maxVelocity, velocity.x)),
+        y: Math.max(-maxVelocity, Math.min(maxVelocity, velocity.y)),
+        z: Math.max(-maxVelocity, Math.min(maxVelocity, velocity.z))
+    };
+
+    if (Math.abs(velocity.x) < epsilon) {
+        velocity.x = 0;
+    }
+    if (Math.abs(velocity.y) < epsilon) {
+        velocity.y = 0;
+    }
+    if (Math.abs(velocity.z) < epsilon) {
+        velocity.z = 0;
+    }
+
+    var length = Vec3.length(velocity);
+    if (length > maxVelocity) {
+        velocity = Vec3.multiply(maxVelocity, Vec3.normalize(velocity));
+    } else if (length < epsilon) {
+        velocity = Vec3.ZERO;
+    }
+    return velocity;
+}
+
+function vec3damp(targetVelocity, velocity, drag) {
+    // If force isn't being applied in a direction, incorporate drag;
+    var dragEffect = {
+        x: targetVelocity.x ? 0 : drag.x,
+        y: targetVelocity.y ? 0 : drag.y,
+        z: targetVelocity.z ? 0 : drag.z,
+    };
+    return Vec3.subtract(Vec3.sum(velocity, targetVelocity), dragEffect);
+}
+
+
+// ----------------------------------------------------------------------------
+function VelocityTracker(defaultValues) {
+    Object.defineProperty(this, 'defaultValues', { configurable: true, value: defaultValues });
+}
+VelocityTracker.prototype = {
+    reset: function() {
+        Object.assign(this, this.defaultValues);
+    },
+    integrate: function(targetState, currentVelocities, drag, settings) {
+        var args = [].slice.call(arguments);
+        this._applyIntegration('translation', args);
+        this._applyIntegration('rotation', args);
+        this._applyIntegration('zoom', args);
+    },
+    _applyIntegration: function(component, args) {
+        return this._integrate.apply(this, [component].concat(args));
+    },
+    _integrate: function(component, targetState, currentVelocities, drag, settings) {
+        var result = vec3damp(
+            targetState[component],
+            currentVelocities[component],
+            drag[component]
+        );
+        var maxVelocity = settings[component].maxVelocity,
+            epsilon = settings[component].epsilon;
+        return this[component] = vec3eclamp(result, epsilon, maxVelocity);
+    },
+};
+
+// ----------------------------------------------------------------------------
+// ----------------------------------------------------------------------------
+Object.assign(CameraControls, {
+    SCRIPT_UPDATE: 'update',
+    ANIMATION_FRAME: 'requestAnimationFrame', // emulated
+    NEXT_TICK: 'nextTick', // emulated
+    SET_IMMEDIATE: 'setImmediate', // emulated
+});
+
+function CameraControls(options) {
+    options = options || {};
+    assert('update' in options && 'threadMode' in options);
+    this.update = options.update;
+    this.threadMode = options.threadMode;
+    this.fps = options.fps || 60;
+    this.getRuntimeSeconds = options.getRuntimeSeconds || function() { return +new Date / 1000.0; };
+    this.backupOptions = _utils.DeferredUpdater.createGroup({
+        MyAvatar: MyAvatar,
+        Camera: Camera,
+        Reticle: Reticle,
+    });
+
+    this.enabled = false;
+    this.enabledChanged = _utils.signal(function enabledChanged(enabled){});
+    this.modeChanged = _utils.signal(function modeChanged(mode, oldMode){});
+}
+CameraControls.prototype = {
+    $animate: null,
+    $start: function() {
+        if (this.$animate) {
+            return;
+        }
+
+        switch(this.threadMode) {
+
+            case CameraControls.SCRIPT_UPDATE: {
+                this.$animate = this.update;
+                Script.update.connect(this, '$animate');
+                this.$animate.disconnect = _utils.bind(this, function() { Script.update.disconnect(this, '$animate'); });
+            } break;
+
+            case CameraControls.ANIMATION_FRAME: {
+                this.requestAnimationFrame = _utils.createAnimationStepper({
+                    getRuntimeSeconds: this.getRuntimeSeconds,
+                    fps: this.fps
+                });
+                this.$animate = _utils.bind(this, function(dt) {
+                    this.update(dt);
+                    this.requestAnimationFrame(this.$animate);
+                });
+                this.$animate.disconnect = _utils.bind(this.requestAnimationFrame, 'reset');
+                this.requestAnimationFrame(this.$animate);
+            } break;
+
+            case CameraControls.SET_IMMEDIATE: {
+                // emulate process.setImmediate (attempt to execute at start of next update frame, sans Script.update throttling)
+                var lastTime = this.getRuntimeSeconds();
+                this.$animate = Script.setInterval(_utils.bind(this, function() {
+                    this.update(this.getRuntimeSeconds(lastTime));
+                    lastTime = this.getRuntimeSeconds();
+                }), 5);
+                this.$animate.disconnect = function() {
+                    Script.clearInterval(this);
+                };
+            } break;
+
+            case CameraControls.NEXT_TICK: {
+                // emulate process.nextTick (attempt to queue at the very next opportunity beyond current scope)
+                var lastTime = this.getRuntimeSeconds();
+                this.$animate = _utils.bind(this, function() {
+                    this.$animate.timeout = 0;
+                    if (this.$animate.quit) {
+                        return;
+                    }
+                    this.update(this.getRuntimeSeconds(lastTime));
+                    lastTime = this.getRuntimeSeconds();
+                    this.$animate.timeout = Script.setTimeout(this.$animate, 1);
+                });
+                this.$animate.quit = false;
+                this.$animate.disconnect = function() {
+                    this.timeout && Script.clearTimeout(this.timeout);
+                    this.timeout = 0;
+                    this.quit = true;
+                };
+                this.$animate();
+            } break;
+            default: throw new Error('unknown threadMode: ' + this.threadMode);
+        }
+        log(
+            '...$started update thread', '(threadMode: ' + this.threadMode + ')',
+            this.threadMode === CameraControls.ANIMATION_FRAME && this.fps
+        );
+    },
+    $stop: function() {
+        if (!this.$animate) {
+            return;
+        }
+        try {
+            this.$animate.disconnect();
+        } catch(e) {
+            log('$animate.disconnect error: ' + e, '(threadMode: ' + this.threadMode +')');
+        }
+        this.$animate = null;
+        log('...$stopped updated thread', '(threadMode: ' + this.threadMode +')');
+    },
+    onModeUpdated: function onModeUpdated(mode, oldMode) {
+        oldMode = oldMode || this.previousMode;
+        this.previousMode = mode;
+        log('onModeUpdated', oldMode + '->' + mode);
+        // user changed modes, so leave the current mode intact later when restoring backup values
+        delete this.backupOptions.Camera.$setModeString;
+        if (/person/.test(oldMode) && /person/.test(mode)) {
+            return; // disregard first -> third and third ->first transitions
+        }
+        this.modeChanged(mode, oldMode);
+    },
+
+    reset: function() {
+        if (this.enabled) {
+            this.disable();
+            this.enable();
+        }
+    },
+    setEnabled: function setEnabled(enabled) {
+        if (!this.enabled && enabled) {
+            this.enable();
+        } else if (this.enabled && !enabled) {
+            this.disable();
+        }
+    },
+    enable: function enable() {
+        if (this.enabled) {
+            throw new Error('CameraControls.enable -- already enabled..');
+        }
+        log('ENABLE enableCameraMove', this.threadMode);
+
+        this._backup();
+
+        this.previousMode = Camera.mode;
+        Camera.modeUpdated.connect(this, 'onModeUpdated');
+
+        this.$start();
+
+        if (this.enabled !== true) {
+            this.enabled = true;
+            this.enabledChanged(this.enabled);
+        }
+    },
+    disable: function disable() {
+        log("DISABLE CameraControls");
+        try {
+            Camera.modeUpdated.disconnect(this, 'onModeUpdated');
+        } catch (e) {
+            debugPrint(e);
+        }
+        this.$stop();
+
+        this._restore();
+
+        if (this.enabled !== false) {
+            this.enabled = false;
+            this.enabledChanged(this.enabled);
+        }
+    },
+    _restore: function() {
+        var submitted = this.backupOptions.submit();
+        log('restored previous values: ' + JSON.stringify(submitted,0,2));
+        return submitted;
+    },
+    _backup: function() {
+        this.backupOptions.reset();
+        Object.assign(this.backupOptions.Reticle, {
+            scale: Reticle.scale,
+        });
+        Object.assign(this.backupOptions.Camera, {
+            $setModeString: Camera.mode,
+        });
+        Object.assign(this.backupOptions.MyAvatar, {
+            motorTimescale: MyAvatar.motorTimescale,
+            motorReferenceFrame: MyAvatar.motorReferenceFrame,
+            motorVelocity: Vec3.ZERO,
+        });
+    },
+}; // CameraControls
+
+// ----------------------------------------------------------------------------
+function applyEasing(deltaTime, direction, settings, state, scaling) {
+    var obj = {};
+    for (var p in scaling) {
+        var group = settings[p],
+            easeConst = group[direction],
+            multiplier = group.speed,
+            scale = scaling[p],
+            stateVector = state[p];
+        var vec = obj[p] = Vec3.multiply(easeConst * scale * deltaTime, stateVector);
+        // vec.x *= multiplier.x;
+        // vec.y *= multiplier.y;
+        // vec.z *= multiplier.z;
+    }
+    return obj;
+}
+
+// ----------------------------------------------------------------------------
+// currently unused
+/*
+function normalizeDegrees(x) {
+    while (x > 360) {
+        x -= 360;
+    }
+    while (x < 0) {
+        x += 360;
+    }
+    return x;
+}
+function getEffectiveVelocities(deltaTime, collisionsEnabled, previousValues) {
+    // use native values if available, otherwise compute a derivative over deltaTime
+    var effectiveVelocity = collisionsEnabled ? MyAvatar.velocity : Vec3.multiply(Vec3.subtract(
+        MyAvatar.position, previousValues.position
+    ), 1/deltaTime);
+    var effectiveAngularVelocity = // collisionsEnabled ? MyAvatar.angularVelocity :
+        Vec3.multiply(
+            Quat.safeEulerAngles(Quat.multiply(
+                MyAvatar.orientation,
+                Quat.inverse(previousValues.orientation)
+            )), 1/deltaTime);
+
+    effectiveVelocity = Vec3.multiplyQbyV(Quat.inverse(previousValues.orientation), effectiveVelocity);
+
+    effectiveAngularVelocity = {
+        x: normalizeDegrees(effectiveAngularVelocity.x),
+        y: normalizeDegrees(effectiveAngularVelocity.y),
+        z: normalizeDegrees(effectiveAngularVelocity.z),
+    };
+
+    return {
+        velocity: effectiveVelocity,
+        angularVelocity: effectiveAngularVelocity,
+    };
+}
+function _isAvatarNearlyUpright(maxDegrees) {
+    maxDegrees = maxDegrees || 5;
+    return Math.abs(MyAvatar.headRoll) < maxDegrees && Math.abs(MyAvatar.bodyPitch) < maxDegrees;
+}
+
+
+*/
diff --git a/unpublishedScripts/marketplace/camera-move/modules/movement-utils.mapping.json b/unpublishedScripts/marketplace/camera-move/modules/movement-utils.mapping.json
new file mode 100644
index 0000000000..2489cb634d
--- /dev/null
+++ b/unpublishedScripts/marketplace/camera-move/modules/movement-utils.mapping.json
@@ -0,0 +1,98 @@
+{
+    "name": "app-camera-move",
+    "channels": [
+
+        { "comment": "------------------ Actions.TranslateX -------------------" },
+        {
+            "from": {"makeAxis": [["Keyboard.Left"],["Keyboard.Right"]]},
+            "when": "Keyboard.Shift",
+            "to": "Actions.TranslateX"
+        },
+        {
+            "from": {"makeAxis": [["Keyboard.A","Keyboard.TouchpadLeft"],["Keyboard.D","Keyboard.TouchpadRight"]]},
+            "when": "Keyboard.Shift",
+            "to": "Actions.TranslateX"
+        },
+
+        { "comment": "------------------ Actions.TranslateY -------------------" },
+        {
+            "from": {"makeAxis": [["Keyboard.C","Keyboard.PgDown"],["Keyboard.E","Keyboard.PgUp"]]},
+            "when": "!Keyboard.Shift",
+            "to": "Actions.TranslateY"
+        },
+
+        { "comment": "------------------ Actions.TranslateZ -------------------" },
+        {
+            "from": {"makeAxis": [["Keyboard.W"],["Keyboard.S"]]},
+            "when": "!Keyboard.Shift",
+            "to": "Actions.TranslateZ"
+        },
+        {
+            "from": {"makeAxis": [["Keyboard.Up"],["Keyboard.Down"]]},
+            "when": "!Keyboard.Shift",
+            "to": "Actions.TranslateZ"
+        },
+
+        { "comment": "------------------ Actions.Yaw -------------------" },
+        {
+            "from": {"makeAxis": [["Keyboard.A","Keyboard.TouchpadLeft"],["Keyboard.D","Keyboard.TouchpadRight"]]},
+            "when": "!Keyboard.Shift",
+            "to": "Actions.Yaw",
+            "filters": ["invert"]
+        },
+        {
+            "from": {"makeAxis": [["Keyboard.MouseMoveLeft"],["Keyboard.MouseMoveRight"]]},
+            "when": "Keyboard.RightMouseButton",
+            "to": "Actions.Yaw",
+            "filters": ["invert"]
+        },
+        {
+            "from": {"makeAxis": [["Keyboard.Left"],["Keyboard.Right"]]},
+            "when": "!Keyboard.Shift",
+            "to": "Actions.Yaw",
+            "filters": ["invert"]
+        },
+        {
+            "from": {"makeAxis": [["Keyboard.Left"],["Keyboard.Right"]]},
+            "when": ["Application.SnapTurn", "!Keyboard.Shift"],
+            "to": "Actions.StepYaw",
+            "filters":
+            [
+                { "type": "invert" },
+                { "type": "pulse", "interval": 0.5, "resetOnZero": true },
+                { "type": "scale", "scale": 22.5 }
+            ]
+        },
+
+        { "comment": "------------------ Actions.Pitch -------------------" },
+        {
+            "from": {"makeAxis": [["Keyboard.W"],["Keyboard.S"]]},
+            "when": "Keyboard.Shift",
+            "to": "Actions.Pitch",
+            "filters": ["invert"]
+        },
+        {
+            "from": {"makeAxis": [["Keyboard.MouseMoveUp"],["Keyboard.MouseMoveDown"]]},
+            "when": "Keyboard.RightMouseButton",
+            "to": "Actions.Pitch",
+            "filters": ["invert"]
+        },
+        {
+            "from": {"makeAxis": [["Keyboard.Up"],["Keyboard.Down"]]},
+            "when": "Keyboard.Shift",
+            "to": "Actions.Pitch",
+            "filters": ["invert"]
+        },
+
+        { "comment": "------------------ Actions.BoomIn -------------------" },
+        {
+            "from": {"makeAxis": [["Keyboard.C","Keyboard.PgDown"],["Keyboard.E","Keyboard.PgUp"]]},
+            "when": "Keyboard.Shift",
+            "to": "Actions.BoomIn",
+            "filters": [{"type": "scale","scale": 0.005}]
+        },
+
+        { "comment": "------------------ end -------------------" }
+
+    ]
+}