fix(studio): the sheet's keys leave a key to the control that took it, and the layers tree works by keyboard

The sheet listened on the window in the capture phase, before any control saw
a key. Delete on a focused gradient stop removed the whole shape, an arrow on
the angle dial or the radial centre also moved the shape, and Delete, the
arrows and Ctrl+V pressed on a button of the assistant's window acted on the
page although the window stops its keys. The sheet now listens on the
document as the key bubbles, after every control had it and still before the
page's Escape on the window, and leaves a key whose default was prevented. A
gradient stop takes Delete and Backspace even when the last two stops cannot
go.

The layers panel was a tree a keyboard could not walk: only its toggles could
be focused. The tree is now one stop in the Tab order, the row last focused or
else the selected one. Up and Down move along the rows, Home and End to the
ends, Right opens a group or steps into it and Left closes it or steps from a
member up to its group (mirrored right to left), Enter or Space selects a row
as a click does with Shift adding, and F2 renames it. Enter and Escape in the
name field hand the focus back to its row, and the row in focus is ringed. A
hidden or locked layer that cannot be clicked on the page is reached this way.
This commit is contained in:
Maurice
2026-10-24 04:06:37 +00:00
parent 84e18e4954
commit 117af4a8a7
8 changed files with 320 additions and 15 deletions
@@ -1,9 +1,11 @@
import type { BookDocument, BookElement } from '@trek/shared';
import { bookPageSetupSchema, bookShapeElementSchema, bookTextElementSchema } from '@trek/shared';
import { beforeEach, describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render } from '../../../tests/helpers/render';
import { useStudioStore } from '../../store/studioStore';
import { AngleDial } from './paint/AngleDial';
import { GradientStopBar } from './paint/GradientStopBar';
import { StudioCanvas } from './StudioCanvas';
/**
@@ -267,6 +269,55 @@ describe('the keyboard', () => {
expect(again.defaultPrevented).toBe(false);
});
it('leaves a key to the control that takes it: a gradient stop, the angle dial', () => {
load([shape('a', 10, 10)]);
store().select(['a']);
draw();
const fade = (n: number) => ({
type: 'linear' as const,
angle: 90,
stops: Array.from({ length: n }, (_, i) => ({ at: i / (n - 1), color: '#112233' })),
});
const onChange = vi.fn();
const labels = { bar: 'bar', stop: (pos: number) => `stop ${pos}` };
const three = render(
<GradientStopBar paint={fade(3)} selected={1} onSelect={() => {}} onChange={onChange} labels={labels} />
);
fireEvent.keyDown(must(three.container.querySelectorAll('.st-stop')[1]), { key: 'Delete' });
expect(onChange.mock.calls[0]?.[0].stops).toHaveLength(2);
three.unmount();
// The last two stops cannot go, and the key still does not reach the shape.
const two = render(
<GradientStopBar paint={fade(2)} selected={0} onSelect={() => {}} onChange={onChange} labels={labels} />
);
fireEvent.keyDown(must(two.container.querySelector('.st-stop')), { key: 'Backspace' });
two.unmount();
expect(elements().map((e) => e.id)).toEqual(['a']);
const dial = render(<AngleDial value={90} onChange={() => {}} label="Angle" />);
fireEvent.keyDown(must(dial.container.querySelector('.st-dial')), { key: 'ArrowRight', shiftKey: true });
expect(el('a').frame).toMatchObject({ x: 10, y: 10 });
});
it("leaves the keys pressed in the assistant's window to it", () => {
load([shape('a', 10, 10)]);
store().select(['a']);
store().copy(0, ['a']);
draw();
// As the Studio's assistant window does it: every key pressed in it stops there (StudioAssistantPanel.tsx).
const chat = render(
<aside className="st-assistant" onKeyDown={(e) => e.stopPropagation()}>
<button type="button">Apply</button>
</aside>
);
const button = must(chat.container.querySelector('button'));
fireEvent.keyDown(button, { key: 'Delete' });
fireEvent.keyDown(button, { key: 'ArrowDown' });
fireEvent.keyDown(button, { key: 'v', ctrlKey: true });
expect(elements().map((e) => e.id)).toEqual(['a']);
expect(el('a').frame).toMatchObject({ x: 10, y: 10 });
});
it('leaves the keys to a field being typed in', () => {
load([shape('a', 10, 10)]);
store().select(['a']);
@@ -152,6 +152,66 @@ describe('the layers panel', () => {
expect(screen.getAllByText('journey.studio.layers.empty').length).toBeGreaterThan(0);
});
it('is one stop in the Tab order, and the arrows walk the rows from there', () => {
const { container } = render(<StudioLayersPanel t={t} />);
expect(rows(container).map((r) => r.tabIndex)).toEqual([0, -1, -1, -1, -1]);
fireEvent.keyDown(must(rows(container)[0]), { key: 'ArrowDown' });
expect(document.activeElement).toBe(rows(container)[1]);
expect(rows(container).map((r) => r.tabIndex)).toEqual([-1, 0, -1, -1, -1]);
fireEvent.keyDown(must(rows(container)[1]), { key: 'End' });
expect(document.activeElement).toBe(rows(container)[4]);
fireEvent.keyDown(must(rows(container)[4]), { key: 'ArrowDown' });
expect(document.activeElement).toBe(rows(container)[4]);
fireEvent.keyDown(must(rows(container)[4]), { key: 'Home' });
expect(document.activeElement).toBe(rows(container)[0]);
});
it('selects with Enter or Space, Shift adding, and keeps the arrow off the page', () => {
store().select(['a']);
const { container } = render(<StudioLayersPanel t={t} />);
// The selected row is the one Tab reaches.
expect(must(rows(container)[4]).tabIndex).toBe(0);
fireEvent.keyDown(must(rows(container)[0]), { key: 'Enter' });
expect(store().selection).toEqual(['t']);
fireEvent.keyDown(must(rows(container)[4]), { key: ' ', shiftKey: true });
expect(store().selection).toEqual(['t', 'a']);
// Taken by the tree, so the sheet's own keys (useStudioKeys) leave the selection where it is.
expect(fireEvent.keyDown(must(rows(container)[4]), { key: 'ArrowUp' })).toBe(false);
expect(fireEvent.keyDown(must(rows(container)[3]), { key: 'Delete' })).toBe(true);
});
it('closes a group with Left, opens it with Right, and steps between a member and its group', () => {
const { container } = render(<StudioLayersPanel t={t} />);
fireEvent.keyDown(must(rows(container)[2]), { key: 'ArrowLeft' });
expect(document.activeElement).toBe(rows(container)[1]);
fireEvent.keyDown(must(rows(container)[1]), { key: 'ArrowLeft' });
expect(rows(container).map((r) => r.dataset.layerKey)).toEqual(['t', 'g:g', 'a']);
expect(must(rows(container)[1]).getAttribute('aria-expanded')).toBe('false');
fireEvent.keyDown(must(rows(container)[1]), { key: 'ArrowRight' });
expect(rows(container)).toHaveLength(5);
fireEvent.keyDown(must(rows(container)[1]), { key: 'ArrowRight' });
expect(document.activeElement).toBe(rows(container)[2]);
});
it('renames with F2, and the keyboard is back on the row after Enter or Escape', () => {
const { container } = render(<StudioLayersPanel t={t} />);
fireEvent.keyDown(must(rows(container)[0]), { key: 'F2' });
const input = container.querySelector('.st-layer-input') as HTMLInputElement;
expect(document.activeElement).toBe(input);
fireEvent.change(input, { target: { value: 'Title' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(el('t').name).toBe('Title');
expect(document.activeElement).toBe(rows(container)[0]);
fireEvent.keyDown(must(rows(container)[0]), { key: 'F2' });
const again = container.querySelector('.st-layer-input') as HTMLInputElement;
fireEvent.change(again, { target: { value: 'Something else' } });
fireEvent.keyDown(again, { key: 'Escape' });
expect(el('t').name).toBe('Title');
expect(container.querySelector('.st-layer-input')).toBeNull();
expect(document.activeElement).toBe(rows(container)[0]);
});
it('offers no drag, lock or visibility to a book held read-only', () => {
useStudioStore.setState({ readOnly: true });
const { container } = render(<StudioLayersPanel t={t} />);
@@ -27,7 +27,17 @@ import { useStudioStore } from '../../store/studioStore';
import '../../styles/studio-layers.css';
import { Tooltip } from '../shared/Tooltip';
import { PanelHead } from './StudioPanelHead';
import { dropOn, layerLabel, layerRows, rowIds, type LayerDrop, type LayerRow } from './studioLayerRows';
import {
dropOn,
layerKeyAction,
layerLabel,
layerRows,
rowIds,
type LayerDrop,
type LayerKeyAction,
type LayerRow,
} from './studioLayerRows';
import { runsRtl } from './studioRoving';
import { useStudioShortcut } from './studioShortcut';
/**
@@ -41,6 +51,12 @@ import { useStudioShortcut } from './studioShortcut';
* sit at the end of every row, always shown when they are on so a locked or
* hidden layer can be told at a glance; a hidden layer is dimmed, not removed,
* since this panel is the way back to it. Every change is one undo step.
*
* The tree is one stop in the Tab order, the row last focused or else the
* selected one, and its keys are a tree view's (studioLayerRows.ts): the arrows
* move along it and open or close a group, Enter or Space selects a row (with
* Shift added), F2 renames it. A hidden or locked layer, which cannot be
* clicked on the page, is reached this way too.
*/
const KIND_ICONS: Record<string, LucideIcon> = {
@@ -90,6 +106,8 @@ export function StudioLayersPanel({ t }: { t: T }) {
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
const [renaming, setRenaming] = useState<string | null>(null);
const [drag, setDrag] = useState<Drag | null>(null);
/** The row the keyboard is on, so the tree keeps its place in the Tab order. */
const [focusKey, setFocusKey] = useState<string | null>(null);
const list = useRef<HTMLDivElement>(null);
/** A drag ends in a click on the row it started from; that click is not a choice. */
const justDragged = useRef(false);
@@ -114,14 +132,55 @@ export function StudioLayersPanel({ t }: { t: T }) {
return next;
});
const selectRow = (row: LayerRow, additive: boolean) => {
if (row.type === 'group') selectGroup(spreadIndex, row.groupId, additive);
else if (row.inGroup && !additive) enterGroup(spreadIndex, row.el.id);
else pick(spreadIndex, row.el.id, additive);
};
const choose = (row: LayerRow, additive: boolean) => {
if (justDragged.current) {
justDragged.current = false;
return;
}
if (row.type === 'group') selectGroup(spreadIndex, row.groupId, additive);
else if (row.inGroup && !additive) enterGroup(spreadIndex, row.el.id);
else pick(spreadIndex, row.el.id, additive);
selectRow(row, additive);
};
/** The one row in the Tab order: the one last focused, else the first selected, else the front one. */
const tabKey =
rows.find((r) => r.key === focusKey)?.key ??
rows.find((r) => rowIds(r).every((id) => selected.has(id)))?.key ??
rows[0]?.key;
const focusRow = (row: LayerRow | undefined) => {
if (!row) return;
setFocusKey(row.key);
const nodes = list.current?.querySelectorAll<HTMLElement>('[data-layer-key]') ?? [];
Array.from(nodes)
.find((n) => n.dataset.layerKey === row.key)
?.focus();
};
const runKey = (action: LayerKeyAction, row: LayerRow) => {
if (action.type === 'focus') focusRow(rows[action.index]);
else if (action.type === 'expand' || action.type === 'collapse') toggleCollapsed(action.groupId);
else if (action.type === 'rename') {
if (!readOnly) setRenaming(row.key);
} else selectRow(row, action.additive);
};
const onTreeKey = (e: React.KeyboardEvent<HTMLDivElement>) => {
const node = e.target as HTMLElement;
// The caret, the toggles and the name field keep their own keys.
const index = rows.findIndex((r) => r.key === node.dataset.layerKey);
const row = rows[index];
if (!row) return;
const additive = e.shiftKey || e.metaKey || e.ctrlKey;
const action = layerKeyAction(e.key, index, rows, { collapsed, rtl: runsRtl(node), additive });
if (!action) return;
// Taken here, so the page does not also nudge or act on the selection (useStudioKeys.ts).
e.preventDefault();
runKey(action, row);
};
/** What a drag from this row carries: the selection when the row is in it, else what the row stands for. */
@@ -203,6 +262,7 @@ export function StudioLayersPanel({ t }: { t: T }) {
className={`st-layers ${drag?.active ? 'is-dragging' : ''}`}
role="tree"
aria-label={t('journey.studio.layers')}
onKeyDown={onTreeKey}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={() => setDrag(null)}
@@ -219,6 +279,8 @@ export function StudioLayersPanel({ t }: { t: T }) {
dragged={!!drag?.active && rowIds(row).every((id) => drag.ids.includes(id))}
dropAt={drag?.active && drag.overKey === row.key ? (drag.upper ? 'above' : 'below') : null}
readOnly={readOnly}
tabStop={row.key === tabKey}
onFocusRow={() => setFocusKey(row.key)}
onPointerDown={(e) => onPointerDown(e, row)}
onChoose={(additive) => choose(row, additive)}
onToggleCollapsed={() => row.type === 'group' && toggleCollapsed(row.groupId)}
@@ -263,6 +325,9 @@ interface RowProps {
dragged: boolean;
dropAt: 'above' | 'below' | null;
readOnly: boolean;
/** The row the Tab key reaches the tree on. */
tabStop: boolean;
onFocusRow: () => void;
onPointerDown: (e: React.PointerEvent) => void;
onChoose: (additive: boolean) => void;
onToggleCollapsed: () => void;
@@ -312,8 +377,11 @@ function LayerRowView(p: RowProps) {
className={rowClasses(p, hidden)}
data-layer-key={row.key}
role="treeitem"
tabIndex={p.tabStop ? 0 : -1}
aria-level={row.type === 'element' && row.inGroup ? 2 : 1}
aria-selected={p.selected}
aria-expanded={row.type === 'group' ? !p.collapsed : undefined}
onFocus={p.onFocusRow}
onPointerDown={p.onPointerDown}
onClick={(e) => p.onChoose(e.shiftKey || e.metaKey || e.ctrlKey)}
>
@@ -363,6 +431,8 @@ function RowName(p: {
onCommit: (value: string) => void;
onCancel: () => void;
}) {
// Escape hands the focus back to the row, which blurs the field: that blur keeps nothing.
const cancelled = useRef(false);
if (!p.renaming) {
return (
<span
@@ -384,14 +454,28 @@ function RowName(p: {
maxLength={60}
aria-label={p.t('journey.studio.layers.rename')}
onClick={(e) => e.stopPropagation()}
onBlur={(e) => p.onCommit(e.target.value)}
onFocus={() => {
cancelled.current = false;
}}
onBlur={(e) => {
if (!cancelled.current) p.onCommit(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
// Back to the row either way, so the keyboard keeps its place in the tree.
const row = e.currentTarget.closest<HTMLElement>('[data-layer-key]');
if (e.key === 'Enter') {
// Moving the focus blurs the field, which keeps the name.
if (row) row.focus();
else e.currentTarget.blur();
return;
}
if (e.key !== 'Escape') return;
// Escape here leaves the name as it was, and must not close Studio.
e.stopPropagation();
e.preventDefault();
cancelled.current = true;
p.onCancel();
row?.focus();
}}
/>
);
@@ -86,8 +86,10 @@ export function GradientStopBar({
const onStopKey = (index: number) => (e: KeyboardEvent<HTMLButtonElement>) => {
const stop = paint.stops[index];
if (!stop) return;
if ((e.key === 'Delete' || e.key === 'Backspace') && canRemove) {
if (e.key === 'Delete' || e.key === 'Backspace') {
// The stop's key even when the last two cannot go: it never reaches the page to delete the shape.
e.preventDefault();
if (!canRemove) return;
onSelect(Math.max(0, index - 1));
onChange(removeStop(paint, index), true);
return;
@@ -2,7 +2,7 @@ import type { BookElement, BookSpread } from '@trek/shared';
import { bookShapeElementSchema, bookTextElementSchema } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { dropOn, layerLabel, layerRows, rowIds } from './studioLayerRows';
import { dropOn, layerKeyAction, layerLabel, layerRows, rowIds } from './studioLayerRows';
import { shortcutKeys } from './studioShortcut';
/** The value, or a failed test when it is missing. */
@@ -59,6 +59,42 @@ describe('dropOn', () => {
});
});
describe('layerKeyAction', () => {
const open = layerRows(spread, new Set());
const ltr = { collapsed: new Set<string>(), rtl: false, additive: false };
it('walks the rows with Up, Down, Home and End, and stops at the ends', () => {
expect(layerKeyAction('ArrowDown', 0, open, ltr)).toEqual({ type: 'focus', index: 1 });
expect(layerKeyAction('ArrowDown', 4, open, ltr)).toEqual({ type: 'focus', index: 4 });
expect(layerKeyAction('ArrowUp', 0, open, ltr)).toEqual({ type: 'focus', index: 0 });
expect(layerKeyAction('End', 1, open, ltr)).toEqual({ type: 'focus', index: 4 });
expect(layerKeyAction('Home', 3, open, ltr)).toEqual({ type: 'focus', index: 0 });
});
it('opens and closes a group, and steps between it and its members, mirrored right to left', () => {
const folded = layerRows(spread, new Set(['g']));
const closed = { ...ltr, collapsed: new Set(['g']) };
expect(layerKeyAction('ArrowLeft', 1, open, ltr)).toEqual({ type: 'collapse', groupId: 'g' });
expect(layerKeyAction('ArrowRight', 1, folded, closed)).toEqual({ type: 'expand', groupId: 'g' });
expect(layerKeyAction('ArrowRight', 1, open, ltr)).toEqual({ type: 'focus', index: 2 });
expect(layerKeyAction('ArrowLeft', 3, open, ltr)).toEqual({ type: 'focus', index: 1 });
expect(layerKeyAction('ArrowLeft', 1, folded, closed)).toBeNull();
expect(layerKeyAction('ArrowRight', 0, open, ltr)).toBeNull();
expect(layerKeyAction('ArrowLeft', 0, open, ltr)).toBeNull();
const rtl = { ...ltr, rtl: true };
expect(layerKeyAction('ArrowRight', 1, open, rtl)).toEqual({ type: 'collapse', groupId: 'g' });
expect(layerKeyAction('ArrowLeft', 1, open, rtl)).toEqual({ type: 'focus', index: 2 });
});
it('selects with Enter or Space, renames with F2, and leaves every other key to the page', () => {
expect(layerKeyAction('Enter', 2, open, ltr)).toEqual({ type: 'choose', additive: false });
expect(layerKeyAction(' ', 2, open, { ...ltr, additive: true })).toEqual({ type: 'choose', additive: true });
expect(layerKeyAction('F2', 0, open, ltr)).toEqual({ type: 'rename' });
expect(layerKeyAction('Delete', 0, open, ltr)).toBeNull();
expect(layerKeyAction('Enter', 9, open, ltr)).toBeNull();
});
});
describe('layerLabel', () => {
it('uses the name, then a text’s first words, then the kind', () => {
expect(layerLabel(shape('a', { name: 'Sun' }), 'Shape')).toBe('Sun');
@@ -91,3 +91,61 @@ export function selectionTitle(spread: BookSpread | undefined, selection: string
}
return t('journey.studio.multiple');
}
/** What a key pressed on a row of the layers tree does. */
export type LayerKeyAction =
| { type: 'focus'; index: number }
| { type: 'expand'; groupId: string }
| { type: 'collapse'; groupId: string }
| { type: 'choose'; additive: boolean }
| { type: 'rename' };
/** Up and Down along the rows, Home and End to the ends; the tree stops at either end. */
function alongTree(key: string, index: number, count: number): number | null {
if (key === 'Home') return 0;
if (key === 'End') return count - 1;
if (key === 'ArrowDown') return Math.min(index + 1, count - 1);
if (key === 'ArrowUp') return Math.max(index - 1, 0);
return null;
}
/** Into a group: a closed one opens, an open one hands the focus to its first member. */
function inward(row: LayerRow, index: number, collapsed: ReadonlySet<string>): LayerKeyAction | null {
if (row.type !== 'group') return null;
return collapsed.has(row.groupId) ? { type: 'expand', groupId: row.groupId } : { type: 'focus', index: index + 1 };
}
/** Out of a group: an open one closes, a member hands the focus to its group's row. */
function outward(rows: readonly LayerRow[], index: number, collapsed: ReadonlySet<string>): LayerKeyAction | null {
const row = rows[index];
if (row?.type === 'group') return collapsed.has(row.groupId) ? null : { type: 'collapse', groupId: row.groupId };
if (!row?.inGroup) return null;
// A member's group is the nearest group row above it: members are listed right under their group.
for (let i = index - 1; i >= 0; i--) if (rows[i]?.type === 'group') return { type: 'focus', index: i };
return null;
}
/**
* The tree's keyboard, as a tree view has it: Up and Down move along the
* rows, Home and End to the ends; Right opens a closed group and steps into
* an open one, Left closes an open group and steps from a member up to its
* group, the two mirrored in a language read right to left; Enter or Space
* selects the row as a click does, `additive` (Shift, Ctrl or Cmd held)
* adding it; F2 renames it. Null for a key the tree leaves to the page.
*/
export function layerKeyAction(
key: string,
index: number,
rows: readonly LayerRow[],
opts: { collapsed: ReadonlySet<string>; rtl: boolean; additive: boolean }
): LayerKeyAction | null {
const row = rows[index];
if (!row) return null;
if (key === 'Enter' || key === ' ') return { type: 'choose', additive: opts.additive };
if (key === 'F2') return { type: 'rename' };
const along = alongTree(key, index, rows.length);
if (along !== null) return { type: 'focus', index: along };
if (key === (opts.rtl ? 'ArrowLeft' : 'ArrowRight')) return inward(row, index, opts.collapsed);
if (key === (opts.rtl ? 'ArrowRight' : 'ArrowLeft')) return outward(rows, index, opts.collapsed);
return null;
}
+12 -6
View File
@@ -9,8 +9,12 @@ import { useStudioStore } from '../../store/studioStore';
* Every shortcut here is one a design tool has taught people already, and none
* of them takes a key away from the browser while somebody is typing: in a
* field, a text area or a text being edited on the page, the keys are the
* field's. Undo and redo are the Studio page's own (useJourneyStudio.ts), and
* every action below is one undo step there.
* field's. Nor from a control that takes the key itself: a gradient stop,
* the angle dial or a row of the layers tree prevents its default, and the
* assistant's window stops its keys from reaching the page, so Delete on a
* focused stop removes the stop, not the shape. Undo and redo are the Studio
* page's own (useJourneyStudio.ts), and every action below is one undo step
* there.
*
* - Delete or Backspace: remove what is selected and not locked.
* - Ctrl+C, Ctrl+V: copy and paste elements, onto any page (#2316).
@@ -134,7 +138,7 @@ export function useStudioKeys(opts: { spreadIndex: number; editing: boolean }) {
};
const onKey = (e: KeyboardEvent) => {
if (editing || isTypingTarget(e.target)) return;
if (editing || e.defaultPrevented || isTypingTarget(e.target)) return;
const store = useStudioStore.getState();
// Read when the key comes, not when the effect ran: a nudge changes the
// page, and an effect that re-ran on it would close the gesture each step.
@@ -164,13 +168,15 @@ export function useStudioKeys(opts: { spreadIndex: number; editing: boolean }) {
if (ARROWS[e.key]) stopNudge();
};
// Capture, so a step out of a group is decided before the page's Escape clears the selection.
window.addEventListener('keydown', onKey, true);
// On the document as the key bubbles: after every control and the assistant's window had it (React
// listens below the document), and before the page's Escape on the window, so a step out of a group
// is decided before the page clears the selection.
document.addEventListener('keydown', onKey);
window.addEventListener('keyup', onKeyUp, true);
window.addEventListener('blur', stopNudge);
return () => {
stopNudge();
window.removeEventListener('keydown', onKey, true);
document.removeEventListener('keydown', onKey);
window.removeEventListener('keyup', onKeyUp, true);
window.removeEventListener('blur', stopNudge);
};
+8
View File
@@ -232,10 +232,18 @@
}
/* Shown on hover and while focused, and always when on, so a locked or hidden layer reads at a glance. */
.trek-dash .st-layer:hover .st-layer-acts .st-act,
.trek-dash .st-layer:focus-visible .st-layer-acts .st-act,
.trek-dash .st-layer-acts .st-act:focus-visible,
.trek-dash .st-layer-acts .st-act.is-kept {
opacity: 1;
}
/* The row the keyboard is on, ringed: the tree is walked with the arrow keys. */
.trek-dash .st-layer:focus {
outline: none;
}
.trek-dash .st-layer:focus-visible {
box-shadow: inset 0 0 0 2px var(--accent);
}
.trek-dash .st-layers-foot {
flex: none;