constructor(element) {
		this.treeBuilt = Mavo.promise();
		this.dataLoaded = Mavo.promise();
		this.deleted = [];

		this.element = element;

		this.inProgress = false;

		// Index among other mavos in the page, 1 is first
		this.index = Object.keys(_.all).length + 1;
		Object.defineProperty(_.all, this.index - 1, {value: this, configurable: true});

		// Assign a unique (for the page) id to this mavo instance
		this.id = Mavo.getAttribute(this.element, "mv-app", "id") || `mavo${this.index}`;

		if (this.id in _.all) {
			// Duplicate app name
			for (var i=2; this.id + i in _.all; i++) {}
			this.id = this.id + i;
		}

		_.all[this.id] = this;
		this.element.setAttribute("mv-app", this.id);

		this.observe({attribute: "lang", deep: false}, () => {
			var lang = Mavo.getClosestAttribute(this.element, "lang") || Mavo.locale;
			this.locale = Mavo.Locale.get(lang);
		})();

		// Should we start in edit mode?
		this.autoEdit = this.element.classList.contains("mv-autoedit");

		// Should we save automatically?
		this.autoSave = this.element.hasAttribute("mv-autosave");
		this.autoSaveDelay = (this.element.getAttribute("mv-autosave") || 0) * 1000;

		Mavo.setAttributeShy(this.element, "typeof", "");

		Mavo.hooks.run("init-start", this);

		// ----- Heuristic for groups ------

		// Now, turn properties that contain other properties into groups
		$$(_.selectors.primitive, this.element).forEach(element => {
			if ($(_.selectors.property, element)) { // contains other properties
				let config = Mavo.Primitive.getConfig(element);

				if (!config.attribute && !config.hasChildren || element.hasAttribute("mv-list-item")) {
					element.setAttribute("mv-group", "");
				}
			}
		});

		this.expressions = new Mavo.Expressions(this);

		_.observers = _.observers || new Mavo.Observers();
		_.observers.observer.observe(this.element, {
			// Observe everything
			characterData: true,
			childList: true,
			subtree: true,
			attributes: true
		});

		// Build mavo objects
		Mavo.hooks.run("init-tree-before", this);

		this.root = new Mavo.Group(this.element, this);
		this.treeBuilt.resolve();

		Mavo.hooks.run("init-tree-after", this);

		this.permissions = new Mavo.Permissions();

		var backendTypes = ["source", "storage", "init", "uploads"]; // order is significant!

		// Figure out backends for storage, data reads, and initialization respectively
		backendTypes.forEach(role => this.updateBackend(role));

		this.observe({deep: false, attribute: true}, ({attribute}) => {
			if (attribute.indexOf("mv-") === 0) {
				// We want to observe changes both in a backend (the mv-role attribute)
				// and its metadata (provided via the mv-role-* family of attributes)
				let role = attribute?.replace(/^mv-/, "")?.split("-")?.[0];

				if (backendTypes.includes(role)) {
					this.updateBackend(role);

					// Do we need to re-load data?
					if (role === "source" || (!this.source && (role === "storage" || role === "init" && !this.root.data))) {
						this.load();
					}
				}
			}

		});

		this.permissions.can("login", () => {
			// We also support a URL param to trigger login, in case the user doesn't want visible login UI
			let loginApp = Mavo.Functions.url("login");

			if (loginApp === "" && this.index === 1 || loginApp === this.id) {
				// Remove param from url
				const currentURL = new URL(location.href);
				currentURL.searchParams.delete("login");
				history.replaceState(null, "", currentURL);

				this.primaryBackend.login();
			}
		});

		// Update login status
		$.bind(this.element, "mv-login.mavo", evt => {
			if (evt.backend == (this.source || this.storage)) {
				// If last time we rendered we got nothing, maybe now we'll have better luck?
				if (!this.root.data && !this.unsavedChanges) {
					this.load();
				}
			}
		});

		this.bar = new Mavo.UI.Bar(this);

		// Is there any control that requires an edit button?
		this.needsEdit = this.calculateNeedsEdit();

		this.setUnsavedChanges(false);

		this.permissions.onchange(({action, value}) => {
			var permissions = this.element.getAttribute("mv-permissions") || "";
			permissions = permissions.trim().split(/\s+/).filter(a => a != action);

			if (value) {
				permissions.push(action);
			}

			this.element.setAttribute("mv-permissions", permissions.join(" "));
		});

		this.permissions.can(["edit", "add", "delete"], () => {
			if (this.autoEdit) {
				this.edit();
			}
		});

		// Observe entire tree for mv-mode changes
		this.observe({attribute: "mv-mode"}, ({element}) => {
			if (!this.permissions.edit && !this.permissions.add && !this.permissions.delete) {
				return;
			}

			let nodes = _.Node.children(element);

			nodeloop: for (let i=0; i<nodes.length; i++) {
				let node = nodes[i];
				let previousMode = node.mode, mode;

				if (node.element == element) {
					// If attribute set directly on a Mavo node, then it forces it into that mode
					// otherwise, descendant nodes still inherit, unless they are also mode-restricted
					mode = node.element.getAttribute("mv-mode");
					node.modes = mode;
				}
				else {
					// Inherited
					if (node.modes) {
						// Mode-restricted, we cannot change to the other mode
						continue nodeloop;
					}

					mode = _.getStyle(node.element.parentNode, "--mv-mode");
				}

				node.mode = mode;

				if (previousMode != node.mode) {
					node[node.mode == "edit"? "edit" : "done"]();
				}
			}
		});

		if (this.primaryBackend) {
			// Fetch existing data
			this.permissions.can("read", () => this.load());
		}
		else {
			// No storage or source
			requestAnimationFrame(() => {
				this.dataLoaded.resolve();
				this.expressions.update();
				this.element.dispatchEvent(new Event("mv-load", {bubbles: true}));
			});
		}

		// Dynamic ids
		$.bind(this.element, "mv-load.mavo", evt => {
			if (location.hash) {
				var callback = () => {
					var target = document.getElementById(location.hash.slice(1));

					if (target || !location.hash) {
						if (this.element.contains(target)) {
							requestAnimationFrame(() => { // Give the browser a chance to render
								Mavo.scrollIntoViewIfNeeded(target);
							});
						}
					}

					return target;
				};

				if (!callback()) {
					// No target, perhaps not yet?
					this.observe({attribute: "id", once: true}, callback);
					// FIXME if expressions take multiple cycles to resolve, this will not scroll to the proper id
					// FIXME also, if the user has started interacting with the document, we shouldn't scroll
				}
			}

			requestAnimationFrame(() => Stretchy.resizeAll());
		});

		this.dataLoaded.then(async evt => {
			await Mavo.defer();

			this.permissions.can("save", () => {
				if (this.autoSave) {
					let debouncedSave = _.debounce(() => {
						this.save();
					}, this.autoSaveDelay);

					$.bind(this.element, "mv-change.mavo:autosave", evt => {
						if (evt.node.saved && this.autoSave) {
							debouncedSave();
						}
					});
				}
			}, () => {
				$.unbind(this.element, "mv-change.mavo:autosave");
			});
		});

		// Keyboard navigation
		this.element.addEventListener("keydown", evt => {
			var element = evt.target;

			// Ctrl + S or Cmd + S to save
			if (this.permissions.save && evt.key == "S" && evt[_.superKey] && !evt.altKey) {
				evt.preventDefault();
				this.save();
			}
			else if (evt.key === "ArrowUp" || evt.key === "ArrowDown") {
				if (element.matches("textarea, input[type=range], input[type=number]")) {
					// Up/down arrow keys are meaningful here
					return;
				}

				if (element.matches(".mv-editor")) {
					var editor = true;
					element = element.parentNode;
				}

				var node = Mavo.Node.get(element);

				if (node?.closestCollection) {
					var nextNode = node.getCousin(evt.key === "ArrowUp"? -1 : 1, {wrap: true});

					if (nextNode) {
						if (editor && nextNode.editing) {
							nextNode.edit();
							nextNode.editor.focus();
						}
						else {
							nextNode.element.focus();
						}

						evt.preventDefault();
					}
				}
			}
		});

		$.bind(this.element, "click submit", _.Actions.listener);

		Mavo.hooks.run("init-end", this);
	}
Mavo: Create web applications by writing HTML and CSS!
@author Lea Verou and contributors
@version %%VERSION%%