How It Works

A deep-dive into the architecture, logic, and implementation details behind Size Matters. Whether you're curious about how Apps Script extensions work or want to understand a specific feature, this covers it all.

Architecture

Size Matters is built entirely with Google Apps Script — no external servers, no build pipeline, no npm. The project splits cleanly into two layers: server-side .gs files that interact with the Google Docs™ API, and client-side HTML template files that form the sidebar UI. These two layers communicate exclusively through google.script.run, Google's built-in async bridge for calling server functions from the sidebar iframe.

Server-side (.gs)
appscript.json Main.gs Margins.gs Reset.gs Colours.gs CurrentPageSettings.gs Cached.gs

Runs on Google's infrastructure. Reads and writes the active document's page properties via DocumentApp. Persists user preferences via PropertiesService.

Client-side (HTML)
Sidebar.html SidebarCSS.html SidebarUtils.html SidebarLogic.html SidebarColorPicker.html

Runs in a sandboxed iframe. Manages all UI state, input validation, and visual feedback. Calls the server asynchronously via google.script.run.

Data flow

Every user action follows the same pattern: the client validates input locally, calls a server function, and handles the response with either .withSuccessHandler() or .withFailureHandler(). The server never pushes data to the client — all communication is pull-based, initiated by the UI.

ℹ️
Important constraintEvery google.script.run call is asynchronous and has real latency (typically 300–1500ms depending on load). Minimizing the number of server round trips — especially during startup — is a key architectural priority. See Single-roundtrip init.

All page dimensions are stored internally on the client in points, regardless of what unit the user has selected. Conversion between points and the display unit only happens at the edges — when populating input fields and when reading from them. This means the core logic is always working in one consistent unit, and unit-switching never corrupts the underlying state.


appscript.json

Server-side Project manifest

The manifest is the first thing Google reads when the extension loads. It declares the runtime version, exception logging destination, and — most importantly — the OAuth scopes the extension requests from the user.

Size Matters requests the absolute minimum: only two scopes, both narrowly scoped.

appscript.jsonJSON
{
  "timeZone": "America/New_York",
  "dependencies": {},
  "exceptionLogging": "STACKDRIVER",
  "oauthScopes": [
    "https://www.googleapis.com/auth/documents.currentonly",
    "https://www.googleapis.com/auth/script.container.ui"
  ],
  "runtimeVersion": "V8"
}
Scope Purpose
documents.currentonly Grants access to the single document currently open in the browser tab. The extension cannot access any other files in your Drive.
script.container.ui Allows the script to render a sidebar panel inside the Google Docs™ UI.
Why this matters for privacyThe documents.currentonly scope only permits reading and writing page-level properties (dimensions, margins, background color). It does not grant access to the text content of your document in any way.

Main.gs

Server-side Entry points & sidebar launcher

onInstall() and onOpen() are reserved Apps Script lifecycle hooks — Google calls them automatically when the add-on is installed or a document is opened. They add a "Size Matters" menu to the Google Docs™ toolbar. onInstall() just delegates to onOpen() so the menu appears immediately after install without needing a page refresh.

showSidebar() compiles the Sidebar.html template via HtmlService.createTemplateFromFile(). This is different from createHtmlOutputFromFile() — templates support scriptlet tags like <?!= ... ?>, which is how multiple HTML files get stitched together into one output at render time.

Main.gsGS
function onOpen() {
  const ui = DocumentApp.getUi();
  ui.createMenu("Size Matters")
    .addItem("Open Sidebar", "showSidebar")
    .addToUi();
}

function showSidebar() {
  const html = HtmlService
    .createTemplateFromFile("Sidebar")
    .evaluate()
    .setTitle("Size Matters");
  DocumentApp.getUi().showSidebar(html);
}

// Called by <?!= include('SidebarCSS') ?> template tags in Sidebar.html
function include(filename) {
  return HtmlService
    .createHtmlOutputFromFile(filename)
    .getContent();
}

The include() helper is what allows the project to be split across multiple HTML files while still producing a single compiled sidebar. At render time, each <?!= include('SidebarCSS') ?> tag in Sidebar.html is replaced with the raw content of that file. The output is one flat HTML document delivered to the browser.


Cached.gs

