@mblinkov/whats-missing 20.0.27 → 20.0.29

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mblinkov/whats-missing",
3
- "version": "20.0.27",
3
+ "version": "20.0.29",
4
4
  "main": "dist/whats-missing.umd.js",
5
5
  "module": "dist/whats-missing.es.js",
6
6
  "types": "dist/index.d.ts",
@@ -101,7 +101,8 @@ export const styles: Record<string, CSSProperties> = {
101
101
  gmGameLayout: {
102
102
  position: "relative",
103
103
  width: "100%",
104
- maxWidth: "600px",
104
+ // allow the layout to expand within the square container for tablets/laptops
105
+ maxWidth: "none",
105
106
  // should fill the parent square, not the full viewport
106
107
  minHeight: "100%",
107
108
  display: "flex",
@@ -116,9 +117,11 @@ export const styles: Record<string, CSSProperties> = {
116
117
 
117
118
  gmGrid: {
118
119
  width: "100%",
119
- maxWidth: "calc(210px * 3 + 20px * 2)",
120
+ // let the grid use all available space; sizing is controlled in Game.tsx
121
+ maxWidth: "none",
120
122
  margin: "0 auto",
121
123
  display: "grid",
124
+ // defaults; overridden responsively in Game.tsx
122
125
  gridTemplateColumns: "repeat(3, 210px)",
123
126
  gridAutoRows: "210px",
124
127
  gap: "20px",
package/src/Game.tsx CHANGED
@@ -37,7 +37,7 @@ const levenshtein = (a: string, b: string) => {
37
37
  return dp[a.length][b.length];
38
38
  };
39
39
 
40
- // ✅ фикс визуального подзума (например, на 1366×768)
40
+ // ✅ фикс визуального подзума
41
41
  export function useNeutralizeBodyZoom(
42
42
  ref: React.RefObject<HTMLDivElement | null>,
43
43
  opts: { minDesktopWidth?: number; forceResize?: boolean } = { minDesktopWidth: 1200, forceResize: false }
@@ -58,7 +58,6 @@ export function useNeutralizeBodyZoom(
58
58
  if (zoomInline && !isNaN(zoomInline) && zoomInline > 0) return zoomInline;
59
59
  const zoomComputed = parseFloat((cs.zoom as string) || "");
60
60
  if (zoomComputed && !isNaN(zoomComputed) && zoomComputed > 0) return zoomComputed;
61
-
62
61
  const probe = document.createElement("div");
63
62
  probe.style.position = "absolute";
64
63
  probe.style.left = "0";
@@ -66,7 +65,6 @@ export function useNeutralizeBodyZoom(
66
65
  probe.style.width = "100px";
67
66
  probe.style.height = "1px";
68
67
  probe.style.visibility = "hidden";
69
- probe.style.pointerEvents = "none";
70
68
  document.documentElement.appendChild(probe);
71
69
  const rect = probe.getBoundingClientRect();
72
70
  document.documentElement.removeChild(probe);
@@ -188,6 +186,22 @@ export default function Game({
188
186
  const [usedHidden, setUsedHidden] = useState<string[]>([]);
189
187
  const [isMobile, setIsMobile] = useState(false);
190
188
 
189
+ // simple responsive flag; we still compute exact sizes from the measured square
190
+ useEffect(() => {
191
+ if (typeof window === "undefined") return;
192
+ const update = () => {
193
+ const w = screenWidth ?? window.innerWidth;
194
+ setIsMobile(w <= 768);
195
+ };
196
+ update();
197
+ window.addEventListener("resize", update);
198
+ window.addEventListener("orientationchange", update);
199
+ return () => {
200
+ window.removeEventListener("resize", update);
201
+ window.removeEventListener("orientationchange", update);
202
+ };
203
+ }, [screenWidth]);
204
+
191
205
  useLayoutEffect(() => {
192
206
  const el = squareRef.current;
193
207
  if (!el || typeof window === "undefined") return;
@@ -270,7 +284,10 @@ export default function Game({
270
284
  setAnswered(true);
271
285
  setResult("wrong");
272
286
  const correct = files[hiddenIndex!].name;
273
- setResultsTable((prev) => [...prev, { round: currentRound, answer: inputValue, correct, result: "wrong" }]);
287
+ setResultsTable((prev) => [
288
+ ...prev,
289
+ { round: currentRound, answer: inputValue, correct, result: "wrong" },
290
+ ]);
274
291
  return;
275
292
  }
276
293
  const t = setTimeout(() => setTimeLeft((s) => s - 1), 1000);
@@ -332,6 +349,17 @@ export default function Game({
332
349
  []
333
350
  );
334
351
 
352
+ // ✅ масштаб интерфейса под реальное разрешение
353
+ const baseWidth = 1440;
354
+ const baseHeight = 900;
355
+ const scale =
356
+ typeof window !== "undefined"
357
+ ? Math.min(
358
+ (screenWidth ?? window.innerWidth) / baseWidth,
359
+ (screenHeight ?? window.innerHeight) / baseHeight
360
+ )
361
+ : 1;
362
+
335
363
  return (
336
364
  <div
337
365
  ref={containerRef}
@@ -342,10 +370,8 @@ export default function Game({
342
370
  justifyContent: "center",
343
371
  alignItems: "center",
344
372
  background: "linear-gradient(to bottom, #fff8f8 0%, #f9fafb 100%)",
345
- transition: "background 0.3s ease",
346
373
  overflow: "hidden",
347
374
  zIndex: 1,
348
- pointerEvents: "auto",
349
375
  }}
350
376
  >
351
377
  <div
@@ -357,267 +383,255 @@ export default function Game({
357
383
  height: isMobile
358
384
  ? "100%"
359
385
  : `${Math.min(gameCubeSize ?? 1000, screenWidth ?? 1000, screenHeight ?? 1000)}px`,
360
- maxWidth: "calc(100vw - 24px)",
361
- maxHeight: "calc(100vh - 20px)",
362
- display: "flex",
363
- justifyContent: "center",
364
- alignItems: "center",
365
- overflow: "hidden",
366
386
  borderRadius: isMobile ? 0 : "20px",
367
387
  background: "linear-gradient(to bottom, #fff8f8 0%, #f9fafb 100%)",
368
- boxShadow: isMobile ? "none" : "0 0 40px rgba(0,0,0,0.08)",
369
388
  position: "relative",
389
+ overflow: "hidden",
390
+ boxShadow: isMobile ? "none" : "0 0 40px rgba(0,0,0,0.08)",
391
+ display: "flex",
392
+ justifyContent: "center",
393
+ alignItems: "center",
370
394
  }}
371
395
  >
372
396
  <div
397
+ id="whats-missing-root"
373
398
  style={{
374
- transform: "none",
375
- transformOrigin: "50% 50%",
376
- width: "100%",
377
- height: "100%",
399
+ transform: `scale(${scale})`,
400
+ transformOrigin: "center center",
401
+ width: `${100 / scale}%`,
402
+ height: `${100 / scale}%`,
378
403
  display: "flex",
404
+ flexDirection: "column",
379
405
  justifyContent: "center",
380
406
  alignItems: "center",
381
407
  }}
382
408
  >
383
- <div id="whats-missing-root">
384
- {!isMobile && MemoizedLogo}
385
- {/* ====== ЛОББИ ====== */}
386
- {!theme && !started && (
387
- <div style={styles.gmCenterScreen}>
388
- <h1 style={styles.gmHeadline1}>WHAT'S MISSING?</h1>
389
- <p style={styles.gmBodyM}>Select a theme:</p>
390
- <div style={{ display: "flex", gap: 16 }}>
391
- {["animals", "food", "toys"].map((t) => (
409
+ {!isMobile && MemoizedLogo}
410
+
411
+ {/* ====== ЛОББИ ====== */}
412
+ {!theme && !started && (
413
+ <div style={styles.gmCenterScreen}>
414
+ <h1 style={styles.gmHeadline1}>WHAT'S MISSING?</h1>
415
+ <p style={styles.gmBodyM}>Select a theme:</p>
416
+ <div style={{ display: "flex", gap: 16 }}>
417
+ {["animals", "food", "toys"].map((t) => (
418
+ <button key={t} style={styles.gmButton} onClick={() => setTheme(t as Theme)}>
419
+ {t === "animals" ? "🐶 Animals" : t === "food" ? "🍎 Food" : "🧸 Toys"}
420
+ </button>
421
+ ))}
422
+ </div>
423
+ <div style={{ marginTop: 24 }}>
424
+ <p style={styles.gmBodyS}>Choose number of rounds:</p>
425
+ <div style={{ display: "flex", gap: 12, marginTop: 8 }}>
426
+ {[3, 4, 5].map((n) => (
392
427
  <button
393
- key={t}
394
- style={styles.gmButton}
395
- onClick={() => setTheme(t as Theme)}
428
+ key={n}
429
+ style={{
430
+ ...styles.gmButton,
431
+ ...(rounds === n ? styles.gmButtonActive : {}),
432
+ }}
433
+ onClick={() => setRounds(n)}
396
434
  >
397
- {t === "animals"
398
- ? "🐶 Animals"
399
- : t === "food"
400
- ? "🍎 Food"
401
- : "🧸 Toys"}
435
+ {n}
402
436
  </button>
403
437
  ))}
404
438
  </div>
405
- <div style={{ marginTop: 24 }}>
406
- <p style={styles.gmBodyS}>Choose number of rounds:</p>
407
- <div style={{ display: "flex", gap: 12, marginTop: 8 }}>
408
- {[3, 4, 5].map((n) => (
409
- <button
410
- key={n}
411
- style={{
412
- ...styles.gmButton,
413
- ...(rounds === n ? styles.gmButtonActive : {}),
414
- }}
415
- onClick={() => setRounds(n)}
416
- >
417
- {n}
418
- </button>
419
- ))}
420
- </div>
421
- </div>
422
439
  </div>
423
- )}
424
- {/* ====== ПОДТВЕРЖДЕНИЕ ====== */}
425
- {theme && !started && (
426
- <div style={styles.gmCenterScreen}>
427
- <h1 style={styles.gmHeadline1}>Theme selected: {theme}</h1>
428
- <p style={styles.gmBodyM}>Rounds: {rounds}</p>
440
+ </div>
441
+ )}
442
+
443
+ {/* ====== ПОДТВЕРЖДЕНИЕ ====== */}
444
+ {theme && !started && (
445
+ <div style={styles.gmCenterScreen}>
446
+ <h1 style={styles.gmHeadline1}>Theme selected: {theme}</h1>
447
+ <p style={styles.gmBodyM}>Rounds: {rounds}</p>
448
+ <button style={styles.gmButton} onClick={startGame}>
449
+ ▶ Start game
450
+ </button>
451
+ </div>
452
+ )}
453
+
454
+ {/* ====== РЕЗУЛЬТАТЫ ====== */}
455
+ {finished && (
456
+ <div style={styles.gmCenterScreen}>
457
+ <h1 style={styles.gmHeadline1}>Results</h1>
458
+ <h2 style={styles.gmHeadline3}>
459
+ Your score: {score} / {rounds}
460
+ </h2>
461
+ <p style={{ ...styles.gmBodyM, color: "#10b981", marginTop: 12 }}>
462
+ Yahoo! You did it! 🍬✨
463
+ </p>
464
+ <table style={styles.gmTable}>
465
+ <thead>
466
+ <tr>
467
+ <th>Round</th>
468
+ <th>Your Answer</th>
469
+ <th>Correct</th>
470
+ <th>Result</th>
471
+ </tr>
472
+ </thead>
473
+ <tbody>
474
+ {resultsTable.map((r, i) => (
475
+ <tr key={i}>
476
+ <td style={styles.gmTableCell}>{r.round}</td>
477
+ <td style={styles.gmTableCell}>{r.answer || "—"}</td>
478
+ <td style={styles.gmTableCell}>{r.correct}</td>
479
+ <td style={styles.gmTableCell}>
480
+ {r.result === "correct"
481
+ ? "✔ Correct"
482
+ : r.result === "almost"
483
+ ? "◐ Almost (0.5)"
484
+ : "✘ Wrong"}
485
+ </td>
486
+ </tr>
487
+ ))}
488
+ </tbody>
489
+ </table>
490
+ <div style={{ display: "flex", gap: 12, marginTop: 24 }}>
429
491
  <button style={styles.gmButton} onClick={startGame}>
430
- Start game
492
+ 🔁 Play again
493
+ </button>
494
+ <button style={styles.gmButton} onClick={exitGame}>
495
+ ⬅️ Choose theme
431
496
  </button>
432
497
  </div>
433
- )}
434
- {/* ====== РЕЗУЛЬТАТЫ ====== */}
435
- {finished && (
436
- <div style={styles.gmCenterScreen}>
437
- <h1 style={styles.gmHeadline1}>Results</h1>
438
- <h2 style={styles.gmHeadline3}>
439
- Your score: {score} / {rounds}
440
- </h2>
441
- <p style={{ ...styles.gmBodyM, color: "#10b981", marginTop: 12 }}>
442
- Yahoo! You did it! 🍬✨
443
- </p>
444
- <table style={styles.gmTable}>
445
- <thead>
446
- <tr>
447
- <th>Round</th>
448
- <th>Your Answer</th>
449
- <th>Correct</th>
450
- <th>Result</th>
451
- </tr>
452
- </thead>
453
- <tbody>
454
- {resultsTable.map((r, i) => (
455
- <tr key={i}>
456
- <td style={styles.gmTableCell}>{r.round}</td>
457
- <td style={styles.gmTableCell}>
458
- {r.answer || "—"}
459
- </td>
460
- <td style={styles.gmTableCell}>{r.correct}</td>
461
- <td style={styles.gmTableCell}>
462
- {r.result === "correct"
463
- ? "✔ Correct"
464
- : r.result === "almost"
465
- ? "◐ Almost (0.5)"
466
- : "✘ Wrong"}
467
- </td>
468
- </tr>
469
- ))}
470
- </tbody>
471
- </table>
472
- <div style={{ display: "flex", gap: 12, marginTop: 24 }}>
473
- <button style={styles.gmButton} onClick={startGame}>
474
- 🔁 Play again
475
- </button>
476
- <button style={styles.gmButton} onClick={exitGame}>
477
- ⬅️ Choose theme
478
- </button>
479
- </div>
498
+ </div>
499
+ )}
500
+
501
+ {/* ====== ИГРОВОЙ ЭКРАН ====== */}
502
+ {started && !finished && (
503
+ <div style={styles.gmGameLayout}>
504
+ <div
505
+ style={{
506
+ minHeight: 160,
507
+ display: "flex",
508
+ flexDirection: "column",
509
+ justifyContent: "center",
510
+ alignItems: "center",
511
+ }}
512
+ >
513
+ {phase === "ready" && (
514
+ <>
515
+ <h1 style={{ ...styles.gmHeadline1, color: "#ec4c44" }}>GET READY</h1>
516
+ <div style={styles.gmHourglass}>⏳</div>
517
+ </>
518
+ )}
519
+ {phase === "memorize" && (
520
+ <p style={{ ...styles.gmBodyM, color: "#10b981" }}>
521
+ MEMORIZE ({memorizeTime})
522
+ </p>
523
+ )}
524
+ {phase === "guess" && !answered && (
525
+ <p style={styles.gmBodyM}>⏳ Time left: {timeLeft}s</p>
526
+ )}
480
527
  </div>
481
- )}
482
- {/* ====== ИГРОВОЙ ЭКРАН ====== */}
483
- {started && !finished && (
484
- <div style={styles.gmGameLayout}>
485
- <div
486
- style={{
487
- minHeight: 160,
488
- display: "flex",
489
- flexDirection: "column",
490
- justifyContent: "center",
491
- alignItems: "center",
492
- }}
493
- >
494
- {phase === "ready" && (
495
- <>
496
- <h1
497
- style={{ ...styles.gmHeadline1, color: "#ec4c44" }}
498
- >
499
- GET READY
500
- </h1>
501
- <div style={styles.gmHourglass}>⏳</div>
502
- </>
503
- )}
504
- {phase === "memorize" && (
505
- <p style={{ ...styles.gmBodyM, color: "#10b981" }}>
506
- MEMORIZE ({memorizeTime})
507
- </p>
508
- )}
509
- {phase === "guess" && !answered && (
510
- <p style={styles.gmBodyM}>
511
- ⏳ Time left: {timeLeft}s
512
- </p>
513
- )}
514
- </div>
515
- {/* Сетка карточек */}
516
- {phase !== "ready" && (() => {
517
- const gap = isMobile ? 12 : 20;
518
- const innerGutters = 32;
519
- const effective = Math.min(
520
- gameCubeSize ?? 1000,
521
- squareSize ?? 1000,
522
- screenHeight ?? 1000,
523
- screenWidth ?? 1000
524
- );
525
- const columns = isMobile ? 2 : 3;
526
- const cardSize = Math.max(
527
- 90,
528
- Math.floor(
529
- (effective - innerGutters - gap * (columns - 1)) /
530
- columns
531
- )
532
- );
533
- return (
534
- <div
535
- style={{
536
- ...styles.gmGrid,
537
- gridTemplateColumns: `repeat(${columns}, ${cardSize}px)`,
538
- gridAutoRows: `${cardSize}px`,
539
- gap: `${gap}px`,
540
- justifyContent: "center",
541
- justifyItems: "center",
542
- padding: 8,
543
- boxSizing: "border-box",
544
- }}
545
- >
546
- {files.map((file, i) => {
547
- const isHidden =
548
- hiddenIndex === i &&
549
- phase === "guess" &&
550
- !answered;
551
- return (
552
- <div
553
- key={i}
554
- style={{
555
- ...styles.gmCard,
556
- width: `${cardSize}px`,
557
- height: `${cardSize}px`,
558
- padding: isMobile ? 8 : 10,
559
- boxSizing: "border-box",
560
- ...(result === "correct" &&
561
- hiddenIndex === i
562
- ? styles.gmCorrect
563
- : {}),
564
- ...(result === "wrong" &&
565
- hiddenIndex === i
566
- ? styles.gmWrong
567
- : {}),
568
- }}
569
- >
570
- {!isHidden && (
571
- <img
572
- src={file.src}
573
- alt={file.name}
574
- style={{
575
- width: "100%",
576
- height: "100%",
577
- objectFit: "cover",
578
- display: "block",
579
- }}
580
- />
581
- )}
582
- </div>
583
- );
584
- })}
585
- </div>
586
- );
587
- })()}
588
- {/* Инпут и кнопки */}
589
- <div style={{ marginTop: 16, height: 80 }}>
590
- {phase === "guess" && !answered && (
591
- <div>
592
- <input
593
- type="text"
594
- placeholder="Type the missing word"
595
- value={inputValue}
596
- onChange={(e) => setInputValue(e.target.value)}
597
- style={styles.gmInput}
598
- />
599
- <button
600
- style={{ ...styles.gmButton, marginLeft: 8 }}
601
- onClick={checkAnswer}
602
- disabled={animating}
603
- >
604
- {animating ? "..." : "Check"}
605
- </button>
606
- </div>
607
- )}
608
- {answered && (
528
+
529
+ {/* Сетка карточек */}
530
+ {phase !== "ready" && (() => {
531
+ const gap = isMobile ? 12 : 20;
532
+ const innerGutters = 32; // total padding inside the grid wrapper
533
+ const effW = squareRef.current?.clientWidth ?? Math.min(gameCubeSize ?? 1000, screenWidth ?? 1000);
534
+ const effH = squareRef.current?.clientHeight ?? Math.min(gameCubeSize ?? 1000, screenHeight ?? 1000);
535
+ const effective = Math.min(effW, effH);
536
+
537
+ // choose a comfortable minimum card size per breakpoint
538
+ const vw = typeof window !== "undefined" ? window.innerWidth : (screenWidth ?? 1000);
539
+ const minCardBase = vw < 600 ? 110 : vw < 1024 ? 140 : 180;
540
+
541
+ // compute columns to best fill available space without going below minCardBase
542
+ const maxColumns = 4;
543
+ const minColumns = 2;
544
+ const possibleColumns = Math.max(
545
+ minColumns,
546
+ Math.min(
547
+ maxColumns,
548
+ Math.floor((effective - innerGutters + gap) / (minCardBase + gap)) || minColumns
549
+ )
550
+ );
551
+
552
+ const columns = possibleColumns;
553
+ const computed = Math.floor((effective - innerGutters - gap * (columns - 1)) / columns);
554
+ const cardSize = Math.max(minCardBase, computed);
555
+
556
+ return (
557
+ <div
558
+ style={{
559
+ ...styles.gmGrid,
560
+ gridTemplateColumns: `repeat(${columns}, ${cardSize}px)`,
561
+ gridAutoRows: `${cardSize}px`,
562
+ gap: `${gap}px`,
563
+ justifyContent: "center",
564
+ justifyItems: "center",
565
+ padding: 8,
566
+ boxSizing: "border-box",
567
+ }}
568
+ >
569
+ {files.map((file, i) => {
570
+ const isHidden = hiddenIndex === i && phase === "guess" && !answered;
571
+ return (
572
+ <div
573
+ key={i}
574
+ style={{
575
+ ...styles.gmCard,
576
+ width: `${cardSize}px`,
577
+ height: `${cardSize}px`,
578
+ padding: cardSize >= 160 ? 10 : 8,
579
+ boxSizing: "border-box",
580
+ ...(result === "correct" && hiddenIndex === i
581
+ ? styles.gmCorrect
582
+ : {}),
583
+ ...(result === "wrong" && hiddenIndex === i
584
+ ? styles.gmWrong
585
+ : {}),
586
+ }}
587
+ >
588
+ {!isHidden && (
589
+ <img
590
+ src={file.src}
591
+ alt={file.name}
592
+ style={{
593
+ width: "100%",
594
+ height: "100%",
595
+ objectFit: "cover",
596
+ display: "block",
597
+ }}
598
+ />
599
+ )}
600
+ </div>
601
+ );
602
+ })}
603
+ </div>
604
+ );
605
+ })()}
606
+
607
+ {/* Инпут и кнопки */}
608
+ <div style={{ marginTop: 16, height: 80 }}>
609
+ {phase === "guess" && !answered && (
610
+ <div>
611
+ <input
612
+ type="text"
613
+ placeholder="Type the missing word"
614
+ value={inputValue}
615
+ onChange={(e) => setInputValue(e.target.value)}
616
+ style={styles.gmInput}
617
+ />
609
618
  <button
610
- type="button"
611
- style={styles.gmButton}
612
- onClick={nextRound}
619
+ style={{ ...styles.gmButton, marginLeft: 8 }}
620
+ onClick={checkAnswer}
621
+ disabled={animating}
613
622
  >
614
- Next round
623
+ {animating ? "..." : "Check"}
615
624
  </button>
616
- )}
617
- </div>
625
+ </div>
626
+ )}
627
+ {answered && (
628
+ <button type="button" style={styles.gmButton} onClick={nextRound}>
629
+ Next round
630
+ </button>
631
+ )}
618
632
  </div>
619
- )}
620
- </div>
633
+ </div>
634
+ )}
621
635
  </div>
622
636
  </div>
623
637
  </div>