a section's controls appear when that section is hovered

All of them: the outline, the name, the shape, Delete, the column outlines and the button that adds to
them. A page with four bands was four labelled boxes at rest, and the page underneath was the one thing
you could not see — which is what an editor is for.

A COMPONENT keeps its own hover rather than inheriting the section's. A band with three paragraphs would
otherwise show three sets of Edit and Delete at once, and none of them would say which one it belonged
to.

Opacity, not display, so nothing moves when it appears. The controls hold their space whether visible or
not, and the page does not reflow under the cursor — which in an editor is worse than the noise it would
be fixing.

The outlines moved from inline styles into the stylesheet, because that is the only place `:hover` can
reach them. The section's MARGIN stays inline: it is layout and has to hold whether or not anything is
hovered.

`:focus-within` on both, so a keyboard reaches everything a cursor does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 05:57:07 +01:00
co-authored by Claude Opus 5
parent 5c2d3baac0
commit 56c0f05056
+42 -29
View File
@@ -108,20 +108,50 @@ export function paintCanvas(document: Document, canvas: Canvas): () => void {
const sheet = document.createElement('style');
sheet.setAttribute(MARK, 'stylesheet');
sheet.textContent = `
/*
EVERYTHING belonging to a section appears when that section is hovered.
Its outline, its name, its shape, its Delete, the outlines of its columns and the button that adds
to them. A page with four bands was four labelled boxes at rest, and the page underneath was the
one thing you could not see — which is what an editor is for.
Opacity rather than display, so nothing moves when it appears: the controls hold their space
whether or not they are visible, and the page does not reflow under the cursor.
*/
[data-section-id] {
outline: 1px solid transparent;
outline-offset: -1px;
transition: outline-color .12s;
}
[data-section-id]:hover { outline-color: ${ACCENT}; }
[${MARK}="section-bar"] { opacity: 0; transition: opacity .12s; }
[data-section-id]:hover [${MARK}="section-bar"] { opacity: 1; }
/* Keyboard reaches them too: focus inside a band is the same intent as hovering it. */
[data-section-id]:focus-within [${MARK}="section-bar"] { opacity: 1; }
[data-column] {
outline: 1px dashed transparent;
outline-offset: -5px;
transition: outline-color .12s;
}
[data-section-id]:hover [data-column] {
outline-color: color-mix(in srgb, ${ACCENT} 45%, transparent);
}
[${MARK}="add-component"] { opacity: 0; transition: opacity .12s; }
[data-section-id]:hover [${MARK}="add-component"] { opacity: 1; }
[${MARK}="add-component"]:focus { opacity: 1; }
/*
A COMPONENT's own handles need their own hover, not the section's. A band with three paragraphs
would otherwise show three sets of Edit and Delete at once, and none of them would say which.
*/
[data-fofos-component] { outline: 1px dotted transparent; transition: outline-color .12s; }
[data-fofos-component]:hover { outline-color: color-mix(in srgb, ${ACCENT} 55%, transparent); }
[${MARK}="component-tools"] { opacity: 0; transition: opacity .12s; }
[data-fofos-component]:hover > [${MARK}="component-tools"] { opacity: 1; }
[${MARK}="component-tools"]:focus-within { opacity: 1; }
/*
The section's Delete, same treatment. Its NAME stays visible — that is what tells you which band
you are looking at, and it sits above the section rather than on it — but a destructive verb does
not need to be on screen at all times.
*/
[${MARK}="section-delete"] { opacity: 0; transition: opacity .12s; }
[data-section-id]:hover [${MARK}="section-delete"] { opacity: 1; }
[${MARK}="section-delete"]:focus { opacity: 1; }
`;
document.head.append(sheet);
undo.push(() => sheet.remove());
@@ -152,24 +182,17 @@ export function paintCanvas(document: Document, canvas: Canvas): () => void {
if (!element) continue;
const previousPosition = element.style.position;
const previousOutline = element.style.outline;
const previousOffset = element.style.outlineOffset;
const previousMargin = element.style.margin;
// `relative` so the label and the controls can be placed against this band rather than the page.
element.style.position = 'relative';
element.style.outline = `1px solid ${ACCENT}`;
// INSET. At `4px` the outline sat outside the element, so a band that fills the content column drew
// its edges past the measure — the guides said the column ended and the skeleton crossed them.
// Editor chrome that overflows the thing it is describing is chrome that lies about it.
element.style.outlineOffset = '-1px';
// Room for the outline and the label above it, so two bands in a row do not touch.
// Room for the outline and the label above it, so two bands in a row do not touch. Kept inline
// because it is layout and must hold whether or not anything is hovered — the outline itself is in
// the stylesheet, INSET, so a band filling the column never draws past the measure.
element.style.margin = '2.5rem 0';
undo.push(() => {
element.style.position = previousPosition;
element.style.outline = previousOutline;
element.style.outlineOffset = previousOffset;
element.style.margin = previousMargin;
});
@@ -198,7 +221,6 @@ export function paintCanvas(document: Document, canvas: Canvas): () => void {
style(shape, ['opacity:0.7']);
const remove = document.createElement('button');
remove.setAttribute(MARK, 'section-delete');
remove.type = 'button';
remove.textContent = 'Delete';
style(remove, [
@@ -228,15 +250,6 @@ export function paintCanvas(document: Document, canvas: Canvas): () => void {
the wrong one.
*/
element.querySelectorAll<HTMLElement>('[data-column]').forEach((columnElement, columnIndex) => {
const was = columnElement.style.outline;
const wasOffset = columnElement.style.outlineOffset;
columnElement.style.outline = `1px dashed color-mix(in srgb, ${ACCENT} 45%, transparent)`;
columnElement.style.outlineOffset = '-5px';
undo.push(() => {
columnElement.style.outline = was;
columnElement.style.outlineOffset = wasOffset;
});
/*
Every component in this column gets a handle.