Server-side User persistence layer

This file handles everything that needs to survive across sessions: the user's preferred unit of measurement, their saved custom page size presets, and their saved custom page colors. All storage goes through PropertiesService.getUserProperties() — a key-value store scoped to the individual Google account that persists indefinitely until explicitly cleared.

Unit preference

The simplest piece of state: a single string ("inches", "cm", or "points") stored under the key "unitPreference". It's read once during sidebar initialization and saved whenever the user changes the unit selector.

Cached.gsGS
function getUserUnitPreference() {
  const userProperties = PropertiesService.getUserProperties();
  return userProperties.getProperty("unitPreference");
  // Returns null if not yet set — caller defaults to "inches"
}

function saveUserUnitPreference(unit) {
  PropertiesService.getUserProperties()
    .setProperty("unitPreference", unit);
}

Custom page size presets

Custom presets are stored as a JSON-serialized object, keyed by a sanitized version of the preset's display name. The sanitization strips everything except lowercase letters and digits, then prepends custom_ to distinguish custom presets from built-in ones like "letter" or "a4" in the client-side PAGE_SIZES lookup.

Cached.gs — saveCustomPreset()GS
function saveCustomPreset(name, width, height, unit) {
  const MAX_PRESETS = 20;
  let presets = getCustomPresets();

  // Sanitize name → deterministic, collision-resistant key
  const safeKey = "custom_" + name.toLowerCase().replace(/[^a-z0-9]/g, "");

  // Enforce cap only for new keys (overwriting an existing one is fine)
  if (!presets[safeKey] && Object.keys(presets).length >= MAX_PRESETS) {
    throw new Error("MAX_LIMIT_REACHED");
  }

  presets[safeKey] = {
    name: name,
    width: parseFloat(width),
    height: parseFloat(height),
    unit: unit
  };

  PropertiesService.getUserProperties()
    .setProperty("customPagePresets", JSON.stringify(presets));
  return presets;
}
ℹ️
Dual enforcementThe 20-preset cap is checked on both the server (here, throwing MAX_LIMIT_REACHED) and the client (in SidebarLogic.html, before the save modal even opens). The server check is the authoritative one — it ensures the limit holds even if somehow the client-side check is bypassed.

Custom colors

Saved colors are stored as a JSON array of hex strings under the key "customPageColors". The array acts as a FIFO queue: new colors are pushed to the end, and if the length exceeds 30, the oldest entry is removed with shift(). Duplicates are silently ignored before the add.

Cached.gs — saveCustomColor()GS
function saveCustomColor(hexColor) {
  let colors = getCustomColors();

  if (!colors.includes(hexColor)) {
    colors.push(hexColor);

    // Enforce max 30 — remove oldest if over limit
    if (colors.length > 30) {
      colors.shift();
    }

    PropertiesService.getUserProperties()
      .setProperty("customPageColors", JSON.stringify(colors));
  }
  return colors;
}

Colours.gs

Server-side Page background color operations

The API color format problem

The Google Docs™ API and a browser color picker speak two different color dialects. The Docs API's body.setBackgroundColor() method expects a hex string like "#f28b82". But the way color travels between the client (the color picker) and the server is as an RGB object with float components between 0 and 1 — because that's the format body.getBackgroundColor() returns as, and it's consistent with how the Docs API represents colors internally.

This means every color operation crosses two format boundaries. On the way in (applying a color), the flow is: browser hex → float RGB object → server → hex string → Docs API. On the way out (reading the current color), the flow reverses: Docs API hex string → float RGB object → client.

Hex ↔ RGB conversion

The server-side conversions in this file work with float components in the 0–1 range. This is the opposite convention from the client-side versions in SidebarUtils.html, which work with integer components in the 0–255 range. Both are correct for their context — the server needs floats to match the Docs API's own format, while the browser's canvas and CSS work naturally with integers.

Colours.gs — rgbToHex() (server-side, floats 0–1)GS
function rgbToHex(r, g, b) {
  // Each component is a float 0–1. Multiply by 255, round,
  // convert to base-16, then zero-pad to 2 digits.
  const toHex = (c) => {
    const hex = Math.round(c * 255).toString(16);
    return hex.length === 1 ? "0" + hex : hex;
  };
  return "#" + toHex(r) + toHex(g) + toHex(b);
}

