diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs
index 69ef789c27..9ddff41e8f 100644
--- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs
+++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs
@@ -57,12 +57,56 @@
[app.main.ui.workspace.viewport.widgets :as widgets]
[app.render-wasm.api :as wasm.api]
[app.util.debug :as dbg]
+ [app.util.dom :as dom]
[app.util.text-editor :as ted]
[app.util.timers :as ts]
+ [cuerdas.core :as str]
[beicon.v2.core :as rx]
[promesa.core :as p]
[rumext.v2 :as mf]))
+;; --- Ruler theme color resolution
+;;
+;; Theme classes (`.default` for dark, `.light` for light) live on
+;; `
`, so the CSS custom properties we care about only resolve
+;; against an element inside body. We read them with
+;; `getComputedStyle(document.body)` and normalize the returned hex.
+
+(def ^:private ruler-color-vars
+ {:bg "--panel-background-color"
+ :border "--panel-border-color"
+ :label "--layer-row-foreground-color"
+ :accent "--color-accent-tertiary"})
+
+(def ^:private ruler-color-fallbacks
+ {:bg "#181818"
+ :border "#2e3434"
+ :label "#8f9da3"
+ :accent "#00d1b8"})
+
+(defn- normalize-hex
+ "CSS may return `#rgb` (4 chars) or `#rrggbb` (7 chars). Expand short
+ form, trim whitespace, and reject anything else (rgba, hsl, empty)."
+ [raw]
+ (let [s (some-> raw str/trim)]
+ (cond
+ (and (string? s) (= 7 (count s)) (str/starts-with? s "#")) s
+ (and (string? s) (= 4 (count s)) (str/starts-with? s "#"))
+ (let [r (subs s 1 2) g (subs s 2 3) b (subs s 3 4)]
+ (str "#" r r g g b b))
+ :else nil)))
+
+(defn- resolve-ruler-color [k]
+ (or (normalize-hex (dom/get-css-variable (get ruler-color-vars k) js/document.body))
+ (get ruler-color-fallbacks k)))
+
+(defn- push-ruler-colors! []
+ (wasm.api/set-rulers-colors!
+ (resolve-ruler-color :bg)
+ (resolve-ruler-color :border)
+ (resolve-ruler-color :label)
+ (resolve-ruler-color :accent)))
+
;; --- Viewport
(defn- apply-modifiers-to-objects
@@ -491,6 +535,41 @@
(when (and @canvas-init? hover-grid?)
(wasm.api/show-grid @hover-top-frame-id)))
+ ;; Rulers-wasm: push visibility / offsets / selection band into the
+ ;; render-wasm overlay (always active, no feature flag).
+ (let [ruler-selection (when (and show-rulers?
+ (d/not-empty? selected-shapes))
+ (gsh/shapes->rect selected-shapes))]
+
+ (mf/with-effect [@canvas-init?]
+ (when @canvas-init?
+ (push-ruler-colors!)
+ (wasm.api/request-render "rulers-colors-init")
+ (let [obs (js/MutationObserver.
+ (fn [_]
+ (push-ruler-colors!)
+ (wasm.api/request-render "rulers-colors-theme")))]
+ (.observe obs js/document.body
+ #js {:attributes true :attributeFilter #js ["class"]})
+ (fn [] (.disconnect obs)))))
+
+ (mf/with-effect [@canvas-init? show-rulers?]
+ (when @canvas-init?
+ (wasm.api/set-rulers-visible! show-rulers?)
+ (wasm.api/request-render "rulers-visible")))
+
+ (mf/with-effect [@canvas-init? show-rulers? offset-x offset-y]
+ (when (and @canvas-init? show-rulers?)
+ (wasm.api/set-rulers-offsets! offset-x offset-y)
+ (wasm.api/request-render "rulers-offsets")))
+
+ (mf/with-effect [@canvas-init? show-rulers?
+ (some-> ruler-selection :x) (some-> ruler-selection :y)
+ (some-> ruler-selection :width) (some-> ruler-selection :height)]
+ (when (and @canvas-init? show-rulers?)
+ (wasm.api/set-rulers-selection! ruler-selection)
+ (wasm.api/request-render "rulers-selection"))))
+
(hooks/setup-dom-events zoom disable-paste-ref in-viewport-ref read-only? drawing-tool path-drawing?)
(hooks/setup-viewport-size vport viewport-ref)
(hooks/setup-cursor cursor alt? mod? space? panning drawing-tool path-drawing? path-editing? z? read-only?)
@@ -773,15 +852,6 @@
[:& presence/active-cursors
{:page-id page-id}])
- (when-not hide-ui?
- [:& rulers/rulers
- {:zoom zoom
- :zoom-inverse zoom-inverse
- :vbox vbox
- :selected-shapes selected-shapes
- :offset-x offset-x
- :offset-y offset-y
- :show-rulers? show-rulers?}])
(when (and show-rulers? show-grids?)
[:> guides/viewport-guides*
diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs
index 6b20f1c9d3..14c9b2b336 100644
--- a/frontend/src/app/render_wasm/api.cljs
+++ b/frontend/src/app/render_wasm/api.cljs
@@ -1999,6 +1999,33 @@
(aget buffer 3)))
(request-render "show-grid"))
+(defn set-rulers-visible!
+ [visible?]
+ (h/call wasm/internal-module "_set_rulers_visible" (if visible? 1 0)))
+
+(defn set-rulers-offsets!
+ [offset-x offset-y]
+ (h/call wasm/internal-module "_set_rulers_offsets"
+ (or offset-x 0) (or offset-y 0)))
+
+(defn set-rulers-selection!
+ [rect]
+ (if (some? rect)
+ (h/call wasm/internal-module "_set_rulers_selection" 1
+ (or (:x rect) 0) (or (:y rect) 0)
+ (or (:width rect) 0) (or (:height rect) 0))
+ (h/call wasm/internal-module "_set_rulers_selection" 0 0 0 0 0)))
+
+(defn set-rulers-colors!
+ "Push ruler chrome / accent colors as ARGB u32. Inputs are hex strings
+ (e.g. \"#181818\"); call once on theme change."
+ [bg-hex border-hex label-hex accent-hex]
+ (h/call wasm/internal-module "_set_rulers_colors"
+ (sr-clr/hex->u32argb bg-hex 1)
+ (sr-clr/hex->u32argb border-hex 1)
+ (sr-clr/hex->u32argb label-hex 1)
+ (sr-clr/hex->u32argb accent-hex 1)))
+
(defn clear-grid
[]
(h/call wasm/internal-module "_hide_grid")
diff --git a/render-wasm/src/fonts/WorkSans-Numeric.ttf b/render-wasm/src/fonts/WorkSans-Numeric.ttf
new file mode 100644
index 0000000000..9566da91be
Binary files /dev/null and b/render-wasm/src/fonts/WorkSans-Numeric.ttf differ
diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs
index 72cc2078f2..abe7ae2f1e 100644
--- a/render-wasm/src/render.rs
+++ b/render-wasm/src/render.rs
@@ -6,6 +6,7 @@ pub mod gpu_state;
pub mod grid_layout;
mod images;
mod options;
+pub mod rulers;
mod shadows;
mod strokes;
mod surfaces;
@@ -26,7 +27,7 @@ use crate::shapes::{
all_with_ancestors, radius_to_sigma, Blur, BlurType, Corners, Fill, Shadow, Shape, SolidColor,
Stroke, StrokeKind, TextContent, Type,
};
-use crate::state::{ShapesPoolMutRef, ShapesPoolRef};
+use crate::state::{RulerState, ShapesPoolMutRef, ShapesPoolRef};
use crate::tiles::{self, PendingTiles, TileRect};
use crate::uuid::Uuid;
use crate::view::Viewbox;
@@ -358,6 +359,7 @@ pub(crate) struct RenderState {
pub nested_blurs: Vec