LazyRuler Developer Guide#
How the extension is put together, how the pieces talk to each other, and how to work on it. Read the user guide first if you have not used the extension yet.
Contents#
Overview#
LazyRuler is a Manifest V3 extension written in plain JavaScript and CSS. There is no build step, no bundler and no dependency: the files in src/ are loaded as they are.
Two parts run at different times:
- A service worker (
src/background.js) that reacts to the toolbar button and theAlt+Rcommand and tells the page to toggle. - Four content scripts (
src/store.js,src/snap.js,src/overlay.js,src/content.js), declared in the manifest for everyhttp,httpsandfilepage atdocument_idle, top frame only. They do nothing visible until they receive a toggle message.
File map#
manifest.json MV3 manifest: permissions, command, content scripts, web-accessible CSS
src/background.js service worker: toolbar click + Alt+R, on-demand injection
src/content.js page bootstrap: owns the one overlay instance, answers messages
src/overlay.js RulerOverlay class: rulers, guides, snapping, measure, display modes, HUD
src/overlay.css overlay styles, scoped to the shadow root
src/store.js chrome.storage.local wrapper: guides per origin, global display mode
src/snap.js edge collection and nearest-edge search
dev/sandbox.html runs the overlay outside the extension with a stubbed chrome.* API
icons/ 16, 32, 48 and 128 px icons
CHROME_WEB_STORE_GUIDE.md store listing text, permission justifications, packaging steps
PRIVACY.md privacy policy linked from the store listing
CHANGELOG.md version history
docs/ this guide and the user guide
Boot and toggle flow#
- The manifest injects the four content scripts into every page. Each file guards against a second load through flags on a shared namespace,
window.__RFB(ns.booted,ns.store,ns.collectEdges,ns.RulerOverlay), so injecting them twice is harmless. -
content.jsregisters achrome.runtime.onMessagelistener and waits. - When the toolbar icon is clicked or
Alt+Ris pressed,background.jssends{ type: 'RFB_TOGGLE' }to the active tab. - If the tab has no listener (it was open before the extension was installed or reloaded),
sendMessagerejects. The worker then runschrome.scripting.executeScriptwith the same four files and sends the message again. This is the only reason thescriptingpermission exists. -
content.jscreates aRulerOverlay, or destroys the existing one, and replies{ active: boolean }.
Tabs whose URL does not match ^https?:|^file: are ignored by the worker.
Messages#
All messages go to the content script of a tab. Each reply is { active: boolean }.
type |
Effect |
|---|---|
RFB_TOGGLE |
Turn the overlay on if off, off if on |
RFB_ON |
Turn on (no-op if already on) |
RFB_OFF |
Turn off (no-op if already off) |
RFB_STATE |
Reply with the current state only |
Only RFB_TOGGLE is sent today. The others exist for scripts or future UI that want explicit control.
The overlay#
RulerOverlay (in src/overlay.js) builds a <rfb-overlay> element, appends it to <html> and gives it an open shadow root, so page CSS cannot reach the overlay and overlay CSS cannot leak out. The host gets inline position: fixed; inset: 0; z-index: 2147483647; pointer-events: none, all !important, so it covers the viewport but lets the mouse through by default. Individual layers opt back in to pointer events.
The stylesheet is loaded with a <link> to src/overlay.css, which is why that file is listed under web_accessible_resources.
Layers inside the shadow root, bottom to top:
| Element | Class | Purpose | Pointer events |
|---|---|---|---|
div |
.rfb-catch |
Full-viewport catch layer for measure mode and drags | Only while .rfb--measure or .rfb--dragging is set on .rfb
|
div |
.rfb-guides |
Container for guide elements | Guides yes, container no. .is-hidden hides them, .is-locked dims and disables them |
div |
.rfb-measure |
Hover outline, hover label, SVG measurement line, distance label | No |
canvas × 2 |
.rfb-ruler--top, .rfb-ruler--left
|
The rulers | Yes (drag source for new guides) |
div |
.rfb-corner |
22 × 22 corner square | Yes |
div |
.rfb-hud |
Bottom-right control panel | Yes |
Rendering#
Every visual update goes through scheduleDraw(), which coalesces calls into one requestAnimationFrame and then runs:
-
_drawRulers(): clears and repaints both canvases atdevicePixelRatio, drawing ticks in document coordinates offset by the current scroll, labels every 100 px, and the cursor line. Colours are read from the CSS custom properties on.rfb(--rfb-bar,--rfb-tick,--rfb-text,--rfb-accent) withgetComputedStyle, so changing the theme inoverlay.cssalso changes the canvases. -
_positionGuides(): moves each guide element with atransform: translateofpos − scroll, updates its label and toggles.is-active,.is-snappedand.is-deleting. -
_renderMeasure(): positions the hover box and label, and the SVG line with its dashed legs and the distance label.
scroll, resize and mousemove all call scheduleDraw(). resize() also resizes the canvases and the SVG viewBox.
Guides#
A guide is a plain object { id, axis: 'h' | 'v', pos } where pos is a document coordinate in CSS px. this.guides is the source of truth; _syncGuideEls() rebuilds the DOM from it and this.guideEls maps each id to { node, label }.
Drag lifecycle:
-
mousedownon a ruler canvas calls_startNewGuide(e, axis), which creates a guide at the pointer and calls_beginDrag(guide, e, isNew = true).mousedownon an existing guide calls_beginDrag(guide, e, false). Both bail out while locked or for non-primary buttons. -
_beginDragstores the drag state (start point,moved,leftRuler,willDelete,snapped, snaptargets), adds.rfb--draggingand attaches capturingmousemove/mouseuplisteners onwindow. -
_dragMoveupdatespos, applies snapping unless Shift is held, and tracks whether the pointer has left the ruler strip (screenPos >= RULER) and then come back (willDelete).MOVE_SLOP(2 px) decides whether the gesture counts as a drag. -
_dragEndremoves the guide ifwillDelete, or if it was new and never moved (a plain click). Otherwise it re-syncs, updates the HUD and persists.
Double-click on a guide removes it unless locked.
Snapping#
src/snap.js exposes two pure functions on the namespace:
-
collectEdges(axis, exclude)walksdocument.body.getElementsByTagName('*')up toMAX_ELEMENTS(5000), skips the overlay host and elements smaller than 1 px or outside the viewport, and returns the document coordinates of the three relevant edges per element (left, right, centre for a vertical guide; top, bottom, middle for a horizontal one), plus 0. -
nearestEdge(targets, pos, threshold)returns the closest target withinthresholdpx, ornull.
Targets are computed once per drag in _beginDrag, so a very large page costs one DOM walk per drag, not one per mouse move. SNAP is 6 px.
Measure mode#
A keydown with altKey turns measure mode on. A keyup of Alt, any keyup without Alt, and window blur turn it off. The mode toggles .rfb--measure, which gives .rfb-catch pointer events and a crosshair cursor.
- Hover:
_elementAt(x, y)usesdocument.elementsFromPointand returns the first element that is neither the overlay host nor<html>.describe(node)builds thetag#id/tag.classlabel. - Distance:
_measureStartrecords{ from, to, done: false }in document coordinates;_measureHoverupdatestowhile the button is down; a capturingmouseuponwindowsetsdone. Leaving measure mode discards an unfinished measurement and keeps a finished one;Escclears both.
Display modes#
MODE_CYCLE is ['overlay', 'push', 'autohide']; cycleMode() applies the next one and saves it. _applyMode(mode) first tears down the previous mode, then:
-
push:
_applyPush()records the root element's inlinemargin-top/margin-left, sets both to22px !important, runs_pushViewportBoxes()and starts aMutationObserveron<html>(child list, subtree, andclass/styleattributes) that re-runs the scanPUSH_SCAN_DELAY(300 ms) after the last change; a window resize re-runs it too._removePush()restores the margins, disconnects the observer and gives every moved box its own values back. See Push mode and viewport boxes. -
autohide: adds
.rfb--autohide._checkAutoHide()runs on every mouse move and toggles.rfb--ruler-visiblewhen the pointer is withinEDGE(30 px) of the top or left edge, or while dragging, with aHIDE_DELAY(300 ms) timer before hiding. The slide is a CSS transition on the ruler canvases and the corner. - overlay: nothing to set up.
destroy() removes the push margins if push was active.
Push mode and viewport boxes#
The root margin moves in-flow content only. _pushViewportBoxes() walks document.body.getElementsByTagName('*') (up to PUSH_MAX_ELEMENTS, 5000) and handles two kinds of box:
-
position: fixed: qualifies for the top edge when its resolvedtopis between −22 and 22 px and its rect touches the top strip (top <= 22,bottom > 0), and for the left edge likewise withleft. Zero-size boxes are skipped, so hidden elements are left alone until they appear. Resolved insets are used values, so a box anchored withbottomreports a largetopand is ignored. -
position: sticky: qualifies by its computedtop/leftalone (autonever qualifies), but only when no ancestor below<body>is a scroll container, because a sticky box sticks to its nearest scrolling ancestor, not to the viewport. Rects are not consulted: a sticky bar that is not stuck yet must still get the new offset for when it is.
A qualifying box gets top: 22px !important and/or left: 22px !important through _pushProp(), which records the previous inline value and priority in this._pushed the first time and re-applies the value if the page overwrote it. Because the inset changes, a box with right or bottom set shrinks by itself; a box with an explicit width or height would overflow instead, so if its rect now crosses the viewport edge it also gets max-width: calc(100% - 22px) or max-height: calc(100% - 22px).
Boxes that stop qualifying on a later scan (no longer fixed, moved away) are restored at once. _unpushBox() removes a property only if the inline value is still the one LazyRuler set, so a value the page wrote in the meantime is kept. The scan ends with takeRecords() so its own style writes do not trigger another scan.
Persistence#
src/store.js wraps chrome.storage.local:
| Key | Value | Written by |
|---|---|---|
guides::<origin> |
Array<{ id, axis, pos }> |
save(), 200 ms after the last change (_persist()), and from destroy()
|
rfb_display_mode |
'overlay', 'push' or 'autohide'
|
saveMode() from cycleMode()
|
Every call is wrapped in try/catch because chrome.storage throws once the extension has been reloaded and the content script's context is invalidated. In that case guides stay in memory for the life of the page.
Teardown#
destroy() removes all window listeners, cancels the pending frame and timers, saves guides, restores push margins and removes the host element. content.js then drops its reference, so the next toggle builds a fresh overlay.
Working on it#
Load unpacked#
Load the repository folder at chrome://extensions with Developer mode on. After editing src/*.js or overlay.css, click the reload icon on the extension card and reload the page you are testing on. Pages that were open during the reload keep a dead copy of the old script, so reloading them is required.
The dev sandbox#
dev/sandbox.html runs the overlay outside the extension so you can iterate without reloading anything. It stubs just enough of chrome.*: runtime.getURL resolves to ../<path> and storage.local is an in-memory object. Open it straight from disk (file:///…/dev/sandbox.html) or through any static server, for example:
npx serve .and then visit /dev/sandbox.html. The page contains three deliberately misaligned cards to snap and measure against, fixed and sticky fixtures for checking push mode (a full-width header, a full-height rail, a transform-centred bar, a bottom-right box and a sticky bar), and exposes the instance as window.overlay.
Guides in the sandbox live only for that page load.
Debugging in a real page#
Content scripts run in the extension's isolated world. In DevTools, switch the console's context dropdown from top to LazyRuler to reach window.__RFB and the RulerOverlay class. chrome.storage.local.get(null) from that context shows the saved guides and mode.
The service worker has its own console: click service worker on the extension's card at chrome://extensions.
Conventions#
- The
RFB/rfb-prefix is kept from the original name, Ruler for Browser. It is the namespace onwindow, the message prefix and the CSS class prefix. - Geometry constants live at the top of
overlay.js:RULER(22 px bar thickness),SNAP(6 px),MOVE_SLOP(2 px),PUSH_MAX_ELEMENTS(5000) andPUSH_SCAN_DELAY(300 ms).--rfb-sizeinoverlay.cssmust matchRULER. - Keep the files dependency-free and loadable in the fixed order
store → snap → overlay → content.background.jslists the same order for on-demand injection.
Releasing#
- Bump
versioninmanifest.jsonand inCHROME_WEB_STORE_GUIDE.md, and update the release file name inREADME.mdanddocs/user-guide.md. - Add the changes to
CHANGELOG.md. - Build
LazyRuler-vX.Y.Z.zipwithmanifest.json,LICENSE,icons/andsrc/at the top level (no wrapping folder; notdev/, the docs or.git). The Chrome Web Store upload is the same withoutLICENSE, as described inCHROME_WEB_STORE_GUIDE.md. - Commit, tag
vX.Y.Z, push the tag, and create a GitHub release namedLazyRuler vX.Y.Zwith the ZIP attached and the changelog entry as its notes.
Permissions#
| Permission | Why |
|---|---|
storage |
Guides per origin and the display mode |
scripting |
On-demand injection into tabs that were open before install or reload |
host_permissions: <all_urls> |
The rulers must be able to appear on any page the user chooses |
web_accessible_resources: src/overlay.css |
The shadow root loads the stylesheet by URL |
No network requests are made, and no page content is read beyond element geometry.
