@astronox/core 0.5.5 → 0.5.7

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.
@@ -0,0 +1,185 @@
1
+ ---
2
+ import type { HTMLAttributes } from 'astro/types';
3
+ import Media from '../../primitives/media/Media/Media.astro';
4
+ import PuzzleGrid from '../PuzzleGrid/PuzzleGrid.astro';
5
+ import type {
6
+ SlidingPuzzleLabels,
7
+ SlidingPuzzleMedia,
8
+ SlidingPuzzleModel,
9
+ } from './contracts';
10
+
11
+ type Props = HTMLAttributes<'section'> & {
12
+ label: string;
13
+ labels: SlidingPuzzleLabels;
14
+ media: SlidingPuzzleMedia;
15
+ puzzles: readonly SlidingPuzzleModel[];
16
+ };
17
+
18
+ const {
19
+ class: className,
20
+ label,
21
+ labels,
22
+ media,
23
+ puzzles,
24
+ ...attrs
25
+ }: Props = Astro.props;
26
+ ---
27
+
28
+ <section
29
+ {...attrs}
30
+ class:list={['sliding-puzzle', className]}
31
+ aria-label={label}
32
+ data-sliding-puzzle
33
+ >
34
+ {
35
+ puzzles.map((puzzle, index) => (
36
+ <div
37
+ class="sliding-puzzle__instance"
38
+ data-puzzle-id={puzzle.id}
39
+ data-puzzle-status="incomplete"
40
+ data-sliding-model={JSON.stringify(puzzle)}
41
+ data-sliding-labels={JSON.stringify(labels)}
42
+ hidden={index !== 0}
43
+ style={`--piece-selection-filter: url(#sliding-outline-${encodeURIComponent(puzzle.id)}); --piece-hover-filter: url(#sliding-hover-${encodeURIComponent(puzzle.id)});`}
44
+ >
45
+ <svg
46
+ class="sliding-puzzle__filters"
47
+ aria-hidden="true"
48
+ focusable="false"
49
+ >
50
+ <defs>
51
+ <filter
52
+ id={`sliding-hover-${encodeURIComponent(puzzle.id)}`}
53
+ color-interpolation-filters="sRGB"
54
+ >
55
+ <feFlood
56
+ class="sliding-puzzle__hover-color"
57
+ flood-opacity="0.1"
58
+ />
59
+ <feComposite operator="in" in2="SourceAlpha" />
60
+ <feMerge>
61
+ <feMergeNode in="SourceGraphic" />
62
+ <feMergeNode />
63
+ </feMerge>
64
+ </filter>
65
+ <filter
66
+ id={`sliding-outline-${encodeURIComponent(puzzle.id)}`}
67
+ x="-25%"
68
+ y="-25%"
69
+ width="150%"
70
+ height="150%"
71
+ color-interpolation-filters="sRGB"
72
+ >
73
+ {/* Soften the square dilation at silhouette corners. */}
74
+ <feMorphology in="SourceAlpha" operator="dilate" radius="2" />
75
+ <feGaussianBlur stdDeviation="0.5" result="outline" />
76
+ <feFlood class="sliding-puzzle__ring-color" />
77
+ <feComposite operator="in" in2="outline" />
78
+ <feMerge>
79
+ <feMergeNode />
80
+ <feMergeNode in="SourceGraphic" />
81
+ </feMerge>
82
+ </filter>
83
+ </defs>
84
+ </svg>
85
+ <PuzzleGrid
86
+ class="sliding-puzzle__board"
87
+ label={puzzle.label}
88
+ rows={puzzle.rows}
89
+ columns={puzzle.columns}
90
+ >
91
+ {Array.from(
92
+ { length: puzzle.rows * puzzle.columns },
93
+ (_, cellIndex) => {
94
+ const row = Math.floor(cellIndex / puzzle.columns);
95
+ const column = cellIndex % puzzle.columns;
96
+ const blocked = puzzle.blockedCells.some(
97
+ (cell) => cell.row === row && cell.column === column,
98
+ );
99
+ const target =
100
+ puzzle.target.row === row && puzzle.target.column === column;
101
+ const hole = puzzle.holes.find(
102
+ ({ position }) =>
103
+ position.row === row && position.column === column,
104
+ );
105
+ const exit = puzzle.holes.find(
106
+ ({ exit }) => exit.row === row && exit.column === column,
107
+ );
108
+ const transferTone = (hole ?? exit)?.tone ?? 'neutral';
109
+ const fragile =
110
+ Boolean(hole) ||
111
+ puzzle.fragileCells.some(
112
+ (cell) => cell.row === row && cell.column === column,
113
+ );
114
+ const cellLabel = blocked
115
+ ? labels.blockedCell
116
+ : target
117
+ ? labels.target
118
+ : fragile
119
+ ? labels.fragileCell
120
+ : '';
121
+ return (
122
+ <button
123
+ class="puzzle-grid__cell sliding-puzzle__cell"
124
+ type="button"
125
+ disabled={blocked}
126
+ tabindex={-1}
127
+ aria-label={cellLabel || undefined}
128
+ data-puzzle-cell
129
+ data-puzzle-cell-disabled={blocked ? '' : undefined}
130
+ data-sliding-cell
131
+ data-sliding-target={target ? '' : undefined}
132
+ data-sliding-fragile={fragile ? '' : undefined}
133
+ data-sliding-hole={hole ? '' : undefined}
134
+ data-cell-row={row}
135
+ data-cell-column={column}
136
+ data-cell-label={cellLabel}
137
+ style={`--cell-row: ${row + 1}; --cell-column: ${column + 1}; --sliding-transfer-color: var(--nox-source-tone-${transferTone});`}
138
+ >
139
+ {fragile && (
140
+ <svg
141
+ class="sliding-puzzle__crack"
142
+ viewBox="0 0 100 100"
143
+ aria-hidden="true"
144
+ focusable="false"
145
+ >
146
+ <path d="M44 0 54 25 40 46 60 65 48 100M40 46 18 36 0 44M60 65 80 49 100 55M54 25 75 14" />
147
+ </svg>
148
+ )}
149
+ {hole && (
150
+ <span class="sliding-puzzle__hole" aria-hidden="true" />
151
+ )}
152
+ {target && (
153
+ <span class="sliding-puzzle__target" aria-hidden="true">
154
+ <Media media={media.target} size="fill" />
155
+ </span>
156
+ )}
157
+ </button>
158
+ );
159
+ },
160
+ )}
161
+ <button
162
+ class="sliding-puzzle__piece"
163
+ type="button"
164
+ aria-label={labels.piece}
165
+ aria-pressed="false"
166
+ data-sliding-piece
167
+ style={`--piece-row: ${puzzle.start.row + 1}; --piece-column: ${puzzle.start.column + 1};`}
168
+ >
169
+ <span class="sliding-puzzle__piece-media" aria-hidden="true">
170
+ <Media media={media.piece} size="fill" />
171
+ </span>
172
+ </button>
173
+ </PuzzleGrid>
174
+ </div>
175
+ ))
176
+ }
177
+ </section>
178
+
179
+ <script>
180
+ import { initSlidingPuzzle } from './slidingPuzzleController';
181
+
182
+ for (const root of document.querySelectorAll('[data-sliding-puzzle]')) {
183
+ if (root instanceof HTMLElement) initSlidingPuzzle(root);
184
+ }
185
+ </script>
@@ -0,0 +1,186 @@
1
+ .sliding-puzzle,
2
+ .sliding-puzzle__instance {
3
+ display: grid;
4
+ min-inline-size: 0;
5
+ }
6
+
7
+ .sliding-puzzle__instance[hidden] {
8
+ display: none;
9
+ }
10
+
11
+ .sliding-puzzle__filters {
12
+ position: absolute;
13
+ inline-size: 0;
14
+ block-size: 0;
15
+ pointer-events: none;
16
+ }
17
+
18
+ .sliding-puzzle__ring-color {
19
+ flood-color: var(--nox-source-tone-info);
20
+ }
21
+
22
+ .sliding-puzzle__hover-color {
23
+ flood-color: var(--nox-tone-color);
24
+ }
25
+
26
+ .sliding-puzzle__board {
27
+ isolation: isolate;
28
+ touch-action: manipulation;
29
+ }
30
+
31
+ .sliding-puzzle__cell {
32
+ appearance: none;
33
+ }
34
+
35
+ .sliding-puzzle__cell[data-sliding-exit-discovered] {
36
+ --puzzle-cell-base-border: var(--sliding-transfer-color);
37
+
38
+ border-style: dashed;
39
+ }
40
+
41
+ .sliding-puzzle__cell[data-sliding-target] {
42
+ --puzzle-cell-base-border: var(--nox-source-tone-success);
43
+
44
+ border-style: dashed;
45
+ }
46
+
47
+ .sliding-puzzle__cell[data-sliding-fragile]:not([data-sliding-collapsed]):not(
48
+ [data-sliding-hole-open]
49
+ ) {
50
+ --puzzle-cell-base-border: var(--nox-source-tone-neutral);
51
+
52
+ border-style: dashed;
53
+ }
54
+
55
+ .sliding-puzzle__crack {
56
+ position: absolute;
57
+ inset: 0;
58
+ inline-size: 100%;
59
+ block-size: 100%;
60
+ fill: none;
61
+ stroke: var(--nox-source-tone-neutral);
62
+ stroke-width: 3;
63
+ pointer-events: none;
64
+ }
65
+
66
+ .sliding-puzzle__cell[data-sliding-collapsed] .sliding-puzzle__crack {
67
+ display: none;
68
+ }
69
+
70
+ .sliding-puzzle__hole {
71
+ display: none;
72
+ inline-size: 72%;
73
+ aspect-ratio: 1;
74
+ border: calc(var(--nox-border-width) * 2) solid var(--sliding-transfer-color);
75
+ border-radius: var(--nox-radius-pill);
76
+ background: color-mix(
77
+ in srgb,
78
+ var(--nox-source-tone-neutral) 20%,
79
+ var(--nox-mix-dark)
80
+ );
81
+ pointer-events: none;
82
+ }
83
+
84
+ .sliding-puzzle__cell[data-sliding-hole-open] .sliding-puzzle__hole {
85
+ display: block;
86
+ }
87
+
88
+ .sliding-puzzle__cell[data-sliding-hole-open] .sliding-puzzle__crack {
89
+ display: none;
90
+ }
91
+
92
+ .sliding-puzzle__cell[data-sliding-destination] {
93
+ --puzzle-cell-ring-width: var(--nox-focus-ring-width);
94
+ --puzzle-cell-ring-color: var(--nox-source-tone-info);
95
+
96
+ z-index: 3;
97
+ overflow: visible;
98
+ }
99
+
100
+ .sliding-puzzle__target {
101
+ display: grid;
102
+ inline-size: 90%;
103
+ aspect-ratio: 1;
104
+ pointer-events: none;
105
+ }
106
+
107
+ .sliding-puzzle__piece {
108
+ position: relative;
109
+ z-index: 4;
110
+ display: grid;
111
+ grid-row: var(--piece-row);
112
+ grid-column: var(--piece-column);
113
+ min-inline-size: 0;
114
+ min-block-size: 0;
115
+ margin: var(--nox-space-1);
116
+ padding: 0;
117
+ place-items: center;
118
+ border: 0;
119
+ border-radius: var(--nox-radius-sm);
120
+ background: transparent;
121
+ cursor: grab;
122
+ touch-action: none;
123
+ user-select: none;
124
+ }
125
+
126
+ .sliding-puzzle__piece-media {
127
+ display: grid;
128
+ inline-size: 80%;
129
+ aspect-ratio: 1;
130
+ pointer-events: none;
131
+ }
132
+
133
+ .sliding-puzzle__piece:focus-visible {
134
+ outline: none;
135
+ }
136
+
137
+ .sliding-puzzle__piece:is([data-piece-selected], :focus-visible):not(
138
+ [data-piece-transferring]
139
+ )
140
+ .sliding-puzzle__piece-media {
141
+ filter: var(--piece-selection-filter);
142
+ }
143
+
144
+ @media (hover: hover) {
145
+ .sliding-puzzle__piece:hover .sliding-puzzle__piece-media {
146
+ filter: var(--piece-hover-filter);
147
+ }
148
+
149
+ .sliding-puzzle__piece:is([data-piece-selected], :focus-visible):hover:not(
150
+ [data-piece-transferring]
151
+ )
152
+ .sliding-puzzle__piece-media {
153
+ filter: var(--piece-hover-filter) var(--piece-selection-filter);
154
+ }
155
+ }
156
+
157
+ .sliding-puzzle__piece[data-piece-dragging] {
158
+ z-index: 6;
159
+ cursor: grabbing;
160
+ }
161
+
162
+ .sliding-puzzle__piece[data-piece-dragging] .sliding-puzzle__piece-media {
163
+ transform: translate3d(var(--piece-drag-x, 0px), var(--piece-drag-y, 0px), 0)
164
+ scale(1.04);
165
+ will-change: transform;
166
+ }
167
+
168
+ .sliding-puzzle__instance[data-puzzle-status='valid'] .sliding-puzzle__piece {
169
+ cursor: default;
170
+ }
171
+
172
+ @media (prefers-reduced-motion: no-preference) {
173
+ .sliding-puzzle__piece[data-piece-moved] {
174
+ animation: sliding-puzzle-move 260ms var(--nox-motion-easing-standard);
175
+ }
176
+ }
177
+
178
+ @keyframes sliding-puzzle-move {
179
+ from {
180
+ transform: translate3d(
181
+ var(--piece-move-inline-distance),
182
+ var(--piece-move-block-distance),
183
+ 0
184
+ );
185
+ }
186
+ }
@@ -0,0 +1,62 @@
1
+ import type { DisplayMedia } from '../../display/contracts';
2
+ import type { Tone } from '../../theme/contracts';
3
+
4
+ export type SlidingDirection = 'north' | 'east' | 'south' | 'west';
5
+ export type SlidingPosition = { row: number; column: number };
6
+ export type SlidingPuzzleStatus = 'incomplete' | 'valid';
7
+
8
+ export type SlidingPuzzleModel = {
9
+ id: string;
10
+ label: string;
11
+ rows: number;
12
+ columns: number;
13
+ start: SlidingPosition;
14
+ target: SlidingPosition;
15
+ blockedCells: readonly SlidingPosition[];
16
+ fragileCells: readonly SlidingPosition[];
17
+ // Entry direction selects the adjacent landing cell around each exit.
18
+ // A transfer ends the move; its landing never triggers another tile.
19
+ holes: readonly {
20
+ position: SlidingPosition;
21
+ exit: SlidingPosition;
22
+ tone?: Tone;
23
+ }[];
24
+ };
25
+
26
+ export type SlidingPuzzleState = {
27
+ position: SlidingPosition;
28
+ collapsedCells: readonly SlidingPosition[];
29
+ // Source tile positions, tracked independently and cleared on reset.
30
+ openHoles: readonly SlidingPosition[];
31
+ };
32
+
33
+ export type SlidingPuzzleLabels = {
34
+ piece: string;
35
+ target: string;
36
+ blockedCell: string;
37
+ fragileCell: string;
38
+ collapsedCell: string;
39
+ hole: string;
40
+ directions: Readonly<Record<SlidingDirection, string>>;
41
+ moveTemplate: string;
42
+ };
43
+
44
+ export type SlidingPuzzleMedia = {
45
+ piece: DisplayMedia;
46
+ target: DisplayMedia;
47
+ };
48
+
49
+ export type SlidingMovement = {
50
+ direction: SlidingDirection;
51
+ from: SlidingPosition;
52
+ slideTo: SlidingPosition;
53
+ to: SlidingPosition;
54
+ teleported: boolean;
55
+ state: SlidingPuzzleState;
56
+ };
57
+
58
+ export type SlidingPuzzleChangeDetail = {
59
+ kind: 'sliding';
60
+ puzzleId: string;
61
+ state: SlidingPuzzleState;
62
+ };
@@ -0,0 +1,106 @@
1
+ import type {
2
+ SlidingDirection,
3
+ SlidingMovement,
4
+ SlidingPuzzleModel,
5
+ SlidingPuzzleState,
6
+ } from './contracts';
7
+
8
+ const deltas = {
9
+ north: [-1, 0],
10
+ east: [0, 1],
11
+ south: [1, 0],
12
+ west: [0, -1],
13
+ } as const;
14
+
15
+ export function createSlidingPuzzleState(
16
+ puzzle: SlidingPuzzleModel,
17
+ ): SlidingPuzzleState {
18
+ return { position: { ...puzzle.start }, collapsedCells: [], openHoles: [] };
19
+ }
20
+
21
+ export function isSlidingPuzzleComplete(
22
+ puzzle: SlidingPuzzleModel,
23
+ state: SlidingPuzzleState,
24
+ ): boolean {
25
+ return (
26
+ state.position.row === puzzle.target.row &&
27
+ state.position.column === puzzle.target.column
28
+ );
29
+ }
30
+
31
+ export function simulateSlidingMove(
32
+ puzzle: SlidingPuzzleModel,
33
+ state: SlidingPuzzleState,
34
+ direction: SlidingDirection,
35
+ ): SlidingMovement | null {
36
+ if (isSlidingPuzzleComplete(puzzle, state)) return null;
37
+ const [dr, dc] = deltas[direction];
38
+ let to = { ...state.position };
39
+ const collapsedCells = [...state.collapsedCells];
40
+ while (true) {
41
+ const next = { row: to.row + dr, column: to.column + dc };
42
+ if (
43
+ next.row < 0 ||
44
+ next.row >= puzzle.rows ||
45
+ next.column < 0 ||
46
+ next.column >= puzzle.columns ||
47
+ [...puzzle.blockedCells, ...collapsedCells].some(
48
+ (cell) => cell.row === next.row && cell.column === next.column,
49
+ )
50
+ )
51
+ break;
52
+ // Entering a fragile tile collapses it, including the stopping tile.
53
+ // A piece resting on a collapsed tile may leave it, but cannot return.
54
+ to = next;
55
+ const hole = puzzle.holes.find(
56
+ ({ position }) =>
57
+ position.row === to.row && position.column === to.column,
58
+ );
59
+ if (hole) {
60
+ const arrival = {
61
+ row: hole.exit.row + dr,
62
+ column: hole.exit.column + dc,
63
+ };
64
+ const openHoles = state.openHoles.some(
65
+ (position) => position.row === to.row && position.column === to.column,
66
+ )
67
+ ? [...state.openHoles]
68
+ : [...state.openHoles, { ...to }];
69
+ return {
70
+ direction,
71
+ from: { ...state.position },
72
+ slideTo: to,
73
+ to: arrival,
74
+ teleported: true,
75
+ state: { position: arrival, collapsedCells, openHoles },
76
+ };
77
+ }
78
+ if (
79
+ puzzle.fragileCells.some(
80
+ (cell) => cell.row === to.row && cell.column === to.column,
81
+ )
82
+ ) {
83
+ collapsedCells.push({ ...to });
84
+ }
85
+ }
86
+ if (to.row === state.position.row && to.column === state.position.column)
87
+ return null;
88
+ return {
89
+ direction,
90
+ from: { ...state.position },
91
+ slideTo: to,
92
+ to,
93
+ teleported: false,
94
+ state: { position: to, collapsedCells, openHoles: [...state.openHoles] },
95
+ };
96
+ }
97
+
98
+ export function getSlidingMoves(
99
+ puzzle: SlidingPuzzleModel,
100
+ state: SlidingPuzzleState,
101
+ ): SlidingMovement[] {
102
+ return (Object.keys(deltas) as SlidingDirection[]).flatMap((direction) => {
103
+ const movement = simulateSlidingMove(puzzle, state, direction);
104
+ return movement ? [movement] : [];
105
+ });
106
+ }