function hexToRgb(hex) {
  if (!hex) return { red: 1, green: 1, blue: 1 }; // default white

  // Parse the 6-digit hex as a single 24-bit integer,
  // then extract R, G, B using bitwise shifts and masking.
  const bigint = parseInt(hex.slice(1), 16);
  return {
    red:   ((bigint >> 16) & 255) / 255,
    green: ((bigint >>  8) & 255) / 255,
    blue:  (bigint           & 255) / 255
  };
}

The bitwise extraction works by treating the 6-digit hex value as a single 24-bit integer. The red component occupies bits 16–23, green bits 8–15, and blue bits 0–7. Right-shifting by 16 or 8 moves the target component into the lowest byte, and & 255 (i.e., & 0xFF) masks off everything above it. Dividing by 255 normalizes the result to the 0–1 float range the Docs API expects.

Contrast with client-side versionsThe client-side hexToRgb() in SidebarUtils.html uses the exact same bitwise logic, but returns integers 0–255 instead of floats — because the canvas and CSS work in that range. The server and client versions are intentionally different scales for their respective environments.

CurrentPageSettings.gs

Server-side Document state reader

Unit conversion

The Docs API always returns page dimensions in points — there's no way to ask for inches or centimeters directly. One point is 1/72 of an inch, which means 1 inch = 72 points, and 1 centimeter = 72 ÷ 2.54 ≈ 28.3465 points.

getCurrentPageSettings(unit) uses a conversion factor lookup to divide each raw point value by the appropriate factor before returning it to the client. Requesting "points" uses a factor of 1 (no-op), which is how the client fetches values for internal storage.

CurrentPageSettings.gs — conversion logicGS
const UNIT_CONVERSIONS = {
  inches: 72,        // 1 inch  = 72 points
  cm:     72 / 2.54,  // 1 cm    ≈ 28.3465 points
  points: 1           // no conversion needed
};

const conversionFactor = UNIT_CONVERSIONS[unit];
const pointsToUnit = (points) => points / conversionFactor;

return {
  width:        pointsToUnit(body.getPageWidth()),
  height:       pointsToUnit(body.getPageHeight()),
  topMargin:    pointsToUnit(body.getMarginTop()),
  bottomMargin: pointsToUnit(body.getMarginBottom()),
  leftMargin:   pointsToUnit(body.getMarginLeft()),
  rightMargin:  pointsToUnit(body.getMarginRight())
};

Single-roundtrip initialization

When the sidebar first opens, it needs three things from the server: the user's unit preference, their saved custom presets, and the current document's page settings. Naively, that's three separate google.script.run calls — meaning up to three serial round trips before the UI can display anything meaningful.

getInitialSidebarData() eliminates this by bundling all three into a single server call. The server reads all three data sources and returns them as one object, which the client receives in a single callback.

CurrentPageSettings.gs — getInitialSidebarData()GS
function getInitialSidebarData() {
  const unit = getUserUnitPreference() || "inches";
  return {
    customPresets: getCustomPresets(),
    unitPreference: unit,
    pageSettings: getCurrentPageSettings(unit)
  };
}
Why this mattersEach google.script.run call has fixed overhead of several hundred milliseconds just for the round trip. Collapsing three calls into one can cut sidebar initialization time by half a second or more — a very noticeable difference for a UI that opens in a sidebar panel.

Margins.gs

Server-side Document writer

setCustomPageSizeWithMargins() is the core write operation — the function that actually changes the document. It receives all six values (width, height, and four margins) already converted to points by the client, then calls the six corresponding setter methods on the document body. The order matters slightly: height and width are set before margins, which is the same order Google's own page setup dialog uses.

Errors are explicitly caught, logged, and re-thrown. The re-throw is important: it causes the .withFailureHandler() registered on the client side to fire, which shows an error toast. Without the re-throw, a Docs API error would silently swallow the failure and the user would never know the apply didn't work.

