Add fidlviz project

fidlviz is a tool that visualizes the FIDL wire format. For now it is a
standalone client-side web app, but it will likely be integrated into
fidlbolt in the future.

Change-Id: I4d3a6abcbb86ad01c7f2523682b289afb5eebdcb
Reviewed-on: https://fuchsia-review.googlesource.com/c/fidl-misc/+/410797
Reviewed-by: Pascal Perez <pascallouis@google.com>
diff --git a/fidlviz/README.md b/fidlviz/README.md
new file mode 100644
index 0000000..c0582aa
--- /dev/null
+++ b/fidlviz/README.md
@@ -0,0 +1,11 @@
+# fidlviz
+
+fidlviz is a tool for visualizing the FIDL wire format.
+
+It is entirely client-side, implemented in `index.html`, `style.css`, and `script.js`.
+
+Click the Help button in the app to learn how it works.
+
+## Known issues
+
+fidlviz has only been tested in Chrome. It requires `BigInt` support, so it does not work in Safari and other browsers that lack support.
diff --git a/fidlviz/favicon.ico b/fidlviz/favicon.ico
new file mode 100644
index 0000000..00b6c9f
--- /dev/null
+++ b/fidlviz/favicon.ico
Binary files differ
diff --git a/fidlviz/index.html b/fidlviz/index.html
new file mode 100644
index 0000000..7d3784c
--- /dev/null
+++ b/fidlviz/index.html
@@ -0,0 +1,71 @@
+<!DOCTYPE html>
+<html>
+    <head>
+        <meta charset="utf-8">
+        <title>fidlviz</title>
+        <link rel="shortcut icon" href="favicon.ico">
+        <link rel="stylesheet" href="style.css">
+    </head>
+    <body>
+        <div class="controls-container">
+            <button id="HelpButton" type="button" class="controls-button">Help</button>
+            <button id="DebugButton" type="button" class="controls-button">Debug</button>
+            <button id="ResetButton" type="button" class="controls-button">Reset</button>
+            <button id="LayoutButton" type="button" class="controls-button">Layout</button>
+        </div>
+        <div id="HelpModal" class="modal-container" hidden>
+            <div id="HelpModalContent" class="modal-box">
+                <h2>Help</h2>
+                <p>Welcome to <b>fidlviz</b>, a web app for visualizing the FIDL wire format.</p>
+                <p>Here are some tips to help you get started:</p>
+                <ul>
+                    <li>The <b>input</b> editor is on the left. Enter your FIDL data structures there. If you forget the syntax, click the <b>Reset</b> button to get back the example.</li>
+                    <li>The <b>output</b> view is on the right. Initially, this shows bytes. By dragging the slider in the top-right, or using <kbd>Ctrl</kbd><kbd>[</kbd> and <kbd>Ctrl</kbd><kbd>]</kbd>, you can instead view intermediate stages where the input gets <b>lowered</b> to progressively simpler elements.</li>
+                    <li>To <b>highlight</b> something, just click on it. Highlighting input will cause the corresponding output to be highlighted too, and vice versa. Click inside curly braces to highlight all contained fields in different colors.</li>
+                    <li>Hover over underlined regions of the output to reveal explanatory <b>tooltips</b>. When viewing bytes, there are no underlines because everything has a tooltip.</li>
+                    <li>Click the <b>Layout</b> button to switch between the left/right editor layout and the top/bottom one (useful for long lines and narrow windows).</li>
+                    <li>The app <b>autosaves</b> everything to your browser’s local storage. You can always start fresh by clicking the <b>Reset</b> button.</li>
+                    <li>To <b>close</b> this help window, press <kbd>Esc</kbd> or <kbd>Enter</kbd>.</li>
+                </ul>
+                <p>Enjoy! If you find a bug, please report it to mkember@google.com.</p>
+                <hr>
+                <p><i>If you like <b>fidlviz</b>, you might also like <a href="https://fidlbolt-6jq5tlqt6q-uc.a.run.app/"><b>fidlbolt</b></a></i>.</p>
+            </div>
+        </div>
+        <div class="wrapper">
+            <div class="heading">
+                <h1><span class="heading-fidl">fidl</span><span class="heading-viz">viz</span></h1>
+            </div>
+            <div id="Container" class="container" hidden>
+                <div id="SplitContainer" class="split-container split-container--vertical">
+                    <div id="InputPart" class="split-item split-item--first">
+                        <div id="InputEditor" class="editor"></div>
+                    </div>
+                    <div id="Splitter" class="split-splitter split-splitter--vertical"></div>
+                    <div id="OutputPart" class="split-item split-item--second">
+                        <div class="part-header">
+                            <h2 id="OutputTitle" class="part-title">&nbsp;</h2>
+                            <input id="OutputSlider" class="slider"
+                            type="range" min="1" max="8"
+                            value="8" list="Tickmarks">
+                        </div>
+                        <div id="OutputEditor" class="editor"></div>
+                    </div>
+                </div>
+            </div>
+        </div>
+        <div id="OutputTooltip" class="tooltip" hidden></div>
+        <datalist id="Tickmarks">
+            <option value="1"></option>
+            <option value="2"></option>
+            <option value="3"></option>
+            <option value="4"></option>
+            <option value="5"></option>
+            <option value="6"></option>
+            <option value="7"></option>
+            <option value="8"></option>
+        </datalist>
+        <script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.4.8/ace.js"></script>
+        <script src="script.js"></script>
+    </body>
+</html>
diff --git a/fidlviz/script.js b/fidlviz/script.js
new file mode 100644
index 0000000..ccf93a1
--- /dev/null
+++ b/fidlviz/script.js
@@ -0,0 +1,2557 @@
+(function () {
+
+// Indent used for formatting output.
+const INDENT = "  ";
+
+// Table of ASCII character names used for tooltips on strings.
+const ASCII_NAMES = {
+  0:   "NUL (null)",
+  1:   "SOH (start of heading)",
+  2:   "STX (start of text)",
+  3:   "ETX (end of text)",
+  4:   "EOT (end of transmission)",
+  5:   "ENQ (enquiry)",
+  6:   "ACK (acknowledge)",
+  7:   "BEL (bell)",
+  8:   "BS (backspace)",
+  9:   "TAB (horizontal tab)",
+  10:  "LF (NL line feed, new line)",
+  11:  "VT (vertical tab)",
+  12:  "FF (NP form feed, new page)",
+  13:  "CR (carriage return)",
+  14:  "SO (shift out)",
+  15:  "SI (shift in)",
+  16:  "DLE (data link escape)",
+  17:  "DC1 (device control 1)",
+  18:  "DC2 (device control 2)",
+  19:  "DC3 (device control 3)",
+  20:  "DC4 (device control 4)",
+  21:  "NAK (negative acknowledge)",
+  22:  "SYN (synchronous idle)",
+  23:  "ETB (end of trans. block)",
+  24:  "CAN (cancel)",
+  25:  "EM (end of medium)",
+  26:  "SUB (substitute)",
+  27:  "ESC (escape)",
+  28:  "FS (file separator)",
+  29:  "GS (group separator)",
+  30:  "RS (record separator)",
+  31:  "US (unit separator)",
+  32:  "SPACE",
+  127: "DEL",
+};
+
+// =============================================================================
+// ===== TOKENIZING ============================================================
+// =============================================================================
+
+// Tokenizes user input.
+function tokenize(input) {
+  const tokens = [];
+  let currentToken = "";
+  let mode = "normal";
+  // True in "string" mode if the current character is escaped by a backslash.
+  let escape = false;
+  // Zero-based row and column of the current character. Col counts in bytes,
+  // so a multibyte character will advance col by > 1.
+  let row = 0, col = 0;
+  // {row, col} of the start of the current token.
+  let start = undefined;
+
+  // Pushes a character onto the current token.
+  function push(c) {
+    currentToken += c;
+  }
+
+  // Undoes the last push.
+  function unpush() {
+    currentToken = currentToken.substring(0, currentToken.length - 1);
+  }
+
+  // Emits the current token.
+  function emit() {
+    if (currentToken !== "") {
+      tokens.push({
+        text: currentToken,
+        // The row and col are 0-based. End row is inclusive, col exclusive.
+        start: {row: start.row, col: start.col},
+        end: {row, col},
+      });
+    }
+    start = undefined;
+    currentToken = "";
+  }
+
+  // Updates start to the current row and column, if not already set.
+  function setStart(c) {
+    if (c.trim() !== "" && start === undefined) {
+      start = {row, col};
+    }
+  }
+
+  // Increments row and col based on c.
+  function incPos(c) {
+    col += c.length;
+    if (c === "\n") {
+      row++;
+      col = 0;
+    }
+  }
+
+  for (const c of input) {
+    setStart(c);
+    if (mode === "normal") {
+      switch (c.trim()) {
+        case "#":
+          mode = "comment";
+          break;
+        case "\"":
+          emit();
+          setStart(c);
+          push(c);
+          mode = "string";
+          break;
+        case ":": case "{": case "}": case ",":
+          emit();
+          setStart(c);
+          push(c);
+          incPos(c);
+          emit();
+          continue;
+        case "":
+          emit();
+          break;
+        default:
+          push(c);
+          break;
+      }
+    } else if (mode === "string") {
+      if (c === "\n") {
+        if (escape) {
+          unpush();
+        } else {
+          push("\\n");
+        }
+      } else {
+        push(c);
+      }
+      switch (c) {
+        case "\\":
+          escape = !escape;
+          break;
+        case "\"":
+          if (!escape) {
+            incPos(c);
+            emit();
+            mode = "normal";
+            continue;
+          }
+          // fallthrough
+        default:
+          escape = false;
+          break;
+      }
+    } else if (mode === "comment") {
+      if (c === "\n") {
+        mode = "normal";
+      }
+    }
+
+    incPos(c);
+  }
+
+  emit();
+  if (mode === "string") {
+    throw {
+      token: tokens[tokens.length-1],
+      message: "unterminated string",
+    };
+  }
+  return tokens;
+}
+
+// =============================================================================
+// ===== PARSING ===============================================================
+// =============================================================================
+
+// Parses a list of tokens.
+function parse(tokens) {
+  let tokenIdx = 0;
+
+  // Store previous int and float tokens so that the next one can be inferred to
+  // have the same signedness and bit width.
+  let prevInt = null;
+  let prevFloat = null;
+
+  // Consumes a single token. If the argument is provided, checks that the token
+  // contains the given text and fails otherwise.
+  function consume(text) {
+    if (tokenIdx === tokens.length) {
+      throw {token: tokens[tokenIdx-1], message: "unexpected end of input"};
+    }
+    const t = tokens[tokenIdx++];
+    if (text !== undefined && t.text !== text) {
+      throw {token: t, message: `expected ${JSON.stringify(text)}`};
+    }
+    return t;
+  }
+
+  // Returns true if the first token contains the given text.
+  function peek(text) {
+    if (tokenIdx === tokens.length) {
+      throw {token: tokens[tokenIdx-1], message: "unexpected end of input"};
+    }
+    return tokens[tokenIdx].text === text;
+  }
+
+  // Returns true if v1 and v2 have compatible types.
+  function sameType(v1, v2) {
+    switch (v1.type) {
+      case "null":
+        return v1.type === v2.type || sameType(v2, v1);
+      case "bool":
+        return v1.type === v2.type;
+      case "string":
+      case "handle":
+        return v1.type === v2.type || (v2.type === "null" && v2.kind === v1.type);
+      case "int":
+      case "float":
+        return v1.type === v2.type && v1.width === v2.width;
+      case "struct":
+        return v1.type === v2.type
+          && v1.fields.every((f, i) => sameType(f, v2.fields[i]));
+      case "array":
+        return v1.type === v2.type && v1.fields.length === v2.fields.length
+          // Note: Only check first element even for array/vector since
+          // validate() checks that all elements are the same.
+          && sameType(v1.fields[0], v2.fields[0]);
+      case "bits":
+      case "enum":
+        return v1.type === v2.type && sameType(v1.fields[0], v2.fields[0]);
+      case "box":
+        return v1.type === v2.type || (v2.type === "null" && v2.kind === "struct");
+      case "vector":
+        return (v1.type === v2.type
+                && (v1.fields.length === 0 || v2.fields.length === 0
+                  || sameType(v1.fields[0], v2.fields[0])))
+          || (v2.type === "null" && v2.kind === v1.type);
+      case "table":
+        const map = {};
+        for (const entry of v1.fields) {
+          map[entry.ordinal] = entry.fields[0];
+        }
+        for (const entry of v2.fields) {
+          const field1 = map[entry.ordinal];
+          const field2 = entry.fields[0];
+          if (field1 !== undefined && !sameType(field1, field2)) {
+            return false;
+          }
+        }
+      case "union":
+        return v1.type === v2.type &&
+          (v1.fields.length === 0 || v2.fields.length === 0
+            || v1.fields[0].ordinal !== v2.fields[0].ordinal
+            || sameType(v1.fields[0], v2.fields[0]));
+      default:
+        throw Error(`unexpected type: ${v1.type}`);
+    }
+  }
+
+  // Validates a parsed FIDL value.
+  function validate(value) {
+    // Check number of fields.
+    switch (value.type) {
+      // Note: We COULD make bits{} mean 0, but we don't know the width. We
+      // could infer from the last int, but that means sometimes bits{} is
+      // allowed and sometimes not, which is confusing.
+      case "bits":
+      // Zero-length arrays are disallowed by fidlc.
+      case "array":
+        if (value.fields.length === 0) {
+          throw {
+            range: value.range,
+            message: `${value.type} must have at least 1 field; got `
+            + `${value.fields.length}`,
+          };
+        }
+        break;
+      case "box":
+      case "enum":
+      case "handle":
+      case "union":
+        if (value.fields.length !== 1) {
+          throw {
+            range: value.range,
+            message: `${value.type} must have 1 field; got `
+            + `${value.fields.length}`,
+          };
+        }
+        break;
+    }
+
+    // Check underlying types.
+    switch (value.type) {
+      case "enum":
+        if (value.fields[0].type !== "int") {
+          const field = value.fields[0];
+          throw {
+            range: field.range,
+            message: `${value.type} value must be an int, not ${field.type}`,
+          };
+        }
+        break;
+      case "bits":
+        if (value.fields[0].type !== "int" || value.fields[0].signed !== "u") {
+          const field = value.fields[0];
+          throw {
+            range: field.range,
+            message: `${value.type} value must be a uint, not ${field.type}`,
+          };
+        }
+        break;
+      case "handle":
+        if (value.fields[0].type !== "int" || value.fields[0].signed !== "u"
+            || value.fields[0].width !== 32) {
+          const field = value.fields[0];
+          throw {
+            range: field.range,
+            message: `${value.type} value must be an uint32, not ${field.type}`,
+          };
+        }
+        if (value.fields[0].value == 0) {
+          const field = value.fields[0];
+          throw {
+            range: field.range,
+            message: `${value.type} value must be nonzero; use "null handle" for null`,
+          };
+        }
+        break;
+      case "box":
+        if (value.fields[0].type !== "struct") {
+          throw {
+            range: value.fields[0].range,
+            message: `${value.fields[0].type} values cannot be boxed`,
+          };
+        }
+        break;
+    }
+
+    // Check type homogeneity.
+    switch (value.type) {
+      case "vector":
+      case "array":
+      case "bits":
+        if (value.fields.length > 0) {
+          for (let i = 1; i < value.fields.length; i++) {
+            if (value.fields[0].type !== value.fields[i].type) {
+              throw {
+                range: value.fields[i].range,
+                message: `${value.type} element 0 (${value.fields[0].type}) `
+                  + `and element ${i} (${value.fields[i].type}) have different `
+                  + `types`,
+              };
+            }
+            if (!sameType(value.fields[0], value.fields[i])) {
+              throw {
+                range: value.fields[i].range,
+                message: `${value.type} element 0 (${value.fields[0].type}) `
+                  + `and element ${i} (${value.fields[i].type}) have `
+                  + `structurally different types`,
+              };
+            }
+          }
+        }
+        break;
+    }
+
+    // Check children.
+    if (value.fields !== undefined) {
+      for (const field of value.fields) {
+        validate(field);
+      }
+    }
+  }
+
+  // Consumes and parses a FIDL value.
+  function parseValue() {
+    const t = consume();
+    const range = {first: t, last: t};
+    if (/^"/.test(t.text)) {
+      let value;
+      try {
+        value = JSON.parse(t.text);
+      } catch (ex) {
+        throw {token: t, message: "invalid string literal"};
+      }
+      return {type: "string", value, range};
+    }
+    if (/^-?(?:\d|\.)/.test(t.text)) {
+      return parseNumber(t);
+    }
+    switch (t.text) {
+      case "true":
+        return {type: "bool", value: true, range};
+      case "false":
+        return {type: "bool", value: false, range};
+      case "handle":
+      case "bits":
+      case "enum":
+      case "struct":
+      case "array":
+      case "vector":
+      case "box":
+        return parseAgg(t, false);
+      case "table":
+      case "union":
+        return parseAgg(t, true);
+      case "null": {
+        const t2 = consume();
+        switch (t2.text) {
+          case "handle": case "string": case "struct": case "union": case "vector":
+            return {type: "null", kind: t2.text, range: {first: t, last: t2}};
+          default:
+            throw {token: t2, message: "expected one of: handle, string, struct, union, vector"};
+        }
+      }
+    }
+    throw {token: t, message: "invalid syntax"};
+  }
+
+  // Parses an already-consumed number token t.
+  function parseNumber(t) {
+    const range = {first: t, last: t};
+    if (/\./.test(t.text) || (!/x/.test(t.text) && /[eEf]/.test(t.text))) {
+      const match = t.text.match(/(.*)f(32|64)$/);
+      let result;
+      if (match) {
+        result = {
+          type: "float",
+          width: parseInt(match[2]),
+          orig: match[1],
+          value: parseFloat(match[1]),
+          range,
+        };
+      } else if (prevFloat) {
+        result = {...prevFloat, orig: t.text, value: parseFloat(t.text), range};
+      } else {
+        throw {token: t, message: "float missing suffix (f32 or f64)"};
+      }
+      // Check both because parseFloat can ignore trailing garbage.
+      if (isNaN(result.orig) || isNaN(result.value)) {
+        throw {token: t, message: "invalid float"};
+      }
+      return prevFloat = result;
+    }
+    const match = t.text.match(/(.*)(u|i)(8|16|32|64)$/);
+    let result;
+    if (match) {
+      result = {
+        type: "int",
+        signed: match[2],
+        width: parseInt(match[3]),
+        orig: match[1],
+        range,
+      };
+    } else if (prevInt) {
+      result = {...prevInt, orig: t.text, range};
+    } else {
+      throw {token: t, message: "int missing suffix (u8, i8, u16, i16, etc.)"};
+    }
+    try {
+      result.value = BigInt(result.orig);
+    } catch (ex) {
+      throw {token: t, message: `invalid ${result.signed}int${result.width}`};
+    }
+    if (result.signed === "u") {
+      if (result.value < 0) {
+        throw {token: t, message: `uint${result.width} cannot be negative`}
+      }
+      if (result.value >= 2n ** BigInt(result.width)) {
+        throw {token: t, message: `uint${result.width} out of range`}
+      }
+    } else {
+      const limit = 2n ** BigInt(result.width - 1);
+      if (result.value < -limit || result.value >= limit) {
+        throw {token: t, message: `int${result.width} out of range`}
+      }
+    }
+    return prevInt = result;
+  }
+
+  // Parses an aggregate structure whose keyword token (struct, table, etc.) has
+  // already been consumed. If ordinals is true, parses ordinals before each
+  // field and stores "$entry" nodes in the fields.
+  function parseAgg(keyword, ordinals) {
+    consume("{");
+    const fields = [];
+    while (!peek("}")) {
+      if (ordinals) {
+        const t = consume();
+        const ordinal = parseInt(t.text, 10);
+        // Check isNaN on both because parseInt can ignore trailing garbage.
+        if (isNaN(t.text) || isNaN(ordinal)) {
+          throw {token: t, message: "expected an integer ordinal"};
+        }
+        if (ordinal <= 0) {
+          throw {token: t, message: "ordinals must be positive"};
+        }
+        consume(":");
+        const value = parseValue();
+        fields.push({
+          // $entry is a special parse tree node that stores the ordinal. We use
+          // dollar signs for special nodes that are not printed literally. In
+          // the case of $entry, format() prints it with the colon syntax.
+          type: "$entry",
+          ordinal,
+          fields: [value],
+          range: {first: t, last: value.range.last},
+        });
+      } else {
+        fields.push(parseValue());
+      }
+      if (!peek("}")) {
+        consume(",");
+      }
+    }
+    return {
+      type: keyword.text,
+      fields,
+      range: {first: keyword, last: consume("}")},
+    };
+  }
+
+  const value = parseValue();
+  if (tokenIdx !== tokens.length) {
+    throw {
+      range: {first: tokens[tokenIdx], last: tokens[tokens.length-1]},
+      message: "extraneous input",
+    };
+  }
+  validate(value);
+  return value;
+}
+
+// =============================================================================
+// ===== FORMATTING ============================================================
+// =============================================================================
+
+// Formats a parse tree back to the input syntax. Also mutates the nodes, adding
+// outputRange properties of the form {start: {row, col}, end: {row, col}}.
+function format(tree) {
+  let result = "";
+  // Zero-based row and column of the current position in result. Col counts in
+  // bytes, so a multibyte character will advance col by > 1.
+  let row = 0, col = 0;
+  // Keep track of secondary objects in order to number them in comments.
+  let secondaryObjectNumber = 1;
+
+  function push(text) {
+    result += text;
+    col += text.length;
+  }
+  function newline() {
+    result += "\n";
+    row++;
+    col = 0;
+  }
+  function commentHeader(msg, first /* = false */) {
+    if (!first) {
+      newline();
+      newline();
+    }
+    push(`# ${msg}`);
+    newline();
+  }
+
+  // Returns true if node should be displayed in a one liner between braces.
+  function isOneLiner(node) {
+    switch (node.type) {
+      case "bool":
+      case "$pointer":
+        return true;
+      case "string":
+        return node.value.length < 50;
+      case "int":
+      case "float":
+        return node.orig.length < 50;
+      case "$entry":
+        return isOneLiner(node.fields[0]);
+      case "struct":
+        return isPointerAndOut(node);
+      default:
+        return false;
+    }
+  }
+
+  // Returns true if node is a {$pointer, $out} struct lowered from a box.
+  function isPointerAndOut(node) {
+    return node.type === "struct" && node.fields.length === 2
+      && node.fields[0].type === "$pointer"
+      && node.fields[1].type === "$out";
+  }
+
+  // Returns true if the tree has any out-of-line blocks.
+  function hasOutOfLine(node) {
+    return node.type === "$out" || (node.fields && node.fields.some(hasOutOfLine));
+  }
+
+  // Helper function that keeps track of the current indentation level.
+  function helper(node, indent, topLevelObject) {
+    const start = {row, col};
+    // The thunks in this function work the same as in formatBytes(). They might
+    // be easier to understand in that function because it is shorter.
+    const thunks = [];
+
+    switch (node.type) {
+      case "null":
+        push(`null ${node.kind}`);
+        break;
+      case "$pointer":
+        push(node.value);
+        break;
+      case "bool":
+      case "string":
+        push(JSON.stringify(node.value));
+        break;
+      case "int":
+        push(node.orig + node.signed + node.width);
+        break;
+      case "float":
+        push(node.orig + "f" + node.width);
+        break;
+      case "$entry":
+        push(node.ordinal + ": ");
+        helper(node.fields[0], indent);
+        break;
+      case "handle":
+      case "bits":
+      case "struct":
+      case "array":
+      case "vector":
+      case "enum":
+      case "envelope":
+      case "box":
+      case "table":
+      case "union": {
+        if (node.fields.length === 0) {
+          push(node.type + " {}");
+        } else if (node.fields.length === 1 && isOneLiner(node.fields[0])) {
+          push(node.type + " { ");
+          thunks.push(helper(node.fields[0], 0, /* topLevelObject = */ false));
+          push(" }");
+        } else if (isPointerAndOut(node)) {
+          // Handle this specially because we don't want to wrap the "present"
+          // in a struct. We just want to emit "present" and add the out-of-line
+          // thunk to be emitting later.
+          const pointer = node.fields[0];
+          const out = node.fields[1];
+          helper(pointer, indent, topLevelObject);
+          thunks.push(helper(out, indent, topLevelObject));
+        } else {
+          push(node.type + " {");
+          newline();
+          for (const field of node.fields) {
+            push(INDENT.repeat(indent + 1));
+            thunks.push(helper(field, indent + 1, /* topLevelObject = */ false));
+            push(",");
+            newline();
+          }
+          push(INDENT.repeat(indent) + "}");
+        }
+        break;
+      }
+      case "$out":
+        // Don't emit anything yet. Return a thunk that will emit the
+        // out-of-line object after the inline portion is done.
+        return function() {
+          commentHeader(`Secondary object ${secondaryObjectNumber}`);
+          secondaryObjectNumber++;
+          helper(node.fields[0], 0, /* topLevelObject = */ true);
+          const ranges = node.fields[0].outputRanges;
+          node.outputRanges = ranges;
+          return ranges;
+        };
+      default:
+        throw Error(`unexpected type: ${node.type}`);
+    }
+    // outputRanges is a list to allow for multiple noncontiguous regions.
+    // This is used for bytes (inline and out-of-line). For format(), there
+    // is always only 1 output range per node.
+    node.outputRanges = [{start, end: {row, col}}];
+
+    const flushOutOfLine = () => {
+      let combined = [];
+      for (thunk of thunks) {
+        const ranges = thunk();
+        if (ranges !== undefined) {
+          node.outputRanges.push(...ranges);
+          combined.push(...ranges);
+        }
+      }
+      return combined;
+    };
+    if (topLevelObject) {
+      flushOutOfLine();
+      return function() {}
+    }
+    return flushOutOfLine;
+  }
+
+  if (hasOutOfLine(tree)) {
+    commentHeader("Primary object", /* first = */ true);
+  }
+  helper(tree, 0, /* topLevelObject = */ true);
+  return result;
+}
+
+// Format the bytes tree as hex bytes.
+function formatBytes(tree) {
+  let result = "";
+  let row = 0, col = 0, byteIndex = 0;
+  const rowLengths = [];
+
+  function emitByte(byte) {
+    result += byte.toString(16).padStart(2, "0");
+    col += 2;
+    // NOTE: Keep this (8) in sync with output editor gutter renderer, which
+    // replaces line numbers with offsets 0, 8, 16, etc.
+    if (byteIndex % 8 == 7) {
+      result += "\n";
+      rowLengths.push(col);
+      col = 0;
+      row++;
+    } else {
+      result += " ";
+      col++;
+    }
+    byteIndex++;
+  }
+
+  // Get the position just before pos, to avoid including whitespace in ranges.
+  function justBefore({row, col}) {
+    if (col > 0) {
+      return {row, col: col - 1};
+    }
+    return {row: row - 1, col: rowLengths[row - 1]};
+  }
+
+  // Get the position just after pos, to avoid including whitespace in ranges.
+  function justAfter({row, col}) {
+    if (col < rowLengths[row]) {
+      return {row, col: col + 1};
+    }
+    return {row: row + 1, col: 0};
+  }
+
+  // Executes f on all padding nodes below node in DFS order.
+  function foreachInlinePadding(node, f) {
+    switch (node.type) {
+      case "bytes":
+        if (node.padding) {
+          f(node);
+        }
+        break;
+      case "inline":
+        for (const field of node.fields) {
+          foreachInlinePadding(field, f);
+        }
+        break;
+    }
+  }
+
+  function helper(node, topLevelObject) {
+    const start = {row, col};
+    switch (node.type) {
+      case "bytes":
+        node.bytes.forEach(emitByte);
+        node.outputRanges = [{start, end: justBefore({row, col})}];
+        return function() {}
+      case "inline":
+        const thunks = node.fields.map(f => helper(f, false));
+        node.outputRanges = [{start, end: justBefore({row, col})}];
+        // Remove padding regions from the node's ouput ranges, so that we never
+        // highlight padding.
+        foreachInlinePadding(node, paddingNode => {
+          const lastRange = node.outputRanges[node.outputRanges.length-1];
+          const {start: padStart, end: padEnd} = paddingNode.outputRanges[0];
+          const oldEnd = lastRange.end;
+          lastRange.end = justBefore(padStart);
+          node.outputRanges.push({start: justAfter(padEnd), end: oldEnd});
+        });
+        const flushOutOfLine = () => {
+          let combined = [];
+          for (thunk of thunks) {
+            const ranges = thunk();
+            if (ranges !== undefined) {
+              node.outputRanges.push(...ranges);
+              combined.push(...ranges);
+            }
+          }
+          return combined;
+        };
+        if (topLevelObject) {
+          flushOutOfLine();
+          return function() {}
+        }
+        return flushOutOfLine;
+      case "secondary":
+        // For secondary objects, don't encode anything yet. Instead, return a
+        // thunk to be executed after the inline portion of the last object has
+        // been fully encoded.
+        return function() {
+          helper(node.fields[0], /* topLevelObject = */ true);
+          const ranges = node.fields[0].outputRanges;
+          node.outputRanges = ranges;
+          return ranges;
+        };
+      default:
+        throw Error(`unexpected type in formatBytes: ${node.type}`);
+    }
+  }
+
+  helper(tree, /* topLevelObject = */ true);
+  return result;
+}
+
+// =============================================================================
+// ===== LOWERING ==============================================================
+// =============================================================================
+
+// Global flag to disable adding "lowered" fields. This is an ugly hack.
+let disableAddLowered = false;
+
+// Adds lowered to node's list of lowered nodes, creating the list if necessary.
+// Also adds links in the reverse direction.
+function addLowered(node, lowered) {
+  if (disableAddLowered) {
+    return;
+  }
+  if (node.lowered === undefined) {
+    node.lowered = [];
+  }
+  node.lowered.push(lowered);
+  // If there is already a loweredFrom, overwrite it. Usually this is when a
+  // lowering stage copies a whole node (including its loweredFrom) field, and
+  // then calls addLowered. Ocassionally there genuinely are multiple things it
+  // was loweredFrom, like bits/enums -- the whole structure and the value
+  // inside all lower the same output. In these cases letting the last
+  // addLowered call win works -- it will be the largest structure, the whole
+  // bits/enum, which is what we want for reverse highlighting (otherwise if the
+  // bits has multiple fields, it will only highlight one of them).
+  lowered.loweredFrom = node;
+}
+
+// Creates a note/tooltip, incorporating an old one if present.
+function addNote(oldNode, additional) {
+  return oldNode.note === undefined
+    ? additional : `${oldNode.note}\n\n${additional}`;
+}
+
+// Creates a note/tooltip, but does not replace an existing one.
+function provideNote(oldNode, newNote) {
+  return oldNode.note === undefined
+    ? newNote : oldNode.note;
+}
+
+// Helper functions for English messages.
+function plural(n) { return n === 1 ? "" : "s"; }
+function article(n) { return n == 8 ? "an" : "a"; }
+
+// Copies a tree node, omitting metadata that should not be copied.
+function copyNode(node) {
+  return {
+    ...node,
+    lowered: undefined,
+    loweredFrom: undefined,
+  };
+}
+
+// Helper combinator for lowering.
+function doLowering(tree, self, helper) {
+  function augmentedHelper(node) {
+    const result = helper(node);
+    if (result !== undefined) {
+      // Reapply self. A proper lowering should not get into infinite recursion.
+      return self(result);
+    }
+    if ("fields" in node) {
+      return {...copyNode(node), fields: node.fields.map(self)};
+    }
+    // Very important that this copies the node rather than returning the same
+    // object. Otherwise highlighting gets into infinite recursion.
+    return {...copyNode(node)};
+  }
+  const lowered = augmentedHelper(tree);
+  addLowered(tree, lowered);
+  return lowered;
+}
+
+// Lowering stage 1: tables and unions.
+function lower1(tree) {
+  return doLowering(tree, lower1, node => {
+    switch (node.type) {
+      case "table": {
+        if (node.fields.length === 0) {
+          return {
+            type: "vector",
+            fields: [],
+            note: addNote(node,
+              `Empty table lowers to an empty vector`),
+          };
+        }
+        const maxOrdinal =
+          node.fields.map(f => f.ordinal).reduce((a, b) => Math.max(a, b));
+        const ordinalToEntry = {};
+        for (const field of node.fields) {
+          ordinalToEntry[field.ordinal] = field;
+        }
+        const fields = [];
+        for (let i = 1; i <= maxOrdinal; i++) {
+          const entry = ordinalToEntry[i];
+          if (entry === undefined) {
+            const empty = {
+              type: "envelope",
+              fields: [],
+              note: `Empty envelope for omitted table field #${i}`,
+            };
+            fields.push(empty);
+          } else {
+            const present = {
+              type: "envelope",
+              fields: [entry.fields[0]],
+              note: `Envelope for table field #${i}`,
+            };
+            fields.push(present);
+            addLowered(entry, present);
+          }
+        }
+        return {
+          type: "vector",
+          fields,
+          note: addNote(node, `Table lowers to a vector of envelopes`),
+        };
+      }
+      case "null": {
+        if (node.kind !== "union") {
+          break;
+        }
+        return {
+          type: "struct",
+          fields: [
+            {
+              type: "int", signed: "u", width: 64,
+              orig: "0", value: 0,
+              note: "Zero ordinal for null union",
+            },
+            {
+              type: "envelope",
+              fields: [],
+              note: "Empty envelope for null union",
+            }
+          ],
+          note: addNote(node,
+            `Null union lowers to ordinal 0 and an empty envelope`),
+        };
+      }
+      case "union": {
+        const entry = node.fields[0];
+        const ordinal = entry.ordinal;
+        const envelope = {
+          type: "envelope",
+          fields: [entry.fields[0]],
+          note: `Envelope wrapping union variant #${ordinal}`,
+        };
+        addLowered(entry, envelope);
+        return {
+          type: "struct",
+          fields: [
+            {
+              type: "int", signed: "u", width: 64,
+              orig: ordinal.toString(), value: ordinal,
+              note: `Union ordinal specifying variant #${ordinal}`,
+            },
+            envelope,
+          ],
+          note: addNote(node,
+            `Union lowers to an ordinal (${ordinal}) and envelope`),
+        };
+      }
+    }
+  });
+}
+
+// Lowering stage 2: bits and enums. Assumes earlier lowerings are done.
+function lower2(tree) {
+  return doLowering(tree, lower2, node => {
+    switch (node.type) {
+      case "bits": {
+        const type = (node.fields[0].signed === "u" ? "uint" : "int")
+          + node.fields[0].width.toString();
+        const value = node.fields.map(f => f.value).reduce((a, b) => a | b);
+        const orig = value == 0 ? "0" : "0x" + value.toString(16);
+        let valueStr = orig;
+        if (node.fields.length > 1) {
+          valueStr = node.fields.map(f => "0x" + f.value.toString(16)).join(" | ") + ` = ${orig}`;
+        }
+        const result = {
+          ...copyNode(node.fields[0]),
+          orig,
+          value,
+          note: addNote(node, `Bits ${valueStr} : ${type}`),
+        };
+        for (const field of node.fields) {
+          addLowered(field, result);
+        }
+        return result;
+      }
+      case "enum": {
+        const type = (node.fields[0].signed === "u" ? "uint" : "int")
+          + node.fields[0].width.toString();
+        const result = {
+          ...copyNode(node.fields[0]),
+          note: addNote(node,
+            `Enum ${node.fields[0].value} : ${type}`),
+        };
+        addLowered(node.fields[0], result);
+        return result;
+      }
+    }
+  });
+}
+
+// Lowering stage 3: strings. Assumes earlier lowerings are done.
+function lower3(tree) {
+  return doLowering(tree, lower3, node => {
+    switch (node.type) {
+      case "null": {
+        if (node.kind !== "string") {
+          break;
+        }
+        return {
+          type: "null",
+          kind: "vector",
+          note: addNote(node, `Null string lowers to a null vector`),
+        };
+      }
+      case "string": {
+        const fields = [];
+        const u8 = (byte, extra) => ({
+          type: "int", signed: "u", width: 8,
+          orig: "0x" + byte.toString(16), value: byte,
+          ...extra,
+        });
+        // Iterate over Unicode characters.
+        for (const char of node.value) {
+          // Trick to convert to UTF-8: https://stackoverflow.com/a/18729536
+          const utf8 = unescape(encodeURIComponent(char));
+          if (utf8.length === 1) {
+            const byte = utf8.charCodeAt(0);
+            const note = `Character: ${ASCII_NAMES[byte] || String.fromCharCode(byte)}`;
+            fields.push(u8(byte, {note}));
+          } else {
+            for (let i = 0; i < utf8.length; i++) {
+              fields.push(u8(utf8.charCodeAt(i), {
+                note: `Character: ${char} (byte ${i+1} of ${utf8.length})`,
+              }));
+            }
+          }
+        }
+        let displayStr;
+        if (node.value.length > 100) {
+          displayStr = JSON.stringify(node.value.substring(0, 100) + "…")
+            + " (truncated)";
+        } else {
+          displayStr = JSON.stringify(node.value);
+        }
+        return {
+          type: "vector",
+          fields,
+          note: addNote(node, `String ${displayStr}`),
+        }
+      }
+    }
+  });
+}
+
+// Lowering stage 4: vectors. Assumes earlier lowerings are done.
+function lower4(tree) {
+  return doLowering(tree, lower4, node => {
+    switch (node.type) {
+      case "null": {
+        if (node.kind !== "vector") {
+          break;
+        }
+        return {
+          type: "struct",
+          fields: [
+            {
+              type: "int", signed: "u", width: 64,
+              orig: "0", value: 0,
+              note: `Vector length: 0 elements (null vector)`,
+            },
+            {
+              type: "$pointer",
+              value: "absent",
+              note: `ABSENT pointer for null vector`,
+            },
+          ],
+          note: addNote(node,
+            `Null vector lowers to length 0 and ABSENT pointer`),
+        };
+      }
+      case "vector": {
+        const len = node.fields.length;
+        const lengthNode = {
+          type: "int", signed: "u", width: 64,
+          orig: len.toString(), value: len,
+          note: `Vector length: ${len} elements`,
+        };
+        if (len === 0) {
+          return {
+            type: "struct",
+            fields: [
+              lengthNode,
+              {
+                type: "$pointer",
+                value: "present",
+                note: `Pointer to empty vector's out-of-line array; PRESENT `
+                  + `even though there is no array, to distinguish from null vectors`,
+              },
+            ],
+            note: addNote(node,
+              `Vector lowers to a length (${len}) and an out-of-line array`),
+          };
+        }
+        return {
+          type: "struct",
+          fields: [
+            lengthNode,
+            {
+              type: "box",
+              fields: [{
+                type: "array",
+                fields: node.fields,
+                note: `Vector's out-of-line array`,
+              }],
+              note: `Pointer to vector's out-of-line array`,
+            },
+          ],
+          note: addNote(node,
+            `Vector lowers to a length (${len}) and an out-of-line array`),
+        };
+      }
+    }
+  });
+}
+
+// Lowering stage 5: handles and envelopes. Assumes earlier lowerings are done.
+function lower5(tree) {
+  function countPresentHandles(node) {
+    if (node.type === "handle") {
+      return 1;
+    }
+    if (node.fields !== undefined) {
+      let sum = 0;
+      for (const f of node.fields) {
+        sum += countPresentHandles(f);
+      }
+      return sum;
+    }
+    return 0;
+  }
+
+  return doLowering(tree, lower5, node => {
+    switch (node.type) {
+      case "null": {
+        if (node.kind !== "handle") {
+          break;
+        }
+        return {
+          type: "int", signed: "u", width: 32, orig: "0", value: 0,
+          note: addNote(node, `Null handle`),
+        };
+      }
+      case "handle": {
+        const result = {
+          ...copyNode(node.fields[0]),
+          type: "int", signed: "u", width: 32, orig: "0xffffffff", value: 0xffffffff,
+          note: addNote(node, `Pointer to handle ${node.fields[0].value} (stored out-of-band in the handle vector)`),
+        };
+        addLowered(node.fields[0], result);
+        return result;
+      }
+      case "envelope": {
+        if (node.fields.length === 0) {
+          return {
+            type: "struct",
+            fields: [
+              {type: "int", signed: "u", width: 32, orig: "0", value: 0,
+               note: "Empty envelope: 0 bytes"},
+              {type: "int", signed: "u", width: 32, orig: "0", value: 0,
+               note: "Empty envelope: 0 handles"},
+              {type: "$pointer", value: "absent",
+               note: "ABSENT pointer for empty envelope"},
+            ],
+            note: node.note,
+          };
+        }
+        disableAddLowered = true;
+        const bytes = countTotalBytes(lowerBytes(lower6(lower5(node.fields[0]))));
+        disableAddLowered = false;
+        const handles = countPresentHandles(node.fields[0]);
+        return {
+          type: "struct",
+          fields: [
+            {type: "int", signed: "u", width: 32, orig: bytes.toString(), value: bytes,
+             note: `Envelope: ${bytes} byte${plural(bytes)}`},
+            {type: "int", signed: "u", width: 32, orig: handles.toString(), value: handles,
+             note: `Envelope: ${handles} handle${plural(handles)}`},
+            {type: "box", fields: node.fields,
+             note: "Pointer to the envelope's out-of-line data"},
+          ],
+          note: node.note,
+        };
+      }
+    }
+  });
+}
+
+// Lowering stage 6: boxes. Assumes earlier lowerings are done.
+function lower6(tree) {
+  return doLowering(tree, lower6, node => {
+    switch (node.type) {
+      case "null": {
+        if (node.kind !== "struct") {
+          break;
+        }
+        return {
+          type: "$pointer",
+          value: "absent",
+          note: addNote(node, `ABSENT pointer for null struct`),
+        };
+      }
+      case "array": {
+        return {
+          ...copyNode(node),
+          type: "struct",
+          note: addNote(node, `Array lowers to a struct where all fields have `
+            + `the same type`),
+        };
+      }
+      case "box": {
+        return {
+          type: "struct",
+          fields: [
+            {
+              type: "$pointer",
+              value: "present",
+              note: provideNote(node, `Pointer to out-of-line struct`),
+            },
+            {
+              type: "$out",
+              fields: node.fields,
+            }
+          ],
+        };
+      }
+    }
+  });
+}
+
+// Final lowering stage, to bytes. Assumes all earlier lowerings are done.
+function lowerBytes(tree) {
+  // object can be "primary", "secondary", or false.
+  function lower(tree, object) {
+    const lowered = helper(tree, object);
+    addLowered(tree, lowered);
+    return lowered;
+  }
+
+  function helper(node, object) {
+    if (object && node.type !== "struct") {
+      // Instead of handling the 8-byte padding in every branch, we just handle
+      // it in the struct case, and wrap other things in structs.
+      node = {type: "struct", fields: [node]};
+    }
+    switch (node.type) {
+      case "int": {
+        const signed = node.signed === "i";
+        const type = signed ? `int${node.width}` : `uint${node.width}`;
+        const arrayType = signed
+        ? {8: Int8Array, 16: Int16Array, 32: Int32Array, 64: BigInt64Array}[node.width]
+        : {8: Uint8Array, 16: Uint16Array, 32: Uint32Array, 64: BigUint64Array}[node.width];
+        const value = node.width === 64 ? BigInt(node.value) : Number(node.value);
+        const size = node.width / 8;
+        return {
+          type: "bytes",
+          bytes: new Uint8Array(arrayType.of(value).buffer),
+          size,
+          align: size,
+          note: provideNote(node, `${node.value} : ${type}`),
+        };
+      }
+      case "float": {
+        const type = `float${node.width}`;
+        const single = node.width === 32;
+        const arrayType = single ? Float32Array : Float64Array;
+        const size = node.width / 8;
+        return {
+          type: "bytes",
+          bytes: new Uint8Array(arrayType.of(node.value).buffer),
+          size,
+          align: size,
+          note: provideNote(node, `${node.value} : ${type}`),
+        };
+      }
+      case "bool": {
+        const value = node.value ? 1 : 0;
+        return {
+          type: "bytes",
+          bytes: Uint8Array.of(value),
+          size: 1,
+          align: 1,
+          note: provideNote(node, `${node.value} : bool`),
+        };
+      }
+      case "$pointer": {
+        let bytes;
+        switch (node.value) {
+          case "absent":
+            bytes = Uint8Array.of(0, 0, 0, 0, 0, 0, 0, 0);
+            break;
+          case "present":
+            bytes = Uint8Array.of(0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff);
+            break;
+          default:
+            throw new Error(`invalid $pointer value: ${node.value}`);
+        }
+        return {
+          type: "bytes",
+          bytes,
+          size: 8,
+          align: 8,
+          note: node.note,
+        };
+      }
+      case "$out": {
+        return {
+          type: "secondary",
+          fields: [lower(node.fields[0], "secondary")],
+        };
+      }
+      case "struct": {
+        const fields = [];
+        let size = 0, align = 0;
+
+        // Helper to add padding.
+        function padToAlign(desiredAlign, message) {
+          align = Math.max(align, desiredAlign);
+          if (size % desiredAlign === 0) {
+            return;
+          }
+          const pad = desiredAlign - size % desiredAlign;
+          fields.push({
+            type: "bytes",
+            bytes: new Uint8Array(pad),
+            size: pad,
+            align: 1,
+            padding: true,
+            note: message(pad),
+          });
+          size += pad;
+        }
+
+        if (node.fields.length === 0) {
+          size = 1;
+          align = 1;
+          fields.push({
+            type: "bytes",
+            bytes: Uint8Array.of(0),
+            size,
+            align,
+            note: `An empty struct encodes to the byte 0x00.`,
+          });
+        }
+        for (const originalField of node.fields) {
+          const f = lower(originalField, false);
+          if (f.type === "secondary") {
+            fields.push(f);
+          } else {
+            padToAlign(f.align, pad => `${pad} byte${plural(pad)} padding `
+              + `to align next field to ${article(f.align)} ${f.align}-byte boundary`);
+            // Note: We used to flatten nested inline fields here, but not
+            // anymore. Instead, functions like formatBytes() and
+            // countTotalBytes() were rewritten to allow for nested inlines.
+            fields.push(f);
+            size += f.size;
+          }
+        }
+        padToAlign(align, pad => `${pad} byte${plural(pad)} tail padding for `
+          + `struct's ${pad}-byte alignment`);
+        if (object) {
+          padToAlign(8, pad => `${pad} byte${plural(pad)} tail padding for `
+            + `${object} object ${align}-byte alignment`);
+        }
+        return {
+          type: "inline",
+          size,
+          align,
+          fields,
+        }
+      }
+    }
+    throw Error(`Unexpected type in lowerBytes: ${node.type}`);
+  }
+
+  return lower(tree, "primary");
+}
+
+// Calculates the total number of bytes that tree (returned by lowerBytes) will
+// serialize to. This is distinct from tree.size, which is only the inline size
+// (i.e. the size of the primary object).
+function countTotalBytes(tree) {
+  let size = 0;
+  function visit(node, topLevelObject) {
+    switch (node.type) {
+      case "bytes":
+        // Do nothing: size is already rolled up into parent "inline" node.
+        break;
+      case "inline":
+        if (topLevelObject) {
+          size += node.size;
+        }
+        for (const field of node.fields) {
+          visit(field, /* topLevelObject = */ false);
+        }
+        break;
+      case "secondary":
+        visit(node.fields[0], /* topLevelObject = */ true);
+        break;
+      default:
+        throw Error(`Unexpected type in countTotalBytes: ${node.type}`);
+    }
+  }
+
+  visit(tree, /* topLevelObject = */ true);
+  return size;
+}
+
+// Applies lowering functions from...to inclusive.
+// 7 means lowerBytes.
+function lowerFromTo(tree, from, to) {
+  const lowerings = [
+    lower1, lower2, lower3, lower4, lower5, lower6, lowerBytes
+  ];
+  for (let i = from - 1; i < to; i++) {
+    tree = lowerings[i](tree);
+  }
+  return tree;
+}
+
+// =============================================================================
+// ===== ACE CUSTOMIZATION =====================================================
+// =============================================================================
+
+// Custom highlighting for Ace editor.
+ace.define("ace/mode/fidlviz",
+    ["require", "exports", "ace/lib/oop", "ace/mode/text", "ace/mode/fidlviz_highlight_rules",
+    "ace/mode/behaviour/cstyle", "ace/mode/folding/cstyle", "ace/mode/matching_brace_outdent"],
+    (require, exports) => {
+  const oop = require("ace/lib/oop");
+  const TextMode = require("ace/mode/text").Mode;
+  const FidlvizHighlightRules = require("ace/mode/fidlviz_highlight_rules").FidlvizHighlightRules;
+  const MatchingBraceOutdent = require("ace/mode/matching_brace_outdent").MatchingBraceOutdent;
+  const CstyleBehaviour = require("ace/mode/behaviour/cstyle").CstyleBehaviour;
+  const CstyleFoldMode = require("ace/mode/folding/cstyle").FoldMode;
+  const FidlvizMode = function FidlvizMode() {
+    this.$id = "ace/mode/fidlviz";
+    this.HighlightRules = FidlvizHighlightRules;
+    this.$outdent = new MatchingBraceOutdent();
+    this.$behaviour = new CstyleBehaviour();
+    this.foldingRules = new CstyleFoldMode();
+    this.lineCommentStart = "#";
+    // Adapted from:
+    // https://fuchsia.googlesource.com/fidlbolt/+/refs/heads/master/frontend/src/ace-modes.js#102
+    this.getNextLineIndent = (state, line, tab) => {
+      let indent = line.match(/^\s*/)[0];
+      const tokens = this.getTokenizer().getLineTokens(line, state).tokens;
+      if (tokens.length && tokens[tokens.length - 1].type === "comment") {
+        return indent;
+      }
+      if (state === "start") {
+        const match = line.match(/^.*\{\s*$/);
+        if (match) {
+          indent += tab;
+        }
+      }
+      return indent;
+    }
+    this.checkOutdent = (state, line, input) => {
+      return this.$outdent.checkOutdent(line, input);
+    }
+    this.autoOutdent = (state, doc, row) => {
+      this.$outdent.autoOutdent(doc, row);
+    }
+  };
+  oop.inherits(FidlvizMode, TextMode);
+  exports.Mode = FidlvizMode;
+});
+ace.define("ace/mode/fidlviz_highlight_rules",
+    ["require", "exports", "ace/lib/oop", "ace/mode/text_highlight_rules"],
+    (require, exports) => {
+  const oop = require("ace/lib/oop");
+  const TextHighlightRules = require("ace/mode/text_highlight_rules").TextHighlightRules;
+  const FidlvizHighlightRules = function FidlvizHighlightRules() {
+    this.$rules = {
+      start: [
+        {
+          token: "comment",
+          regex: "#.*$",
+        },
+      ],
+    };
+  };
+  oop.inherits(FidlvizHighlightRules, TextHighlightRules);
+  exports.FidlvizHighlightRules = FidlvizHighlightRules;
+});
+
+// Custom theme for Ace editor.
+ace.define("ace/theme/fidlviz_light", ["require", "exports", "ace/lib/dom"], (require, exports) => {
+  exports.isDark = false;
+  exports.cssClass = "ace-fl";
+  exports.cssText = `
+.ace-fl {
+  background: #fff;
+  color: #111;
+}
+.ace-fl .ace_gutter {
+  background: #eee;
+  color: #666;
+}
+.ace-fl .ace_comment {
+  color: #777;
+}
+.ace-fl .ace_selection {
+  background: #d0d0d0;
+}
+.ace-fl .ace_gutter-active-line {
+  background: #e0e0e0;
+}
+.ace-fl .ace_active-line {
+  background: rgba(0, 0, 0, 0.07);
+}
+  `;
+  exports.$id = "ace/theme/fidlviz_light";
+  const dom = require("ace/lib/dom");
+  dom.importCssString(exports.cssText, exports.cssClass);
+});
+ace.define("ace/theme/fidlviz_dark", ["require", "exports", "ace/lib/dom"], (require, exports) => {
+  exports.isDark = false;
+  exports.cssClass = "ace-fd";
+  exports.cssText = `
+.ace-fd {
+  background: #303030;
+  color: #eee;
+}
+.ace-fd .ace_gutter {
+  background: #404040;
+  color: #aaa;
+}
+.ace-fd .ace_comment {
+  color: #b0b0b0;
+}
+.ace-fd .ace_selection {
+  background: #777;
+}
+.ace-fd .ace_gutter-active-line {
+  background: #505050;
+}
+.ace-fd .ace_active-line {
+  background: rgba(255, 255, 255, 0.07);
+}
+  `;
+  exports.$id = "ace/theme/fidlviz_dark";
+  const dom = require("ace/lib/dom");
+  dom.importCssString(exports.cssText, exports.cssClass);
+});
+
+// =============================================================================
+// ===== DEFAULT INPUT =========================================================
+// =============================================================================
+
+// Input shown when the user has nothing saved (or after clicking Reset).
+const DEFAULT_INPUT = `\
+struct {
+  # Integers
+  0u8, -1i8, 2u16, 3u32, 4u64,
+
+  # Other bases
+  0b1010u8, 0xffu8, 0o755u16,
+
+  # Floats
+  1.2f32, 1e-8f64,
+
+  # Booleans
+  true, false,
+
+  # Strings
+  "Hello 🌎\\u0021",
+  null string, "",
+
+  # Handles
+  null handle, handle { 0xBEEFu32 },
+
+  # Arrays and vectors
+  array { 12u8, 223, 0xff }, # all u8
+  vector { 1.0f64, 2., .3 }, # all f64
+  null vector, vector {},
+
+  # Bits and enums
+  bits { 1, 2, 4 }, enum { 42 },
+
+  # Structs
+  struct {}, struct { 1u64 },
+  null struct, box { struct { 42 } },
+
+  # Tables
+  table {}, table { 1: true, 3: false },
+
+  # Unions
+  null union, union { 7: "hi" },
+}
+`;
+
+// =============================================================================
+// ===== MAIN UI CODE ==========================================================
+// =============================================================================
+
+// Main function called after the window loads.
+function main() {
+  // ---------------------------------------------------------------------------
+  // ----- ACE CONFIG ----------------------------------------------------------
+  // ---------------------------------------------------------------------------
+
+  const inputConfig = {
+    mode: "ace/mode/fidlviz",
+    theme: getTheme(window.matchMedia
+      && window.matchMedia("(prefers-color-scheme: dark)").matches),
+    fontSize: 14,
+    readOnly: false,
+    tabSize: 2,
+    useSoftTabs: true,
+    indentedSoftWrap: false,
+    scrollPastEnd: 1,
+    showGutter: true,
+    showLineNumbers: true,
+    showPrintMargin: false,
+  };
+  const outputConfig = {
+    ...inputConfig,
+    readOnly: true,
+    highlightActiveLine: false,
+  };
+
+  // ---------------------------------------------------------------------------
+  // ----- DOM ELEMENTS --------------------------------------------------------
+  // ---------------------------------------------------------------------------
+
+  const $inputEditor = ace.edit("InputEditor", inputConfig);
+  const $outputEditor = ace.edit("OutputEditor", outputConfig);
+  const $outputSlider = document.getElementById("OutputSlider");
+  const $outputTitle = document.getElementById("OutputTitle");
+  const $outputTooltip = document.getElementById("OutputTooltip");
+  const $resetButton = document.getElementById("ResetButton");
+  const $debugButton = document.getElementById("DebugButton");
+  const $layoutButton = document.getElementById("LayoutButton");
+  const $helpButton = document.getElementById("HelpButton");
+  const $helpModal = document.getElementById("HelpModal");
+  const $helpModalContent = document.getElementById("HelpModalContent");
+  const $container = document.getElementById("Container");
+  const $inputPart = document.getElementById("InputPart");
+  const $splitContainer = document.getElementById("SplitContainer");
+  const $splitter = document.getElementById("Splitter");
+
+  for (editor of [$inputEditor, $outputEditor]) {
+    // We use Ctrl-[ and Ctrl-] to move the slider.
+    editor.commands.removeCommand('blockindent');
+    editor.commands.removeCommand('blockoutdent');
+  }
+
+  // ---------------------------------------------------------------------------
+  // ----- DARK MODE -----------------------------------------------------------
+  // ---------------------------------------------------------------------------
+
+  // Gets the Ace theme depending on whether dark mode is enabled.
+  function getTheme(darkMode) {
+    return darkMode ? "ace/theme/fidlviz_dark" : "ace/theme/fidlviz_light";
+  }
+  if (window.matchMedia) {
+    window.matchMedia("(prefers-color-scheme: dark)").addListener(e => {
+      const theme = getTheme(e.matches);
+      $inputEditor.setTheme(theme);
+      $outputEditor.setTheme(theme);
+    });
+  }
+
+  // ---------------------------------------------------------------------------
+  // ----- UI HELPERS ----------------------------------------------------------
+  // ---------------------------------------------------------------------------
+
+  // Vertical (left & right) or Horizontal (top & bottom).
+  let editorSplitOrientation;
+
+  function setEditorSplitOrientation(orientation) {
+    function toggle(o) {
+      return o === "vertical" ? "horizontal" : "vertical";
+    }
+    if (orientation === "toggle") {
+      orientation = toggle(editorSplitOrientation);
+    }
+    editorSplitOrientation = orientation;
+    const other = toggle(orientation);
+    $splitter.classList.remove(`split-splitter--${other}`);
+    $splitter.classList.add(`split-splitter--${orientation}`);
+    $splitContainer.classList.remove(`split-container--${other}`);
+    $splitContainer.classList.add(`split-container--${orientation}`);
+    resizeEditors(editorSplitPosition);
+    window.localStorage.editorSplitOrientation = orientation;
+  }
+
+  // Keep in sync with the output slider in index.html.
+  const LAST_DEBUG_OUTPUT_MODE = "3";
+  const LAST_OUTPUT_MODE = "8";
+
+  // In Debug mode, the output shows tokens or parse trees.
+  let debugEnabled;
+  let outputModeWhenDebugIs;
+  function setDebugEnabled(enabled, restoring) {
+    debugEnabled = !!enabled;
+    if (debugEnabled) {
+      $debugButton.classList.add("button--active");
+      $outputSlider.max = LAST_DEBUG_OUTPUT_MODE;
+    } else {
+      $debugButton.classList.remove("button--active");
+      $outputSlider.max = LAST_OUTPUT_MODE;
+    }
+    // Skip this at very beginning when called from restore().
+    if (restoring === undefined) {
+      setOuputMode(outputModeWhenDebugIs[debugEnabled]);
+    }
+    window.localStorage.debugEnabled = debugEnabled;
+  }
+
+  // Show byte offsets instead of line numbers when viewing bytes.
+  let currentOutputMode = undefined;
+  $outputEditor.session.gutterRenderer =  {
+    getWidth: function(_session, lastLineNumber, config) {
+      // Note: lastLineNumber is a value returned by getText(), so it has
+      // already been multiplied by 8 if necessary.
+      return lastLineNumber.toString().length * config.characterWidth;
+    },
+    getText: function(_session, row) {
+      if (currentOutputMode === LAST_OUTPUT_MODE && !state.error) {
+        return row * 8;
+      }
+      return (row + 1).toString();
+    }
+  };
+
+  // Updates output slider and title, but not the editors.
+  function setOuputMode(mode) {
+    $outputSlider.value = mode;
+    $outputTitle.innerHTML = getTitle(mode);
+    outputModeWhenDebugIs[debugEnabled] = mode;
+    window.localStorage.outputModeWhenDebugIs = JSON.stringify(outputModeWhenDebugIs);
+    // Do a full update whenever the line number format needs to change.
+    if ((currentOutputMode === LAST_OUTPUT_MODE) !== (mode === LAST_OUTPUT_MODE)) {
+      $outputEditor.renderer.updateFull();
+    }
+    currentOutputMode = mode;
+  }
+
+  // Number from 0 to 1 (0.5 means split halfway).
+  let editorSplitPosition;
+
+  // Applies styling to resize the editors to a split defined by the position
+  // from 0 to 1 (0.5 means each editor takes up half the space).
+  function resizeEditors(position) {
+    // 4px is half the splitter thickness, 8px.
+    $inputPart.style.flexBasis = `calc(${position * 100}% - 4px)`;
+    if (editorSplitOrientation === "vertical") {
+      $inputPart.style.maxWidth = `calc(100% - 8px)`;
+      $inputPart.style.maxHeight = "none";
+    } else {
+      $inputPart.style.maxHeight = `calc(100% - 8px)`;
+      $inputPart.style.maxWidth = "none";
+    }
+    editorSplitPosition = position;
+    window.localStorage.editorPosition = JSON.stringify(position);
+    // Let Ace editors know their parent has resized.
+    $inputEditor.resize();
+    $outputEditor.resize();
+  }
+
+  // Converts a mode (slider value) to a title.
+  function getTitle(mode) {
+    if (debugEnabled) {
+      switch (mode) {
+        case "1":
+          return "Tokens";
+        case "2":
+          return "Annotated Tokens";
+        case "3":
+          return "Parse tree";
+      }
+    }
+    switch (mode) {
+      case "1":
+        return "Format";
+      case "2":
+        return "[1] Lower tables & unions";
+      case "3":
+        return "[2] Lower bits & enums";
+      case "4":
+        return "[3] Lower strings";
+      case "5":
+        return "[4] Lower vectors";
+      case "6":
+        return "[5] Lower handles & envelopes";
+      case "7":
+        return "[6] Lower arrays & boxes";
+      case "8":
+        return "Bytes";
+    }
+  }
+
+  // ---------------------------------------------------------------------------
+  // ----- UTILITIES --------------------------------------------------------------
+  // ---------------------------------------------------------------------------
+
+  // Converts an object to a pretty-printed JSON string.
+  function stringify(obj) {
+    return JSON.stringify(
+      obj,
+      (_key, value) => typeof value === "bigint" ? value.toString() : value,
+      INDENT
+    );
+  };
+
+  // Wraps text to the given maximum width, preserving existing newlines.
+  // https://fuchsia.googlesource.com/fidlbolt/+/refs/heads/master/frontend/src/util.ts#16
+  function wordWrap(text, width) {
+    function wrap(line) {
+      const words = line.split(' ');
+      let lineLength = 0;
+      const withNewlines = words.map(w => {
+        lineLength += w.length + 1;
+        if (lineLength > width) {
+          lineLength = w.length;
+          return '\n' + w;
+        }
+        return w;
+      });
+      return withNewlines.join(' ');
+    }
+    const lines = text.split('\n');
+    return lines.map(wrap).join('\n');
+  }
+
+  // Returns true if r1 and r2 represent the same output editor range.
+  function outputRangesEqual(r1, r2) {
+    return r1.start.row === r2.start.row
+      && r1.start.col === r2.start.col
+      && r1.end.row === r2.end.row
+      && r1.end.col === r2.end.col;
+  }
+
+  // ---------------------------------------------------------------------------
+  // ----- MAIN UI UPDATES -----------------------------------------------------
+  // ---------------------------------------------------------------------------
+
+  // State retained between updates.
+  let state = {
+    // True if there is an error (which we are displaying in the output).
+    error: false,
+    // Keep track of markers in order to remove them later.
+    errorMarker: undefined,
+    inputMarkers: [],
+    outputMarkers: [],
+    underlineMarkers: {"has-tooltip": [], "has-tooltip-active": []},
+    // Parse tree and final output tree.
+    tree: undefined,
+    outputTree: undefined,
+    // Highlighting and tooltip state.
+    highlightState: {lastBestNode: undefined, lastOnFirst: undefined},
+    tooltipState: {lastBestNode: undefined, lastDocPos: undefined},
+  };
+
+  // Flag to avoid trigerring cursor events when writing output.
+  let updateOutputInProgress = false;
+
+  // Updates the output given input text.
+  function update() {
+    const outputMode = $outputSlider.value;
+    const previouslyHadError = state.error;
+
+    function getOutput(input) {
+      $inputEditor.session.clearAnnotations();
+      state.error = false;
+      if (state.errorMarker !== undefined) {
+        $inputEditor.session.removeMarker(state.errorMarker);
+        state.errorMarker = undefined;
+      }
+      clearHighlights();
+      clearTooltip();
+      clearUnderlines();
+      state.tree = undefined;
+      state.outputTree = undefined;
+      try {
+        const tokens = tokenize(input);
+        const tree = parse(tokens);
+        state.tree = tree;
+        if (debugEnabled) {
+          switch (outputMode) {
+            case "1":
+              return stringify(tokens.map(t => t.text));
+            case "2":
+              return stringify(tokens);
+            case "3":
+              return stringify(tree);
+          }
+        }
+        const outputTree = lowerFromTo(state.tree, 1, outputMode - 1);
+        state.outputTree = outputTree;
+        return outputMode === LAST_OUTPUT_MODE ? formatBytes(outputTree) : format(outputTree);
+      } catch (ex) {
+        state.error = true;
+        let start, end, message;
+        if (ex.message !== undefined) {
+          if (ex.token !== undefined) {
+            start = ex.token.start;
+            end = ex.token.end;
+            message = `${JSON.stringify(ex.token.text)}: ${ex.message}`;
+          } else if (ex.range !== undefined) {
+            start = ex.range.first.start;
+            end = ex.range.last.end;
+            message = ex.message;
+          } else {
+            message = ex.message;
+          }
+        } else {
+          message = ex;
+        }
+
+        if (start && end) {
+          state.errorMarker = $inputEditor.session.addMarker(
+            new ace.Range(start.row, start.col, end.row, end.col),
+            "input-error",
+            "text",
+          );
+          $inputEditor.session.setAnnotations([{
+            row: start.row,
+            column: start.col,
+            type: "error",
+            text: wordWrap(message, 66),
+          }]);
+          return `ERROR at ${start.row+1}:${start.col+1}: ${message}`;
+        }
+        return `ERROR: ${message}`;
+      }
+    }
+
+    const input = $inputEditor.getValue();
+    const output = getOutput(input);
+    updateOutputInProgress = true;
+    $outputEditor.session.setValue(output);
+    updateOutputInProgress = false;
+    // We need to rehighlight now that the output is changed. Even though the
+    // user might have had output-cursor-driven highlighting, we can't maintain
+    // that because the output could be completely different now. So highlight
+    // from the input cursor position.
+    highlight("input");
+    // The underlines are useless for bytes -- every byte would get underlined.
+    // Instead, we only underline for the active (hovered) node.
+    if (outputMode !== LAST_OUTPUT_MODE) {
+      underlineTooltipRegions(
+        state.outputTree, /* skipWhitespace = */ true, "has-tooltip");
+    }
+    // If we go between error/non-error in the byte output mode, update the
+    // renderer since the line numbers have changed (non-error bytes mode uses
+    // line numbers 0, 8, 16, etc.).
+    if (outputMode === LAST_OUTPUT_MODE && (state.error !== previouslyHadError)) {
+      $outputEditor.renderer.updateFull();
+    }
+    // Enable output editor wrapping only when showing errors, to ensure they
+    // are always fully visible.
+    $outputEditor.session.setUseWrapMode(state.error);
+  }
+
+  // Clears all input and output highlights.
+  function clearHighlights() {
+    for (const marker of state.inputMarkers) {
+      $inputEditor.session.removeMarker(marker);
+    }
+    for (const marker of state.outputMarkers) {
+      $outputEditor.session.removeMarker(marker);
+    }
+    state.inputMarkers = [];
+    state.outputMarkers = [];
+  }
+
+  // Highlights input and output editors. Also underlines output regions that
+  // have tooltips.
+  function highlight(cursorFrom /* : "input" | "output" */) {
+    if (state.tree === undefined) {
+      return;
+    }
+
+    function le(p1, p2) {
+      return p1.row < p2.row || (p1.row === p2.row && p1.col <= p2.col);
+    }
+
+    let bestNode;
+    let inputPos;
+    if (cursorFrom === "input") {
+      const cursor = $inputEditor.getCursorPosition();
+      const pos = {row: cursor.row, col: cursor.column};
+      inputPos = pos;
+
+      let bestStart = {row: -Infinity, col: -Infinity};
+      function visit(node) {
+        const start = node.range.first.start;
+        const end = node.range.last.end;
+        if (le(start, pos) && le(pos, end) && le(bestStart, start)) {
+          bestNode = node;
+          bestStart = start;
+        }
+        if (node.fields !== undefined) {
+          for (const field of node.fields) {
+            visit(field);
+          }
+        }
+      }
+      visit(state.tree);
+    } else if (cursorFrom === "output") {
+      if (state.outputTree === undefined) {
+        return;
+      }
+      const cursor = $outputEditor.getCursorPosition();
+      const pos = {row: cursor.row, col: cursor.column};
+      let node = findClosestOutputNode(pos, /* hasInputSource = */true);
+      if (!node) {
+        return;
+      }
+      // Walk back to find the input node.
+      while (node.loweredFrom !== undefined) {
+        node = node.loweredFrom;
+      }
+      if (node.range === undefined) {
+        throw Error("loweredFrom chain did not lead to input node with range");
+      }
+      bestNode = node;
+    } else {
+      throw Error(`unexpected cursorFrom: ${cursorFrom}`);
+    }
+
+    if (bestNode === undefined) {
+      state.highlightState.lastBestNode = undefined;
+      state.highlightState.lastOnFirst = undefined;
+      clearHighlights();
+      return;
+    }
+    const onFirst = inputPos === undefined ||
+      le(bestNode.range.first.start, inputPos) && le(inputPos, bestNode.range.first.end);
+    if (bestNode === state.highlightState.lastBestNode && onFirst === state.highlightState.lastOnFirst) {
+      return;
+    }
+    state.highlightState.lastBestNode = bestNode;
+    state.highlightState.lastOnFirst = onFirst;
+
+    clearHighlights();
+    const nodes =
+      onFirst || bestNode.fields === undefined || bestNode.fields.length === 0
+      ? [bestNode] : bestNode.fields;
+    // List of lists of output ranges. Element i corresponds to nodes[i].
+    const outputRangesForNode = [];
+    // Keep track of whether all nodes map to the same output range. If so,
+    // we'll give them the same color to avoid confusion. This is the case when
+    // highlighting all bits members after they've been lowered to 1 integer.
+    let outputsCoincide = true;
+
+    // Populate outputRangesForNode and calculate outputsCoincide.
+    for (const node of nodes) {
+      // outputRanges: [{start, end}] positions.
+      // Position: {row, col} zero-based.
+      let outputRanges = [];
+      function explore(node) {
+        if (node.lowered) {
+          for (const lowered of node.lowered) {
+            explore(lowered);
+          }
+        } else if (node.outputRanges !== undefined) {
+          outputRanges.push(...node.outputRanges);
+        }
+      }
+      explore(node);
+      outputRangesForNode.push(outputRanges);
+      if (outputsCoincide
+        && (outputRanges.length !== 1 || !outputRangesEqual(outputRanges[0], outputRangesForNode[0][0]))) {
+        outputsCoincide = false;
+      }
+    }
+
+    // Add highlight markers.
+    for (let i = 0; i < nodes.length; i++) {
+      // Range: {first, last} tokens.
+      // Token: {start, end} positions.
+      // Position: {row, col} zero-based.
+      const range = nodes[i].range;
+      // NOTE: Keep 9 in sync with the number of highlight classes in style.css.
+      const highlightClass = outputsCoincide ? "highlight-1" : `highlight-${i%9+1}`;
+      state.inputMarkers.push($inputEditor.session.addMarker(
+        new ace.Range(
+          range.first.start.row,
+          range.first.start.col,
+          range.last.end.row,
+          range.last.end.col,
+        ),
+        highlightClass,
+        "text",
+        false, // behind the text, not in front
+      ));
+
+      for (const {start, end} of outputRangesForNode[i]) {
+        state.outputMarkers.push($outputEditor.session.addMarker(
+          new ace.Range(
+            start.row,
+            start.col,
+            end.row,
+            end.col,
+          ),
+          highlightClass,
+          "text",
+          false, // behind the text, not in front
+        ));
+      }
+    }
+  }
+
+  // Clears underline markers in the output.
+  function clearUnderlines(cssClass) {
+    if (cssClass === undefined) {
+      clearUnderlines("has-tooltip");
+      clearUnderlines("has-tooltip-active");
+      return;
+    }
+    for (const marker of state.underlineMarkers[cssClass]) {
+      $outputEditor.session.removeMarker(marker);
+    }
+    state.underlineMarkers[cssClass] = [];
+  }
+
+  // Underlines regions of the output that have tooltips.
+  function underlineTooltipRegions(startingOutputNode, skipWhitespace, cssClass) {
+    clearUnderlines(cssClass);
+    if (state.outputTree === undefined) {
+      return;
+    }
+    // Comparison operators for {row, col} ranges (col exclusive).
+    function le(p1, p2) {
+      return p1.row < p2.row || (p1.row === p2.row && p1.col <= p2.col);
+    }
+    function lt(p1, p2) {
+      return !(p1.row === p2.row && p1.col === p2.col) && le(p1, p2);
+    }
+
+    // Subtract a list of [{start, end}] from range: {start, end}.
+    function subtract(range, others, p) {
+      let result = [{...range}];
+      for (const other of others) {
+        const newResult = [];
+        for (const r of result) {
+          if (lt(other.start, r.end) && lt(r.start, other.end)) {
+            if (le(other.start, r.start) && lt(other.end, r.end)) {
+              newResult.push({start: other.end, end: r.end});
+            } else if (lt(r.start, other.start) && lt(other.end, r.end)) {
+              newResult.push({start: r.start, end: other.start});
+              newResult.push({start: other.end, end: r.end});
+            } else if (lt(r.start, other.start) && le(r.end, other.end)) {
+              newResult.push({start: r.start, end: other.start});
+            } else {
+            }
+          } else {
+            newResult.push(r);
+          }
+        }
+        result = newResult;
+      }
+      return result;
+    }
+
+    // Returns a list of ranges that have notes/tooltips (if wantNote is true)
+    // or that do *not* (if wantNote is false).
+    function getTooltipRanges(node, wantNote) {
+      const children = node.fields === undefined ? [] : node.fields;
+      const haveNote = node.note !== undefined;
+      if (wantNote === haveNote) {
+        const opposite = getTooltipRanges(node, !wantNote);
+        return node.outputRanges.flatMap(r => subtract(r, opposite));
+      }
+      return children.flatMap(n => getTooltipRanges(n, wantNote));
+    }
+
+    // Get whitespace (or other tokens we don't want to underline).
+    const whitespaceRanges = [];
+    if (skipWhitespace) {
+      let row = 0, col = 0;
+      let whitespaceStart = undefined;
+      let inWhitespace = false;
+      for (const ch of $outputEditor.getValue()) {
+        if (!inWhitespace && (ch.trim() === "" || ch === "," || ch === "{" || ch === "}")) {
+          inWhitespace = true;
+          whitespaceStart = {row, col};
+        } else if (inWhitespace && !(ch.trim() === "" || ch === "," || ch === "{" || ch === "}")) {
+          whitespaceRanges.push({start: whitespaceStart, end: {row, col}});
+          inWhitespace = false;
+          whitespaceStart = undefined;
+        }
+        col++;
+        if (ch === "\n") {
+          row++;
+          col = 0;
+        }
+      }
+      if (whitespaceStart !== undefined) {
+        whitespaceRanges.push({start: whitespaceStart, end: {row, col}});
+      }
+    }
+
+    for (const fullRange of getTooltipRanges(startingOutputNode, true)) {
+      for (const range of
+            skipWhitespace ? subtract(fullRange, whitespaceRanges) : [fullRange]) {
+        state.underlineMarkers[cssClass].push($outputEditor.session.addMarker(
+          new ace.Range(
+            range.start.row,
+            range.start.col,
+            range.end.row,
+            range.end.col,
+          ),
+          cssClass,
+          "text",
+          true, // in front of the text, not behind
+        ));
+      }
+    }
+  }
+
+  // Clears the tooltip in the output editor.
+  function clearTooltip() {
+    $outputTooltip.hidden = true;
+    $outputTooltip.innerHTML = "";
+    state.tooltipState.lastBestNode = undefined;
+    // Remove the active underline since it's no longer active.
+    clearUnderlines("has-tooltip-active");
+  }
+
+  // Finds the output node closest to pos.
+  function findClosestOutputNode(pos, hasInputSource) {
+    function le(p1, p2) {
+      return p1.row < p2.row || (p1.row === p2.row && p1.col <= p2.col);
+    }
+
+    // Returns true if node can be traced back to an input node, with a range
+    // specifying the input's location in the input editor.
+    function searchForInput(node) {
+      while (node !== undefined) {
+        if (node.range !== undefined) {
+          return true;
+        }
+        node = node.loweredFrom;
+      }
+      return false;
+    }
+
+    let bestNode;
+    let bestStart = {row: -Infinity, col: -Infinity};
+    function visit(node) {
+      for (const {start, end} of node.outputRanges) {
+        if (le(start, pos) && le(pos, end) && le(bestStart, start)
+            && (!hasInputSource || searchForInput(node))) {
+          bestNode = node;
+          bestStart = start;
+        }
+      }
+      if (node.fields !== undefined) {
+        for (const field of node.fields) {
+          visit(field);
+        }
+      }
+    }
+
+    visit(state.outputTree);
+    return bestNode;
+  }
+
+  // Shows tooltips based on the cursor position in the output editor.
+  function updateTooltip(docPos, pixelPos) {
+    if (state.outputTree === undefined) {
+      return;
+    }
+
+    if (docPos === state.tooltipState.lastDocPos) {
+      $outputTooltip.style.left = `${pixelPos.pageX}px`;
+      $outputTooltip.style.top = `${pixelPos.pageY}px`;
+      return;
+    }
+    state.tooltipState.lastDocPos = docPos;
+
+    const pos = {row: docPos.row, col: docPos.column};
+    const bestNode = findClosestOutputNode(pos, /* hasInputSource = */ false);
+
+    if (bestNode === undefined || bestNode.note === undefined) {
+      clearTooltip();
+      return;
+    }
+    if (bestNode === state.tooltipState.lastBestNode) {
+      $outputTooltip.style.left = `${pixelPos.pageX}px`;
+      $outputTooltip.style.top = `${pixelPos.pageY}px`;
+      return;
+    }
+    state.tooltipState.lastBestNode = bestNode;
+
+    $outputTooltip.style.left = `${pixelPos.pageX}px`;
+    $outputTooltip.style.top = `${pixelPos.pageY}px`;
+    $outputTooltip.innerHTML = wordWrap(bestNode.note, 66);
+    $outputTooltip.hidden = false;
+
+    // In bytes mode, we don't underline the things that can be hovered over
+    // (in update()) because *everything* can be hovered. Instead, we show an
+    // underline for the current tooltip to show the extent of it. In other
+    // modes, we also show a more distinct underline for the active one.
+    underlineTooltipRegions(
+      bestNode,
+      /* skipWhitespace = */ $outputSlider.value !== LAST_OUTPUT_MODE,
+      "has-tooltip-active");
+  }
+
+  // ---------------------------------------------------------------------------
+  // ----- RESTORE -------------------------------------------------------------
+  // ---------------------------------------------------------------------------
+
+  // Used to avoid firing events while restoring (either in the beginning or
+  // when the Reset button is clicked).
+  let restoreInProgress;
+
+  // Restores the site from localStorage.
+  function restore() {
+    restoreInProgress = true;
+    if (window.localStorage.editorPosition) {
+      editorSplitPosition = JSON.parse(window.localStorage.editorPosition);
+    } else {
+      editorSplitPosition = 0.5;
+    }
+    // This will also call resizeEditors.
+    setEditorSplitOrientation(window.localStorage.editorSplitOrientation || "vertical");
+    if (window.localStorage.debugEnabled) {
+      setDebugEnabled(JSON.parse(window.localStorage.debugEnabled), "restoring");
+    } else {
+      setDebugEnabled(false, "restoring");
+    }
+    if (window.localStorage.outputModeWhenDebugIs) {
+      outputModeWhenDebugIs = JSON.parse(window.localStorage.outputModeWhenDebugIs);
+    } else {
+      outputModeWhenDebugIs = {false: $outputSlider.max, true: $outputSlider.min};
+    }
+    setOuputMode(outputModeWhenDebugIs[debugEnabled]);
+    // Hidden at first to avoid a flash from default 50% editor split to the
+    // restored value. This also applies for the output slider (Debug or not
+    // Debug can cause flash). So we just hide the whole container till ready.
+    $container.hidden = false;
+    let input = window.localStorage.input;
+    if (!input || input.trim() === "") {
+      input = DEFAULT_INPUT;
+    }
+    $inputEditor.session.setValue(input);
+    // Move the cursor to the open brace so that we get the rainbow highlights.
+    // Note: still do this even if we restored from localStorage, but it was
+    // unchanged from the default.
+    if (input === DEFAULT_INPUT) {
+      $inputEditor.moveCursorTo(0, 8);
+    }
+    update();
+    restoreInProgress = false;
+  }
+
+  // ---------------------------------------------------------------------------
+  // ----- EVENTS --------------------------------------------------------------
+  // ---------------------------------------------------------------------------
+
+  // Update and save whenever the input changes.
+  $inputEditor.on("change", () => {
+    if (restoreInProgress) {
+      return;
+    }
+    update();
+    window.localStorage.input = $inputEditor.getValue();
+  });
+
+  // Re-highlight whenever the cursor moves. But not when selecting text.
+  $inputEditor.session.getSelection().on("changeCursor", () => {
+    if (restoreInProgress) {
+      return;
+    }
+    if ($inputEditor.session.getSelection().isEmpty()) {
+      highlight("input");
+    }
+  });
+  $outputEditor.session.getSelection().on("changeCursor", () => {
+    if (restoreInProgress || updateOutputInProgress) {
+      return;
+    }
+    if ($outputEditor.session.getSelection().isEmpty()) {
+      highlight("output");
+    }
+  });
+
+  // Update the output view whenever the slider changes.
+  $outputSlider.oninput = () => {
+    if (restoreInProgress) {
+      return;
+    }
+    setOuputMode($outputSlider.value);
+    update();
+  };
+  // Also allow moving the slider using Ctrl-[ and Ctrl-].
+  document.addEventListener("keydown", (e) => {
+    let delta;
+    if (e.ctrlKey && e.key === "[") {
+      delta = -1;
+    } else if (e.ctrlKey && e.key === "]") {
+      delta = 1;
+    } else {
+      return;
+    }
+    const newValue = (parseInt($outputSlider.value) + delta).toString();
+    if (newValue < $outputSlider.min || newValue > $outputSlider.max) {
+      return;
+    }
+    $outputSlider.value = newValue;
+    setOuputMode(newValue);
+    update();
+  });
+
+  // Update output tooltip when the cursor moves.
+  $outputEditor.on("mousemove", e => {
+    const docPos = e.getDocumentPosition();
+    if (!docPos || !$outputEditor.session.getSelection().isEmpty()) {
+      clearTooltip();
+      return;
+    }
+    const screenPos = $outputEditor.session.documentToScreenPosition(docPos);
+    if (
+        // Avoid tooltip persisting when moving into the blank space to the right.
+        $outputEditor.session.getScreenLastRowColumn(screenPos.row) === screenPos.column
+        // Avoid weird flicker when moving past top edge of window.
+        || (docPos.row === 0 && docPos.column === 0 && screenPos.row === 0 && screenPos.column === 0)) {
+      clearTooltip();
+      return;
+    }
+    const pixelPos = $outputEditor.renderer.textToScreenCoordinates(docPos);
+    pixelPos.pageX += 10;
+
+    // Removing this because large tooltips at the bottom cause a scrollbar
+    // on the entire document, jerking the page when the tooltip appears.
+    // It can still happen with large tooltips, but it's alleviated by the fact
+    // that I enabled scrollPastEnd on the editors.
+    //
+    // EDIT: uncommenting it again because I have made body overflow: hidden
+    // so that problem is gone. On bytes it's nice to have the tooltip below
+    // to not obstruct the rest of the line.
+    pixelPos.pageY += $outputEditor.renderer.lineHeight + 2;
+
+    updateTooltip(docPos, pixelPos);
+  });
+
+  document.getElementById("OutputEditor").addEventListener("mouseleave", () => {
+    clearTooltip();
+  });
+
+  // Allow the editor splitter to be dragged.
+  let draggingSplitter = false;
+  $splitter.addEventListener("mousedown", () => draggingSplitter = true);
+  document.addEventListener("mouseup", () => draggingSplitter = false);
+  document.addEventListener("mousemove", (e) => {
+    if (!draggingSplitter) {
+      return;
+    }
+    const rect = $container.getBoundingClientRect();
+    // NOTE: Keep the number below in sync with style.css.
+    const position = editorSplitOrientation === "vertical"
+      ? (e.pageX - rect.x) / rect.width
+      : (e.pageY - rect.y) / rect.height;
+    const clamped = Math.max(0, Math.min(1, position));
+    resizeEditors(clamped);
+  });
+
+  // Toggle Debug mode when the Debug button is clicked.
+  $debugButton.onclick = () => {
+    setDebugEnabled(!debugEnabled);
+    update();
+  };
+
+  $layoutButton.onclick = () => setEditorSplitOrientation("toggle");
+
+  function exitModalKeyListener(e) {
+    if (e.key === "Enter" || e.key === "Escape") {
+      // Without preventDefault, pressing Enter would hide the window and then
+      // immediately show it again because it presses the Help button again (if
+      // it's still in focus).
+      e.preventDefault();
+      hideHelpModal();
+    }
+  }
+  function showHelpModal() {
+    $helpModal.hidden = false;
+    document.addEventListener("keydown", exitModalKeyListener);
+  }
+  function hideHelpModal() {
+    $helpModal.hidden = true;
+    document.removeEventListener("keydown", exitModalKeyListener);
+  }
+
+  // Open the help window on clicking the Help button. It also functions as a
+  // toggle so that if you press space while it's still in focus, it will close
+  // the help window.
+  $helpButton.onclick = () => {
+    if ($helpModal.hidden) {
+      showHelpModal();
+    } else {
+      hideHelpModal();
+    }
+  };
+  // Click outside the modal to hid eit. But use stopPropagation to prevent
+  // clicks on the modal content itself from closing it.
+  $helpModal.onclick = hideHelpModal;
+  $helpModalContent.onclick = (e) => e.stopPropagation();
+
+  // Reset all data to the defaults on clicking the Reset buton.
+  $resetButton.onclick = () => {
+    window.localStorage.clear();
+    restore();
+  };
+
+  restore();
+};
+
+window.onload = () => {
+  // Load ace/mode/json first. Running main() eventually results in setting an
+  // editor mode to ace/mode/fidlviz. This imports some resources that are not
+  // exposed as separate files by the CDN, like ace/mode/behaviour/cstyle. By
+  // first loading ace/mode/json, these common resources get populated in Ace's
+  // internal map, so it won't attempt and fail to load them from the CDN.
+  ace.config.loadModule("ace/mode/json", () => {
+    main();
+  });
+};
+
+})(); // end IIFE
diff --git a/fidlviz/style.css b/fidlviz/style.css
new file mode 100644
index 0000000..d3ffdbd
--- /dev/null
+++ b/fidlviz/style.css
@@ -0,0 +1,423 @@
+html, body {
+    margin: 0;
+    padding: 0;
+}
+
+body {
+    /* Copied from fidlbolt */
+    --fg: #333;
+    --bg: #f0f0f0;
+    --border: #666;
+    --element: #ddd;
+    --element-hover: #f5f5f5;
+    --intense: #fff;
+    --intense-hover: #e3e3e3;
+    --border: #666;
+    --link: #06d;
+
+    /* Not in fidlbolt */
+    --cover: rgba(255, 255, 255, 0.75);
+    --underline: rgba(0, 0, 0, 0.25);
+    --underline-active: rgba(0, 0, 0, 0.75);
+    --shadow: rgba(0, 0, 0, 0.3);
+    --tooltip-gradient: rgba(0, 0, 0, 0.1);
+    --input-error: #ffaaaa;
+    --highlight-1: #ffeea2;
+    --highlight-2: #cfff90;
+    --highlight-3: #a6ffec;
+    --highlight-4: #a4dbff;
+    --highlight-5: #adbcff;
+    --highlight-6: #dbb8fc;
+    --highlight-7: #ffb9f6;
+    --highlight-8: #ffb9b9;
+    --highlight-9: #ffd8a6;
+
+    font: 16px Helvetica, sans-serif;
+    color: var(--fg);
+    background: var(--bg);
+
+    /* Prevent tooltips near edges from making the document have scrollbars,
+    causing jankiness when mousing onto and off that element. */
+    overflow: hidden;
+}
+
+@media (prefers-color-scheme: dark) {
+    body {
+        /* Copied from fidlbolt */
+        --fg: #eee;
+        --bg: #333;
+        --element: #111;
+        --element-hover: #444;
+        --intense: #111;
+        --intense-hover: #444;
+        --border: #aaa;
+        --link: #6af;
+
+        /* Not in fidlbolt */
+        --cover: rgba(30, 30, 30, 0.75);
+        --underline: rgba(255, 255, 255, 0.8);
+        --underline-active: rgba(255, 255, 255, 1);
+        --shadow: rgba(255, 255, 255, 0.5);
+        --tooltip-gradient: rgba(255, 255, 255, 0.2);
+        --input-error: #c75252;
+        --highlight-1: #ad8e00;
+        --highlight-2: #518f00;
+        --highlight-3: #009474;
+        --highlight-4: #286b97;
+        --highlight-5: #4356a8;
+        --highlight-6: #7239a8;
+        --highlight-7: #971c87;
+        --highlight-8: #992121;
+        --highlight-9: #bd6b00;
+    }
+}
+
+.part-header {
+    display: flex;
+    justify-content: space-between;
+    background: var(--element);
+    border-bottom: 1px solid var(--border);
+    padding: 5px 10px 4px;
+    user-select: none;
+}
+
+.part-header:hover {
+    /* Prevent weird flicker when dragging splitter by disabling selection,
+    and enabling only on hover. Not perfect (selection reappears when you
+    hove again) but better than before. */
+    user-select: auto;
+}
+
+.part-title {
+    font-size: 18px;
+    margin: 0;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+.button--active {
+    font-weight: bold;
+}
+
+.slider {
+    flex: 0 0 165px;
+}
+
+.input-error {
+    background: var(--input-error);
+    position: absolute;
+}
+
+.highlight-1 {
+    background: var(--highlight-1);
+    position: absolute;
+}
+
+.highlight-2 {
+    background: var(--highlight-2);
+    position: absolute;
+}
+
+.highlight-3 {
+    background: var(--highlight-3);
+    position: absolute;
+}
+
+.highlight-4 {
+    background: var(--highlight-4);
+    position: absolute;
+}
+
+.highlight-5 {
+    background: var(--highlight-5);
+    position: absolute;
+}
+
+.highlight-6 {
+    background: var(--highlight-6);
+    position: absolute;
+}
+
+.highlight-7 {
+    background: var(--highlight-7);
+    position: absolute;
+}
+
+.highlight-8 {
+    background: var(--highlight-8);
+    position: absolute;
+}
+
+.highlight-9 {
+    background: var(--highlight-9);
+    position: absolute;
+}
+
+.has-tooltip, .has-tooltip-active {
+    border-bottom: 3px solid var(--underline);
+    border-radius: 0;
+    box-sizing: border-box;
+    position: absolute;
+}
+
+.has-tooltip-active {
+    border-bottom-color: var(--underline-active);
+}
+
+/*
+https://github.com/devuxd/SeeCodeRun/wiki/Ace-code-editor#i-want-to-create-a-popup-that-comes-up-whenever-the-user-hovers-over-a-piece-of-code-whats-the-best-way-to-do-that
+*/
+.tooltip {
+    background-color: var(--bg);
+    background-image: -webkit-linear-gradient(top, transparent, var(--tooltip-gradient));
+    background-image: linear-gradient(to bottom, transparent, var(--tooltip-gradient));
+    border: 1px solid var(--border);
+    border-radius: 1px;
+    box-shadow: 0 1px 2px var(--shadow);
+    color: var(--fg);
+    max-width: 100%;
+    padding: 3px 4px;
+    position: absolute;
+    z-index: 999999;
+    -moz-box-sizing: border-box;
+    -webkit-box-sizing: border-box;
+    box-sizing: border-box;
+    cursor: default;
+    white-space: pre;
+    word-wrap: break-word;
+    line-height: normal;
+    font-style: normal;
+    font-weight: normal;
+    letter-spacing: normal;
+    pointer-events: none;
+}
+
+/* ========================================================================== */
+/* ===== START copied from fidlbolt ========================================= */
+/* ========================================================================== */
+h1 {
+    font-size: 40px;
+    font-weight: bold;
+    margin: 20px 0;
+}
+h2 {
+    font-size: 25px;
+    font-weight: bold;
+    margin: 30px 0;
+}
+a {
+    text-decoration: none;
+    color: var(--link);
+}
+a:hover {
+    text-decoration: underline;
+}
+p {
+    margin: 0 0 1em 0;
+    line-height: 1.5;
+}
+ul, ol {
+    margin: 0;
+    padding: 0 0 0 25px;
+    list-style-position: outside;
+}
+li {
+    margin: 0 0 1em 0;
+    padding: 0;
+    line-height: 1.5;
+}
+.close-list li {
+    margin: 0;
+}
+kbd {
+    display: inline-block;
+    margin: 0 1px;
+    padding: 2px 3px;
+    border: 1px solid var(--border);
+    border-radius: 3px;
+    background: var(--intense);
+    font: 11px "Menlo", "Consolas", monospace;
+    white-space: nowrap;
+}
+.kbd-group {
+    white-space: nowrap;
+}
+hr {
+    border: none;
+    border-bottom: 1px solid var(--border);
+    margin: 30px auto;
+    padding: 0;
+}
+button {
+    font: inherit;
+    color: inherit;
+    outline: none;
+    border: none;
+    margin: 0;
+}
+button:focus {
+    text-decoration: underline;
+}
+.heading-fidl {
+    margin-left: 10px;
+}
+/* CHANGED (from heading-bolt) */
+.heading-viz {
+    color: var(--intense);
+    -webkit-text-stroke-width: 1.5px;
+    -webkit-text-stroke-color: var(--fg);
+    margin-right: 5px;
+}
+.wrapper {
+    margin: 0 20px;
+    display: flex;
+    flex-flow: column;
+    min-height: 100vh;
+}
+.heading {
+    text-align: center;
+}
+.container {
+    border: 1px solid var(--border);
+    border-radius: 5px;
+    overflow: hidden;
+    flex-grow: 1;
+    margin-bottom: 20px;
+    position: relative;
+}
+.split-container {
+    position: absolute;
+    top: 0;
+    left: 0;
+    bottom: 0;
+    right: 0;
+    width: 100%;
+    height: 100%;
+    display: flex;
+}
+.split-container--horizontal {
+    flex-direction: column;
+}
+.split-container--vertical {
+    flex-direction: row;
+}
+.split-item {
+    display: flex;
+    min-width: 0;
+    flex-direction: column;
+    height: 100%;
+}
+.split-item--first {
+    flex-shrink: 0;
+}
+.split-item--second {
+    flex-grow: 1;
+}
+.split-splitter {
+    flex-shrink: 0;
+    background: var(--border);
+}
+.split-splitter--vertical {
+    cursor: ew-resize;
+    width: 8px;
+}
+.split-splitter--horizontal {
+    cursor: ns-resize;
+    height: 8px;
+}
+.editor {
+    font: 14px "Menlo", "Consolas", monospace;
+    /* ADDED */ line-height: 18px;
+    flex-grow: 1;
+}
+.controls-container {
+    position: absolute;
+    top: 23px;
+    right: 20px;
+}
+.controls-button {
+    background: var(--intense);
+    padding: 10px;
+    border: 1px solid var(--border);
+    border-radius: 5px;
+    cursor: pointer;
+    display: inline-block;
+    margin-left: 10px;
+    user-select: none;
+    -webkit-user-select: none;
+}
+.controls-button:hover {
+    background: var(--intense-hover);
+}
+.modal-container {
+    position: absolute;
+    /* CHANGED */
+    /* background: rgba(255, 255, 255, 0.5); */
+    background: var(--cover);
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    z-index: 999;
+}
+.modal-box {
+    position: absolute;
+    top: 0;
+    bottom: 0;
+    right: 0;
+    max-width: min(100% - 100px, 525px);
+    padding: 0 20px 20px;
+    background: var(--bg);
+    border-left: 1px solid var(--border);
+    overflow-y: auto;
+}
+.modal-box .form-control {
+    margin-bottom: 12px;
+}
+.form-select {
+    margin-left: 8px;
+    width: 120px;
+}
+.form-checkbox {
+    margin-right: 8px;
+}
+.form-text, .form-number {
+    margin-left: 8px;
+    width: 40px;
+}
+.form-number--invalid {
+    color: #f33;
+}
+.form-button-group {
+    margin-top: 30px;
+}
+.form-button {
+    background: var(--intense);
+    padding: 8px;
+    margin: 0 5px;
+    border: 1px solid var(--border);
+    border-radius: 5px;
+    cursor: pointer;
+    display: inline-block;
+    font: inherit;
+    color: inherit;
+    font-size: 14px;
+}
+.form-button:hover {
+    background: var(--intense-hover);
+}
+/* CHANGED from 750px (because more buttons) */
+@media (max-width: 850px) {
+    .heading {
+        text-align: left;
+    }
+    .status {
+        left: 0;
+        right: 0;
+        text-align: center;
+    }
+}
+/* ========================================================================== */
+/* ===== END copied from fidlbolt =======================--================== */
+/* ========================================================================== */