From 0eb21647c368aa556d26ed5dc3fd44c6e672cfd7 Mon Sep 17 00:00:00 2001 From: SylvainDruaux Date: Mon, 19 Oct 2026 23:05:31 +0200 Subject: [PATCH] feat(preview): add a column guide over the canvas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new toolbar button, next to the breakpoints buttons, opens the column guide's menu. When on, the guide draws full-height columns over the preview, under the selection outlines. It never takes a click and never writes to the project. It works in the desktop app and in Studio, which share the same implementation. In a Lumos project, the guide reads the layout variables at the current preview width: the column count from --site-column-count, --site-columns or --_column-count, and the margin, gutter and max width from --site-margin, --site-gutter and --max-width-main. Fluid clamp() values are resolved to pixels by the preview frame. Without those variables, Stacki's defaults apply: 12 columns, a fluid margin (16–48px) and gutter (16–32px), and no max width. In the menu, the column count can be picked from an editable list of whole numbers from 4 to 24, or read from a project variable. A count outside that range is ignored, and the menu says why. The margin, gutter and max width take a length (px or rem) or a project variable. Reset brings back the project's value or the default. Colour, opacity, numbers and visibility are saved per project, and ⇧G shows or hides the guide. The menu reuses the style panel's controls, which adds a preview edge to eslint.config.mjs. --- electron/preload/frameBasics.ts | 4 + electron/preload/frameDesign.ts | 5 +- electron/preload/frameGuide.ts | 243 ++++++ electron/preload/frameQueries.ts | 9 +- electron/preload/frameReports.ts | 3 +- eslint.config.mjs | 11 + src/app/shell/AppPanes.tsx | 1 + src/features/preview/GuideMenu.tsx | 789 ++++++++++++++++++ src/features/preview/GuideOverlay.tsx | 51 ++ src/features/preview/PreviewPane.tsx | 35 +- src/features/preview/PreviewToolbar.tsx | 4 + src/features/preview/guide.css | 110 +++ src/features/preview/guideModel.ts | 545 ++++++++++++ src/features/preview/guideSettings.ts | 277 ++++++ src/features/preview/previewMessages.ts | 86 ++ src/features/preview/previewRuntime.ts | 68 +- src/features/style/LayerPopover.tsx | 6 +- src/ui/Icons.tsx | 9 + studio/scripts/shared-source.json | 6 + studio/src/app/Editor.tsx | 1 + studio/src/features/preview/PreviewCanvas.tsx | 29 +- studio/src/features/preview/previewClient.ts | 3 + studio/src/features/preview/previewKeys.ts | 2 + studio/src/features/preview/usePreview.ts | 36 +- test/renderer/preview/guideModel.test.js | 326 ++++++++ test/renderer/preview/guideSettings.test.js | 118 +++ test/renderer/preview/previewMessages.test.js | 64 ++ 27 files changed, 2812 insertions(+), 29 deletions(-) create mode 100644 electron/preload/frameGuide.ts create mode 100644 src/features/preview/GuideMenu.tsx create mode 100644 src/features/preview/GuideOverlay.tsx create mode 100644 src/features/preview/guide.css create mode 100644 src/features/preview/guideModel.ts create mode 100644 src/features/preview/guideSettings.ts create mode 100644 test/renderer/preview/guideModel.test.js create mode 100644 test/renderer/preview/guideSettings.test.js diff --git a/electron/preload/frameBasics.ts b/electron/preload/frameBasics.ts index f8b56738..b19c0684 100644 --- a/electron/preload/frameBasics.ts +++ b/electron/preload/frameBasics.ts @@ -27,6 +27,10 @@ export type GapRow = { top: number; bottom: number; items: DOMRect[] }; // come from the project's markup, and what lies below the bound is skipped. export const PRELOAD_LIMITS = { domDepthMax: 512, cssRuleDepthMax: 64 } as const; +// Marks the elements the canvas adds to the page to measure it: its own +// writes, which never start a page walk (mutationKind in frameReports.ts). +export const PROBE_ATTR = 'data-avb-probe'; + export const isElement = (node: Node): node is Element => node.nodeType === 1; export const isComment = (node: Node): node is Comment => node.nodeType === 8; export const isText = (node: Node): node is Text => node.nodeType === 3; diff --git a/electron/preload/frameDesign.ts b/electron/preload/frameDesign.ts index 573f5881..3afc732d 100644 --- a/electron/preload/frameDesign.ts +++ b/electron/preload/frameDesign.ts @@ -175,7 +175,10 @@ function forwardShortcut(event: KeyboardEvent): void { // usually made, so it has to reach the app from in here too. const isClassJump = mod && !event.altKey && !event.shiftKey && event.key === 'Enter'; const isProperties = !mod && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'k'; - if (isDelete || isDuplicate || isClassJump || isProperties) { + // ⇧G shows or hides the column guide drawn over this frame. It is replayed + // as the key "G", which says Shift on its own (src/features/preview/guideSettings.ts). + const isGuide = !mod && !event.altKey && event.shiftKey && event.key.toLowerCase() === 'g'; + if (isDelete || isDuplicate || isClassJump || isProperties || isGuide) { event.preventDefault(); try { window.parent.postMessage( diff --git a/electron/preload/frameGuide.ts b/electron/preload/frameGuide.ts new file mode 100644 index 00000000..7eef82b3 --- /dev/null +++ b/electron/preload/frameGuide.ts @@ -0,0 +1,243 @@ +// What the column guide is drawn from: the page's own layout values, measured +// where they apply. The guide is drawn by the app over the frame +// (src/features/preview/GuideOverlay.tsx); the frame only says how wide the +// page is and what its margin, gutter, maximum width and column count come to +// at that width. +// +// The values are read from the page, never written to it. A project that +// declares them (Lumos does: `--site-margin`, `--site-gutter`, +// `--max-width-main`) gets a guide that follows its fluid `clamp()` values at +// every preview width; a project that declares none gets `undefined` for each. +// A variable typed into the guide's menu (`--container-padding`) is sent here +// by name and measured the same way, so a project with its own names follows +// its own fluid values too; each name is also read as a whole number, for a +// column count of the project's own (`--grid-columns`). Which value wins is the app's to decide +// (src/features/preview/guideModel.ts). + +import { PROBE_ATTR } from './frameBasics'; + +// The custom properties a guide is built from, as Lumos names them. The app +// keeps the same names (GUIDE_PROJECT_VARIABLES): the preload imports nothing +// of the renderer's. +const GUIDE_VARIABLES = { + margin: '--site-margin', + gutter: '--site-gutter', + widthMax: '--max-width-main', +} as const; +// A site-wide column count on :root, tried in this order. The app's parser +// accepts exactly these names (guideModel.ts). +const SITE_COLUMN_COUNT_VARIABLES = ['--site-column-count', '--site-columns'] as const; +// Lumos keeps its column count on its auto grid rather than on :root, so a +// page with no site-wide count is asked through an element of that class. +const GRID_CLASS = 'grid-autofit'; +const GRID_COLUMN_COUNT_VARIABLE = '--_column-count'; +const COLUMN_COUNT_PATTERN = /^\d{1,3}$/; +// The names the app may ask about: one per menu value (margin, gutter, maximum +// width and column count), each a custom property name within the app's own +// bound (guideModel.ts). +const MENU_VARIABLES_MAX = 4; +const MENU_VARIABLE_CHARS_MAX = 64; +const MENU_VARIABLE_PATTERN = /^--[A-Za-z0-9_-]+$/; + +/** How the host sends to the editor: the desktop canvas and Studio each post their own way. */ +export type GuidePost = (message: unknown) => void; + +/** The page side of the column guide, as a host wires it. */ +export interface GuideFrame { + /** Handles a message from the editor, once the host has checked its sender. */ + readonly receive: (data: unknown) => void; +} + +// What was last posted, so a measurement that changed nothing stays quiet. +let lastSent = ''; +let measureQueued = false; +// The variables typed into the guide's menu, as the editor last sent them. +let menuVariables: readonly string[] = []; +let post: GuidePost = () => undefined; + +/** Starts reporting the guide's measurements, now and whenever they may change. */ +export function startGuide(send: GuidePost): GuideFrame { + post = send; + queueGuide(); + window.addEventListener('load', queueGuide); + window.addEventListener('resize', queueGuide); + // A page patched in place after an edit (electron/previewClient/morphClient.ts). + document.addEventListener('avb:morphed', queueGuide); + watchStyles(); + return { receive: receiveGuideMessage }; +} + +// The editor sends the menu's variables when a frame loads and whenever they +// change. Each message asks for a full report, even one that repeats the last: +// the editor listening now may not have heard the earlier ones. +function receiveGuideMessage(data: unknown): void { + if (messageType(data) === 'avb:guide-variables') { + menuVariables = parseMenuVariables(data); + lastSent = ''; + queueGuide(); + } +} + +// The dev server delivers CSS by swapping a