@astronox/core 0.5.8 → 0.5.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/package.json +1 -1
  2. package/src/client/scroll.ts +14 -1
  3. package/src/components/data-display/IconList/IconList.astro +20 -2
  4. package/src/components/data-display/MetricTile/MetricTileContent.astro +6 -1
  5. package/src/components/feedback/Tooltip/Tooltip.css +9 -1
  6. package/src/components/feedback/Tooltip/tooltipController.ts +12 -9
  7. package/src/components/media/EmbedFrame/EmbedFrame.astro +9 -0
  8. package/src/components/media/EmbedFrame/EmbedFrame.css +5 -0
  9. package/src/components/media/EmbedFrame/embedFrameController.ts +84 -1
  10. package/src/display/contracts.ts +1 -0
  11. package/src/patterns/CircuitPuzzle/CircuitPuzzle.astro +25 -2
  12. package/src/patterns/CircuitPuzzle/CircuitPuzzle.css +24 -0
  13. package/src/patterns/CircuitPuzzle/circuitPuzzleController.ts +49 -0
  14. package/src/patterns/LightMazePuzzle/LightMazePuzzle.astro +1 -0
  15. package/src/patterns/LightMazePuzzle/LightMazePuzzle.css +9 -3
  16. package/src/patterns/LightMazePuzzle/lightMazePuzzleController.ts +43 -56
  17. package/src/patterns/MagneticFieldPuzzle/MagneticFieldPuzzle.astro +65 -3
  18. package/src/patterns/MagneticFieldPuzzle/MagneticFieldPuzzle.css +69 -0
  19. package/src/patterns/MagneticFieldPuzzle/contracts.ts +4 -0
  20. package/src/patterns/MagneticFieldPuzzle/magneticFieldPuzzleController.ts +50 -64
  21. package/src/patterns/PuzzleGrid/PuzzleGrid.astro +2 -0
  22. package/src/patterns/PuzzleGrid/keyboard.ts +220 -0
  23. package/src/patterns/PuzzleGrid/movement.css +4 -0
  24. package/src/patterns/PuzzleGrid/movement.ts +67 -3
  25. package/src/patterns/PuzzleGrid/selectionDismissal.ts +2 -2
  26. package/src/patterns/SlidingPuzzle/SlidingPuzzle.astro +9 -1
  27. package/src/patterns/SlidingPuzzle/SlidingPuzzle.css +13 -7
  28. package/src/patterns/SlidingPuzzle/slidingPuzzleController.ts +71 -74
  29. package/src/patterns/Table/TableCell.astro +1 -1
  30. package/src/patterns/Table/TableCellValue.astro +6 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astronox/core",
3
- "version": "0.5.8",
3
+ "version": "0.5.10",
4
4
  "description": "Reusable Astro components, patterns, styles, and infrastructure for AstroNox sites.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -1,5 +1,5 @@
1
1
  type JumpToElementOptions = {
2
- block?: ScrollLogicalPosition;
2
+ block?: ScrollLogicalPosition | 'auto';
3
3
  inline?: ScrollLogicalPosition;
4
4
  };
5
5
 
