From f75e6f9ecd25e02052536aa642f5e6e7c6e3934d Mon Sep 17 00:00:00 2001 From: Hunter-Kendall Date: Thu, 27 Aug 2026 08:47:19 -0400 Subject: [PATCH 1/8] route style edits through a store so they can be undone and exported MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every feature wrote `el.style[prop] = value` directly, which made two things impossible: recording a before/after for undo, and exporting usable CSS instead of a page full of inline styles. Introduce app/core as the single funnel. editStyle() writes the declaration into an editor-owned stylesheet keyed by a generated class and hands a change record to the history journal. Selectors repeat the class three times (0,3,0) so the rule beats most author CSS without resorting to !important, which would be noise in an export. history.js journals DOM moves, attributes and text alongside styles, so duplicate, delete, group/ungroup and keyboard nudges all undo. Rapid edits coalesce on an idle window, and beginGesture/endGesture collapses a continuous drag into one entry. Bound to cmd+z / cmd+shift+z. Overlays and drag ghosts keep their inline styles — isEditorChrome() keeps ephemeral UI out of the journal and out of exports. Export falls out cheaply now that edits live in a real stylesheet: /export, /export css strip the editor's bookkeeping and emit the markup alongside the rules. Existing tests asserted against el.style, so they read the authored value back through the new readStyle helper. Co-Authored-By: Claude Opus 5 --- app/core/context.js | 20 +++ app/core/edit.js | 27 ++++ app/core/export.js | 101 ++++++++++++++ app/core/history.js | 232 +++++++++++++++++++++++++++++++++ app/core/index.js | 5 + app/core/style-store.js | 164 +++++++++++++++++++++++ app/features/boxshadow.js | 8 +- app/features/boxshadow.test.js | 4 +- app/features/color.js | 11 +- app/features/flex.js | 17 +-- app/features/flex.test.js | 42 +++--- app/features/font.js | 25 ++-- app/features/font.test.js | 6 +- app/features/hueshift.js | 5 +- app/features/margin.js | 3 +- app/features/margin.test.js | 5 +- app/features/padding.js | 3 +- app/features/padding.test.js | 5 +- app/features/position.js | 14 +- app/features/selectable.js | 76 ++++++++--- app/plugins/_registry.js | 3 + app/plugins/export.js | 46 +++++++ tests/helpers.js | 23 +++- 23 files changed, 753 insertions(+), 92 deletions(-) create mode 100644 app/core/context.js create mode 100644 app/core/edit.js create mode 100644 app/core/export.js create mode 100644 app/core/history.js create mode 100644 app/core/index.js create mode 100644 app/core/style-store.js create mode 100644 app/plugins/export.js diff --git a/app/core/context.js b/app/core/context.js new file mode 100644 index 00000000..77542f2e --- /dev/null +++ b/app/core/context.js @@ -0,0 +1,20 @@ +/** + * Single source of truth for "which document are we editing?" + * + * Today this is always the host page's document. Phase 3 (standalone shell) + * swaps in the canvas iframe's document by calling setDoc() once — every + * feature that goes through here follows along for free. + */ + +let target_doc = typeof document !== 'undefined' ? document : null + +export const getDoc = () => + target_doc + +export const getWin = () => + target_doc?.defaultView ?? (typeof window !== 'undefined' ? window : null) + +export const setDoc = doc => { + target_doc = doc + return target_doc +} diff --git a/app/core/edit.js b/app/core/edit.js new file mode 100644 index 00000000..e2b64162 --- /dev/null +++ b/app/core/edit.js @@ -0,0 +1,27 @@ +import { setStyle, setStyles, clearStyles } from './style-store' +import * as history from './history' + +/** + * Set a style *and* journal it. This is what feature modules call — + * it's the drop-in replacement for `el.style[prop] = value`. + * + * `label` groups rapid edits into one undo entry, so holding an arrow key + * to nudge padding costs one cmd+Z, not forty. + */ +export const editStyle = (el, prop, value, label = prop) => { + const change = setStyle(el, prop, value) + history.record(change, label) + return change +} + +export const editStyles = (el, styles, label = 'styles') => { + const changes = setStyles(el, styles) + history.record(changes, label) + return changes +} + +export const editClearStyles = (el, label = 'clear styles') => { + const changes = clearStyles(el) + history.record(changes, label) + return changes +} diff --git a/app/core/export.js b/app/core/export.js new file mode 100644 index 00000000..0116671d --- /dev/null +++ b/app/core/export.js @@ -0,0 +1,101 @@ +import { getDoc } from './context' +import { serialize as serializeStyles, ID_ATTR } from './style-store' + +/** + * Turn the edited document into deliverable HTML + CSS. + * + * This is cheap only because of the style seam: edits already live in a real + * stylesheet keyed by generated classes, so there's no inline-style soup to + * untangle here — just strip the editor's own bookkeeping and hand back the + * markup alongside the rules. + */ + +// attributes the tools write onto the page purely to track state +const EDITOR_ATTRS = [ + ID_ATTR, + 'data-selected', + 'data-selected-hide', + 'data-label-id', + 'data-pseudo-select', + 'data-measuring', + 'data-outward', + 'visbug-drag-src', + 'visbug-drag-container', + 'contenteditable', + 'spellcheck', + 'draggable', +] + +// editor UI that lives in the page but isn't part of the design +const EDITOR_NODES = [ + 'vis-bug', 'hotkey-map', 'visbug-metatip', 'visbug-ally', 'visbug-label', + 'visbug-handles', 'visbug-handle', 'visbug-corners', 'visbug-grip', + 'visbug-gridlines', 'visbug-insertion', 'visbug-hover', 'visbug-distance', + 'visbug-overlay', 'visbug-boxmodel', '.visbug-metatip', +].join(',') + +// inline properties the tools set for their own feedback (grab cursors, +// paint hints) rather than as design decisions +const EDITOR_INLINE_PROPS = ['cursor', 'will-change'] + +const clean = root => { + root.querySelectorAll(EDITOR_NODES).forEach(node => node.remove()) + + root.querySelectorAll('[style]').forEach(el => { + EDITOR_INLINE_PROPS.forEach(prop => el.style.removeProperty(prop)) + if (!el.style.length) el.removeAttribute('style') + }) + + root.querySelectorAll(`[${ID_ATTR}]`).forEach(el => { + // the generated class stays — it's what the exported CSS targets + EDITOR_ATTRS.forEach(attr => el.removeAttribute(attr)) + if (el.getAttribute('style') === '') el.removeAttribute('style') + if (el.getAttribute('class') === '') el.removeAttribute('class') + }) + + EDITOR_ATTRS.forEach(attr => + root.querySelectorAll(`[${attr}]`).forEach(el => el.removeAttribute(attr))) + + return root +} + +/** + * @param {object} opts + * @param {boolean} opts.inline embed the CSS in a + +

flex row

+
one
two
three
+ +

flex column

+
one
two
three
+ +

flex row-reverse

+
one
two
three
+ +

flex wrap

+
alpha
bravo
charlie
delta
echo
foxtrot
+ +

grid (auto-placed)

+
one
two
three
four
+ +

grid (explicitly placed)

+
one
two
three
+ +

block stack

+
one
two
three
+ +

inline-block run

+
one
two
three
four
+ +

empty container

+
+ +

absolute / free placement

+
a
b
c
+ +

table

+
r1c1r1c2r1c3
r2c1r2c2r2c3
+ + + diff --git a/app/features/dropzones.js b/app/features/dropzones.js new file mode 100644 index 00000000..643455ad --- /dev/null +++ b/app/features/dropzones.js @@ -0,0 +1,444 @@ +import { getWin } from '../core' +import { isOffBounds } from '../utilities/' + +/** + * Resolves "where would this element land if I dropped it here?" + * + * The old drag simply swapped two siblings and never looked at the parent's + * layout. This reads the drop container's computed `display` and produces a + * real insertion point: a caret between flex items along the correct axis, a + * cell in a grid, a line break inside wrapped/inline flow, or free XY + * placement for absolutely positioned elements. + * + * Pure geometry — it never mutates the document. move.js applies the result. + */ + +const BAR_THICKNESS = 3 +const SNAP_PX = 8 + +// elements that can't take children, so a drop near them means "next to" +const VOID_TAGS = new Set([ + 'img','input','br','hr','source','track','embed','area','col','wbr', + 'video','audio','canvas','iframe','object','textarea','select','svg','picture', +]) + +// only the displays whose axis is genuinely fixed by spec; block/inline/etc +// get measured instead — see inferAxis() +const AXIS_BY_DISPLAY = { + 'table': 'y', + 'table-row-group': 'y', + 'table-header-group': 'y', + 'table-footer-group': 'y', + 'table-row': 'x', +} + +const cs = el => getWin().getComputedStyle(el) + +const contentBox = el => { + const r = el.getBoundingClientRect() + const s = cs(el) + const l = parseFloat(s.borderLeftWidth) + parseFloat(s.paddingLeft) + const t = parseFloat(s.borderTopWidth) + parseFloat(s.paddingTop) + const r_ = parseFloat(s.borderRightWidth) + parseFloat(s.paddingRight) + const b = parseFloat(s.borderBottomWidth) + parseFloat(s.paddingBottom) + + return { + left: r.left + l, + top: r.top + t, + width: Math.max(0, r.width - l - r_), + height: Math.max(0, r.height - t - b), + get right() { return this.left + this.width }, + get bottom() { return this.top + this.height }, + } +} + +const paddingBox = el => { + const r = el.getBoundingClientRect() + const s = cs(el) + const l = parseFloat(s.borderLeftWidth) + const t = parseFloat(s.borderTopWidth) + const r_ = parseFloat(s.borderRightWidth) + const b = parseFloat(s.borderBottomWidth) + + return { + left: r.left + l, + top: r.top + t, + width: Math.max(0, r.width - l - r_), + height: Math.max(0, r.height - t - b), + } +} + +const tracks = value => + !value || value === 'none' + ? [] + : value.trim().split(/\s+/).map(parseFloat).filter(n => !Number.isNaN(n)) + +const isDroppableChild = (el, dragged) => + el.nodeType === 1 + && !isOffBounds(el) + && el !== dragged + && !dragged?.contains(el) + && cs(el).display !== 'none' + +const childrenOf = (container, dragged) => + [...container.children].filter(el => isDroppableChild(el, dragged)) + +const acceptsChildren = el => { + if (!el || el.nodeType !== 1) return false + if (VOID_TAGS.has(el.tagName.toLowerCase())) return false + if (el.isContentEditable) return false + + // a text-only leaf (a

