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.
Runs on Google's infrastructure. Reads and writes the active document's page
properties via DocumentApp. Persists user preferences via PropertiesService.
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.
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
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.
{
"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. |
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
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.
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
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.
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.
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;
}
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.
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
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.
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.
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
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.
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.
function getInitialSidebarData() {
const unit = getUserUnitPreference() || "inches";
return {
customPresets: getCustomPresets(),
unitPreference: unit,
pageSettings: getCurrentPageSettings(unit)
};
}
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
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.
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
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.
// 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);
Sidebar.html
Structure & includes
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.
<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.
Modal system
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
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).
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.
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.
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.
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.
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
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.
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:
- Out-of-range values — dimensions must be between 0.1 inches (7.2pt) and 120 inches (8640pt). Values outside this range would either cause the Docs API to throw or produce a nonsensical document.
- Margin overlap — the combined left + right margins must be smaller than the page width (with a small buffer), and combined top + bottom margins must be smaller than the page height. If margins would leave zero or negative space for content, an error toast is shown immediately.
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.
// 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.
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:
- Invalid dimension check — if either width or height is currently flagged invalid, the save modal never opens.
- Duplicate dimension check — the client iterates over all existing presets (both
built-in and custom) and uses
compareFloats()to check if the current dimensions already exist. If they do, a warning toast is shown with the name of the matching preset. - Overwrite confirmation — if the user types a name that matches an existing custom preset's key, the Confirm button changes to "Overwrite" and turns red. A second click is required to proceed.
- 20-preset limit — checked on both client (before the modal opens) and server (which
throws
MAX_LIMIT_REACHEDas a safety net).
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.
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
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.
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);
}
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.
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).
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
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.
: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.
@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`; */