Margins.gsGS
function setCustomPageSizeWithMargins(settings, unit) {
  const body = DocumentApp.getActiveDocument().getBody();
  try {
    body.setPageHeight(settings.height);
    body.setPageWidth(settings.width);
    body.setMarginTop(settings.topMargin);
    body.setMarginBottom(settings.bottomMargin);
    body.setMarginLeft(settings.leftMargin);
    body.setMarginRight(settings.rightMargin);
  } catch (error) {
    Logger.log(`Error: ${error}`);
    throw error; // re-throw so withFailureHandler() fires on client
  }
}

Reset.gs

Server-side Default restoration

The default Google Docs™ document is US Letter: 8.5 × 11 inches with 1-inch margins on all sides. Since the Docs API works in points, these values are hardcoded as their point equivalents — no conversion math needed at runtime.

Reset.gsGS
// 1 inch = 72 points, so:
//   8.5 inches = 612 points
//   11  inches = 792 points
//   1   inch   =  72 points (margins)

body.setPageHeight(11  * 72);  // 792pt
body.setPageWidth(8.5 * 72);  // 612pt
body.setMarginTop(72);
body.setMarginBottom(72);
body.setMarginLeft(72);
body.setMarginRight(72);

Client-side Main HTML structure

This file is the root template. It defines all the DOM elements the user interacts with — inputs, dropdowns, buttons, radio buttons, modals — but contains no CSS or JavaScript itself. Those are pulled in at render time via the include() helper and scriptlet tags.

Sidebar.html — include patternHTML
<head>
  <!-- CSS inlined here at compile time -->
  <?!= include('SidebarCSS') ?>
</head>
<body>
  <!-- ... all DOM elements ... -->

  <!-- JS files inlined at bottom of body, in dependency order -->
  <?!= include('SidebarUtils') ?>
  <?!= include('SidebarLogic') ?>
  <?!= include('SidebarColorPicker') ?>
</body>

The order of the JS includes matters: SidebarUtils defines shared helpers that both SidebarLogic and SidebarColorPicker depend on (like CONVERSIONS, showToast, and the color math functions), so it must come first.

The sidebar contains three separate modals baked into the HTML: a Reset Confirmation modal (with a live current-vs-default comparison table), a Save Preset modal, and a reusable Generic Confirm modal used for deletions. All three follow the same pattern — a .modal-background overlay and a .modal wrapper, both toggled with an .active class that drives CSS transitions.

The Generic Confirm modal is particularly flexible: its title, message, confirm button text, and an optional extra HTML block (used to show a color swatch or preset card inside the confirmation) are all set dynamically by ModalManager.genericConfirm.show() in the logic layer.


SidebarUtils.html

Client-side Shared utilities, color math, toast system

This file loads first and defines everything the other client-side files depend on. It has no UI of its own — it's a pure utility layer.

The CONVERSIONS object

Rather than scattering conversion math throughout the codebase, all unit logic lives in one place: a CONVERSIONS lookup object keyed by unit name. Each entry has a toPoints() function, a fromPoints() function, and a display label. Any code that needs to convert a value just calls CONVERSIONS[unit].toPoints(value) or CONVERSIONS[unit].fromPoints(value).

SidebarUtils.html — CONVERSIONSJS
const CONVERSIONS = {
  inches: {
    label: "inches",
    toPoints:   (v) => v * 72,
    fromPoints: (v) => v / 72
  },
  cm: {
    label: "cm",
    // 1 cm = 72/2.54 points ≈ 28.3465pt
    toPoints:   (v) => (v / 2.54) * 72,
    fromPoints: (v) => (v * 2.54) / 72
  },
  points: {
    label: "points",
    toPoints:   (v) => v,  // no-op
    fromPoints: (v) => v   // no-op
  }
};

Color math: HSL ↔ RGB ↔ Hex

The color picker works in HSL internally (hue, saturation, lightness) because it's the most natural model for a gradient canvas — the hue slider sets H, and the X/Y axes of the canvas represent S and L respectively. But the Docs API and CSS both work in RGB/hex. So the client needs the full conversion chain in both directions.

HSL → RGB follows the standard algorithm. The key insight is that the six hue sectors (each 60° wide) map to different arrangements of the three color components. The chromaticity c determines the color's vividness, and x is the intermediate value for the second-strongest component in the current sector. The lightness offset m shifts the entire result up or down the brightness scale.

