@diffenderfer-games/hub 2.0.0-next.2 → 2.0.0-next.4

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 (49) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/auto.d.ts +3 -3
  3. package/dist/auto.js +3 -3
  4. package/dist/doctor/cli.mjs +1 -1
  5. package/dist/index.d.ts +3 -3
  6. package/dist/index.js +3 -3
  7. package/dist/input.d.ts +2 -2
  8. package/dist/input.js +2 -2
  9. package/dist/rooms.js +1 -1
  10. package/dist/shared/client-version.gen.js +1 -1
  11. package/dist/shared/daily.d.ts +13 -4
  12. package/dist/shared/daily.d.ts.map +1 -1
  13. package/dist/shared/daily.js +15 -6
  14. package/dist/shared/daily.js.map +1 -1
  15. package/dist/shared/index.d.ts +204 -4
  16. package/dist/shared/index.d.ts.map +1 -1
  17. package/dist/shared/input.d.ts +47 -5
  18. package/dist/shared/input.d.ts.map +1 -1
  19. package/dist/shared/input.js +132 -21
  20. package/dist/shared/input.js.map +1 -1
  21. package/dist/shared/rooms.d.ts.map +1 -1
  22. package/dist/shared/rooms.js +12 -1
  23. package/dist/shared/rooms.js.map +1 -1
  24. package/dist/shared/src.js +531 -8
  25. package/dist/shared/src.js.map +1 -1
  26. package/package.json +2 -2
  27. package/src/core/connection.ts +5 -0
  28. package/src/core/hub.ts +55 -4
  29. package/src/core/offline/hub-kinds/index.ts +6 -0
  30. package/src/core/offline/hub-kinds/resume-kind.ts +108 -0
  31. package/src/core/page.ts +10 -0
  32. package/src/core/rest/routes.gen.ts +4 -0
  33. package/src/core/resume/hub-resume.ts +252 -0
  34. package/src/core/resume/local-resume.ts +130 -0
  35. package/src/core/resume/resume-list.ts +65 -0
  36. package/src/core/resume/resume-request.ts +52 -0
  37. package/src/core/resume/resume-types.ts +64 -0
  38. package/src/core/version/client-version.gen.ts +1 -1
  39. package/src/daily/daily-system.ts +14 -5
  40. package/src/index.ts +3 -1
  41. package/src/input/focus-ring.ts +13 -0
  42. package/src/input/input-system.ts +4 -0
  43. package/src/input/modality.ts +122 -0
  44. package/src/input/nav-state.ts +13 -4
  45. package/src/input/zero-config.ts +1 -14
  46. package/src/input.ts +3 -0
  47. package/src/overlay/overlay.ts +5 -3
  48. package/src/overlay/styles.ts +4 -3
  49. package/src/realtime/rooms-forwarder.ts +14 -1
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Which kind of input the player used last, published as `data-hub-input` on
3
+ * `<html>` so focus styles show only to keyboard and gamepad players: a tap or
4
+ * click never leaves a ring behind, and touch devices show none at all. The
5
+ * attribute is the shared state, so every bundle's copy of the client agrees.
6
+ */
7
+ import { isTypingTarget } from './device-state';
8
+ import { FOCUS_RING_SHADOW } from './focus-ring';
9
+
10
+ /** `keys`: keyboard or gamepad. `pointer`: mouse or pen. `touch`: a finger. */
11
+ export type InputModality = 'keys' | 'pointer' | 'touch';
12
+
13
+ /** The `<html>` attribute that holds the modality. */
14
+ export const MODALITY_ATTRIBUTE = 'data-hub-input';
15
+
16
+ const STYLE_ID = 'hub-focus-style';
17
+ const MODIFIER_KEYS = new Set(['Shift', 'Control', 'Alt', 'Meta', 'AltGraph', 'CapsLock']);
18
+ const MODALITIES: readonly InputModality[] = ['keys', 'pointer', 'touch'];
19
+
20
+ /**
21
+ * The page focus styles: the gamepad highlight (`gp-focus`) for keyboard and
22
+ * gamepad players only, and no browser focus ring on buttons after a tap.
23
+ */
24
+ const PAGE_FOCUS_CSS = `html[${MODALITY_ATTRIBUTE}="keys"] .gp-focus{outline:none !important;box-shadow:${FOCUS_RING_SHADOW} !important;}
25
+ html[${MODALITY_ATTRIBUTE}="touch"] :is(button,a,summary,[role="button"],[tabindex]):focus{outline:none;}`;
26
+
27
+ /** The modality a pointer event's `pointerType` implies. */
28
+ export function modalityOfPointer(pointerType: string): InputModality {
29
+ return pointerType === 'touch' ? 'touch' : 'pointer';
30
+ }
31
+
32
+ /** The modality a key press implies, or `null` when it says nothing (a lone modifier, typing in a field). */
33
+ export function modalityOfKey(key: string, isTyping: boolean): InputModality | null {
34
+ if (MODIFIER_KEYS.has(key)) {
35
+ return null;
36
+ }
37
+ // Typing (an on-screen keyboard fires keydown too) is not navigating; Tab always is.
38
+ return isTyping && key !== 'Tab' ? null : 'keys';
39
+ }
40
+
41
+ /** Where the modality is stored: `<html>`, or a fake in tests. */
42
+ export interface ModalityRoot {
43
+ getAttribute(name: string): string | null;
44
+ setAttribute(name: string, value: string): void;
45
+ }
46
+
47
+ /** Reads and writes the page's modality attribute. */
48
+ export class ModalityTracker {
49
+ private readonly root: ModalityRoot;
50
+
51
+ /** Start at `initial` unless another copy already set the attribute. */
52
+ constructor(root: ModalityRoot, initial: InputModality) {
53
+ this.root = root;
54
+ if (this.readAttribute() === null) {
55
+ this.set(initial);
56
+ }
57
+ }
58
+
59
+ /** The last modality used. */
60
+ get current(): InputModality {
61
+ return this.readAttribute() ?? 'pointer';
62
+ }
63
+
64
+ /** Whether focus should show: the last input was a keyboard or gamepad. */
65
+ get showsFocus(): boolean {
66
+ return this.current === 'keys';
67
+ }
68
+
69
+ /** Record the modality the player just used. */
70
+ set(modality: InputModality): void {
71
+ if (this.readAttribute() !== modality) {
72
+ this.root.setAttribute(MODALITY_ATTRIBUTE, modality);
73
+ }
74
+ }
75
+
76
+ /** A key went down (`isTyping`: in a text field). */
77
+ noteKey(key: string, isTyping: boolean): void {
78
+ const modality = modalityOfKey(key, isTyping);
79
+ if (modality !== null) {
80
+ this.set(modality);
81
+ }
82
+ }
83
+
84
+ /** A pointer went down. */
85
+ notePointer(pointerType: string): void {
86
+ this.set(modalityOfPointer(pointerType));
87
+ }
88
+
89
+ private readAttribute(): InputModality | null {
90
+ const value = this.root.getAttribute(MODALITY_ATTRIBUTE);
91
+ return MODALITIES.find((modality) => modality === value) ?? null;
92
+ }
93
+ }
94
+
95
+ let pageTracker: ModalityTracker | null = null;
96
+
97
+ /** Track the page's modality (once per bundle) and add the focus styles (once per page). */
98
+ export function trackInputModality(): ModalityTracker {
99
+ if (pageTracker !== null) {
100
+ return pageTracker;
101
+ }
102
+ const root = document.documentElement;
103
+ const isTouchDevice = typeof matchMedia === 'function' && matchMedia('(hover: none) and (pointer: coarse)').matches;
104
+ const isFirstCopy = root.getAttribute(MODALITY_ATTRIBUTE) === null;
105
+ const tracker = new ModalityTracker(root, isTouchDevice ? 'touch' : 'pointer');
106
+ pageTracker = tracker;
107
+ if (isFirstCopy) {
108
+ document.addEventListener('keydown', (event) => {
109
+ tracker.noteKey(event.key, isTypingTarget(event.target));
110
+ }, true);
111
+ document.addEventListener('pointerdown', (event) => {
112
+ tracker.notePointer(event.pointerType);
113
+ }, true);
114
+ }
115
+ if (document.getElementById(STYLE_ID) === null) {
116
+ const style = document.createElement('style');
117
+ style.id = STYLE_ID;
118
+ style.textContent = PAGE_FOCUS_CSS;
119
+ document.head.appendChild(style);
120
+ }
121
+ return tracker;
122
+ }
@@ -9,8 +9,8 @@ import type { NavBlurEvent, NavElement, NavElementKind, NavFocusEvent, NavTarget
9
9
 
10
10
  /** What a navigable list draws with (the overlay). */
11
11
  export interface NavHighlighter {
12
- /** Ring the highlighted element. */
13
- showHighlight(bounds: Rect): void;
12
+ /** Ring the highlighted element, following its corner radius (a CSS `border-radius`, or empty). */
13
+ showHighlight(bounds: Rect, radius: string): void;
14
14
  /** Remove the ring. */
15
15
  hideHighlight(): void;
16
16
  /** Open the on-screen keyboard for a text field. */
@@ -50,6 +50,11 @@ function boundsOf(element: NavElement): Rect {
50
50
  return element.el === undefined ? { x: 0, y: 0, width: 0, height: 0 } : rectOf(element.el);
51
51
  }
52
52
 
53
+ /** A navigable element's CSS corner radius, so the ring can follow it (empty for a non-DOM element). */
54
+ function radiusOf(element: NavElement): string {
55
+ return element.el === undefined ? '' : getComputedStyle(element.el).borderRadius;
56
+ }
57
+
53
58
  /** The text field a navigable element edits, if it is one. */
54
59
  function textFieldOf(element: NavElement): HTMLInputElement | HTMLTextAreaElement | null {
55
60
  return element.el instanceof HTMLInputElement || element.el instanceof HTMLTextAreaElement ? element.el : null;
@@ -148,16 +153,20 @@ export class NavState {
148
153
  this.position(highlighter);
149
154
  }
150
155
 
151
- /** Move the ring to the highlighted element (or hide it). */
156
+ /** Move the ring to the highlighted element (or hide it), following keyboard focus (Tab) within the list. */
152
157
  position(highlighter: NavHighlighter | null): void {
153
158
  if (highlighter === null) {
154
159
  return;
155
160
  }
161
+ const focused = this.elements.findIndex((element) => element.el !== undefined && element.el === document.activeElement);
162
+ if (focused >= 0) {
163
+ this.index = focused;
164
+ }
156
165
  const current = this.elements[this.index];
157
166
  if (current === undefined) {
158
167
  highlighter.hideHighlight();
159
168
  } else {
160
- highlighter.showHighlight(boundsOf(current));
169
+ highlighter.showHighlight(boundsOf(current), radiusOf(current));
161
170
  }
162
171
  }
163
172
 
@@ -14,9 +14,8 @@ import { isTypingTarget } from './device-state';
14
14
 
15
15
  /** Synthetic keys stay pressed past half deflection. */
16
16
  const SYNTH_DEADZONE = 0.5;
17
+ /** The catalog tile highlight; its style (keyboard and gamepad players only) is in `modality.ts`. */
17
18
  const FOCUS_CLASS = 'gp-focus';
18
- const FOCUS_STYLE_ID = 'gp-focus-style';
19
- const FOCUS_CSS = '.gp-focus{outline:3px solid #ff5f3b !important;outline-offset:2px;border-radius:4px;}';
20
19
 
21
20
  /** `KeyboardEvent.code` → its `key` and legacy `keyCode`. */
22
21
  const KEY_FOR_CODE = new Map<string, readonly [string, number]>([
@@ -51,17 +50,6 @@ function dispatchKey(type: 'keydown' | 'keyup', code: string): void {
51
50
  target.dispatchEvent(event);
52
51
  }
53
52
 
54
- /** Add the highlight style to the page once. */
55
- function ensureFocusStyle(): void {
56
- if (document.getElementById(FOCUS_STYLE_ID) !== null) {
57
- return;
58
- }
59
- const style = document.createElement('style');
60
- style.id = FOCUS_STYLE_ID;
61
- style.textContent = FOCUS_CSS;
62
- document.head.appendChild(style);
63
- }
64
-
65
53
  /** The keys each pad holds down, by pad index. */
66
54
  const heldCodes = new Map<number, Set<string>>();
67
55
 
@@ -168,7 +156,6 @@ export function runZeroConfigFrame(frame: ZeroConfigFrame): void {
168
156
  if (frame.gamepads.length === 0) {
169
157
  return;
170
158
  }
171
- ensureFocusStyle();
172
159
  if (frame.slug === null) {
173
160
  navigateCatalog(frame);
174
161
  } else {
package/src/input.ts CHANGED
@@ -9,6 +9,9 @@ export { InputSystem } from './input/input-system';
9
9
  export type { InputSystemOptions } from './input/input-system';
10
10
  export { getInput, INPUT_SINGLETON_API, isInputSystem, parseInputOverrides } from './input/page-input';
11
11
  export type { InputOptions } from './input/page-input';
12
+ export { FOCUS_RING_SHADOW } from './input/focus-ring';
13
+ export { MODALITY_ATTRIBUTE, trackInputModality } from './input/modality';
14
+ export type { InputModality } from './input/modality';
12
15
  export { isPageInputControl } from './input/page-control';
13
16
  export type { PageInputControl } from './input/page-control';
14
17
  export type * from './input/types';
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import type { NavHighlighter } from '../input/nav-state';
8
8
  import type { InputConfig, InputOverrides, Rect, VirtualDef } from '../input/types';
9
+ import { trackInputModality } from '../input/modality';
9
10
  import { readField } from '../realtime/cross-bundle';
10
11
  import { ControlsPanel } from './controls-panel';
11
12
  import type { ControlsPanelHost } from './controls-panel';
@@ -160,9 +161,10 @@ export class Overlay implements NavHighlighter {
160
161
  }
161
162
  }
162
163
 
163
- /** Ring the highlighted element. */
164
- showHighlight(bounds: Rect): void {
165
- this.ring.hidden = false;
164
+ /** Ring the highlighted element, for keyboard and gamepad players only (never after a tap or click). */
165
+ showHighlight(bounds: Rect, radius: string): void {
166
+ this.ring.hidden = !trackInputModality().showsFocus;
167
+ this.ring.style.borderRadius = radius;
166
168
  this.ring.style.left = `${String(bounds.x)}px`;
167
169
  this.ring.style.top = `${String(bounds.y)}px`;
168
170
  this.ring.style.width = `${String(bounds.width)}px`;
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * The overlay's styles (inside its shadow root, so games can't restyle it).
3
3
  */
4
+ import { FOCUS_RING_SHADOW } from '../input/focus-ring';
4
5
 
5
6
  /** The hub accent colour. */
6
7
  export const ACCENT_COLOR = '#ff5f3b';
@@ -28,8 +29,8 @@ export const OVERLAY_CSS = `
28
29
  :host(.editing) .edit-bar .btn { margin: 0; }
29
30
  .safe-probe { position: fixed; left: 0; top: 0; width: 0; height: 0; visibility: hidden; pointer-events: none;
30
31
  padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px); }
31
- .ring { position: fixed; pointer-events: none; z-index: 2147483200; border: 3px solid ${ACCENT_COLOR}; border-radius: 8px;
32
- box-shadow: 0 0 0 2px rgba(0,0,0,.4); transition: left .08s, top .08s, width .08s, height .08s; }
32
+ .ring { position: fixed; pointer-events: none; z-index: 2147483200; box-shadow: ${FOCUS_RING_SHADOW};
33
+ transition: left .08s, top .08s, width .08s, height .08s; }
33
34
  .ring[hidden] { display: none; }
34
35
  .kb { position: fixed; left: 0; right: 0; bottom: 0; z-index: 2147483400; background: #0d0f14ee; padding: 10px;
35
36
  pointer-events: auto; border-top: 1px solid #2a3142; }
@@ -38,7 +39,7 @@ export const OVERLAY_CSS = `
38
39
  .kb .k { flex: 1; min-width: 0; max-width: 64px; height: 46px; border-radius: 8px; border: 1px solid #2a3142;
39
40
  background: #11141c; color: #e8e6df; font: 16px monospace; cursor: pointer; }
40
41
  .kb .k.space { max-width: 280px; } .kb .k.wide { max-width: 90px; }
41
- .kb .k.kfocus { border-color: ${ACCENT_COLOR}; box-shadow: 0 0 0 2px ${ACCENT_COLOR}; }
42
+ .kb .k.kfocus { box-shadow: ${FOCUS_RING_SHADOW}; }
42
43
  .panel { position: fixed; inset: 0; z-index: 2147483500; display: grid; place-items: center; background: rgba(0,0,0,.6); pointer-events: auto;
43
44
  font-family: 'Space Mono', ui-monospace, monospace; }
44
45
  .panel-card { background: #0d0f14; color: #e8e6df; border: 1px solid #2a3142; border-radius: 12px; padding: 18px;
@@ -1,18 +1,30 @@
1
1
  /**
2
2
  * `hub.rooms`: hub relay rooms, forwarded to the social runtime. Each room is
3
- * wrapped so its rejections use the core client's error classes.
3
+ * wrapped so its rejections use the core client's error classes, and the
4
+ * page's open rooms are tracked (`isInHubRoom`), so `hub.resume` can refuse
5
+ * to save an online run.
4
6
  */
5
7
  import type { Json, RoomInfo } from '@diffenderfer-games/hub-contract';
6
8
  import { outdatedRuntime, pageBridge, rethrowAsHubError } from './bridge';
7
9
  import type { HubRoom, HubRoomsApi, RoomEvents, RoomSelf, RoomSendOptions, RoomState, RoomStateOptions } from './room-types';
8
10
  import type { RoomSeat } from './wire-types';
9
11
 
12
+ /** The rooms this page is in (created, joined or reclaimed, and not yet left or closed). */
13
+ const openRooms = new Set<HubRoom>();
14
+
15
+ /** Whether this page is in a hub room right now. */
16
+ export function isInHubRoom(): boolean {
17
+ return openRooms.size > 0;
18
+ }
19
+
10
20
  /** A runtime room whose rejections are re-created as the core client's errors. */
11
21
  class ForwardedRoom implements HubRoom {
12
22
  private readonly room: HubRoom;
13
23
 
14
24
  constructor(room: HubRoom) {
15
25
  this.room = room;
26
+ openRooms.add(room);
27
+ room.on('closed', () => { openRooms.delete(room); });
16
28
  }
17
29
 
18
30
  get info(): RoomInfo {
@@ -68,6 +80,7 @@ class ForwardedRoom implements HubRoom {
68
80
  }
69
81
 
70
82
  leave(): Promise<void> {
83
+ openRooms.delete(this.room);
71
84
  return rethrowAsHubError(this.room.leave());
72
85
  }
73
86