@@ -7,6 +7,19 @@ export function jumpToElement(
7
7
  element: Element,
8
8
  { block = 'center', inline = 'nearest' }: JumpToElementOptions = {},
9
9
  ): void {
10
+ if (block === 'auto') {
11
+ const header = document.querySelector<HTMLElement>('[data-site-header]');
12
+ const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
13
+ const availableHeight = Math.max(
14
+ 0,
15
+ viewportHeight - (header?.getBoundingClientRect().height ?? 0),
16
+ );
17
+ block =
18
+ element.getBoundingClientRect().height <= availableHeight
19
+ ? 'center'
20
+ : 'start';
21
+ }
22
+
10
23
  element.scrollIntoView({
11
24
  behavior: 'auto',
12
25
  block,
@@ -2,6 +2,7 @@
2
2
  import type { HTMLAttributes } from 'astro/types';
3
3
  import type { DisplayIconItem } from '../../../display/contracts';
4
4
  import type { Tone } from '../../../theme/contracts';
5
+ import Tooltip from '../../feedback/Tooltip/Tooltip.astro';
5
6
 
6
7
  type Props = HTMLAttributes<'div'> & {
7
8
  'data-tone'?: Tone;
@@ -9,6 +10,7 @@ type Props = HTMLAttributes<'div'> & {
9
10
  label?: string;
10
11
  size?: 'small' | 'medium';
11
12
  tone?: Tone;
13
+ tooltips?: boolean;
12
14
  };
13
15
 
14
16
  const {
@@ -18,6 +20,7 @@ const {
18
20
  label,
19
21
  size = 'small',
20
22
  tone,
23
+ tooltips = true,
21
24
  ...attrs
22
25
  }: Props = Astro.props;
23
26
  const resolvedTone = tone ?? dataTone;
@@ -34,6 +37,11 @@ const resolvedTone = tone ?? dataTone;
34
37
  {
35
38
  items.map((item) => {
36
39
  const Icon = item.icon;
40
+ const tooltip = tooltips
41
+ ? typeof item.tooltip === 'string'
42
+ ? item.tooltip.trim()
43
+ : item.tooltip
44
+ : undefined;
37
45
  const icon = (
38
46
  <span class="icon-list__icon">
39
47
  <Icon />
@@ -44,14 +52,16 @@ const resolvedTone = tone ?? dataTone;
44
52
  <span class="icon-list__value">{item.value}</span>
45
53
  ) : null;
46
54
 
47
- return (
55
+ const content = (
48
56
  <span
49
57
  class="icon-list__item"
50
58
  data-muted={item.muted ? 'true' : undefined}
51
59
  data-tone={item.tone}
52
60
  role="group"
53
61
  aria-label={item.label}
54
- title={item.label}
62
+ title={tooltips && !tooltip ? item.label : undefined}
63
+ tabindex={tooltip ? '0' : undefined}
64
+ data-tooltip-trigger={tooltip ? true : undefined}
55
65
  >
56
66
  <span class="icon-list__visible" aria-hidden="true">
57
67
  {item.iconPosition === 'end' ? value : icon}
@@ -59,6 +69,14 @@ const resolvedTone = tone ?? dataTone;
59
69
  </span>
60
70
  </span>
61
71
  );
72
+
73
+ return tooltip ? (
74
+ <Tooltip content={tooltip} activation="hover">
75
+ {content}
76
+ </Tooltip>
77
+ ) : (
78
+ content
79
+ );
62
80
  })
63
81
  }
64
82
  </div>
@@ -42,7 +42,12 @@ const ValueIcon = icon?.component;
42
42
  <dd class="metric-tile__value">
43
43
  {
44
44
  items ? (
45
- <IconList items={items} label={itemsLabel} size="medium" />
45
+ <IconList
46
+ items={items}
47
+ label={itemsLabel}
48
+ size="medium"
49
+ tooltips={!hint}
50
+ />
46
51
  ) : (
47
52
  <>
48
53
  <strong data-metric-value>{value}</strong>
@@ -26,8 +26,16 @@
26
26
  position: fixed;
27
27
  z-index: 100;
28
28
  inset: auto;
29
+ box-sizing: border-box;
29
30
  inline-size: max-content;
30
- max-inline-size: min(22rem, calc(100vw - var(--nox-space-4) * 2));
31
+ min-inline-size: 0;
32
+ max-inline-size: min(
33
+ 26rem,
34
+ var(
35
+ --tooltip-available-inline-size,
36
+ calc(100vw - var(--tooltip-viewport-gap) * 2)
37
+ )
38
+ );
31
39
  max-block-size: calc(100dvh - var(--nox-space-4) * 2);
32
40
  margin: 0;
33
41
  padding: var(--nox-space-3) var(--nox-space-4);
@@ -125,17 +125,16 @@ export function initTooltip(root: HTMLElement): () => void {
125
125
  const triggerGap = getCssLength(panel, '--tooltip-trigger-gap', 8);
126
126
  const triggerRect = trigger.getBoundingClientRect();
127
127
  const boundary =
128
- root.dataset.tooltipAlignTo === 'boundary'
129
- ? root.closest<HTMLElement>('[data-tooltip-boundary]')
130
- : null;
131
- const boundaryRect = boundary?.getBoundingClientRect();
128
+ root.closest<HTMLElement>('[data-tooltip-boundary], main, dialog') ??
129
+ document.body;
130
+ const boundaryRect = boundary.getBoundingClientRect();
132
131
  const horizontalLeft = Math.max(
133
132
  viewport.left + viewportGap,
134
- boundaryRect?.left ?? viewport.left + viewportGap,
133
+ boundaryRect.left,
135
134
  );
136
135
  const horizontalRight = Math.min(
137
136
  viewport.right - viewportGap,
138
- boundaryRect?.right ?? viewport.right - viewportGap,
137
+ boundaryRect.right,
139
138
  );
140
139
  const maximumInlineSize = Math.max(0, horizontalRight - horizontalLeft);
141
140
  const maximumBlockSize = Math.max(
@@ -143,7 +142,10 @@ export function initTooltip(root: HTMLElement): () => void {
143
142
  viewport.bottom - viewport.top - viewportGap * 2,
144
143
  );
145
144
 
146
- panel.style.maxInlineSize = `${Math.floor(maximumInlineSize)}px`;
145
+ panel.style.setProperty(
146
+ '--tooltip-available-inline-size',
147
+ `${Math.floor(maximumInlineSize)}px`,
148
+ );
147
149
  panel.style.maxBlockSize = `${Math.floor(maximumBlockSize)}px`;
148
150
  panel.style.visibility = 'hidden';
149
151
  panel.style.left = `${Math.round(horizontalLeft)}px`;
@@ -165,7 +167,8 @@ export function initTooltip(root: HTMLElement): () => void {
165
167
 
166
168
  panel.style.maxBlockSize = `${Math.floor(availableBlockSize)}px`;
167
169
  const panelRect = panel.getBoundingClientRect();
168
- const horizontalAnchor = boundaryRect ?? triggerRect;
170
+ const horizontalAnchor =
171
+ root.dataset.tooltipAlignTo === 'boundary' ? boundaryRect : triggerRect;
169
172
  const centeredLeft =
170
173
  horizontalAnchor.left + (horizontalAnchor.width - panelRect.width) / 2;
171
174
  const left = Math.min(
@@ -199,7 +202,7 @@ export function initTooltip(root: HTMLElement): () => void {
199
202
  panel.hidden = true;
200
203
  panel.style.removeProperty('left');
201
204
  panel.style.removeProperty('top');
202
- panel.style.removeProperty('max-inline-size');
205
+ panel.style.removeProperty('--tooltip-available-inline-size');
203
206
  panel.style.removeProperty('max-block-size');
204
207
  panel.style.removeProperty('visibility');
205
208
  };
@@ -21,6 +21,9 @@ type Props = Omit<
21
21
  loading?: EmbedLoading;
22
22
  loadingIndicator?: EmbedLoadingIndicator;
23
23
  loadingLabel: string;
24
+ pageScroll?: 'auto' | 'lock-while-interacting';
25
+ scrollOnActivate?: boolean;
26
+ scrollTargetId?: string;
24
27
  src: string;
25
28
  title: string;
26
29
  tone?: Tone;
@@ -45,6 +48,9 @@ const {
45
48
  loading = 'lazy',
46
49
  loadingIndicator = 'spinner',
47
50
  loadingLabel,
51
+ pageScroll = 'auto',
52
+ scrollOnActivate = false,
53
+ scrollTargetId,
48
54
  src,
49
55
  title,
50
56
  tone,
@@ -59,6 +65,9 @@ const resolvedTone = tone ?? dataTone;
59
65
  class="embed-frame"
60
66
  data-embed-frame-activation={activation}
61
67
  data-embed-frame-source={activation === 'manual' ? src : undefined}
68
+ data-embed-frame-page-scroll={pageScroll}
69
+ data-embed-frame-scroll-on-activate={scrollOnActivate ? 'true' : undefined}
70
+ data-embed-frame-scroll-target={scrollTargetId}
62
71
  data-aspect-ratio={aspectRatio}
63
72
  data-fullscreen-region={fullscreen ? true : undefined}
64
73
  data-widget="embed-frame"
@@ -6,6 +6,11 @@
6
6
  overflow: hidden;
7
7
  }
8
8
 
9
+ html:has([data-embed-frame-scroll-locked]),
10
+ [data-site-scroll-root]:has([data-embed-frame-scroll-locked]) {
11
+ overflow: hidden;
12
+ }
13
+
9
14
  .embed-frame[data-aspect-ratio='16/9'] {
10
15
  aspect-ratio: 16 / 9;
11
16
  }
@@ -1,3 +1,5 @@
1
+ import { jumpToElement } from '../../../client/scroll';
2
+
1
3
  const activateEvent = 'nox:embed-frame:activate';
2
4
 
3
5
  export function activateEmbedFrame(
@@ -27,6 +29,55 @@ export const initEmbedFrame = (root: HTMLElement): (() => void) => {
27
29
  let activated =
28
30
  root.dataset.embedFrameActivation !== 'manual' ||
29
31
  root.dataset.embedFrameActive === 'true';
32
+ let activationFocusFrame = 0;
33
+ let focusCheckFrame = 0;
34
+ let interactionDismissed = false;
35
+ const lockPageScroll =
36
+ root.dataset.embedFramePageScroll === 'lock-while-interacting';
37
+
38
+ const setScrollLock = (locked: boolean) => {
39
+ root.toggleAttribute(
40
+ 'data-embed-frame-scroll-locked',
41
+ lockPageScroll && activated && locked,
42
+ );
43
+ };
44
+
45
+ const releaseScroll = () => {
46
+ cancelAnimationFrame(activationFocusFrame);
47
+ cancelAnimationFrame(focusCheckFrame);
48
+ activationFocusFrame = 0;
49
+ focusCheckFrame = 0;
50
+ interactionDismissed = true;
51
+ setScrollLock(false);
52
+ };
53
+ const enterFrame = () => {
54
+ cancelAnimationFrame(focusCheckFrame);
55
+ focusCheckFrame = 0;
56
+ interactionDismissed = false;
57
+ setScrollLock(true);
58
+ };
59
+ const leaveFrame = (event: PointerEvent) => {
60
+ if (event.pointerType !== 'touch') releaseScroll();
61
+ };
62
+ const updateFocus = () => {
63
+ cancelAnimationFrame(focusCheckFrame);
64
+ focusCheckFrame = requestAnimationFrame(() => {
65
+ focusCheckFrame = 0;
66
+ setScrollLock(!interactionDismissed && document.activeElement === iframe);
67
+ });
68
+ };
69
+ const interactOutside = (event: Event) => {
70
+ if (event.target instanceof Node && !root.contains(event.target)) {
71
+ releaseScroll();
72
+ }
73
+ };
74
+
75
+ const scrollToFrame = () => {
76
+ if (root.dataset.embedFrameScrollOnActivate !== 'true') return;
77
+ const targetId = root.dataset.embedFrameScrollTarget;
78
+ const target = (targetId && document.getElementById(targetId)) || root;
79
+ jumpToElement(target, { block: 'auto' });
80
+ };
30
81
 
31
82
  const markLoaded = () => {
32
83
  if (!activated) return;
@@ -46,19 +97,51 @@ export const initEmbedFrame = (root: HTMLElement): (() => void) => {
46
97
  root.setAttribute('aria-busy', loading ? 'true' : 'false');
47
98
  root.setAttribute('data-embed-frame-active', 'true');
48
99
  iframe.src = src;
100
+ scrollToFrame();
49
101
  };
50
102
 
51
103
  const activateFromControl = () => {
52
104
  activate();
53
- requestAnimationFrame(() => iframe.focus({ preventScroll: true }));
105
+ cancelAnimationFrame(activationFocusFrame);
106
+ interactionDismissed = false;
107
+ activationFocusFrame = requestAnimationFrame(() => {
108
+ activationFocusFrame = 0;
109
+ iframe.focus({ preventScroll: true });
110
+ setScrollLock(document.activeElement === iframe);
111
+ });
54
112
  };
55
113
 
56
114
  if (activated && activationLayer) activationLayer.hidden = true;
57
115
  iframe.addEventListener('load', markLoaded);
58
116
  root.addEventListener(activateEvent, activate);
59
117
  activationControl?.addEventListener('click', activateFromControl);
118
+ if (lockPageScroll) {
119
+ root.addEventListener('pointerenter', enterFrame);
120
+ root.addEventListener('pointerleave', leaveFrame);
121
+ iframe.addEventListener('focus', enterFrame);
122
+ iframe.addEventListener('blur', releaseScroll);
123
+ window.addEventListener('blur', updateFocus);
124
+ window.addEventListener('focus', updateFocus);
125
+ window.addEventListener('pagehide', releaseScroll);
126
+ document.addEventListener('focusin', interactOutside);
127
+ document.addEventListener('pointerdown', interactOutside, true);
128
+ document.addEventListener('wheel', interactOutside, { passive: true });
129
+ document.addEventListener('touchstart', interactOutside, { passive: true });
130
+ }
60
131
 
61
132
  return () => {
133
+ releaseScroll();
134
+ root.removeEventListener('pointerenter', enterFrame);
135
+ root.removeEventListener('pointerleave', leaveFrame);
136
+ iframe.removeEventListener('focus', enterFrame);
137
+ iframe.removeEventListener('blur', releaseScroll);
138
+ window.removeEventListener('blur', updateFocus);
139
+ window.removeEventListener('focus', updateFocus);
140
+ window.removeEventListener('pagehide', releaseScroll);
141
+ document.removeEventListener('focusin', interactOutside);
142
+ document.removeEventListener('pointerdown', interactOutside, true);
143
+ document.removeEventListener('wheel', interactOutside);
144
+ document.removeEventListener('touchstart', interactOutside);
62
145
  iframe.removeEventListener('load', markLoaded);
63
146
  root.removeEventListener(activateEvent, activate);
64
147
  activationControl?.removeEventListener('click', activateFromControl);
@@ -92,6 +92,7 @@ export type DisplayIconItem = {
92
92
  label: string;
93
93
  muted?: boolean;
94
94
  tone?: Tone;
95
+ tooltip?: string | DisplayTooltip;
95
96
  value?: DisplayTextValue;
96
97
  };
97
98
 
@@ -1,11 +1,14 @@
1
1
  ---
2
2
  import type { HTMLAttributes } from 'astro/types';
3
+ import type { DisplayMedia } from '../../display/contracts';
4
+ import Media from '../../primitives/media/Media/Media.astro';
3
5
  import PuzzleGrid from '../PuzzleGrid/PuzzleGrid.astro';
4
6
  import type { CircuitPuzzleLabels, CircuitPuzzleModel } from './contracts';
5
7
 
6
8
  type Props = HTMLAttributes<'section'> & {
7
9
  label: string;
8
10
  labels: CircuitPuzzleLabels;
11
+ coreMedia?: DisplayMedia;
9
12
  puzzles: readonly CircuitPuzzleModel[];
10
13
  };
11
14
 
@@ -13,6 +16,7 @@ const {
13
16
  class: className,
14
17
  label,
15
18
  labels,
19
+ coreMedia,
16
20
  puzzles,
17
21
  ...attrs
18
22
  }: Props = Astro.props;
@@ -54,6 +58,9 @@ const {
54
58
  data-puzzle-cell-interactive={cell.fixed ? undefined : ''}
55
59
  data-puzzle-cell-disabled={cell.fixed ? '' : undefined}
56
60
  data-circuit-cell={cell.id}
61
+ data-puzzle-control={cell.fixed ? undefined : ''}
62
+ data-cell-row={cell.row}
63
+ data-cell-column={cell.column}
57
64
  data-cell-kind={cell.kind}
58
65
  data-cell-label={cellLabel}
59
66
  data-orientation-label={labels.orientation}
@@ -64,7 +71,16 @@ const {
64
71
  disabled={cell.fixed ? true : undefined}
65
72
  style={`--cell-row: ${cell.row + 1}; --cell-column: ${cell.column + 1}; --cell-rotation: ${cell.initialRotation}deg; --cell-index: ${cellIndex};`}
66
73
  >
67
- <span class="circuit-puzzle__piece" aria-hidden="true">
74
+ <span
75
+ class:list={[
76
+ 'circuit-puzzle__piece',
77
+ {
78
+ 'circuit-puzzle__piece--with-media':
79
+ cell.kind === 'core' && coreMedia,
80
+ },
81
+ ]}
82
+ aria-hidden="true"
83
+ >
68
84
  {cell.connections.map((direction) => (
69
85
  <span
70
86
  class:list={[
@@ -73,8 +89,15 @@ const {
73
89
  ]}
74
90
  />
75
91
  ))}
76
- <span class="circuit-puzzle__node" />
92
+ {!(cell.kind === 'core' && coreMedia) && (
93
+ <span class="circuit-puzzle__node" />
94
+ )}
77
95
  </span>
96
+ {cell.kind === 'core' && coreMedia && (
97
+ <span class="circuit-puzzle__core-media" aria-hidden="true">
98
+ <Media media={coreMedia} size="fill" />
99
+ </span>
100
+ )}
78
101
  </button>
79
102
  );
80
103
  })}
@@ -36,6 +36,15 @@
36
36
  --circuit-puzzle-symbol-color: var(--nox-source-tone-danger);
37
37
  }
38
38
 
39
+ .circuit-puzzle__instance[data-puzzle-status='valid']
40
+ .circuit-puzzle__cell[data-cell-kind='core'] {
41
+ --puzzle-cell-state-background: color-mix(
42
+ in srgb,
43
+ var(--nox-source-tone-success) 14%,
44
+ var(--nox-surface)
45
+ );
46
+ }
47
+
39
48
  .circuit-puzzle__piece {
40
49
  position: relative;
41
50
  display: grid;
@@ -46,6 +55,21 @@
46
55
  color: var(--circuit-puzzle-symbol-color);
47
56
  }
48
57
 
58
+ .circuit-puzzle__piece--with-media {
59
+ grid-area: 1 / 1;
60
+ inline-size: 100%;
61
+ block-size: 100%;
62
+ }
63
+
64
+ .circuit-puzzle__core-media {
65
+ z-index: 1;
66
+ grid-area: 1 / 1;
67
+ display: grid;
68
+ inline-size: 84%;
69
+ aspect-ratio: 1;
70
+ pointer-events: none;
71
+ }
72
+
49
73
  .circuit-puzzle__node {
50
74
  z-index: 1;
51
75
  inline-size: 32%;
@@ -3,6 +3,11 @@ import {
3
3
  setPuzzleGridState,
4
4
  } from '../PuzzleGrid/state';
5
5
  import { initPuzzleSelectionDismissal } from '../PuzzleGrid/selectionDismissal';
6
+ import {
7
+ initPuzzleKeyboard,
8
+ type PuzzleKeyboard,
9
+ } from '../PuzzleGrid/keyboard';
10
+ import type { PuzzleDirection } from '../PuzzleGrid/movement';
6
11
  import type {
7
12
  CircuitPuzzleChangeDetail,
8
13
  CircuitPuzzleEvaluation,
@@ -11,6 +16,7 @@ import type {
11
16
 
12
17
  type CircuitInstance = {
13
18
  activeIndex: number;
19
+ keyboard?: PuzzleKeyboard;
14
20
  };
15
21
 
16
22
  const rotations: readonly CircuitRotation[] = [0, 90, 180, 270];
@@ -60,6 +66,21 @@ export function initCircuitPuzzle(root: HTMLElement): void {
60
66
  setRotation(cell, rotations[nextIndex], visualRotation + 90);
61
67
  emitChange(root);
62
68
  });
69
+ instances.get(root)!.keyboard = initPuzzleKeyboard(root, {
70
+ getPanel: () => getActivePanel(root),
71
+ getSelected: () =>
72
+ getActivePanel(root)?.querySelector<HTMLButtonElement>(
73
+ '[data-circuit-cell][data-puzzle-cell-selected]',
74
+ ) ?? null,
75
+ select: (cell) => {
76
+ const panel = getActivePanel(root);
77
+ if (panel) selectCell(panel, cell);
78
+ },
79
+ move: (cell, direction) => {
80
+ const panel = getActivePanel(root);
81
+ if (panel) moveCellFocus(panel, cell, direction);
82
+ },
83
+ });
63
84
  }
64
85
 
65
86
  export function selectCircuitPuzzle(root: HTMLElement, index: number): void {
@@ -100,6 +121,7 @@ export function resetCircuitPuzzle(root: HTMLElement): void {
100
121
  panel.removeAttribute('data-puzzle-resetting');
101
122
  });
102
123
  });
124
+ instances.get(root)?.keyboard?.reset();
103
125
  emitChange(root);
104
126
  }
105
127
 
@@ -132,6 +154,7 @@ export function setCircuitPuzzleEvaluation(
132
154
  if (grid) {
133
155
  setPuzzleGridState(grid, evaluation.status === 'valid' ? 'valid' : null);
134
156
  }
157
+ if (evaluation.status === 'valid') selectCell(panel, null);
135
158
 
136
159
  if (evaluation.status === 'valid' && previous !== 'valid') {
137
160
  root.dispatchEvent(
@@ -170,6 +193,32 @@ function selectCell(
170
193
  }
171
194
  }
172
195
 
196
+ function moveCellFocus(
197
+ panel: HTMLElement,
198
+ origin: HTMLButtonElement,
199
+ direction: PuzzleDirection,
200
+ ): void {
201
+ const vertical = direction === 'north' || direction === 'south';
202
+ const sign = direction === 'north' || direction === 'west' ? -1 : 1;
203
+ const row = Number(origin.dataset.cellRow);
204
+ const column = Number(origin.dataset.cellColumn);
205
+ let nearest: HTMLButtonElement | null = null;
206
+ let nearestDistance = Infinity;
207
+ for (const cell of panel.querySelectorAll<HTMLButtonElement>(
208
+ '[data-puzzle-control]:not(:disabled)',
209
+ )) {
210
+ const rowDistance = Number(cell.dataset.cellRow) - row;
211
+ const columnDistance = Number(cell.dataset.cellColumn) - column;
212
+ if (vertical ? columnDistance !== 0 : rowDistance !== 0) continue;
213
+ const distance = (vertical ? rowDistance : columnDistance) * sign;
214
+ if (distance > 0 && distance < nearestDistance) {
215
+ nearest = cell;
216
+ nearestDistance = distance;
217
+ }
218
+ }
219
+ nearest?.focus({ preventScroll: true });
220
+ }
221
+
173
222
  function emitChange(root: HTMLElement): void {
174
223
  const panel = getActivePanel(root);
175
224
  if (!panel?.dataset.puzzleId) return;
@@ -188,6 +188,7 @@ if (puzzles.some((puzzle) => puzzle.activators?.length) && !media.mechanism)
188
188
  aria-label={labels[piece]}
189
189
  aria-pressed="false"
190
190
  data-light-maze-piece={piece}
191
+ data-puzzle-control
191
192
  style={`--piece-row: ${initial[piece].row + 1}; --piece-column: ${initial[piece].column + 1};`}
192
193
  >
193
194
  <span class="light-maze-puzzle__piece-media" aria-hidden="true">
@@ -33,7 +33,7 @@
33
33
 
34
34
  .light-maze-puzzle__activator {
35
35
  position: absolute;
36
- inset: 18%;
36
+ inset: 8%;
37
37
  display: grid;
38
38
  pointer-events: none;
39
39
  }
@@ -110,7 +110,7 @@
110
110
  margin: var(--nox-space-1);
111
111
  padding: 0;
112
112
  place-items: center;
113
- border: 0;
113
+ border: var(--nox-border-width) solid transparent;
114
114
  border-radius: var(--nox-radius-sm);
115
115
  background: transparent;
116
116
  cursor: grab;
@@ -120,7 +120,7 @@
120
120
 
121
121
  .light-maze-puzzle__piece-media {
122
122
  display: grid;
123
- inline-size: 80%;
123
+ inline-size: 84%;
124
124
  aspect-ratio: 1;
125
125
  pointer-events: none;
126
126
  }
@@ -173,6 +173,12 @@
173
173
  --puzzle-cell-ring-width: 0px;
174
174
  }
175
175
 
176
+ @media (width < 34rem) {
177
+ .light-maze-puzzle__piece {
178
+ margin: 2px;
179
+ }
180
+ }
181
+
176
182
  @media (prefers-reduced-motion: no-preference) {
177
183
  .light-maze-puzzle__piece[data-piece-moved] {
178
184
  animation: light-maze-puzzle-move 260ms var(--nox-motion-easing-standard);