SidebarUtils.html — hslToRgb()JS
function hslToRgb(h, s, l) {
  h = ((h % 360) + 360) % 360;  // normalize hue to [0, 360)
  s = Math.max(0, Math.min(1, s));  // clamp saturation
  l = Math.max(0, Math.min(1, l));  // clamp lightness

  if (s === 0) {
    // Achromatic (grey): all channels equal lightness
    const v = Math.round(l * 255);
    return [v, v, v];
  }

  // c = chroma (color vividness, 0 at L=0 or L=1, max at L=0.5)
  const c = (1 - Math.abs(2 * l - 1)) * s;
  // x = secondary component for this hue sector
  const x = c * (1 - Math.abs((h / 60) % 2 - 1));
  // m = lightness offset (shifts all channels up/down)
  const m = l - c / 2;

  let r, g, b;
  // Each 60° sector has a different dominant/secondary channel
  if      (h <  60) [r,g,b] = [c,x,0];  // red → yellow
  else if (h < 120) [r,g,b] = [x,c,0];  // yellow → green
  else if (h < 180) [r,g,b] = [0,c,x];  // green → cyan
  else if (h < 240) [r,g,b] = [0,x,c];  // cyan → blue
  else if (h < 300) [r,g,b] = [x,0,c];  // blue → magenta
  else                [r,g,b] = [c,0,x];  // magenta → red

  return [
    Math.round((r + m) * 255),
    Math.round((g + m) * 255),
    Math.round((b + m) * 255)
  ];
}

RGB → HSL is the reverse path, used when syncing the canvas and hue slider to a hex color the user has typed or selected from a preset. It finds the dominant channel (max), uses the difference between max and min to calculate chroma and saturation, then determines the hue sector from which channel is dominant.

SidebarUtils.html — rgbToHsl()JS
function rgbToHsl(r, g, b) {
  r /= 255; g /= 255; b /= 255;
  const max = Math.max(r, g, b), min = Math.min(r, g, b);
  let h, s, l = (max + min) / 2;

  if (max === min) {
    h = s = 0;  // achromatic grey
  } else {
    const d = max - min;
    // Saturation formula differs above/below L=0.5
    s = l > 0.5
      ? d / (2 - max - min)
      : d / (max + min);
    switch (max) {
      case r: h = (g - b) / d + (g < b ? 6 : 0); break;
      case g: h = (b - r) / d + 2;             break;
      case b: h = (r - g) / d + 4;             break;
    }
    h /= 6;  // normalize to [0, 1], then multiply by 360 below
  }
  return [Math.round(h * 360), s, l];
}

Float comparison

Floating-point arithmetic is famously imprecise. 8.5 * 72 should be 612, but JavaScript may return 611.9999999999999. Direct equality checks (===) would then falsely conclude a document isn't at its default size and incorrectly enable the Reset button. compareFloats() solves this by checking whether two values are within a small tolerance of each other.

SidebarUtils.html — compareFloats()JS
function compareFloats(a, b, tolerance = 0.001) {
  return Math.abs(a - b) <= tolerance;
}

// Used everywhere a numeric equality check is needed, e.g.:
const isDefault =
  compareFloats(612, width)   // 8.5in in points
  && compareFloats(792, height)  // 11in in points
  && compareFloats(72, topMargin); // 1in in points

Toast notification system

All toast messages are defined declaratively in a TOAST_TYPES config object — separated entirely from the display logic. Each type (success, error, warning, info) has its own color scheme and an array of numbered messages. Message functions can accept a unit string argument to produce dynamic text like "Page size updated in centimeters."

ToastManager.show(typeKey, messageIndex, unit, duration) handles everything: applying styles, setting content, resetting and replaying the progress bar animation, and scheduling auto-dismiss timers. Calling it again before a previous toast has dismissed cancels the old timers first, so toasts never pile up.

SidebarUtils.html — toast message config (excerpt)JS
const TOAST_TYPES = {
  success: {
    icon: "fa-check", iconClass: "success",
    textColor: "#155724", backgroundColor: "#d4edda",
    messages: [
      // index 0: plain string
      { title: "Updated!", message: (unit) => `Updated in ${unit}` },
      // index 1: function that receives unit arg
      { title: "Success!", message: "Document reset to default settings!" }
    ]
  },
  // ... error, warning, info ...
};