of prose) is a sibling target, not a container + const hasElementChildren = el.children.length > 0 + const hasText = [...el.childNodes].some(n => + n.nodeType === 3 && n.textContent.trim().length) + + return hasElementChildren || !hasText +} + +/** Deepest element under the pointer that isn't chrome or the dragged node. */ +const hitTest = (x, y, dragged, doc) => + doc.elementsFromPoint(x, y) + .filter(el => + !isOffBounds(el) + && el !== dragged + && !dragged?.contains(el) + && el !== doc.documentElement) + +const resolveContainer = (x, y, dragged, doc) => { + const hits = hitTest(x, y, dragged, doc) + + for (const hit of hits) { + if (acceptsChildren(hit)) return hit + if (hit.parentElement && acceptsChildren(hit.parentElement)) + return hit.parentElement + } + + return doc.body && acceptsChildren(doc.body) ? doc.body : null +} + +/* ---------------------------------------------------------------- flow --- */ + +const start = (rect, axis) => axis === 'x' ? rect.left : rect.top +const end = (rect, axis) => axis === 'x' ? rect.right : rect.bottom +const mid = (rect, axis) => start(rect, axis) + (axis === 'x' ? rect.width : rect.height) / 2 + +/** + * Group items into visual lines along the cross axis — what makes a + * wrapped flex container or a run of inline elements resolve correctly. + */ +const intoLines = (items, axis) => { + const cross = axis === 'x' ? 'y' : 'x' + const sorted = [...items].sort((a, b) => + start(a.rect, cross) - start(b.rect, cross) + || start(a.rect, axis) - start(b.rect, axis)) + + const lines = [] + + sorted.forEach(item => { + const line = lines[lines.length - 1] + const overlaps = line && line.some(other => + start(item.rect, cross) < end(other.rect, cross) - 1 + && end(item.rect, cross) > start(other.rect, cross) + 1) + + overlaps ? line.push(item) : lines.push([item]) + }) + + return lines.map(line => + line.sort((a, b) => start(a.rect, axis) - start(b.rect, axis))) +} + +/** + * For anything that isn't flex or grid, `display` doesn't tell you the axis — + * a block container full of inline-block chips lays out in a row. Read the + * children's actual geometry instead: if adjacent items share a horizontal + * band but don't overlap horizontally, they're a row. + */ +const inferAxis = items => { + if (items.length < 2) return 'y' + + const sorted = [...items].sort((a, b) => a.rect.top - b.rect.top) + let side_by_side = 0 + + for (let i = 1; i < sorted.length; i++) { + const a = sorted[i - 1].rect + const b = sorted[i].rect + + const y_overlap = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top) + const x_overlap = Math.min(a.right, b.right) - Math.max(a.left, b.left) + + if (y_overlap > 0 && x_overlap <= 0) side_by_side++ + } + + return side_by_side > (sorted.length - 1) / 2 ? 'x' : 'y' +} + +const barRect = (prev, next, axis, box) => { + const cross_start = prev && next + ? Math.min(start(prev.rect, axis === 'x' ? 'y' : 'x'), start(next.rect, axis === 'x' ? 'y' : 'x')) + : start((prev || next).rect, axis === 'x' ? 'y' : 'x') + + const cross_end = prev && next + ? Math.max(end(prev.rect, axis === 'x' ? 'y' : 'x'), end(next.rect, axis === 'x' ? 'y' : 'x')) + : end((prev || next).rect, axis === 'x' ? 'y' : 'x') + + // sit in the gap, or hug the single neighbour's edge + const at = prev && next + ? (end(prev.rect, axis) + start(next.rect, axis)) / 2 + : prev + ? end(prev.rect, axis) + : start(next.rect, axis) + + return axis === 'x' + ? { + left: at - BAR_THICKNESS / 2, + top: cross_start, + width: BAR_THICKNESS, + height: Math.max(BAR_THICKNESS, cross_end - cross_start), + } + : { + left: cross_start, + top: at - BAR_THICKNESS / 2, + width: Math.max(BAR_THICKNESS, cross_end - cross_start), + height: BAR_THICKNESS, + } +} + +const emptyContainerDrop = container => ({ + container, + mode: 'flow', + reference: null, + indicator: { rect: paddingBox(container), kind: 'container', axis: 'y' }, +}) + +const resolveFlow = ({ container, x, y, dragged, axis, wrapped, reversed }) => { + const items = childrenOf(container, dragged) + .map(el => ({ el, rect: el.getBoundingClientRect() })) + .filter(({ rect }) => rect.width || rect.height) + + if (!items.length) + return emptyContainerDrop(container) + + if (axis === 'auto') axis = inferAxis(items) + + const lines = wrapped ? intoLines(items, axis) : [ + [...items].sort((a, b) => start(a.rect, axis) - start(b.rect, axis)) + ] + + const cross = axis === 'x' ? 'y' : 'x' + const point = { x, y } + + // pick the line the pointer is on, else the nearest one + const line = lines.find(l => + point[cross] >= Math.min(...l.map(i => start(i.rect, cross))) + && point[cross] <= Math.max(...l.map(i => end(i.rect, cross))) + ) || lines.reduce((best, l) => { + const d = Math.min(...l.map(i => + Math.abs(mid(i.rect, cross) - point[cross]))) + return !best || d < best.d ? { line: l, d } : best + }, null).line + + // first item whose midpoint is past the pointer wins the "insert before" + const vi = line.findIndex(item => point[axis] < mid(item.rect, axis)) + const at = vi === -1 ? line.length : vi + + const prev = line[at - 1] || null + const next = line[at] || null + + // in a reversed track, visual order is the inverse of DOM order, so the + // node to insertBefore is the one on the *other* side of the caret + const reference = reversed + ? (prev ? prev.el : null) + : (next ? next.el : null) + + return { + container, + mode: 'flow', + reference, + indicator: { rect: barRect(prev, next, axis, contentBox(container)), kind: 'bar', axis }, + } +} + +/* ---------------------------------------------------------------- grid --- */ + +const gridCells = container => { + const s = cs(container) + const box = contentBox(container) + const cols = tracks(s.gridTemplateColumns) + const rows = tracks(s.gridTemplateRows) + + if (!cols.length || !rows.length) return null + + const col_gap = parseFloat(s.columnGap) || 0 + const row_gap = parseFloat(s.rowGap) || 0 + + const x_edges = [] + cols.reduce((x, w) => { x_edges.push({ start: x, size: w }); return x + w + col_gap }, box.left) + + const y_edges = [] + rows.reduce((y, h) => { y_edges.push({ start: y, size: h }); return y + h + row_gap }, box.top) + + return { x_edges, y_edges, col_gap, row_gap } +} + +const nearestTrack = (edges, pos, gap) => { + for (let i = 0; i < edges.length; i++) { + const { start, size } = edges[i] + if (pos < start + size + gap / 2) return i + } + return edges.length - 1 +} + +const isAutoPlaced = (el) => { + const s = cs(el) + return s.gridColumnStart === 'auto' && s.gridRowStart === 'auto' +} + +const resolveGrid = ({ container, x, y, dragged }) => { + const cells = gridCells(container) + if (!cells) return resolveFlow({ container, x, y, dragged, axis: 'auto', wrapped: true, reversed: false }) + + const items = childrenOf(container, dragged) + if (!items.length) return emptyContainerDrop(container) + + const col = nearestTrack(cells.x_edges, x, cells.col_gap) + const row = nearestTrack(cells.y_edges, y, cells.row_gap) + + const rect = { + left: cells.x_edges[col].start, + top: cells.y_edges[row].start, + width: cells.x_edges[col].size, + height: cells.y_edges[row].size, + } + + // Auto-flow grid: keep it auto-flowing — reorder in the DOM and let the + // browser re-place. Explicitly placed grid: pin the element to the cell. + const auto_flow = items.every(isAutoPlaced) + + if (!auto_flow) + return { + container, + mode: 'grid', + reference: null, + styles: { gridColumn: `${col + 1}`, gridRow: `${row + 1}` }, + indicator: { rect, kind: 'cell', axis: 'y' }, + } + + // the child currently occupying (or nearest past) this cell in flow order + const target = items.find(el => { + const r = el.getBoundingClientRect() + return r.top + r.height / 2 > rect.top && r.left + r.width / 2 > rect.left + }) || items.find(el => { + const r = el.getBoundingClientRect() + return r.top + r.height / 2 > rect.top + rect.height + }) || null + + return { + container, + mode: 'grid', + reference: target, + indicator: { rect, kind: 'cell', axis: 'y' }, + } +} + +/* ---------------------------------------------------------------- free --- */ + +const snapEdges = (container, dragged) => { + const box = paddingBox(container) + const xs = [box.left, box.left + box.width / 2, box.left + box.width] + const ys = [box.top, box.top + box.height / 2, box.top + box.height] + + childrenOf(container, dragged).forEach(el => { + const r = el.getBoundingClientRect() + xs.push(r.left, r.left + r.width / 2, r.right) + ys.push(r.top, r.top + r.height / 2, r.bottom) + }) + + return { xs, ys } +} + +const snapTo = (candidates, values) => { + let best = null + + values.forEach(value => + candidates.forEach(candidate => { + const delta = candidate - value + if (Math.abs(delta) <= SNAP_PX && (!best || Math.abs(delta) < Math.abs(best.delta))) + best = { delta, at: candidate } + })) + + return best +} + +/** + * Absolutely positioned elements don't participate in flow, so there's no + * caret to draw — snap their edges and centers to siblings instead. + */ +const resolveFree = ({ container, dragged, rect }) => { + const { xs, ys } = snapEdges(container, dragged) + + const snap_x = snapTo(xs, [rect.left, rect.left + rect.width / 2, rect.left + rect.width]) + const snap_y = snapTo(ys, [rect.top, rect.top + rect.height / 2, rect.top + rect.height]) + + return { + container, + mode: 'free', + reference: null, + snap: { + dx: snap_x ? snap_x.delta : 0, + dy: snap_y ? snap_y.delta : 0, + x: snap_x ? snap_x.at : null, + y: snap_y ? snap_y.at : null, + }, + indicator: { rect: paddingBox(container), kind: 'container', axis: 'y' }, + } +} + +/* ------------------------------------------------------------- resolve --- */ + +export const isFreeFloating = el => { + const position = cs(el).position + return position === 'absolute' || position === 'fixed' +} + +/** + * @returns {null | {container, mode, reference, indicator, styles?, snap?}} + * `reference` is the node to insertBefore — null means append. + */ +export const resolveDrop = ({ x, y, dragged, doc = dragged?.ownerDocument, rect = null }) => { + if (!dragged || !doc) return null + + const container = resolveContainer(x, y, dragged, doc) + if (!container || container === dragged || dragged.contains(container)) return null + + if (isFreeFloating(dragged) && cs(container).position !== 'static') + return resolveFree({ container, dragged, rect: rect || dragged.getBoundingClientRect() }) + + const display = cs(container).display + + if (display === 'grid' || display === 'inline-grid') + return resolveGrid({ container, x, y, dragged }) + + if (display === 'flex' || display === 'inline-flex') { + const s = cs(container) + const axis = s.flexDirection.startsWith('column') ? 'y' : 'x' + + return resolveFlow({ + container, x, y, dragged, axis, + wrapped: s.flexWrap !== 'nowrap', + reversed: s.flexDirection.endsWith('reverse'), + }) + } + + // table parts declare their own axis; everything else we measure + return resolveFlow({ + container, x, y, dragged, + axis: AXIS_BY_DISPLAY[display] || 'auto', + wrapped: true, + reversed: false, + }) +} + +export const __test__ = { + intoLines, nearestTrack, gridCells, acceptsChildren, contentBox, snapTo, inferAxis, +} diff --git a/app/features/dropzones.test.js b/app/features/dropzones.test.js new file mode 100644 index 00000000..ed31a63a --- /dev/null +++ b/app/features/dropzones.test.js @@ -0,0 +1,119 @@ +import test from 'ava' + +import { + setupFixtureTab, teardownPptrTab, changeMode, + centerOf, pointIn, dragFromTo, childText, +} from '../../tests/helpers' + +const tool = 'move' + +test.beforeEach(async t => { + await setupFixtureTab(t) + await changeMode({ tool, page: t.context.page }) +}) + +test('flex row: drops past the last item append it', async t => { + const { page } = t.context + + t.deepEqual(await childText(page, '#flex-row .box'), ['one', 'two', 'three']) + + const start = await pointIn(page, '#flex-row .box', 0, 0.5, 0.5) + const end = await pointIn(page, '#flex-row .box', 2, 0.95, 0.5) + + await page.mouse.click(start.x, start.y) + await dragFromTo(page, start, end) + + t.deepEqual(await childText(page, '#flex-row .box'), ['two', 'three', 'one']) +}) + +test('flex row: drops before an item insert, not swap', async t => { + const { page } = t.context + + // the old implementation swapped siblings; inserting is the difference + const start = await pointIn(page, '#flex-row .box', 2, 0.5, 0.5) + const end = await pointIn(page, '#flex-row .box', 0, 0.1, 0.5) + + await page.mouse.click(start.x, start.y) + await dragFromTo(page, start, end) + + t.deepEqual(await childText(page, '#flex-row .box'), ['three', 'one', 'two']) +}) + +test('flex column: resolves along the vertical axis', async t => { + const { page } = t.context + + const start = await pointIn(page, '#flex-col .box', 0, 0.5, 0.5) + const end = await pointIn(page, '#flex-col .box', 2, 0.5, 0.95) + + await page.mouse.click(start.x, start.y) + await dragFromTo(page, start, end) + + t.deepEqual(await childText(page, '#flex-col .box'), ['two', 'three', 'one']) +}) + +test('grid: an auto-flow grid reorders without pinning cells', async t => { + const { page } = t.context + + const start = await pointIn(page, '#grid-auto .box', 0, 0.5, 0.5) + const end = await pointIn(page, '#grid-auto .box', 3, 0.5, 0.5) + + await page.mouse.click(start.x, start.y) + await dragFromTo(page, start, end) + + t.deepEqual(await childText(page, '#grid-auto .box'), ['two', 'three', 'four', 'one']) + + // auto-placement must be preserved — no explicit grid-column/row written + const pinned = await page.$$eval('#grid-auto .box', els => + els.some(el => el.style.gridColumn || el.style.gridRow)) + t.false(pinned) +}) + +test('empty container accepts a drop', async t => { + const { page } = t.context + + // the old drag bailed out entirely when there were no siblings to swap with + const start = await pointIn(page, '#flex-row .box', 0, 0.5, 0.5) + const end = await centerOf(page, '#empty') + + await page.mouse.click(start.x, start.y) + await dragFromTo(page, start, end) + + t.deepEqual(await childText(page, '#empty > *'), ['one']) +}) + +test('a drag is one undo step', async t => { + const { page } = t.context + + const before = await childText(page, '#flex-row .box') + + const start = await pointIn(page, '#flex-row .box', 0, 0.5, 0.5) + const end = await pointIn(page, '#flex-row .box', 2, 0.95, 0.5) + + await page.mouse.click(start.x, start.y) + await dragFromTo(page, start, end) + + t.notDeepEqual(await childText(page, '#flex-row .box'), before) + + await page.evaluate(() => + document.dispatchEvent(new KeyboardEvent('keydown', { + key: 'z', code: 'KeyZ', keyCode: 90, which: 90, + metaKey: true, ctrlKey: true, bubbles: true, cancelable: true, + }))) + + t.deepEqual(await childText(page, '#flex-row .box'), before) +}) + +test('a click without travel still selects rather than dragging', async t => { + const { page } = t.context + + const before = await childText(page, '#flex-row .box') + const at = await pointIn(page, '#flex-row .box', 1, 0.5, 0.5) + + await page.mouse.click(at.x, at.y) + + t.deepEqual(await childText(page, '#flex-row .box'), before) + t.true(await page.$eval('#flex-row .box:nth-child(2)', el => + el.hasAttribute('data-selected'))) +}) + +test.afterEach(teardownPptrTab) diff --git a/app/features/move.js b/app/features/move.js index d38f9e32..db988eb6 100644 --- a/app/features/move.js +++ b/app/features/move.js @@ -1,23 +1,27 @@ import $ from 'blingblingjs' import hotkeys from 'hotkeys-js' -import { getNodeIndex, showEdge, swapElements, notList } from '../utilities/' +import { getNodeIndex, showEdge, isFixed } from '../utilities/' +import { editStyle, history } from '../core' +import { resolveDrop } from './dropzones' import { toggleWatching } from './imageswap' const key_events = 'up,down,left,right' + +// pixels of travel before a mousedown becomes a drag rather than a click +const DRAG_THRESHOLD = 4 + const state = { drag: { src: null, - parent: null, - parent_ui: [], - siblings: new Map(), - swapping: new Map(), - }, - hover: { - dropzones: [], - observers: [], + active: false, + pointer_id: null, + origin: null, + offset: null, + rect: null, + drop: null, + indicator: null, }, } -// todo: indicator for when node can descend // todo: have it work with shadowDOM export function Moveable(visbug) { hotkeys(key_events, (e, {key}) => { @@ -46,33 +50,36 @@ export function Moveable(visbug) { export function moveElement(el, direction) { if (!el) return + const step = apply => + history.recordDOM(el, apply, 'nudge') + switch(direction) { case 'left': if (canMoveLeft(el)) - el.parentNode.insertBefore(el, el.previousElementSibling) + step(() => el.parentNode.insertBefore(el, el.previousElementSibling)) else showEdge(el.parentNode) break case 'right': if (canMoveRight(el) && el.nextElementSibling.nextSibling) - el.parentNode.insertBefore(el, el.nextElementSibling.nextSibling) + step(() => el.parentNode.insertBefore(el, el.nextElementSibling.nextSibling)) else if (canMoveRight(el)) - el.parentNode.appendChild(el) + step(() => el.parentNode.appendChild(el)) else showEdge(el.parentNode) break case 'up': if (canMoveUp(el)) - popOut({el}) + step(() => popOut({el})) break case 'down': if (canMoveUnder(el)) - popOut({el, under: true}) + step(() => popOut({el, under: true})) else if (canMoveDown(el)) - el.nextElementSibling.prepend(el) + step(() => el.nextElementSibling.prepend(el)) break } } @@ -91,221 +98,193 @@ export const popOut = ({el, under = false}) => : getNodeIndex(el)]) export function dragNDrop(selection) { - if (!selection.length) - return - clearListeners() - const [src] = selection - const {parentNode} = src - - const validMoveableChildren = [...parentNode.querySelectorAll(':scope > *' + notList)] + if (selection.length !== 1) return - const tooManySelected = selection.length !== 1 - const hasNoSiblingsToDrag = validMoveableChildren.length <= 1 - const isAnSVG = src instanceof SVGElement + const [src] = selection + if (src instanceof SVGElement) return - if (tooManySelected || hasNoSiblingsToDrag || isAnSVG) - return - - validMoveableChildren.forEach(sibling => - state.drag.siblings.set(sibling, createGripUI(sibling))) - - state.drag.parent = parentNode - state.drag.parent_ui = createParentUI(parentNode) - - moveWatch(state.drag.parent) + state.drag.src = src + src.style.cursor = 'grab' + $(src).on('pointerdown', onPointerDown) } -const moveWatch = node => { - const $node = $(node) +const onPointerDown = e => { + const src = state.drag.src + if (!src || e.button !== 0) return - $node.on('mouseleave', dragDrop) - $node.on('dragstart', dragStart) - $node.on('drop', dragDrop) - - state.drag.siblings.forEach((grip, sibling) => { - sibling.setAttribute('draggable', true) - $(sibling).on('dragover', dragOver) - $(sibling).on('mouseenter', siblingHoverIn) - $(sibling).on('mouseleave', siblingHoverOut) - }) -} + // let text selection and form controls keep working + if (e.target.isContentEditable || e.target.closest('input,textarea,select')) return -const moveUnwatch = node => { - const $node = $(node) + e.preventDefault() + e.stopPropagation() - $node.off('mouseleave', dragDrop) - $node.off('dragstart', dragStart) - $node.off('drop', dragDrop) + state.drag.pointer_id = e.pointerId + state.drag.origin = { x: e.clientX, y: e.clientY } + state.drag.rect = src.getBoundingClientRect() + state.drag.offset = { + x: e.clientX - state.drag.rect.left, + y: e.clientY - state.drag.rect.top, + } + state.drag.active = false - state.drag.siblings.forEach((grip, sibling) => { - sibling.removeAttribute('draggable') - $(sibling).off('dragover', dragOver) - $(sibling).off('mouseenter', siblingHoverIn) - $(sibling).off('mouseleave', siblingHoverOut) - }) + src.setPointerCapture(e.pointerId) + $(src).on('pointermove', onPointerMove) + $(src).on('pointerup', onPointerUp) + $(src).on('pointercancel', onPointerUp) } -const dragStart = ({target}) => { - if (!state.drag.siblings.has(target)) - return - - state.drag.src = target - state.hover.dropzones.push(createDropzoneUI(target)) - state.drag.siblings.get(target).style.opacity = 0.01 +const beginDrag = () => { + const src = state.drag.src - target.setAttribute('visbug-drag-src', true) - ghostNode(target) + state.drag.active = true + src.style.cursor = 'grabbing' - $('visbug-hover').forEach(el => - !el.hasAttribute('visbug-drag-container') && el.remove()) -} + history.beginGesture('move') -const dragOver = e => { - if ( - !state.drag.src || - state.drag.swapping.get(e.target) || - e.target.hasAttribute('visbug-drag-src') || - !state.drag.siblings.has(e.currentTarget) || - e.currentTarget !== e.target - ) return - - state.drag.swapping.set(e.target, true) - swapElements(state.drag.src, e.target) - - setTimeout(() => - state.drag.swapping.delete(e.target) - , 250) + ghostNode(src) + state.drag.indicator = createInsertionUI() } -const dragDrop = e => { - if (!state.drag.src) return +const onPointerMove = e => { + const src = state.drag.src + if (!src || e.pointerId !== state.drag.pointer_id) return - state.drag.src.removeAttribute('visbug-drag-src') - ghostBuster(state.drag.src) + const travelled = Math.hypot( + e.clientX - state.drag.origin.x, + e.clientY - state.drag.origin.y) - if (state.drag.siblings.has(state.drag.src)) - state.drag.siblings.get(state.drag.src).style.opacity = null + if (!state.drag.active) { + if (travelled < DRAG_THRESHOLD) return + beginDrag() + } - state.hover.dropzones.forEach(zone => - zone.remove()) + e.preventDefault() - state.drag.src = null -} + const rect = { + left: e.clientX - state.drag.offset.x, + top: e.clientY - state.drag.offset.y, + width: state.drag.rect.width, + height: state.drag.rect.height, + } -const siblingHoverIn = ({target}) => { - if (!state.drag.siblings.has(target)) - return + const drop = resolveDrop({ + x: e.clientX, + y: e.clientY, + dragged: src, + doc: src.ownerDocument, + rect, + }) - state.drag.siblings.get(target) - .toggleHovering({hovering:true}) -} + state.drag.drop = drop -const siblingHoverOut = ({target}) => { - if (!state.drag.siblings.has(target)) + if (!drop) { + state.drag.indicator.style.display = 'none' return + } - state.drag.siblings.get(target) - .toggleHovering({hovering:false}) -} - -const ghostNode = ({style}) => { - style.transition = 'opacity .25s ease-out' - style.opacity = 0.01 -} + state.drag.indicator.style.display = '' + state.drag.indicator.placement = { + ...drop.indicator, + isFixed: isFixed(drop.container), + } -const ghostBuster = ({style}) => { - style.transition = null - style.opacity = null + // free placement follows the cursor live; flow drops preview via the caret + if (drop.mode === 'free') + moveFreely(src, rect, drop) } -const createDropzoneUI = el => { - const zone = document.createElement('visbug-corners') +const moveFreely = (src, rect, drop) => { + const x = rect.left + (drop.snap?.dx || 0) + const y = rect.top + (drop.snap?.dy || 0) - zone.position = {el} - document.body.appendChild(zone) + // fixed elements are already positioned against the viewport; absolute ones + // resolve against their offset parent, so subtract that origin + const origin = getComputedStyle(src).position === 'fixed' + ? { left: 0, top: 0 } + : (src.offsetParent || drop.container).getBoundingClientRect() - const observer = new MutationObserver(list => - zone.position = {el}) - - observer.observe(el.parentNode, { - childList: true, - subtree: true, - }) - - state.hover.observers.push(observer) - - return zone + editStyle(src, 'left', `${Math.round(x - origin.left)}px`, 'move') + editStyle(src, 'top', `${Math.round(y - origin.top)}px`, 'move') } -const createGripUI = el => { - const grip = document.createElement('visbug-grip') - - grip.position = {el} - document.body.appendChild(grip) - - const observer = new MutationObserver(list => - grip.position = {el}) +const onPointerUp = e => { + const src = state.drag.src + if (!src) return - observer.observe(el.parentNode, { - childList: true, - subtree: true, - }) + $(src).off('pointermove', onPointerMove) + $(src).off('pointerup', onPointerUp) + $(src).off('pointercancel', onPointerUp) - state.hover.observers.push(observer) + if (src.hasPointerCapture?.(state.drag.pointer_id)) + src.releasePointerCapture(state.drag.pointer_id) - return grip -} + if (!state.drag.active) return -const createParentUI = parent => { - const hover = document.createElement('visbug-hover') - const label = document.createElement('visbug-label') + const drop = state.drag.drop - hover.position = {el:parent} - hover.setAttribute('visbug-drag-container', true) + if (drop && drop.mode !== 'free') { + // one insert, one undo entry, even though the pointer moved a hundred times + if (drop.reference !== src && drop.container !== src) + history.recordDOM(src, () => + drop.container.insertBefore(src, drop.reference), 'move') - label.text = 'Drag Bounds' - label.position = {boundingRect: parent.getBoundingClientRect()} - label.style.setProperty('--label-bg', 'var(--theme-purple)') + if (drop.styles) + Object.entries(drop.styles).forEach(([prop, value]) => + editStyle(src, prop, value, 'move')) + } - document.body.appendChild(hover) - document.body.appendChild(label) + history.endGesture() - const observer = new MutationObserver(list => { - hover.position = {el:parent} - label.position = {boundingRect: parent.getBoundingClientRect()} - }) + ghostBuster(src) + src.style.cursor = 'grab' + state.drag.active = false + state.drag.drop = null - observer.observe(parent, { - childList: true, - subtree: true, - }) + clearIndicator() +} - state.hover.observers.push(observer) +const createInsertionUI = () => { + const indicator = document.createElement('visbug-insertion') + document.body.appendChild(indicator) + return indicator +} - return [hover,label] +const clearIndicator = () => { + state.drag.indicator?.remove() + state.drag.indicator = null } export function clearListeners() { - moveUnwatch(state.drag.parent) + const src = state.drag.src + + if (src) { + $(src).off('pointerdown', onPointerDown) + $(src).off('pointermove', onPointerMove) + $(src).off('pointerup', onPointerUp) + $(src).off('pointercancel', onPointerUp) + src.style.cursor = null + ghostBuster(src) + } - state.hover.observers.forEach(observer => - observer.disconnect()) + if (state.drag.active) history.endGesture() - state.hover.dropzones.forEach(zone => - zone.remove()) + clearIndicator() - state.drag.siblings.forEach((grip, sibling) => - grip.remove()) + state.drag.src = null + state.drag.drop = null + state.drag.active = false +} - state.drag.parent_ui.forEach(ui => - ui.remove()) +const ghostNode = ({style}) => { + style.transition = 'opacity .15s ease-out' + style.opacity = 0.4 +} - state.hover.observers = [] - state.hover.dropzones = [] - state.drag.parent_ui = [] - state.drag.siblings.clear() +const ghostBuster = ({style}) => { + style.transition = null + style.opacity = null } const updateFeedback = el => { diff --git a/app/utilities/common.js b/app/utilities/common.js index 688f0df4..8989276d 100644 --- a/app/utilities/common.js +++ b/app/utilities/common.js @@ -89,6 +89,11 @@ export const isOffBounds = node => || node.closest('visbug-corners') || node.closest('visbug-grip') || node.closest('visbug-gridlines') + || node.closest('visbug-insertion') + || node.closest('visbug-hover') + || node.closest('visbug-distance') + || node.closest('visbug-overlay') + || node.closest('visbug-boxmodel') ) export const isSelectorValid = (qs => ( diff --git a/tests/helpers.js b/tests/helpers.js index 856b09c0..7f83e096 100644 --- a/tests/helpers.js +++ b/tests/helpers.js @@ -52,3 +52,45 @@ export const readStyle = async (page, selector, prop) => return el.style[prop] || '' }, prop) +/** Boots a tab on the layout fixture used by the drop-resolver tests. */ +export const setupFixtureTab = async (t, fixture = 'dropzones.html') => { + t.context.browser = await puppeteer.launch({ args: ['--no-sandbox'] }) + t.context.page = await t.context.browser.newPage() + + await t.context.page.setViewport({ width: 1200, height: 900 }) + await t.context.page.goto(`http://localhost:3000/${fixture}`) + await t.context.page.waitForSelector('vis-bug') +} + +/** Center of an element, in viewport coordinates. */ +export const centerOf = async (page, selector, index = 0) => + await page.evaluate((selector, index) => { + const el = document.querySelectorAll(selector)[index] + el.scrollIntoView({ block: 'center' }) + const r = el.getBoundingClientRect() + return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) } + }, selector, index) + +/** A point at a fraction across an element's box. */ +export const pointIn = async (page, selector, index, fx, fy) => + await page.evaluate((selector, index, fx, fy) => { + const el = document.querySelectorAll(selector)[index] + el.scrollIntoView({ block: 'center' }) + const r = el.getBoundingClientRect() + return { x: Math.round(r.left + r.width * fx), y: Math.round(r.top + r.height * fy) } + }, selector, index, fx, fy) + +/** + * A real pointer drag. The first small move is what pushes past move.js's + * DRAG_THRESHOLD and promotes the press into a drag. + */ +export const dragFromTo = async (page, from, to) => { + await page.mouse.move(from.x, from.y) + await page.mouse.down() + await page.mouse.move(from.x + 6, from.y) + await page.mouse.move(to.x, to.y, { steps: 8 }) + await page.mouse.up() +} + +export const childText = async (page, selector) => + await page.$$eval(selector, els => els.map(el => el.textContent.trim())) From d64efcdd428b25462a24df6dd5c6872289bda718 Mon Sep 17 00:00:00 2001 From: Hunter-Kendall Date: Thu, 27 Aug 2026 09:05:57 -0400 Subject: [PATCH 3/8] add an export button that saves the page as one HTML and one CSS file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Export existed only as a search command, and it emitted just the editor's own rules — the page's stylesheets stayed behind as tags pointing at the origin, so a saved file looked unstyled offline. collectCSS() now gathers everything that styles the page: inline `) + + await new Promise(resolve => frame.onload = resolve) + await new Promise(resolve => setTimeout(resolve, 300)) + + const read = (doc, selector, props) => { + const el = doc.querySelector(selector) + const cs = doc.defaultView.getComputedStyle(el) + return props.reduce((o, p) => (o[p] = cs[p], o), {}) + } + + const targets = [ + ['.card', ['padding', 'backgroundColor', 'color', 'borderRadius', 'backgroundImage']], + ['.chip', ['backgroundColor', 'borderRadius', 'padding']], + ['.row', ['display', 'gap']], + ['body', ['padding', 'margin']], + ] + + const out = targets.flatMap(([selector, props]) => { + const live = read(document, selector, props) + const exported = read(frame.contentDocument, selector, props) + return props + .filter(p => live[p] !== exported[p]) + .map(p => `${selector} ${p}: ${live[p]} vs ${exported[p]}`) + }) + + frame.remove() + return out + }, { html, css }) + + t.deepEqual(diffs, []) +}) + +test.afterEach(teardownPptrTab) diff --git a/app/core/style-store.js b/app/core/style-store.js index dfd3a77d..8462f55f 100644 --- a/app/core/style-store.js +++ b/app/core/style-store.js @@ -150,6 +150,10 @@ export const serialize = ({ pretty = true } = {}) => { .join('\n\n') } +/** Identity check — export skips our sheet so it isn't emitted twice. */ +export const isEditorSheet = candidate => + !!state.sheet && candidate === state.sheet + /** Test/teardown hook. */ export const reset = () => { if (state.sheet) { diff --git a/app/export-imported.css b/app/export-imported.css new file mode 100644 index 00000000..6238bc9b --- /dev/null +++ b/app/export-imported.css @@ -0,0 +1 @@ +.chip { background: rebeccapurple; color: white; padding: 8px 14px; border-radius: 999px; } diff --git a/app/export-print.css b/app/export-print.css new file mode 100644 index 00000000..b44cdf9b --- /dev/null +++ b/app/export-print.css @@ -0,0 +1 @@ +.card { box-shadow: none; } diff --git a/app/export.css b/app/export.css new file mode 100644 index 00000000..c53b32cb --- /dev/null +++ b/app/export.css @@ -0,0 +1,10 @@ +@import url("export-imported.css"); + +.card { + background: #222; + color: #eee; + padding: 16px; + border-radius: 8px; + background-image: url(assets/texture.png); +} +.row { display: flex; gap: 10px; margin-top: 16px; } diff --git a/app/export.html b/app/export.html new file mode 100644 index 00000000..c1d132b6 --- /dev/null +++ b/app/export.html @@ -0,0 +1,26 @@ + + +Export fixture + + + + + + + + + + +

Export fixture

+ +
a
b
c
+ + + diff --git a/app/plugins/export.js b/app/plugins/export.js index af514e17..865915a6 100644 --- a/app/plugins/export.js +++ b/app/plugins/export.js @@ -1,46 +1,29 @@ -import { exportDocument, exportElement } from '../core' +import { exportBundle, exportElement, downloadBundle } from '../core' export const commands = [ 'export', 'export html', 'export css', + 'export single', 'download', ] -export const description = 'export the edited page as HTML + CSS' - -const download = (filename, text, type = 'text/html') => { - const url = URL.createObjectURL(new Blob([text], { type })) - const link = document.createElement('a') - - Object.assign(link, { href: url, download: filename }) - document.body.appendChild(link) - link.click() - link.remove() - - URL.revokeObjectURL(url) -} +export const description = 'download the page as one HTML file and one CSS file' export default async function({ selected = [], query = '' } = {}) { - // "/export css" hands back just the stylesheet - if (query.includes('css')) { - const { css } = exportDocument({ inline: false }) - - if (!css) return console.info('VisBug: nothing edited yet, no CSS to export') - - await navigator.clipboard?.writeText(css).catch(() => {}) - download('design.css', css, 'text/css') - return - } - - // with a selection, export just that subtree — handy for pulling a component out + // with a selection, hand back just that subtree — pulling a component out if (selected.length) { const html = selected.map(exportElement).join('\n') await navigator.clipboard?.writeText(html).catch(() => {}) - download('component.html', html) - return + return downloadBundle({ html, filenames: { html: 'component.html' } }) + } + + if (query.includes('css')) { + const { css, filenames } = await exportBundle() + return downloadBundle({ css, filenames }) } - const { html } = exportDocument({ inline: true }) - download('design.html', html) + // "single" folds the CSS into a + +
+

Welcome

+ + Read docs +
+ +
+
one
+
two
+
three
+
+ + + diff --git a/app/components/vis-bug/vis-bug.element.css b/app/components/vis-bug/vis-bug.element.css index a4de2dab..3e3ac609 100644 --- a/app/components/vis-bug/vis-bug.element.css +++ b/app/components/vis-bug/vis-bug.element.css @@ -249,19 +249,25 @@ outline-offset: 2px; } - /* collecting cross-origin stylesheets is a network round trip */ - &[data-busy] { - pointer-events: none; + /* the button is the only feedback that a copy happened */ + &[data-state="copied"] { + background-color: var(--neon-pink); - & > svg { - opacity: 0.4; - animation: export-pulse 1s ease-in-out infinite; - } + & > svg { fill: white; } } + + &[data-state="empty"] > svg, + &[data-state="failed"] > svg { + animation: copy-shake .4s ease; + } + + &[data-state="empty"] > svg { opacity: .4; } + &[data-state="failed"] > svg { fill: var(--neon-pink); } } -@keyframes export-pulse { - 50% { opacity: 1; } +@keyframes copy-shake { + 25% { transform: translateX(-3px); } + 75% { transform: translateX(3px); } } :host [colors] { diff --git a/app/components/vis-bug/vis-bug.element.js b/app/components/vis-bug/vis-bug.element.js index 2c7d793d..20b5f902 100644 --- a/app/components/vis-bug/vis-bug.element.js +++ b/app/components/vis-bug/vis-bug.element.js @@ -18,7 +18,7 @@ import { VisBugDarkStyles } from '../styles.store' -import { exportBundle, downloadBundle } from '../../core' +import { copyPrompt } from '../../core' import { VisBugModel } from './model' import * as Icons from './vis-bug.icons' import { provideSelectorEngine } from '../../features/search' @@ -122,21 +122,34 @@ export default class VisBug extends HTMLElement { }) ) - const export_button = this.$shadow.querySelector('#export') + const copy_button = this.$shadow.querySelector('#copy-changes') - const runExport = async e => { + const flash = state => { + copy_button.setAttribute('data-state', state) + clearTimeout(this._copy_timer) + this._copy_timer = setTimeout(() => + copy_button.removeAttribute('data-state'), 2000) + } + + const runCopy = async e => { e.preventDefault() e.stopPropagation() - export_button.setAttribute('data-busy', true) - try { await downloadBundle(await exportBundle()) } - catch (err) { console.error('VisBug export failed', err) } - finally { export_button.removeAttribute('data-busy') } + try { + const { copied, count } = await copyPrompt() + flash(copied ? 'copied' : 'empty') + if (copied) console.info(`VisBug: copied changes for ${count} element${count === 1 ? '' : 's'}`) + else console.info('VisBug: nothing has been edited yet') + } + catch (err) { + console.error('VisBug: could not copy changes', err) + flash('failed') + } } - export_button.addEventListener('click', runExport) - export_button.addEventListener('keydown', e => - (e.key === 'Enter' || e.key === ' ') && runExport(e)) + copy_button.addEventListener('click', runCopy) + copy_button.addEventListener('keydown', e => + (e.key === 'Enter' || e.key === ' ') && runCopy(e)) hotkeys(`${metaKey}+/,${metaKey}+.`, e => this.$shadow.host.style.display = @@ -203,17 +216,17 @@ export default class VisBug extends HTMLElement {
  1. - ${Icons.download} -
  2. diff --git a/app/components/vis-bug/vis-bug.icons.js b/app/components/vis-bug/vis-bug.icons.js index 85fda5d7..7d9e58ec 100644 --- a/app/components/vis-bug/vis-bug.icons.js +++ b/app/components/vis-bug/vis-bug.icons.js @@ -138,3 +138,10 @@ export const download = ` ` + +export const copy = ` + + + + +` diff --git a/app/core/changes.js b/app/core/changes.js new file mode 100644 index 00000000..45330ca7 --- /dev/null +++ b/app/core/changes.js @@ -0,0 +1,263 @@ +import { getWin } from './context' +import { getAuthoredStyles, CLASS_PREFIX, ID_ATTR } from './style-store' + +/** + * Remembers what a thing looked like *before* VisBug touched it, so an edit + * can be reported as "was X, now Y" rather than just "Y". + * + * The history journal already records before/after per step, but a step is + * not what you want to hand a developer — nudging padding forty times is + * forty entries and one change. This tracks the original only, and the net + * change is worked out at report time against what's authored right now. + * Anything undone therefore drops out on its own. + */ + +const tracked = new Map() // Element -> { styles: {prop: original}, position, created } + +const entryFor = el => { + if (!tracked.has(el)) + tracked.set(el, { styles: {}, position: undefined, created: false }) + + return tracked.get(el) +} + +const kebab = prop => prop + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .toLowerCase() + +/** Called before the first write to a property, to capture what was there. */ +export const noteStyle = (el, prop) => { + const entry = entryFor(el) + if (prop in entry.styles) return + + entry.styles[prop] = getWin().getComputedStyle(el).getPropertyValue(kebab(prop)) +} + +/** + * Called on an element's first structural change. `from` is null when the + * node wasn't in the tree yet, which means VisBug created it. + */ +export const notePosition = (el, from) => { + const entry = entryFor(el) + if (entry.position !== undefined) return + + if (!from) { + entry.created = true + entry.position = null + return + } + + entry.position = { + parent: from.parent, + index: [...from.parent.children].indexOf(el), + } +} + +export const forget = () => tracked.clear() + +/* ------------------------------------------------------------ describing --- */ + +const TEST_ATTRS = [ + 'data-testid', 'data-test-id', 'data-test', 'data-cy', 'data-qa', 'data-e2e', +] + +const IDENTIFYING_ATTRS = [ + 'name', 'type', 'role', 'aria-label', 'alt', 'placeholder', 'title', 'href', 'src', +] + +const isGeneratedClass = name => + name.startsWith(CLASS_PREFIX) + +const stableClasses = el => + [...el.classList].filter(name => !isGeneratedClass(name)) + +const cssEscape = value => + getWin().CSS?.escape ? getWin().CSS.escape(value) : value + +const isUnique = (selector, doc) => { + try { return doc.querySelectorAll(selector).length === 1 } + catch { return false } +} + +/** tag + classes, scoped to the nearest id'd ancestor if that makes it unique */ +const readableSelector = (el, doc) => { + const tag = el.tagName.toLowerCase() + const classes = stableClasses(el).map(c => `.${cssEscape(c)}`).join('') + const own = `${tag}${classes}` + + if (isUnique(own, doc)) return own + + let scope = el.parentElement + while (scope && scope !== doc.body) { + if (scope.id) { + const scoped = `#${cssEscape(scope.id)} ${own}` + if (isUnique(scoped, doc)) return scoped + + // still ambiguous — pin the position, but keep the container visible + const nth = [...scope.children].indexOf(el) + 1 + const pinned = `#${cssEscape(scope.id)} > ${own}:nth-child(${nth})` + if (isUnique(pinned, doc)) return pinned + } + scope = scope.parentElement + } + + const parent = el.parentElement + if (parent && parent !== doc.body) { + const nth = [...parent.children].indexOf(el) + 1 + const withNth = `${own}:nth-child(${nth})` + if (isUnique(withNth, doc)) return withNth + } + + return null +} + +/** readable ancestor trail — says which component owns this element */ +const ancestorTrail = el => { + const parts = [] + let node = el.parentElement + + while (node && node.tagName !== 'BODY' && parts.length < 4) { + const tag = node.tagName.toLowerCase() + const id = node.id ? `#${node.id}` : '' + const classes = stableClasses(node).slice(0, 3).map(c => `.${c}`).join('') + + parts.unshift(`${tag}${id}${classes}`) + node = node.parentElement + } + + return parts.join(' > ') +} + +/** always-unique structural path, the fallback when nothing else identifies it */ +const uniquePath = el => { + const steps = [] + let node = el + + while (node && node.nodeType === 1 && node.tagName !== 'BODY') { + const tag = node.tagName.toLowerCase() + const index = [...node.parentNode.children].indexOf(node) + 1 + steps.unshift(`${tag}:nth-child(${index})`) + node = node.parentElement + } + + return `body > ${steps.join(' > ')}` +} + +// bookkeeping the tools write onto elements, invisible in the source +const EDITOR_ATTRS = [ + ID_ATTR, 'data-selected', 'data-label-id', 'data-selected-hide', + 'data-pseudo-select', 'data-measuring', 'data-outward', 'draggable', + 'visbug-drag-src', 'visbug-drag-container', 'contenteditable', 'spellcheck', +] + +// inline properties the tools set for their own feedback, not as design +const EDITOR_INLINE_PROPS = ['cursor', 'will-change', 'transition', 'opacity'] + +/** + * The opening tag as the source probably writes it — usually the most + * greppable anchor there is, so it has to be free of editor residue. + */ +const openingTag = el => { + const clone = el.cloneNode(false) + + EDITOR_ATTRS.forEach(attr => clone.removeAttribute(attr)) + ;[...clone.classList] + .filter(isGeneratedClass) + .forEach(name => clone.classList.remove(name)) + + if (clone.hasAttribute('style')) { + EDITOR_INLINE_PROPS.forEach(prop => clone.style.removeProperty(prop)) + if (!clone.style.length) clone.removeAttribute('style') + } + + if (clone.getAttribute('class') === '') clone.removeAttribute('class') + + const html = clone.outerHTML + const end = html.indexOf('>') + + return end === -1 ? html : html.slice(0, end + 1) +} + +const textOf = el => { + const text = (el.textContent || '').replace(/\s+/g, ' ').trim() + return text.length > 80 ? `${text.slice(0, 80)}…` : text +} + +/** Several independent ways to point at one element, best first. */ +export const describeElement = el => { + const doc = el.ownerDocument + + const testAttr = TEST_ATTRS + .map(attr => el.hasAttribute(attr) ? `[${attr}="${el.getAttribute(attr)}"]` : null) + .find(Boolean) + + const attributes = IDENTIFYING_ATTRS + .filter(attr => el.hasAttribute(attr)) + .reduce((o, attr) => (o[attr] = el.getAttribute(attr), o), {}) + + return { + tag: el.tagName.toLowerCase(), + id: el.id ? `#${cssEscape(el.id)}` : null, + testAttr, + selector: readableSelector(el, doc), + path: uniquePath(el), + within: ancestorTrail(el), + markup: openingTag(el), + text: textOf(el), + classes: stableClasses(el), + attributes, + } +} + +/* ------------------------------------------------------------ collecting --- */ + +const positionNow = el => ({ + parent: el.parentElement, + index: el.parentElement ? [...el.parentElement.children].indexOf(el) : -1, +}) + +/** + * The net effect of the session: one entry per element that still differs + * from how it started. + */ +export const collectChanges = () => { + const changes = [] + + tracked.forEach((entry, el) => { + const removed = !el.isConnected + + const styles = removed ? [] : Object.entries(getAuthoredStyles(el)) + .map(([prop, after]) => { + // originals were captured under the name the feature used + const before = prop in entry.styles + ? entry.styles[prop] + : entry.styles[Object.keys(entry.styles).find(k => kebab(k) === prop)] + + return { prop, before: before ?? '(not set)', after } + }) + .filter(({ before, after }) => before !== after) + + let moved = null + + if (!removed && entry.position) { + const now = positionNow(el) + if (now.parent !== entry.position.parent || now.index !== entry.position.index) + moved = { from: entry.position, to: now } + } + + if (!styles.length && !moved && !removed && !entry.created) return + + changes.push({ + el, + describe: describeElement(el), + styles, + moved, + removed, + created: entry.created, + }) + }) + + return changes +} + +export const hasChanges = () => collectChanges().length > 0 diff --git a/app/core/changes.test.js b/app/core/changes.test.js new file mode 100644 index 00000000..617e7039 --- /dev/null +++ b/app/core/changes.test.js @@ -0,0 +1,154 @@ +import test from 'ava' + +import { setupFixtureTab, teardownPptrTab, changeMode, pointIn, dragFromTo } from '../../tests/helpers' + +test.beforeEach(async t => { + await setupFixtureTab(t, 'changes.html') + + // keep the prompt out of the real clipboard so tests can read it + await t.context.page.evaluate(() => { + window.__copied = null + Object.defineProperty(navigator.clipboard, 'writeText', { + configurable: true, + value: async text => { window.__copied = text }, + }) + }) +}) + +const clickCopy = async page => { + await page.evaluate(() => { + window.__copied = null + document.querySelector('vis-bug').$shadow.querySelector('#copy-changes').click() + }) + + await page.waitForTimeout(400) + + return page.evaluate(() => ({ + text: window.__copied, + state: document.querySelector('vis-bug').$shadow + .querySelector('#copy-changes').getAttribute('data-state'), + })) +} + +const nudgePadding = async (page, times = 4) => { + await changeMode({ tool: 'padding', page }) + await (await page.$('[data-testid="hero-cta"]')).click() + + for (let i = 0; i < times; i++) + await page.keyboard.press('ArrowUp') + + await page.waitForTimeout(600) +} + +test('copies nothing when nothing has been edited', async t => { + const { text, state } = await clickCopy(t.context.page) + + t.is(text, null) + t.is(state, 'empty') +}) + +test('a style edit is reported with before and after values', async t => { + const { page } = t.context + await nudgePadding(page) + + const { text, state } = await clickCopy(page) + + t.is(state, 'copied') + t.regex(text, /\*\*Elements changed:\*\* 1/) + t.regex(text, /\| `padding-top` \| `8px` \| `12px` \|/) +}) + +test('an edited element carries several independent anchors', async t => { + const { page } = t.context + await nudgePadding(page) + + const { text } = await clickCopy(page) + + t.regex(text, /test attribute: `\[data-testid="hero-cta"\]`/) + t.regex(text, /css selector: `button\.cta\.primary`/) + t.regex(text, /opening tag: `
  1. - + ${Icons.color_text}
  2. - + ${Icons.color_background}
  3. - + ${Icons.color_border}
  4. + +
` } diff --git a/app/core/index.js b/app/core/index.js index dcec1b60..f0cb8276 100644 --- a/app/core/index.js +++ b/app/core/index.js @@ -3,3 +3,4 @@ export * from './style-store' export * from './edit' export * from './export' export * as history from './history' +export * from './tokens' diff --git a/app/core/tokens.js b/app/core/tokens.js new file mode 100644 index 00000000..60ed075a --- /dev/null +++ b/app/core/tokens.js @@ -0,0 +1,435 @@ +import { getDoc, getWin } from './context' +import { getAuthoredStyle } from './style-store' + +/** + * "What design system does this page already speak?" + * + * VisBug's nudges were pure arithmetic — padding + 1px, font-size + 1px. On a + * page built from a token scale that's the wrong unit of thought: the designer + * wants *the next step*, and the export should read `var(--op-space-medium)` + * rather than a number that happens to match it today. + * + * So before editing we read the page's own custom properties, order the ones + * that form a scale, and let features step through them. Nothing here is + * Optics-specific except the probe list at the bottom — any page whose tokens + * are named for what they are gets the same treatment. + * + * Discovery is best-effort by design. Same-origin stylesheets can be walked; + * a cross-origin one (Optics served from a CDN) throws on .cssRules, so we + * fall back to probing known names. Anything that doesn't resolve is dropped, + * and a page with no scale falls through to the old px math untouched. + */ + +const PROBE_TAG = 'visbug-token-probe' + +// letter-spacing and color are *inherited*, so a var() the browser can't +// resolve leaves the sentinel showing through from the parent probe. Without +// that we couldn't tell "not a length" from "a length that happens to be 0". +// +// The length sentinel is absurd *and negative* on purpose: --op-radius-pill is +// 9999px, and a token colliding with the sentinel would read as unresolvable. +const LENGTH_SENTINEL = '-99999px' +const COLOR_SENTINEL = 'rgb(1, 2, 3)' + +// scale tokens (--op-space-medium) vs. the arithmetic they're built from +// (--op-space-scale-unit), and the raw hsl channels behind each color +const NOT_A_TOKEN = /(?:-unit|-original)$|(?:^|-)[hsl]$/ + +const CLASSIFIERS = [ + [/(?:^|-)(?:radius|rounded|corner)(?:-|$)/, 'radius'], + [/(?:^|-)(?:shadow|elevation)(?:-|$)/, 'shadow'], + [/(?:^|-)(?:font-)?weight(?:-|$)/, 'fontWeight'], + [/(?:^|-)(?:line-height|leading)(?:-|$)/, 'lineHeight'], + [/(?:^|-)(?:font-size|text-size|type-scale)(?:-|$)/, 'fontSize'], + [/^--op-font-/, 'fontSize'], // optics names its type scale --op-font-* + [/(?:^|-)colou?r(?:-|$)/, 'color'], + [/(?:^|-)(?:space|spacing|gap|size)(?:-|$)/, 'space'], +] + +// how a name reads on a t-shirt scale, for groups we can't sort numerically +const SCALE_WORDS = [ + '4x-small', '3x-small', '2x-small', 'x-small', 'small', + 'base', 'default', 'regular', 'medium', + 'large', 'x-large', '2x-large', '3x-large', '4x-large', '5x-large', '6x-large', +] + +const LENGTH_KINDS = ['space', 'fontSize', 'radius'] +const NUMBER_KINDS = ['fontWeight', 'lineHeight'] + +const classify = name => { + if (NOT_A_TOKEN.test(name)) return null + + for (const [pattern, kind] of CLASSIFIERS) + if (pattern.test(name)) return kind + + return null +} + +const scaleRank = name => { + const index = SCALE_WORDS.findIndex(word => name.endsWith(`-${word}`)) + return index === -1 ? Number.MAX_SAFE_INTEGER : index +} + +const clamp = (n, min, max) => + n < min ? min : n > max ? max : n + +/** The number a scale entry sorts and steps by. */ +const measure = token => + token.px != null ? token.px : token.number + +/* ------------------------------------------------------------------ probe */ + +/** + * A hidden pair of elements we can bounce var() through. The outer one holds + * the sentinels; the inner one is where we actually evaluate. `display:none` + * keeps this off the layout and paint path — computed styles still resolve. + */ +const withProbe = fn => { + const doc = getDoc() + const win = getWin() + + if (!doc || !win) return fn(null, null) + + const outer = doc.createElement(PROBE_TAG) + const inner = doc.createElement(PROBE_TAG) + + outer.style.cssText = `display:none;letter-spacing:${LENGTH_SENTINEL};color:${COLOR_SENTINEL}` + outer.appendChild(inner) + ;(doc.body || doc.documentElement).appendChild(outer) + + try { return fn(inner, win) } + finally { outer.remove() } +} + +const resolveLength = (probe, win, name) => { + probe.style.setProperty('letter-spacing', `var(${name})`) + const computed = win.getComputedStyle(probe).letterSpacing + probe.style.removeProperty('letter-spacing') + + // an absolute length always serializes as px — anything else (`50%` from + // --op-radius-circle, `normal`) isn't a step on a scale we can walk + if (!computed || computed === LENGTH_SENTINEL || !computed.endsWith('px')) + return null + + const px = parseFloat(computed) + return Number.isFinite(px) ? px : null +} + +const resolveColor = (probe, win, name) => { + probe.style.setProperty('color', `var(${name})`) + const computed = win.getComputedStyle(probe).color + probe.style.removeProperty('color') + + return !computed || computed === COLOR_SENTINEL + ? null + : computed +} + +/** The token's own text, after var() substitution but before any evaluation. */ +const resolveRaw = (probe, win, name) => + win.getComputedStyle(probe).getPropertyValue(name).trim() + +/** Strictly unitless — keeps `1.5rem` out of the line-height scale. */ +const resolveNumber = (probe, win, name) => { + const raw = resolveRaw(probe, win, name) + const num = parseFloat(raw) + + return raw !== '' && Number.isFinite(num) && String(num) === raw + ? num + : null +} + +/* -------------------------------------------------------------- discovery */ + +const collectFromRules = (rules, names) => { + for (const rule of rules) { + if (rule.style) + for (const prop of rule.style) + if (prop.startsWith('--')) names.add(prop) + + // @media / @supports / @layer wrap the rules we're after + if (rule.cssRules) collectFromRules(rule.cssRules, names) + } +} + +const discoverNames = () => { + const doc = getDoc() + const names = new Set() + + const sheets = [ + ...(doc.styleSheets || []), + ...(doc.adoptedStyleSheets || []), + ] + + for (const sheet of sheets) { + let rules + try { rules = sheet.cssRules } + catch { continue } // cross-origin, nothing to read + + if (rules) collectFromRules(rules, names) + } + + for (const el of [doc.documentElement, doc.body]) + if (el) + for (const prop of el.style) + if (prop.startsWith('--')) names.add(prop) + + return names +} + +/* ------------------------------------------------------------------ build */ + +const buildTokens = () => withProbe((probe, win) => { + if (!probe) return [] + + const names = discoverNames() + addOpticsNames(probe, win, names) + + const tokens = [] + + for (const name of names) { + if (NOT_A_TOKEN.test(name)) continue + + const kind = classify(name) + const token = { name, kind, css: `var(${name})` } + + if (kind === 'color') { + token.value = resolveColor(probe, win, name) + if (!token.value) continue + } + else if (kind === 'shadow') { + token.value = resolveRaw(probe, win, name) + if (!token.value) continue + token.rank = scaleRank(name) + } + else if (NUMBER_KINDS.includes(kind)) { + token.number = resolveNumber(probe, win, name) + if (token.number == null) continue + token.value = String(token.number) + } + else if (LENGTH_KINDS.includes(kind)) { + token.px = resolveLength(probe, win, name) + if (token.px == null) continue + token.value = `${token.px}px` + } + else { + // Unnamed for its purpose. We'll take it as a color if it resolves as + // one — a swatch we don't recognize costs nothing. We deliberately do + // *not* guess at lengths: an unrecognized number sneaking into the + // spacing scale would silently change what the arrow keys do. + token.value = resolveColor(probe, win, name) + if (!token.value) continue + token.kind = 'color' + } + + tokens.push(token) + } + + return tokens +}) + +const buildScale = tokens => { + const sorted = [...tokens].sort((a, b) => measure(a) - measure(b)) + const scale = [] + + // aliases pointing at the same value would make a step feel like a no-op + for (const token of sorted) { + const previous = scale[scale.length - 1] + + if (previous && measure(previous) === measure(token)) { + if (token.name.length < previous.name.length) scale[scale.length - 1] = token + continue + } + + scale.push(token) + } + + return scale +} + +const buildIndex = tokens => { + const of_kind = kind => tokens.filter(token => token.kind === kind) + + const scales = {} + + for (const kind of [...LENGTH_KINDS, ...NUMBER_KINDS]) + scales[kind] = buildScale(of_kind(kind)) + + scales.shadow = of_kind('shadow').sort((a, b) => + a.rank - b.rank || a.name.localeCompare(b.name)) + + const colors = of_kind('color') + + return { tokens, scales, colors } +} + +/* ------------------------------------------------------------------ cache */ + +const state = { + index: null, + sheets: -1, +} + +// cheap staleness check: a page that lazy-loads a stylesheet gets rescanned +const sheetCount = () => + getDoc()?.styleSheets?.length ?? 0 + +const index = () => { + if (state.index && state.sheets === sheetCount()) + return state.index + + state.sheets = sheetCount() + state.index = buildIndex(buildTokens()) + + return state.index +} + +export const refreshTokens = () => { + state.index = null + return index() +} + +/* ----------------------------------------------------------------- public */ + +/** Ordered scale for a kind — [] when the page doesn't have one. */ +export const getScale = kind => + index().scales[kind] || [] + +/** Every color token the page defines, in discovery order. */ +export const getPalette = () => + index().colors + +export const hasScale = kind => + getScale(kind).length > 1 + +/** `var(--op-space-medium)` -> `--op-space-medium`, anything else -> null. */ +export const tokenName = value => { + const match = /^\s*var\(\s*(--[^,)\s]+)/.exec(value || '') + return match ? match[1] : null +} + +export const findToken = name => + index().tokens.find(token => token.name === name) || null + +/** + * The scale step's full name when `px` lands exactly on one, e.g. + * "--op-space-medium" — null when the page has no such scale or the value + * falls between steps. Lets a measurement read as the token it was authored + * from instead of raw pixels. + */ +export const labelForLength = (kind, px) => { + if (!Number.isFinite(px)) return null + + const token = getScale(kind).find(({px: step}) => Math.abs(step - px) < 0.05) + return token ? token.name : null +} + +/** + * Where `value` lands on a scale when it isn't already a token — the index we + * move to on the *first* press, so a nudge always travels the way you asked + * instead of snapping backwards onto the nearest step. + */ +const snapIndex = (scale, value, delta) => { + if (!Number.isFinite(value)) + return delta > 0 ? 0 : scale.length - 1 + + if (delta > 0) { + const next = scale.findIndex(token => measure(token) > value) + return next === -1 ? scale.length - 1 : next + } + + for (let i = scale.length - 1; i >= 0; i--) + if (measure(scale[i]) < value) return i + + return 0 +} + +/** + * The token one step along `kind`'s scale from what `el` currently has. + * + * Returns null when the page has no such scale, which is the caller's signal + * to keep doing its old px arithmetic — token-less pages behave exactly as + * they always have. + */ +export const stepStyleToken = ({ el, prop, kind, delta, current }) => { + const scale = getScale(kind) + if (scale.length < 2) return null + + const authored = tokenName(getAuthoredStyle(el, prop)) + const at = authored ? scale.findIndex(token => token.name === authored) : -1 + + return at === -1 + ? scale[snapIndex(scale, current, delta)] + : scale[clamp(at + delta, 0, scale.length - 1)] +} + +/** Step a scale that isn't attached to a numeric current value (shadows). */ +export const stepScaleFrom = ({ el, prop, kind, delta }) => { + const scale = getScale(kind) + if (!scale.length) return null + + const authored = tokenName(getAuthoredStyle(el, prop)) + const at = authored ? scale.findIndex(token => token.name === authored) : -1 + + return at === -1 + ? scale[delta > 0 ? 0 : scale.length - 1] + : scale[clamp(at + delta, 0, scale.length - 1)] +} + +/* ----------------------------------------------------------------- optics */ + +// A cross-origin Optics build hides its rules from discoverNames(), so we ask +// for the names directly. Generated rather than listed — the scale words and +// color ramp are regular, and a name that doesn't resolve is dropped anyway. +const OPTICS_SENTINEL = '--op-space-medium' + +const OPTICS_SIZES = [ + '3x-small', '2x-small', 'x-small', 'small', 'medium', + 'large', 'x-large', '2x-large', '3x-large', '4x-large', + '5x-large', '6x-large', +] + +const OPTICS_WEIGHTS = [ + 'thin', 'extra-light', 'light', 'normal', 'medium', + 'semi-bold', 'bold', 'extra-bold', 'black', +] + +const OPTICS_LINE_HEIGHTS = [ + 'none', 'densest', 'denser', 'dense', 'base', + 'loose', 'looser', 'loosest', +] + +const OPTICS_RADII = ['small', 'medium', 'large', 'x-large', '2x-large', 'pill'] + +const OPTICS_COLOR_FAMILIES = [ + 'primary', 'neutral', 'border', 'background', 'black', 'white', + 'alerts-danger', 'alerts-info', 'alerts-notice', 'alerts-warning', +] + +const OPTICS_COLOR_STEPS = [ + 'base', + ...['max', 'eight', 'seven', 'six', 'five', 'four', 'three', 'two', 'one'] + .flatMap(step => [`minus-${step}`, `plus-${step}`]), +] + +const addOpticsNames = (probe, win, names) => { + // one probe tells us whether any of the rest is worth asking about + if (names.has(OPTICS_SENTINEL)) return + if (resolveLength(probe, win, OPTICS_SENTINEL) == null) return + + const add = name => names.add(name) + + OPTICS_SIZES.forEach(size => { + add(`--op-space-${size}`) + add(`--op-font-${size}`) + add(`--op-shadow-${size}`) + }) + + OPTICS_WEIGHTS.forEach(weight => add(`--op-font-weight-${weight}`)) + OPTICS_LINE_HEIGHTS.forEach(lh => add(`--op-line-height-${lh}`)) + OPTICS_RADII.forEach(radius => add(`--op-radius-${radius}`)) + + OPTICS_COLOR_FAMILIES.forEach(family => + OPTICS_COLOR_STEPS.forEach(step => + add(`--op-color-${family}-${step}`))) +} diff --git a/app/core/tokens.test.js b/app/core/tokens.test.js new file mode 100644 index 00000000..3b9cc5e8 --- /dev/null +++ b/app/core/tokens.test.js @@ -0,0 +1,145 @@ +import test from 'ava' + +import { setupFixtureTab, teardownPptrTab, changeMode, readStyle } +from '../../tests/helpers' + +const on_scale = '#on-scale' // padding: var(--op-space-small) -> 12px +const off_scale = '#off-scale' // padding: 7px, between two steps + +const press = async (page, key, modifiers = []) => { + for (const modifier of modifiers) await page.keyboard.down(modifier) + await page.keyboard.press(key) + for (const modifier of [...modifiers].reverse()) await page.keyboard.up(modifier) +} + +const select = async (page, tool, selector) => { + await changeMode({ tool, page }) + await page.click(selector) +} + +test.beforeEach(async t => { + await setupFixtureTab(t, 'optics.html') +}) + +test('padding steps the spacing scale instead of counting pixels', async t => { + const { page } = t.context + + await select(page, 'padding', on_scale) + t.is(await readStyle(page, on_scale, 'paddingTop'), '') + + await press(page, 'ArrowUp') + t.is(await readStyle(page, on_scale, 'paddingTop'), 'var(--op-space-medium)') + + await press(page, 'ArrowUp') + t.is(await readStyle(page, on_scale, 'paddingTop'), 'var(--op-space-large)') + + await press(page, 'ArrowUp', ['Alt']) + t.is(await readStyle(page, on_scale, 'paddingTop'), 'var(--op-space-medium)') +}) + +test('a value between two steps snaps onto the scale, moving the way you asked', async t => { + const { page } = t.context + + await select(page, 'padding', off_scale) + + // 7px sits between --op-space-2x-small (4px) and --op-space-x-small (8px) + await press(page, 'ArrowUp') + t.is(await readStyle(page, off_scale, 'paddingTop'), 'var(--op-space-x-small)') +}) + +test('shift is the escape hatch back to raw pixels', async t => { + const { page } = t.context + + await select(page, 'padding', on_scale) + + await press(page, 'ArrowUp', ['Shift']) + t.is(await readStyle(page, on_scale, 'paddingTop'), '22px') +}) + +test('margin steps the same scale', async t => { + const { page } = t.context + + await select(page, 'margin', on_scale) + + await press(page, 'ArrowUp') + t.is(await readStyle(page, on_scale, 'marginTop'), 'var(--op-space-3x-small)') +}) + +test('font size steps the type scale, and alt keeps pixels available', async t => { + const { page } = t.context + + await select(page, 'font', on_scale) + + // font-size: var(--op-font-small) -> 14px + await press(page, 'ArrowUp') + t.is(await readStyle(page, on_scale, 'fontSize'), 'var(--op-font-medium)') + + await press(page, 'ArrowUp', ['Alt']) + t.is(await readStyle(page, on_scale, 'fontSize'), '17px') +}) + +test('leading steps the line height scale', async t => { + const { page } = t.context + + await select(page, 'font', on_scale) + + await press(page, 'ArrowUp', ['Shift']) + t.is(await readStyle(page, on_scale, 'lineHeight'), 'var(--op-line-height-loose)') +}) + +test('brackets walk the shadow scale', async t => { + const { page } = t.context + + await select(page, 'boxshadow', on_scale) + + await press(page, 'BracketRight') + t.is(await readStyle(page, on_scale, 'boxShadow'), 'var(--op-shadow-x-small)') + + await press(page, 'BracketRight') + t.is(await readStyle(page, on_scale, 'boxShadow'), 'var(--op-shadow-small)') + + await press(page, 'BracketLeft') + t.is(await readStyle(page, on_scale, 'boxShadow'), 'var(--op-shadow-x-small)') +}) + +test('shift+brackets walk the radius scale', async t => { + const { page } = t.context + + await select(page, 'boxshadow', on_scale) + + await press(page, 'BracketRight', ['Shift']) + t.is(await readStyle(page, on_scale, 'borderRadius'), 'var(--op-radius-small)') + + await press(page, 'BracketRight', ['Shift']) + t.is(await readStyle(page, on_scale, 'borderRadius'), 'var(--op-radius-medium)') +}) + +test('the color picker offers the page palette as swatches', async t => { + const { page } = t.context + + await select(page, 'guides', on_scale) + + const swatches = await page.$eval('vis-bug', el => + [...el.$shadow.querySelectorAll('#token_swatches option')] + .map(option => option.textContent)) + + t.true(swatches.includes('primary base')) + t.true(swatches.includes('alerts danger base')) +}) + +test('token edits export as var(), not as resolved pixels', async t => { + const { page } = t.context + + await select(page, 'padding', on_scale) + await press(page, 'ArrowUp') + + const css = await page.evaluate(() => + [...document.adoptedStyleSheets] + .flatMap(sheet => [...sheet.cssRules]) + .map(rule => rule.cssText) + .join('\n')) + + t.true(css.includes('padding-top: var(--op-space-medium)')) +}) + +test.afterEach(teardownPptrTab) diff --git a/app/features/boxshadow.js b/app/features/boxshadow.js index cb7c461f..79b071ec 100644 --- a/app/features/boxshadow.js +++ b/app/features/boxshadow.js @@ -1,6 +1,6 @@ import hotkeys from 'hotkeys-js' import { metaKey, getStyle, showHideSelected } from '../utilities/' -import { editStyle, getAuthoredStyle } from '../core' +import { editStyle, getAuthoredStyle, stepScaleFrom } from '../core' const key_events = 'up,down,left,right' .split(',') @@ -11,6 +11,11 @@ const key_events = 'up,down,left,right' const command_events = `${metaKey}+up,${metaKey}+shift+up,${metaKey}+down,${metaKey}+shift+down,${metaKey}+left,${metaKey}+shift+left,${metaKey}+right,${metaKey}+shift+right` +// Shadows and radii come out of a design system as whole values, not as a pile +// of offsets you dial in — so they get their own keys rather than sharing the +// arrows with the per-component editing above. +const token_events = '[,],shift+[,shift+]' + export function BoxShadow({selection}) { hotkeys(key_events, (e, handler) => { if (e.cancelBubble) return @@ -38,13 +43,39 @@ export function BoxShadow({selection}) { : changeBoxShadow(selection(), keys, 'inset') }) + hotkeys(token_events, (e, handler) => { + if (e.cancelBubble) return + + e.preventDefault() + + const keys = handler.key.split('+') + const delta = keys.includes(']') ? 1 : -1 + + keys.includes('shift') + ? stepToken(selection(), 'borderRadius', 'radius', delta) + : stepToken(selection(), 'boxShadow', 'shadow', delta) + }) + return () => { hotkeys.unbind(key_events) hotkeys.unbind(command_events) + hotkeys.unbind(token_events) hotkeys.unbind('up,down,left,right') } } +/** + * Walk `prop` along a named scale the page already defines. No scale, no edit — + * we'd rather do nothing than invent a shadow the design system never had. + */ +const stepToken = (els, prop, kind, delta) => + els + .map(el => showHideSelected(el, 1500)) + .forEach(el => { + const token = stepScaleFrom({ el, prop, kind, delta }) + if (token) editStyle(el, prop, token.css, kind) + }) + const ensureHasShadow = el => { const current = getAuthoredStyle(el, 'boxShadow') if (current == '' || current == 'none') diff --git a/app/features/color.js b/app/features/color.js index e2c2ec8b..a91e7d5c 100644 --- a/app/features/color.js +++ b/app/features/color.js @@ -2,11 +2,67 @@ import $ from 'blingblingjs' import { TinyColor } from '@ctrl/tinycolor' import Color from 'colorjs.io' import { getStyle, contrast_color } from '../utilities/' -import { editStyle } from '../core' +import { editStyle, getPalette } from '../core' const state = { active_color: 'undefined', elements: [], + swatches: null, +} + +// renders a datalist as swatches in its own picker, so the +// page's palette shows up where you'd reach for it without VisBug growing a +// second colour UI. Native pickers only show a handful, so we cap the list. +const SWATCH_LIMIT = 40 + +const swatchLabel = name => + name + .replace(/^--/, '') + .replace(/^op-color-/, '') + .replace(/-/g, ' ') + +/** + * The page's colour tokens, keyed by the hex the picker will hand back. + * + * Tokens named `*-on-*` are the text-on-surface half of a pair — worth having, + * but they shouldn't crowd the surfaces themselves out of a capped list. + */ +const buildSwatches = () => { + const by_hex = new Map() + + getPalette() + .slice() + .sort((a, b) => + Number(a.name.includes('-on-')) - Number(b.name.includes('-on-'))) + .forEach(token => { + const color = new TinyColor(token.value) + if (!color.isValid || color.getAlpha() === 0) return + + const hex = `#${color.toHex()}` + if (!by_hex.has(hex)) by_hex.set(hex, token) + }) + + return new Map([...by_hex].slice(0, SWATCH_LIMIT)) +} + +const swatches = () => { + if (!state.swatches || !state.swatches.size) + state.swatches = buildSwatches() + + return state.swatches +} + +/** The token behind a picked colour, so we author `var(--…)` and not a hex. */ +const tokenFor = value => { + const color = new TinyColor(value) + return color.isValid + ? swatches().get(`#${color.toHex()}`) + : null +} + +const colorValue = value => { + const token = tokenFor(value) + return token ? token.css : value } export function ColorPicker(pallete, selectorEngine) { @@ -23,34 +79,53 @@ export function ColorPicker(pallete, selectorEngine) { } fgInput.on('input', ({target:{value}}) => { + const authored = colorValue(value) + state.elements.map(el => - editStyle(el, 'color', value, 'color')) + editStyle(el, 'color', authored, 'color')) foregroundPicker[0].style.setProperty(`--contextual_color`, value) }) bgInput.on('input', ({target:{value}}) => { + const authored = colorValue(value) + state.elements.map(el => editStyle(el, el instanceof SVGElement ? 'fill' : 'backgroundColor' - , value, 'background')) + , authored, 'background')) backgroundPicker[0].style.setProperty(`--contextual_color`, value) }) boInput.on('input', ({target:{value}}) => { + const authored = colorValue(value) + state.elements.map(el => editStyle(el, el instanceof SVGElement ? 'stroke' : 'borderColor' - , value, 'border color')) + , authored, 'border color')) borderPicker[0].style.setProperty(`--contextual_color`, value) }) + const paintSwatches = () => { + const list = $('#token_swatches', pallete)[0] + if (!list) return + + const options = [...swatches()] + .map(([hex, {name}]) => + ``) + .join('') + + if (list.innerHTML !== options) list.innerHTML = options + } + const extractColors = elements => { state.elements = elements + paintSwatches() let isMeaningfulForeground = false let isMeaningfulBackground = false diff --git a/app/features/font.js b/app/features/font.js index 8db826b2..bbf92790 100644 --- a/app/features/font.js +++ b/app/features/font.js @@ -1,13 +1,13 @@ import hotkeys from 'hotkeys-js' import { metaKey, getStyle, showHideSelected } from '../utilities/' -import { editStyle, getAuthoredStyle } from '../core' +import { editStyle, getAuthoredStyle, getScale, stepStyleToken, tokenName } from '../core' -const key_events = 'up,down,left,right' - .split(',') - .reduce((events, event) => - `${events},${event},shift+${event}` - , '') - .substring(1) +const key_events = [ + ...'up,down,left,right'.split(',').flatMap(event => [event, `shift+${event}`]), + // alt is the px escape hatch: on a page with a type scale the bare arrows + // step the scale, and these keep the old ±1 / ±10 arithmetic available + ...'up,down'.split(',').flatMap(event => [`alt+${event}`, `alt+shift+${event}`]), +].join(',') const command_events = `${metaKey}+up,${metaKey}+down` @@ -24,6 +24,8 @@ export function Font({selection}) { keys.includes('shift') ? changeKerning(selectedNodes, handler.key) : changeAlignment(selectedNodes, handler.key) + else if (keys.includes('alt')) + changeFontSize(selectedNodes, handler.key, { raw_px: true }) else keys.includes('shift') ? changeLeading(selectedNodes, handler.key) @@ -37,11 +39,17 @@ export function Font({selection}) { }) hotkeys('cmd+b', e => { - selection().forEach(el => - editStyle(el, 'fontWeight', - getAuthoredStyle(el, 'fontWeight') == 'bold' - ? null - : 'bold', 'bold')) + const bold = getScale('fontWeight').find(({number}) => number === 700) + + selection().forEach(el => { + const authored = getAuthoredStyle(el, 'fontWeight') + const is_bold = authored == 'bold' + || (bold && tokenName(authored) === bold.name) + + editStyle(el, 'fontWeight', is_bold + ? null + : bold ? bold.css : 'bold', 'bold') + }) }) hotkeys('cmd+i', e => { @@ -61,29 +69,49 @@ export function Font({selection}) { } export function changeLeading(els, direction) { + const negative = direction.split('+').includes('down') + els .map(el => showHideSelected(el)) .map(el => ({ el, - style: 'lineHeight', - current: parseInt(getStyle(el, 'lineHeight')), - amount: 1, - negative: direction.split('+').includes('down'), + style: 'lineHeight', + font_size: parseFloat(getStyle(el, 'fontSize')), + // the px path has always rounded; the ratio below must not, or a + // 25.5px leading reads as 1.47 and re-snaps to the step it's already on + exact: parseFloat(getStyle(el, 'lineHeight')), + current: parseInt(getStyle(el, 'lineHeight')), + amount: 1, + negative, })) .map(payload => Object.assign(payload, { current: payload.current == 'normal' || isNaN(payload.current) - ? 1.14 * parseInt(getStyle(payload.el, 'fontSize')) // document this choice - : payload.current + ? 1.14 * payload.font_size // document this choice + : payload.current, + exact: isNaN(payload.exact) + ? 1.14 * payload.font_size + : payload.exact, })) .map(payload => Object.assign(payload, { + // line-height scales are authored as unitless ratios, but what the + // browser hands back is px — compare on the scale's own terms + token: stepStyleToken({ + el: payload.el, + prop: 'lineHeight', + kind: 'lineHeight', + delta: negative ? -1 : 1, + current: payload.exact / payload.font_size, + }), value: payload.negative ? payload.current - payload.amount : payload.current + payload.amount })) - .forEach(({el, style, value}) => - editStyle(el, style, `${value}px`, 'leading')) + .forEach(({el, style, token, value}) => + editStyle(el, style, token + ? token.css + : `${value}px`, 'leading')) } export function changeKerning(els, direction) { @@ -112,24 +140,36 @@ export function changeKerning(els, direction) { editStyle(el, style, `${value <= -2 ? -2 : value}px`, 'kerning')) } -export function changeFontSize(els, direction) { +export function changeFontSize(els, direction, { raw_px = false } = {}) { + const keys = direction.split('+') + const negative = keys.includes('down') + els .map(el => showHideSelected(el)) .map(el => ({ el, style: 'fontSize', current: parseInt(getStyle(el, 'fontSize')), - amount: direction.split('+').includes('shift') ? 10 : 1, - negative: direction.split('+').includes('down'), + amount: keys.includes('shift') ? 10 : 1, + negative, })) .map(payload => Object.assign(payload, { + token: raw_px ? null : stepStyleToken({ + el: payload.el, + prop: 'fontSize', + kind: 'fontSize', + delta: negative ? -1 : 1, + current: payload.current, + }), font_size: payload.negative ? payload.current - payload.amount : payload.current + payload.amount })) - .forEach(({el, style, font_size}) => - editStyle(el, style, `${font_size <= 6 ? 6 : font_size}px`, 'font size')) + .forEach(({el, style, token, font_size}) => + editStyle(el, style, token + ? token.css + : `${font_size <= 6 ? 6 : font_size}px`, 'font size')) } const weightMap = { @@ -152,15 +192,24 @@ export function changeFontWeight(els, direction) { })) .map(payload => Object.assign(payload, { + token: stepStyleToken({ + el: payload.el, + prop: 'fontWeight', + kind: 'fontWeight', + delta: payload.direction ? -1 : 1, + current: parseInt(payload.current, 10), + }), value: payload.direction ? weightMap[payload.current] - 1 : weightMap[payload.current] + 1 })) - .forEach(({el, style, value}) => - editStyle(el, style, weightOptions[value < 0 ? 0 : value >= weightOptions.length - ? weightOptions.length - : value - ], 'font weight')) + .forEach(({el, style, token, value}) => + editStyle(el, style, token + ? token.css + : weightOptions[value < 0 ? 0 : value >= weightOptions.length + ? weightOptions.length + : value + ], 'font weight')) } const alignMap = { diff --git a/app/features/margin.js b/app/features/margin.js index be6c3442..ef939465 100644 --- a/app/features/margin.js +++ b/app/features/margin.js @@ -1,6 +1,6 @@ import hotkeys from 'hotkeys-js' import { metaKey, getStyle, getSide, showHideSelected } from '../utilities/' -import { editStyle } from '../core' +import { editStyle, stepStyleToken } from '../core' const key_events = 'up,down,left,right' .split(',') @@ -36,23 +36,39 @@ export function Margin(visbug) { } export function pushElement(els, direction) { + const keys = direction.split('+') + const negative = keys.includes('alt') + const raw_px = keys.includes('shift') // see padding.js — shift means px + const style = 'margin' + getSide(direction) + els .map(el => showHideSelected(el)) .map(el => ({ el, - style: 'margin' + getSide(direction), - current: parseInt(getStyle(el, 'margin' + getSide(direction)), 10), - amount: direction.split('+').includes('shift') ? 10 : 1, - negative: direction.split('+').includes('alt'), + current: parseInt(getStyle(el, style), 10), + amount: raw_px ? 10 : 1, + negative, })) + .map(payload => + Object.assign(payload, { + token: raw_px ? null : stepStyleToken({ + el: payload.el, + prop: style, + kind: 'space', + delta: negative ? -1 : 1, + current: payload.current, + }) + })) .map(payload => Object.assign(payload, { margin: payload.negative ? payload.current - payload.amount : payload.current + payload.amount })) - .forEach(({el, style, margin}) => - editStyle(el, style, `${margin < 0 ? 0 : margin}px`, 'margin')) + .forEach(({el, token, margin}) => + editStyle(el, style, token + ? token.css + : `${margin < 0 ? 0 : margin}px`, 'margin')) } export function pushAllElementSides(els, keycommand) { diff --git a/app/features/padding.js b/app/features/padding.js index 1a5dc43a..8fb3b540 100644 --- a/app/features/padding.js +++ b/app/features/padding.js @@ -1,6 +1,6 @@ import hotkeys from 'hotkeys-js' import { metaKey, getStyle, getSide, showHideSelected, expandBorders } from '../utilities/' -import { editStyle } from '../core' +import { editStyle, stepStyleToken } from '../core' const key_events = 'up,down,left,right' .split(',') @@ -36,23 +36,41 @@ export function Padding(visbug) { } export function padElement(els, direction) { + const keys = direction.split('+') + const negative = keys.includes('alt') + // shift has always meant "coarser"; on a page with a spacing scale it doubles + // as the escape hatch back to raw px, since the scale is the coarse mode now + const raw_px = keys.includes('shift') + const style = 'padding' + getSide(direction) + els .map(el => showHideSelected(el)) .map(el => ({ el, - style: 'padding' + getSide(direction), - current: parseInt(getStyle(el, 'padding' + getSide(direction)), 10), - amount: direction.split('+').includes('shift') ? 10 : 1, - negative: direction.split('+').includes('alt'), + current: parseInt(getStyle(el, style), 10), + amount: raw_px ? 10 : 1, + negative, })) + .map(payload => + Object.assign(payload, { + token: raw_px ? null : stepStyleToken({ + el: payload.el, + prop: style, + kind: 'space', + delta: negative ? -1 : 1, + current: payload.current, + }) + })) .map(payload => Object.assign(payload, { padding: payload.negative ? payload.current - payload.amount : payload.current + payload.amount })) - .forEach(({el, style, padding}) => - editStyle(el, style, `${padding < 0 ? 0 : padding}px`, 'padding')) + .forEach(({el, token, padding}) => + editStyle(el, style, token + ? token.css + : `${padding < 0 ? 0 : padding}px`, 'padding')) } export function padAllElementSides(els, keycommand) { diff --git a/app/optics-tokens.css b/app/optics-tokens.css new file mode 100644 index 00000000..be4bb657 --- /dev/null +++ b/app/optics-tokens.css @@ -0,0 +1,118 @@ +/** + * Optics design tokens — a fixture, not a dependency. + * + * Excerpted verbatim from @rolemodel/optics@2.4.0 (dist/css/optics.css) so the + * token tests can run offline against real values rather than invented ones. + * Only the scales VisBug steps through are here; the full system is at + * https://docs.optics.rolemodel.design + */ + +html { + font-size: 62.5%; /* 1rem = 10px — the whole scale is built on this */ +} + +:root { + /* spacing */ + --op-space-scale-unit: 1rem; + --op-space-3x-small: calc(var(--op-space-scale-unit) * 0.2); + --op-space-2x-small: calc(var(--op-space-scale-unit) * 0.4); + --op-space-x-small: calc(var(--op-space-scale-unit) * 0.8); + --op-space-small: calc(var(--op-space-scale-unit) * 1.2); + --op-space-medium: calc(var(--op-space-scale-unit) * 1.6); + --op-space-large: calc(var(--op-space-scale-unit) * 2); + --op-space-x-large: calc(var(--op-space-scale-unit) * 2.4); + --op-space-2x-large: calc(var(--op-space-scale-unit) * 2.8); + --op-space-3x-large: calc(var(--op-space-scale-unit) * 4); + --op-space-4x-large: calc(var(--op-space-scale-unit) * 8); + + /* type */ + --op-font-scale-unit: 1rem; + --op-font-2x-small: calc(var(--op-font-scale-unit) * 1); + --op-font-x-small: calc(var(--op-font-scale-unit) * 1.2); + --op-font-small: calc(var(--op-font-scale-unit) * 1.4); + --op-font-medium: calc(var(--op-font-scale-unit) * 1.6); + --op-font-large: calc(var(--op-font-scale-unit) * 1.8); + --op-font-x-large: calc(var(--op-font-scale-unit) * 2); + --op-font-2x-large: calc(var(--op-font-scale-unit) * 2.4); + --op-font-3x-large: calc(var(--op-font-scale-unit) * 2.8); + --op-font-4x-large: calc(var(--op-font-scale-unit) * 3.2); + --op-font-5x-large: calc(var(--op-font-scale-unit) * 3.6); + --op-font-6x-large: calc(var(--op-font-scale-unit) * 4.8); + --op-font-weight-thin: 100; + --op-font-weight-extra-light: 200; + --op-font-weight-light: 300; + --op-font-weight-normal: 400; + --op-font-weight-medium: 500; + --op-font-weight-semi-bold: 600; + --op-font-weight-bold: 700; + --op-font-weight-extra-bold: 800; + --op-font-weight-black: 900; + --op-font-family: 'Noto Sans', sans-serif; + --op-font-family-alt: 'Noto Serif', serif; + + /* line height */ + --op-line-height-none: 0; + --op-line-height-densest: 1; + --op-line-height-denser: 1.15; + --op-line-height-dense: 1.3; + --op-line-height-base: 1.5; + --op-line-height-loose: 1.6; + --op-line-height-looser: 1.7; + --op-line-height-loosest: 1.8; + + /* radius */ + --op-radius-small: 2px; + --op-radius-medium: 4px; + --op-radius-large: 8px; + --op-radius-x-large: 12px; + --op-radius-2x-large: 16px; + --op-radius-circle: 50%; + --op-radius-pill: 9999px; + + /* shadow */ + --op-shadow-x-small: 0 1px 2px hsl(0deg 0% 0% / 3%), 0 1px 3px hsl(0deg 0% 0% / 15%); + --op-shadow-small: 0 1px 2px hsl(0deg 0% 0% / 3%), 0 2px 6px hsl(0deg 0% 0% / 15%); + --op-shadow-medium: 0 4px 8px hsl(0deg 0% 0% / 15%), 0 1px 3px hsl(0deg 0% 0% / 3%); + --op-shadow-large: 0 6px 10px hsl(0deg 0% 0% / 15%), 0 2px 3px hsl(0deg 0% 0% / 3%); + --op-shadow-x-large: 0 8px 12px hsl(0deg 0% 0% / 15%), 0 4px 4px hsl(0deg 0% 0% / 3%); + + /* color channels */ + --op-color-primary-h: 216; + --op-color-primary-s: 58%; + --op-color-primary-l: 48%; + --op-color-neutral-h: var(--op-color-primary-h); + --op-color-neutral-s: 4%; + --op-color-neutral-l: var(--op-color-primary-l); + --op-color-alerts-warning-h: 47; + --op-color-alerts-warning-s: 100%; + --op-color-alerts-warning-l: 61%; + --op-color-alerts-danger-h: 0; + --op-color-alerts-danger-s: 99%; + --op-color-alerts-danger-l: 76%; + --op-color-alerts-info-h: 216; + --op-color-alerts-info-s: 58%; + --op-color-alerts-info-l: 48%; + --op-color-alerts-notice-h: 130; + --op-color-alerts-notice-s: 61%; + --op-color-alerts-notice-l: 64%; + + /* color */ + --op-color-primary-plus-three: light-dark( hsl(var(--op-color-primary-h) var(--op-color-primary-s) 70%), hsl(var(--op-color-primary-h) var(--op-color-primary-s) 29%) ); + --op-color-primary-base: light-dark( hsl(var(--op-color-primary-h) var(--op-color-primary-s) 40%), hsl(var(--op-color-primary-h) var(--op-color-primary-s) 38%) ); + --op-color-primary-minus-three: light-dark( hsl(var(--op-color-primary-h) var(--op-color-primary-s) 28%), hsl(var(--op-color-primary-h) var(--op-color-primary-s) 48%) ); + --op-color-neutral-plus-three: light-dark( hsl(var(--op-color-neutral-h) var(--op-color-neutral-s) 70%), hsl(var(--op-color-neutral-h) var(--op-color-neutral-s) 24%) ); + --op-color-neutral-base: light-dark( hsl(var(--op-color-neutral-h) var(--op-color-neutral-s) 40%), hsl(var(--op-color-neutral-h) var(--op-color-neutral-s) 32%) ); + --op-color-neutral-minus-three: light-dark( hsl(var(--op-color-neutral-h) var(--op-color-neutral-s) 28%), hsl(var(--op-color-neutral-h) var(--op-color-neutral-s) 44%) ); + --op-color-alerts-warning-plus-three: light-dark( hsl(var(--op-color-alerts-warning-h) var(--op-color-alerts-warning-s) 70%), hsl(var(--op-color-alerts-warning-h) var(--op-color-alerts-warning-s) 20%) ); + --op-color-alerts-warning-base: light-dark( hsl(var(--op-color-alerts-warning-h) var(--op-color-alerts-warning-s) 40%), hsl(var(--op-color-alerts-warning-h) var(--op-color-alerts-warning-s) 32%) ); + --op-color-alerts-warning-minus-three: light-dark( hsl(var(--op-color-alerts-warning-h) var(--op-color-alerts-warning-s) 18%), hsl(var(--op-color-alerts-warning-h) var(--op-color-alerts-warning-s) 48%) ); + --op-color-alerts-danger-plus-three: light-dark( hsl(var(--op-color-alerts-danger-h) var(--op-color-alerts-danger-s) 70%), hsl(var(--op-color-alerts-danger-h) var(--op-color-alerts-danger-s) 20%) ); + --op-color-alerts-danger-base: light-dark( hsl(var(--op-color-alerts-danger-h) var(--op-color-alerts-danger-s) 40%), hsl(var(--op-color-alerts-danger-h) var(--op-color-alerts-danger-s) 32%) ); + --op-color-alerts-danger-minus-three: light-dark( hsl(var(--op-color-alerts-danger-h) var(--op-color-alerts-danger-s) 28%), hsl(var(--op-color-alerts-danger-h) var(--op-color-alerts-danger-s) 48%) ); + --op-color-alerts-info-plus-three: light-dark( hsl(var(--op-color-alerts-info-h) var(--op-color-alerts-info-s) 70%), hsl(var(--op-color-alerts-info-h) var(--op-color-alerts-info-s) 20%) ); + --op-color-alerts-info-base: light-dark( hsl(var(--op-color-alerts-info-h) var(--op-color-alerts-info-s) 40%), hsl(var(--op-color-alerts-info-h) var(--op-color-alerts-info-s) 32%) ); + --op-color-alerts-info-minus-three: light-dark( hsl(var(--op-color-alerts-info-h) var(--op-color-alerts-info-s) 28%), hsl(var(--op-color-alerts-info-h) var(--op-color-alerts-info-s) 48%) ); + --op-color-alerts-notice-plus-three: light-dark( hsl(var(--op-color-alerts-notice-h) var(--op-color-alerts-notice-s) 70%), hsl(var(--op-color-alerts-notice-h) var(--op-color-alerts-notice-s) 20%) ); + --op-color-alerts-notice-base: light-dark( hsl(var(--op-color-alerts-notice-h) var(--op-color-alerts-notice-s) 40%), hsl(var(--op-color-alerts-notice-h) var(--op-color-alerts-notice-s) 32%) ); + --op-color-alerts-notice-minus-three: light-dark( hsl(var(--op-color-alerts-notice-h) var(--op-color-alerts-notice-s) 26%), hsl(var(--op-color-alerts-notice-h) var(--op-color-alerts-notice-s) 48%) ); +} diff --git a/app/optics.html b/app/optics.html new file mode 100644 index 00000000..e9f0ff28 --- /dev/null +++ b/app/optics.html @@ -0,0 +1,66 @@ + + +Optics token fixture + + + +

on the scale

+
on-scale
+ +

off the scale

+
off-scale
+ +

untokened

+
untokened
+ +

swatches

+
+
primary
+
danger
+
info
+
+ + + diff --git a/extension/contextmenu/colormode.js b/extension/contextmenu/colormode.js index afababa2..08dd946a 100644 --- a/extension/contextmenu/colormode.js +++ b/extension/contextmenu/colormode.js @@ -1,3 +1,5 @@ +import { tellActiveTab } from './send.js' + const storagekey = 'visbug-color-mode' const defaultcolormode = 'hex' @@ -23,11 +25,9 @@ var platform = typeof browser === 'undefined' : browser const sendColorMode = () => { - platform.tabs.query({active: true, currentWindow: true}, ([tab]) => { - tab && platform.tabs.sendMessage(tab.id, { - action: 'COLOR_MODE', - params: {mode:colormodestate.mode}, - }) + tellActiveTab({ + action: 'COLOR_MODE', + params: {mode:colormodestate.mode}, }) } diff --git a/extension/contextmenu/colorscheme.js b/extension/contextmenu/colorscheme.js index 8bae16b7..6f850032 100644 --- a/extension/contextmenu/colorscheme.js +++ b/extension/contextmenu/colorscheme.js @@ -1,3 +1,5 @@ +import { tellActiveTab } from './send.js' + const schemestoragekey = 'visbug-color-scheme'; const defaultcolorscheme = 'auto'; @@ -16,11 +18,9 @@ var platform = typeof browser === 'undefined' : browser const sendColorScheme = () => { - platform.tabs.query({active: true, currentWindow: true}, ([tab]) => { - tab && platform.tabs.sendMessage(tab.id, { - action: 'COLOR_SCHEME', - params: {mode:colorschemestate.mode}, - }) + tellActiveTab({ + action: 'COLOR_SCHEME', + params: {mode:colorschemestate.mode}, }) } diff --git a/extension/contextmenu/send.js b/extension/contextmenu/send.js new file mode 100644 index 00000000..b6084af2 --- /dev/null +++ b/extension/contextmenu/send.js @@ -0,0 +1,31 @@ +var platform = typeof browser === 'undefined' + ? chrome + : browser + +/** + * Push a message at the active tab, and shrug if nobody's listening. + * + * The only receiver is toolbar/inject.js, which exists solely in tabs where + * VisBug has actually been launched. Sending to any other tab rejects with + * "Could not establish connection. Receiving end does not exist." — which is + * the *normal* case, not a fault: every tab you haven't launched VisBug on, + * chrome:// and Web Store pages, and file:// pages unless the extension has + * been granted "Allow access to file URLs". + * + * Left unhandled it surfaces as an uncaught promise rejection against the + * service worker, so it's swallowed here rather than at each call site. + */ +export const tellActiveTab = message => + platform.tabs.query({active: true, currentWindow: true}, ([tab]) => { + if (!tab) return + + let sending + + // a tab that closed mid-query throws synchronously instead of rejecting + try { sending = platform.tabs.sendMessage(tab.id, message) } + catch { return } + + // chrome (no callback passed) and firefox both hand back a promise + if (sending && typeof sending.catch === 'function') + sending.catch(() => {}) + }) diff --git a/extension/visbug.js b/extension/visbug.js index ac61f8d5..f336abdc 100644 --- a/extension/visbug.js +++ b/extension/visbug.js @@ -11,10 +11,19 @@ var platform = typeof browser === 'undefined' ? chrome : browser -const toggleIn = ({id:tab_id}) => { +// The preferences ride over runtime.sendMessage, so they can only land once +// toolbar/inject.js has registered its listener. Firing them off alongside +// executeScript raced it, and the loser was silent: your colour mode and theme +// simply didn't apply on the first launch in a tab. +const pushPreferences = () => { + getColorMode() + getColorScheme() +} + +const toggle = async ({id:tab_id}) => { // toggle out: it's currently loaded and injected if (state.loaded[tab_id] && state.injected[tab_id]) { - platform.scripting.executeScript({ + await platform.scripting.executeScript({ target: {tabId: tab_id}, files: ['toolbar/eject.js'], }) @@ -23,30 +32,28 @@ const toggleIn = ({id:tab_id}) => { // toggle in: it's loaded and needs injected else if (state.loaded[tab_id] && !state.injected[tab_id]) { - platform.scripting.executeScript({ + await platform.scripting.executeScript({ target: {tabId: tab_id}, files: ['toolbar/restore.js'], }) state.injected[tab_id] = true - getColorMode() - getColorScheme() + pushPreferences() } // fresh start in tab else { - platform.scripting.insertCSS({ + await platform.scripting.insertCSS({ target: {tabId: tab_id}, files: ['toolbar/bundle.css' ], }) - platform.scripting.executeScript({ + await platform.scripting.executeScript({ target: {tabId: tab_id}, files: ['toolbar/inject.js'], }) state.loaded[tab_id] = true state.injected[tab_id] = true - getColorMode() - getColorScheme() + pushPreferences() } platform.tabs.onUpdated.addListener(function(tabId) { @@ -55,4 +62,16 @@ const toggleIn = ({id:tab_id}) => { }) } +const toggleIn = tab => + toggle(tab).catch(why => { + // Almost always "this page is off-limits to extensions": a chrome:// or + // Web Store tab, a PDF viewer, or a file:// URL while "Allow access to + // file URLs" is switched off for VisBug in chrome://extensions. + // + // There's nothing to recover — the state flags are set after the awaits, + // so a failed launch leaves them untouched and the next click retries. + // Say it once rather than leaving an uncaught rejection on the worker. + console.warn('VisBug could not launch in this tab:', why?.message ?? why) + }) + gimmeToggle(toggleIn) From a3639295bb88a1d343a9df5c7afc900c92f222c8 Mon Sep 17 00:00:00 2001 From: Hunter-Kendall Date: Thu, 27 Aug 2026 15:44:54 -0400 Subject: [PATCH 6/8] group the copy-changes button with the color swatches Moves the copy-prompt button into the same
    as the foreground/background/border swatches so it reads as one group, and swaps its bespoke circle styling for the shared color-swatch look. --- app/components/vis-bug/vis-bug.element.css | 10 ++-------- app/components/vis-bug/vis-bug.element.js | 6 ++---- extension/manifest.json | 4 ++-- 3 files changed, 6 insertions(+), 14 deletions(-) diff --git a/app/components/vis-bug/vis-bug.element.css b/app/components/vis-bug/vis-bug.element.css index 3e3ac609..b485123b 100644 --- a/app/components/vis-bug/vis-bug.element.css +++ b/app/components/vis-bug/vis-bug.element.css @@ -208,7 +208,7 @@ margin: 0.5em 0 0.25em 0.75em; } - :host :is([colors], [actions]) { + :host [colors] { margin-top: 0.25em; } @@ -224,15 +224,9 @@ } } -:host [actions] { - margin-top: .5em; -} - -:host [actions] > li { +:host [colors] > #copy-changes { cursor: pointer; - border-radius: 50%; background-color: var(--theme-bd-2); - box-shadow: 0 0.25em 0.5em hsla(0,0%,0%,10%); backdrop-filter: blur(5px); -webkit-backdrop-filter: blur(5px); diff --git a/app/components/vis-bug/vis-bug.element.js b/app/components/vis-bug/vis-bug.element.js index 66af8deb..2a7a2840 100644 --- a/app/components/vis-bug/vis-bug.element.js +++ b/app/components/vis-bug/vis-bug.element.js @@ -213,10 +213,6 @@ export default class VisBug extends HTMLElement { ${Icons.color_border} - - -
-
  1. + +
` } diff --git a/extension/manifest.json b/extension/manifest.json index 2ea23dce..38406e65 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -15,9 +15,9 @@ "type": "module" }, "action": { - "default_title": "Click or press Alt+Shift+D to launch VisBug", + "default_title": "Click or press Alt+Shift+D to launch DevBug", "default_icon": { - "128": "icons/visbug.png" + "128": "icons/visbug-dev.png" } }, "web_accessible_resources": [{ From 6f738306132210d6aa0acb352eebbfb3700e24f3 Mon Sep 17 00:00:00 2001 From: Hunter-Kendall Date: Thu, 27 Aug 2026 16:15:57 -0400 Subject: [PATCH 7/8] updated prompt copy --- app/core/prompt.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/core/prompt.js b/app/core/prompt.js index 628b7fe7..8f7bdbd8 100644 --- a/app/core/prompt.js +++ b/app/core/prompt.js @@ -130,9 +130,13 @@ export const buildPrompt = () => { return { text: '', count: 0 } const doc = getDoc() - const url = getWin().location?.href || '(unknown page)' + const win = getWin() + const url = win.location?.href || '(unknown page)' + const viewport = win.innerWidth && win.innerHeight + ? `${win.innerWidth}×${win.innerHeight}` + : '(unknown)' - const body = changes.map(changeBlock).join('\n\n---\n\n') + const body = changes.map(changeBlock).join('\n\n') const css = rawCSS(changes) @@ -143,6 +147,7 @@ export const buildPrompt = () => { '', `**Page:** ${url}`, `**Title:** ${doc.title || '(untitled)'}`, + `**Viewport:** ${viewport}`, `**Elements changed:** ${changes.length}`, '', '---', From 8d55725ca290ce44ddea2051a33fc256e609f7ac Mon Sep 17 00:00:00 2001 From: Hunter-Kendall Date: Thu, 27 Aug 2026 17:10:09 -0400 Subject: [PATCH 8/8] fix text tool bug --- app/components/vis-bug/vis-bug.element.js | 9 ++++-- app/features/index.js | 2 +- app/features/text.js | 5 ++- extension/contextmenu/colormode.js | 28 ++++++++++------- extension/contextmenu/colorscheme.js | 28 ++++++++++------- extension/contextmenu/launcher.js | 12 ++++--- extension/toolbar/inject.js | 8 ++++- extension/visbug.js | 38 ++++++++++------------- 8 files changed, 76 insertions(+), 54 deletions(-) diff --git a/app/components/vis-bug/vis-bug.element.js b/app/components/vis-bug/vis-bug.element.js index 2a7a2840..7bd77d0f 100644 --- a/app/components/vis-bug/vis-bug.element.js +++ b/app/components/vis-bug/vis-bug.element.js @@ -7,7 +7,7 @@ import { } from '../' import { - Selectable, Moveable, Padding, Margin, EditText, Font, + Selectable, Moveable, Padding, Margin, EditText, exitTextEditing, Font, Flex, Search, ColorPicker, BoxShadow, HueShift, MetaTip, Guides, Screenshot, Position, Accessibility, draggable } from '../../features/' @@ -270,8 +270,13 @@ export default class VisBug extends HTMLElement { text() { this.selectorEngine.onSelectedUpdate(EditText) - this.deactivate_feature = () => + this.deactivate_feature = () => { this.selectorEngine.removeSelectedCallback(EditText) + // Switching tools mid-edit left the caret and contenteditable behind + // on whatever element was being edited, since only the selection + // callback was torn down. + exitTextEditing() + } } align() { diff --git a/app/features/index.js b/app/features/index.js index 2af5becf..ff8e623f 100644 --- a/app/features/index.js +++ b/app/features/index.js @@ -2,7 +2,7 @@ export { Margin } from './margin' export { Selectable } from './selectable' export { Moveable } from './move' export { Padding } from './padding' -export { EditText } from './text' +export { EditText, exitTextEditing } from './text' export { Font } from './font' export { Flex } from './flex' export { Search } from './search' diff --git a/app/features/text.js b/app/features/text.js index 2d945bb0..df231678 100644 --- a/app/features/text.js +++ b/app/features/text.js @@ -35,4 +35,7 @@ export function EditText(elements) { }) hotkeys('escape,esc', cleanup) -} \ No newline at end of file +} + +/** Ends any in-progress editing, so switching tools can't strand a live caret. */ +export const exitTextEditing = () => cleanup() \ No newline at end of file diff --git a/extension/contextmenu/colormode.js b/extension/contextmenu/colormode.js index 08dd946a..72194ad7 100644 --- a/extension/contextmenu/colormode.js +++ b/extension/contextmenu/colormode.js @@ -73,21 +73,25 @@ export const getColorMode = () => { // load synced color choice on load getColorMode() -platform.contextMenus.create({ - id: 'color-mode', - title: 'Colors', - contexts: ['all'], -}) - -color_options.forEach(option => { +// onInstalled fires once per install/update/enable — not on every service +// worker wake-up — so this runs exactly once, unlike top-level code. +platform.runtime.onInstalled.addListener(() => { platform.contextMenus.create({ - id: option, - parentId: 'color-mode', - title: ' '+option, - checked: false, - type: 'radio', + id: 'color-mode', + title: 'Colors', contexts: ['all'], }) + + color_options.forEach(option => { + platform.contextMenus.create({ + id: option, + parentId: 'color-mode', + title: ' '+option, + checked: false, + type: 'radio', + contexts: ['all'], + }) + }) }) platform.contextMenus.onClicked.addListener(({parentMenuItemId, menuItemId}, tab) => { diff --git a/extension/contextmenu/colorscheme.js b/extension/contextmenu/colorscheme.js index 6f850032..990b3dfb 100644 --- a/extension/contextmenu/colorscheme.js +++ b/extension/contextmenu/colorscheme.js @@ -52,21 +52,25 @@ export const getColorScheme = () => { // load synced scheme choice on load getColorScheme() -platform.contextMenus.create({ - id: 'color-scheme', - title: 'Theme', - contexts: ['all'], -}) - -scheme_option.forEach(option => { +// onInstalled fires once per install/update/enable — not on every service +// worker wake-up — so this runs exactly once, unlike top-level code. +platform.runtime.onInstalled.addListener(() => { platform.contextMenus.create({ - id: option, - parentId: 'color-scheme', - title: ' '+option, - checked: false, - type: 'radio', + id: 'color-scheme', + title: 'Theme', contexts: ['all'], }) + + scheme_option.forEach(option => { + platform.contextMenus.create({ + id: option, + parentId: 'color-scheme', + title: ' '+option, + checked: false, + type: 'radio', + contexts: ['all'], + }) + }) }) platform.contextMenus.onClicked.addListener(({parentMenuItemId, menuItemId}, tab) => { diff --git a/extension/contextmenu/launcher.js b/extension/contextmenu/launcher.js index b8887bdc..7bdd3bb0 100644 --- a/extension/contextmenu/launcher.js +++ b/extension/contextmenu/launcher.js @@ -9,10 +9,14 @@ export const gimmeToggle = toggleIn => { platform.action.onClicked.addListener(toggleIt) } -platform.contextMenus.create({ - id: 'launcher', - title: 'Show/Hide', - contexts: ['all'], +// onInstalled fires once per install/update/enable — not on every service +// worker wake-up — so this runs exactly once, unlike top-level code. +platform.runtime.onInstalled.addListener(() => { + platform.contextMenus.create({ + id: 'launcher', + title: 'Show/Hide', + contexts: ['all'], + }) }) platform.contextMenus.onClicked.addListener(({menuItemId}, tab) => { diff --git a/extension/toolbar/inject.js b/extension/toolbar/inject.js index 2b42b518..42ed7bcf 100644 --- a/extension/toolbar/inject.js +++ b/extension/toolbar/inject.js @@ -14,9 +14,15 @@ visbug.setAttribute('tutsBaseURL', src_path.slice(0, src_path.lastIndexOf('/'))) document.body.prepend(visbug) -platform.runtime.onMessage.addListener(request => { +platform.runtime.onMessage.addListener((request, sender, sendResponse) => { if (request.action === 'COLOR_MODE') visbug.setAttribute('color-mode', request.params.mode) else if (request.action === 'COLOR_SCHEME') visbug.setAttribute("color-scheme", request.params.mode) + // Lets the background worker tell whether this tab already has VisBug + // loaded, since its own in-memory bookkeeping doesn't survive a service + // worker restart — without this it can re-run this file into a tab that + // already has it, redeclaring top-level bindings like `script` above. + else if (request.action === 'PING') + sendResponse({injected: !!document.querySelector('vis-bug')}) }) diff --git a/extension/visbug.js b/extension/visbug.js index f336abdc..863a8df7 100644 --- a/extension/visbug.js +++ b/extension/visbug.js @@ -2,11 +2,6 @@ import {gimmeToggle} from "./contextmenu/launcher.js" import {getColorMode} from "./contextmenu/colormode.js" import {getColorScheme} from "./contextmenu/colorscheme.js" -const state = { - loaded: {}, - injected: {}, -} - var platform = typeof browser === 'undefined' ? chrome : browser @@ -20,23 +15,31 @@ const pushPreferences = () => { getColorScheme() } +// Whether VisBug is already loaded in this tab, and if so, currently shown. +// The background service worker gets killed and restarted by the browser +// whenever it's idle, wiping any in-memory bookkeeping, so the tab itself — +// not worker memory — has to be the source of truth. `null` means no content +// script answered at all (never loaded here, or the page navigated since). +const status = tab_id => + platform.tabs.sendMessage(tab_id, {action: 'PING'}).catch(() => null) + const toggle = async ({id:tab_id}) => { - // toggle out: it's currently loaded and injected - if (state.loaded[tab_id] && state.injected[tab_id]) { + const loaded = await status(tab_id) + + // it's currently shown: hide it + if (loaded?.injected) { await platform.scripting.executeScript({ target: {tabId: tab_id}, files: ['toolbar/eject.js'], }) - state.injected[tab_id] = false } - // toggle in: it's loaded and needs injected - else if (state.loaded[tab_id] && !state.injected[tab_id]) { + // it's loaded but hidden: show it again + else if (loaded) { await platform.scripting.executeScript({ target: {tabId: tab_id}, files: ['toolbar/restore.js'], }) - state.injected[tab_id] = true pushPreferences() } @@ -51,15 +54,8 @@ const toggle = async ({id:tab_id}) => { files: ['toolbar/inject.js'], }) - state.loaded[tab_id] = true - state.injected[tab_id] = true pushPreferences() } - - platform.tabs.onUpdated.addListener(function(tabId) { - if (tabId === tab_id) - state.loaded[tabId] = false - }) } const toggleIn = tab => @@ -68,9 +64,9 @@ const toggleIn = tab => // Web Store tab, a PDF viewer, or a file:// URL while "Allow access to // file URLs" is switched off for VisBug in chrome://extensions. // - // There's nothing to recover — the state flags are set after the awaits, - // so a failed launch leaves them untouched and the next click retries. - // Say it once rather than leaving an uncaught rejection on the worker. + // There's nothing to recover — status() re-checks the tab fresh on the + // next click regardless. Say it once rather than leaving an uncaught + // rejection on the worker. console.warn('VisBug could not launch in this tab:', why?.message ?? why) })