// Calling it anywhere in the codebase:
showToast('success', 0, 'centimeters', 5000);
// → "Updated in centimeters" for 5 seconds

Spinner & animation helpers

The loading spinner is a CSS SVG animation that overlays the entire sidebar with a frosted glass effect during server calls. showSpinner() sets display: flex first, then adds the .visible class on the next frame — this two-step approach is necessary because CSS transitions don't fire if display changes in the same frame as the opacity/transform transition starts.

waitForAnimations() is used after server calls complete to delay the success toast until any in-progress CSS animations (like the tumble entrance) have finished, preventing the toast from appearing over a still-animating UI.


SidebarLogic.html

Client-side Core application logic

AppState — the single source of truth

All mutable UI state lives in one object: AppState. Internal page dimensions are always stored in points, regardless of which unit is displayed. This means conversions only happen at the display layer — writing values into inputs and reading them back out. Everywhere else, the code works in one consistent unit.

SidebarLogic.html — AppStateJS
const AppState = {
  // All page dimensions stored internally in points
  internalValues: {
    width: 0, height: 0,
    topMargin: 0, bottomMargin: 0,
    leftMargin: 0, rightMargin: 0
  },
  modifiedInputs: {},   // tracks which inputs differ from saved state
  invalidInputs: {},    // tracks which inputs have failed validation
  flags: {
    isProgrammatic: false,  // suppresses modification detection during script-driven updates
    isLandscape: false,
    marginsLinked: false
  },
  currentUnit: "inches"
};

The isProgrammatic flag is particularly important. When the script itself changes input values (e.g., after applying settings, switching units, or clearing changes), it sets this flag to true first. The markInputAsModified() function checks this flag and skips modification detection entirely, preventing false "modified" states from appearing after programmatic updates.

Input validation

Validation runs inside a debounced handler that fires 300ms after the user stops typing. Two categories of errors are checked:

SidebarLogic.html — margin overlap checkJS
const buffer = CONVERSIONS[unit].fromPoints(7.2);  // ~0.1 inches minimum content area

if ((l + r >= w - buffer) || (t + b >= h - buffer)) {
  AppState.invalidInputs['marginOverlap'] = true;
  showToast('error', 1, "Margins cannot be larger than the page size", 4000);
}

Orientation swap with margin transposition

Switching orientation isn't just swapping width and height. When you rotate a page, the margins rotate with it — what was the top margin becomes the left margin, and so on. The swap logic handles this explicitly by transposing vertical margins (top/bottom) with horizontal margins (left/right) at the same time it swaps the dimensions.

SidebarLogic.html — applyOrientation()JS
// Only swap if the current orientation doesn't match the target
if ((orientation === "landscape" && vals.w < vals.h)
 || (orientation === "portrait"  && vals.w > vals.h)) {

  // Swap dimensions
  inputs.width.value  = vals.h.toFixed(2);
  inputs.height.value = vals.w.toFixed(2);

  // Transpose margins: top ↔ left, bottom ↔ right
  inputs.top.value    = vals.l.toFixed(2);
  inputs.left.value   = vals.t.toFixed(2);
  inputs.bottom.value = vals.r.toFixed(2);
  inputs.right.value  = vals.b.toFixed(2);
}

Unit switching

When the user changes the unit selector, the currently displayed input values are converted to the new unit — not the saved internal values. This is an important distinction: if a user has typed a modified value they haven't applied yet, that modified value is what gets converted and preserved, not the last-saved state.

SidebarLogic.html — unit selector change handlerJS
AppState.flags.isProgrammatic = true;  // suppress modification detection

INPUT_KEYS.forEach(key => {
  const input = document.getElementById(key);
  const currentValue = parseSafeFloat(input.value);

  // Convert what the user typed (in old unit) → points → new unit
  const inPoints = CONVERSIONS[oldUnit].toPoints(currentValue);
  input.value = parseFloat(
    CONVERSIONS[newUnit].fromPoints(inPoints)
  ).toFixed(2);
});

AppState.flags.isProgrammatic = false;
AppState.currentUnit = newUnit;
google.script.run.saveUserUnitPreference(newUnit);

Custom preset save flow

Saving a preset has several layers of protection that run in sequence before any server call is made:

Button state management

The Apply and Clear buttons are only enabled when there is at least one modified input and no invalid inputs. The Reset button is only enabled when the current document settings differ from the default Letter configuration. These checks run after every input event, unit change, and server response.

SidebarLogic.html — updatePageSettingsButtonsState()JS
function updatePageSettingsButtonsState() {
  const anyModified = Object.values(AppState.modifiedInputs).some(v => v);
  const anyInvalid = Object.values(AppState.invalidInputs).some(v => v);

  // Apply: needs at least one change AND no errors
  applyButton.disabled = !anyModified || anyInvalid;
  // Clear: only needs at least one change
  clearButton.disabled = !anyModified;
}

Initialization sequence

On DOMContentLoaded, the sidebar makes a single call to getInitialSidebarData(). The response contains everything needed to bootstrap the UI: unit preference, custom presets, and current page settings. From there, initializePageSettings() populates AppState, fills the input fields, checks orientation, and wires up all event listeners.

Event listeners are attached after the initial data is loaded, not before — this prevents the debounced input handler from firing spuriously during programmatic population of the fields.


SidebarColorPicker.html

Client-side Color picker feature

Gradient canvas rendering

The color gradient is drawn by iterating over every pixel in the canvas and computing its color from its position. The X axis maps to saturation (0 at the left, 1 at the right) and the Y axis maps to lightness (1 at the top, 0 at the bottom). For each pixel, hslToRgb() is called with the current hue from the slider and the positional S/L values. The result is written directly into a flat ImageData array — 4 bytes per pixel (RGBA) — and put onto the canvas in one operation.

SidebarColorPicker.html — drawGradient()JS
function drawGradient(hue) {
  const { width, height } = ColorPickerUI.canvas;
  const imageData = ColorPickerUI.ctx.createImageData(width, height);
  const data = imageData.data;  // flat Uint8ClampedArray, 4 bytes per pixel

  for (let y = 0; y < height; y++) {
    for (let x = 0; x < width; x++) {
      const s = x / (width  - 1);  // x → saturation 0..1
      const l = 1 - (y / (height - 1));  // y → lightness 1..0 (inverted)
      const [r, g, b] = hslToRgb(hue, s, l);
      const i = (y * width + x) * 4;  // byte index in flat array
      data[i]     = r;
      data[i+1]   = g;
      data[i+2]   = b;
      data[i+3]   = 255;  // alpha: fully opaque
    }
  }
  ColorPickerUI.ctx.putImageData(imageData, 0, 0);
}
ℹ️
Why not CSS gradients?A CSS gradient approach (e.g., layering a white-to-hue gradient over a white-to-black gradient) would be simpler to write but less accurate — the color at any given pixel wouldn't precisely match an HSL value, making it impossible to reliably reverse the position back to a hex color. Pixel-level ImageData rendering ensures the canvas is an exact representation of the HSL color space at the current hue.

Smart hex input with ghost-text prediction

The hex input field has a ghost-text overlay system that predicts the completed hex value as you type. It's implemented using two stacked <input> elements: the visible foreground input the user types into, and a disabled background input that shows the predicted completion in a muted color. Both are the same size and font, positioned on top of each other so the ghost text appears to extend the user's real input.

SidebarColorPicker.html — ghost text prediction logicJS
hexInput.addEventListener("input", (e) => {
  const hexOnly = e.target.value.replace(/[^a-fA-F0-9]/g, '');

  if (hexOnly.length === 1) {
    // Single char → repeat 5 times: "f" predicts "ffffff"
    ghostInput.value = "#" + hexOnly + hexOnly.repeat(5);
  } else if (hexOnly.length === 3) {
    // 3-char shorthand → expand inline: "abc" → live preview of "#aabbcc"
    const expanded = hexOnly.split('').map(c => c + c).join('');
    syncUIFromHex('#' + expanded);
  } else if (hexOnly.length === 6) {
    // Complete → sync immediately
    syncUIFromHex('#' + hexOnly.toLowerCase());
  }
});

// Tab accepts the ghost prediction
hexInput.addEventListener("keydown", (e) => {
  if (e.key === "Tab" && ghostInput.value) {
    e.preventDefault();
    const finalHex = '#' + ghostInput.value
      .replace(/[^a-fA-F0-9]/g, '')
      .toLowerCase();
    hexInput.value = finalHex;
    syncUIFromHex(finalHex);
  }
});

Custom color management

Custom colors use an optimistic UI pattern: when the user saves a color, the swatch appears in the grid immediately — before any server confirmation. The server call happens asynchronously in the background. If it fails, an error toast appears, but the swatch is already there. This makes the interaction feel instant rather than waiting for a round trip.

Right-clicking a swatch triggers a delete confirmation modal, built dynamically using ModalManager.genericConfirm.show() with a custom HTML block showing the color's hex value and a circular preview. Deletion is also optimistic — the swatch animates out (CSS scale to 0) and is removed from the DOM immediately, then the server call fires.

Server actions: Apply and Reset

Both the Apply and Reset color buttons use the same async wrapper pattern: show spinner → await server call → hide spinner → show toast. The server receives an RGB float object ({ red, green, blue } in 0–1 range) and writes it to the document. On success, ColorState.currentPageColor is updated so the Apply button correctly becomes disabled (since the page color now matches the selected color).

SidebarColorPicker.html — apply color actionJS
applyButton.addEventListener("click", async () => {
  const hex = hexInput.value;

  // Parse hex → normalized RGB floats for the server
  const rgbVals = hex.match(/\w\w/g).map(x => parseInt(x, 16) / 255);
  const rgbObj = { red: rgbVals[0], green: rgbVals[1], blue: rgbVals[2] };

  await runColorAction(() => new Promise(resolve => {
    google.script.run
      .withSuccessHandler(() => {
        ColorState.currentPageColor = hex;  // update local state
        updateApplyButtonState();
        updateResetColorButtonState();
        resolve();
      })
      .withFailureHandler(() => resolve())
      .setPageColor(rgbObj);
  }), 4);
});

SidebarCSS.html

Client-side All styling

Design tokens

Every color used in the sidebar is defined as a CSS custom property on :root, organized into semantic groups. Nothing is hardcoded at the component level — a button's hover color references var(--color-button-secondary-hover), not a hex value. This makes the entire color scheme adjustable from one place and makes it immediately clear what role each color plays.

SidebarCSS.html — design token structure (excerpt)CSS
:root {
  /* Text */
  --color-text-primary:   #202124;   /* body text */
  --color-text-secondary: #5f6368;   /* labels, muted text */
  --color-text-disabled:  #666666;

  /* Accents */
  --color-accent-blue:    #4285f4;   /* focus rings, active states */
  --color-button-primary: #1a73e8;   /* primary button fill */

  /* Feedback colors */
  --color-toast-success:  #4caf50;
  --color-toast-error:    #f44336;
  --color-toast-warning:  #ff9800;

  /* Shadows & overlays */
  --color-shadow-glow-low: rgba(16, 150, 221, 0.35);
  --color-overlay-background-low: rgba(240, 244, 249, 0.3);
}

The tumble entrance animation

When the sidebar opens, every UI element animates in with a "hard slam" effect — dropping from above with a randomized rotation and a subtle bounce on impact. The animation is defined as a single @keyframes rule. Each element's starting rotation is set via a CSS custom property (--random-rotation), which JavaScript sets to a random value between -20° and +20° for each element during DOMContentLoaded. Elements are staggered by applying incrementing animation-delay values so they land in sequence rather than all at once.

SidebarCSS.html — hard-slam keyframesCSS
@keyframes hard-slam {
  0%   {
    opacity: 0;
    transform: translateY(-800px) rotate(var(--random-rotation, 10deg));
  }
  75%  {
    opacity: 1;
    transform: translateY(0) rotate(0deg);   /* slam into position */
  }
  85%  { transform: translateY(3px); }   /* impact overshoot */
  100% { transform: translateY(0); }    /* settle */
}

/* JavaScript sets this per-element on DOMContentLoaded: */
/* el.style.setProperty('--random-rotation', `${Math.random() * 40 - 20}deg`); */
/* el.style.animationDelay = `${index * 0.1}s`; */