entangled-worlds-orbiters-shared 5.0.0 → 5.1.0

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/README.md CHANGED
@@ -99,7 +99,7 @@ The capability registry (`createCapabilityRegistry`, `validate` / `assertValid`,
99
99
  - `largestFunction`, `shrinkWasm` - The size check and the shrink step
100
100
 
101
101
  ### Keyboard shortcuts (`entangled-worlds-orbiters-shared/shortcuts`)
102
- - `ShortcutAction` - One catalogue entry: `id`, `label` (+ `names` by language), `defaultKeys` (by key place, `event.code`), `scope`
102
+ - `ShortcutAction` - One catalogue entry: `id`, `label` (+ `names` by language), `defaultKeys` (named as a US keyboard names them, `event.code`; a press matches by the character it types, by its place only where it types no US character, so keys work on any keyboard), `scope`
103
103
  - `matchShortcut(event, bindings)` - Which action a press is, or `null`: modifiers, a field being typed in and surface claims decided in one place
104
104
  - Key strings: `Shift+KeyR`, `Mod+KeyZ` (Ctrl or ⌘), `Shift?+KeyQ` (Shift may be held); `parseChord`, `chordsOverlap`
105
105
  - `resolveBindings(catalogue, overrides)`, `findClashes` - Defaults plus a person's overrides (`[{ id, keys }]` as their settings store them, `keys: []` unbinds), and the clash check
@@ -18,8 +18,30 @@ export interface ModifierState {
18
18
  }
19
19
  /** The chord a key string names, or `null` when it names none (an unknown modifier, a doubled one, no key). */
20
20
  export declare function parseChord(keys: string): Chord | null;
21
- /** Whether a press of `code` with these modifiers is this chord. */
22
- export declare function chordMatches(chord: Chord, code: string, state: ModifierState): boolean;
21
+ /** A press as a chord reads it: the modifiers, the key's place and, when the browser says, the character it typed. */
22
+ export interface ChordPress extends ModifierState {
23
+ readonly code: string;
24
+ readonly key?: string;
25
+ }
26
+ /** How a press is this chord: by the character it typed, by the key's place, or not at all. */
27
+ export type ChordMatch = 'character' | 'place' | null;
28
+ /**
29
+ * How a press is this chord. `character` beats `place`: on a Spanish keyboard Shift+7 types `/`, so it
30
+ * is the `/` key before it is the `7` key. A press with no `key` (an old caller) matches by place only.
31
+ */
32
+ export declare function chordMatch(chord: Chord, press: ChordPress): ChordMatch;
33
+ /** Whether a press of `code` with these modifiers (and, when given, the character it typed) is this chord. */
34
+ export declare function chordMatches(chord: Chord, code: string, state: ModifierState & {
35
+ readonly key?: string;
36
+ }): boolean;
37
+ /**
38
+ * The key string a typed character names (`/` → `Slash`, `?` → `Shift+Slash`, `q` → `KeyQ`), for
39
+ * someone setting a key; `null` for a character a US keyboard does not have, or none.
40
+ */
41
+ export declare function typedKey(key: string | undefined): {
42
+ readonly code: string;
43
+ readonly shift?: boolean;
44
+ } | null;
23
45
  /** A chord that needs Ctrl or ⌘: a command, never a character typed. */
24
46
  export declare function isCommandChord(chord: Chord): boolean;
25
47
  /** Whether one press could be both chords — the test for a clash. */
@@ -1,7 +1,13 @@
1
1
  // One key with its modifiers, written as a string: `Shift+KeyR`, `Mod+KeyZ`, `Shift?+Digit1`.
2
2
  //
3
- // The last part is the key's PLACE (`KeyboardEvent.code`), so a French or German keyboard keeps the
4
- // same layout: the key, not the letter it prints. Before it, the modifiers:
3
+ // The last part names the key the way a US keyboard does (`KeyboardEvent.code`: `KeyQ`, `Slash`,
4
+ // `Digit1`). A press matches it by the CHARACTER it types wherever it types one, so `/` is the key
5
+ // that prints `/` on this person's keyboard (Shift+7 on a Spanish one), and `KeyQ` is the key that
6
+ // prints Q (top left on a French one). Only where the key types no character the US keyboard has
7
+ // (a Greek or Cyrillic letter, ⌥ on a Mac changing it, a dead key, no letter on a letter key) is the
8
+ // key matched by its place.
9
+ // Digits match by character first, then by place: a French keyboard types `&` there.
10
+ // Before the key, the modifiers:
5
11
  //
6
12
  // Shift, Alt, Ctrl, Meta held. One left out must NOT be held.
7
13
  // Mod Ctrl or ⌘ (either, or both), the command key of every platform.
@@ -48,18 +54,90 @@ function parse(keys) {
48
54
  return { code, ...rules };
49
55
  }
50
56
  const fits = (rule, down) => rule === 'any' || (rule === 'held') === Boolean(down);
51
- /** Whether a press of `code` with these modifiers is this chord. */
52
- export function chordMatches(chord, code, state) {
53
- if (chord.code !== code)
54
- return false;
55
- if (!fits(chord.shift, state.shiftKey) || !fits(chord.alt, state.altKey))
56
- return false;
57
+ /**
58
+ * The punctuation keys a chord may name, and what a US keyboard types there without and with Shift:
59
+ * what the chord means on every keyboard. With these, Shift is part of the character (`?` is
60
+ * `Shift+Slash`), so a keyboard that needs Shift, or AltGr, for a `/` still types the `/`.
61
+ */
62
+ const PUNCTUATION = {
63
+ Minus: ['-', '_'], Equal: ['=', '+'], BracketLeft: ['[', '{'], BracketRight: [']', '}'], Backslash: ['\\', '|'],
64
+ Semicolon: [';', ':'], Quote: ["'", '"'], Backquote: ['`', '~'], Comma: [',', '<'], Period: ['.', '>'], Slash: ['/', '?'],
65
+ };
66
+ /** The code a US keyboard types each punctuation character at, and whether it takes Shift. */
67
+ const PUNCTUATION_PLACE = new Map(Object.entries(PUNCTUATION).flatMap(([code, [plain, shifted]]) => [[plain, [code, false]], [shifted, [code, true]]]));
68
+ /** The character a press typed, when it is one a US keyboard has (printable ASCII); else `null`. */
69
+ function typed(key) {
70
+ return typeof key === 'string' && key.length === 1 && key >= '!' && key <= '~' ? key : null;
71
+ }
72
+ function commandFits(chord, state) {
57
73
  if (chord.mod === 'held')
58
74
  return Boolean(state.ctrlKey || state.metaKey);
59
75
  if (chord.mod === 'any')
60
76
  return true;
61
77
  return fits(chord.ctrl, state.ctrlKey) && fits(chord.meta, state.metaKey);
62
78
  }
79
+ function placeMatch(chord, code, state) {
80
+ return chord.code === code && fits(chord.shift, state.shiftKey) && fits(chord.alt, state.altKey) && commandFits(chord, state);
81
+ }
82
+ /**
83
+ * How a press is this chord. `character` beats `place`: on a Spanish keyboard Shift+7 types `/`, so it
84
+ * is the `/` key before it is the `7` key. A press with no `key` (an old caller) matches by place only.
85
+ */
86
+ export function chordMatch(chord, press) {
87
+ const char = typed(press.key);
88
+ if (char === null)
89
+ return placeMatch(chord, press.code, press) ? 'place' : null;
90
+ const punctuation = PUNCTUATION[chord.code];
91
+ if (punctuation) {
92
+ // Shift is in the character (a Spanish `/` is Shift+7); with ⌘ some browsers report the key
93
+ // unshifted, so `⇧⌘,` is also a `,` with Shift held. Alt only when the chord asks for it (AltGr
94
+ // and ⌥ type characters).
95
+ const [plain, shifted] = punctuation;
96
+ const asShifted = char === shifted || (isCommandChord(chord) && char === plain && press.shiftKey);
97
+ const ok = chord.shift === 'held' ? asShifted : chord.shift === 'any' ? char === plain || asShifted : char === plain;
98
+ // A command (⇧⌘,) where this keyboard types another character there: the key's place, as menus do.
99
+ if (!ok)
100
+ return isCommandChord(chord) && placeMatch(chord, press.code, press) ? 'place' : null;
101
+ if (chord.alt === 'held' && !press.altKey)
102
+ return null;
103
+ // AltGr arrives as Ctrl+Alt on Windows: that Ctrl typed the character, it is no command.
104
+ const altGr = press.altKey && press.ctrlKey && !press.metaKey;
105
+ return commandFits(chord, { ctrlKey: press.ctrlKey && !altGr, metaKey: press.metaKey, shiftKey: press.shiftKey, altKey: press.altKey }) ? 'character' : null;
106
+ }
107
+ const letter = /^Key([A-Z])$/.exec(chord.code)?.[1];
108
+ if (letter) {
109
+ // A key that types no letter (`;` where Q is on a Greek keyboard) is read by its place.
110
+ if (!/^[a-zA-Z]$/.test(char))
111
+ return placeMatch(chord, press.code, press) ? 'place' : null;
112
+ if (char.toUpperCase() !== letter)
113
+ return null;
114
+ return fits(chord.shift, press.shiftKey) && fits(chord.alt, press.altKey) && commandFits(chord, press) ? 'character' : null;
115
+ }
116
+ const digit = /^Digit(\d)$/.exec(chord.code)?.[1];
117
+ // A digit typed; where the keyboard types something else there (`&` on a French one), its place.
118
+ if (digit && char === digit && fits(chord.shift, press.shiftKey) && fits(chord.alt, press.altKey) && commandFits(chord, press))
119
+ return 'character';
120
+ return placeMatch(chord, press.code, press) ? 'place' : null;
121
+ }
122
+ /** Whether a press of `code` with these modifiers (and, when given, the character it typed) is this chord. */
123
+ export function chordMatches(chord, code, state) {
124
+ // Field by field: a browser's KeyboardEvent keeps them on its prototype, where a spread does not reach.
125
+ const { shiftKey, altKey, ctrlKey, metaKey, key } = state;
126
+ return chordMatch(chord, { code, key, shiftKey, altKey, ctrlKey, metaKey }) !== null;
127
+ }
128
+ /**
129
+ * The key string a typed character names (`/` → `Slash`, `?` → `Shift+Slash`, `q` → `KeyQ`), for
130
+ * someone setting a key; `null` for a character a US keyboard does not have, or none.
131
+ */
132
+ export function typedKey(key) {
133
+ const char = typed(key);
134
+ if (char === null)
135
+ return null;
136
+ if (/^[a-zA-Z]$/.test(char))
137
+ return { code: `Key${char.toUpperCase()}` };
138
+ const place = PUNCTUATION_PLACE.get(char);
139
+ return place ? { code: place[0], shift: place[1] } : null;
140
+ }
63
141
  /** A chord that needs Ctrl or ⌘: a command, never a character typed. */
64
142
  export function isCommandChord(chord) {
65
143
  return chord.mod === 'held' || chord.ctrl === 'held' || chord.meta === 'held';
@@ -4,8 +4,8 @@ export type ShortcutPlatform = 'apple' | 'other';
4
4
  export declare function shortcutPlatform(): ShortcutPlatform;
5
5
  /**
6
6
  * The character a key place prints on this person's keyboard, when the browser can say
7
- * (`navigator.keyboard.getLayoutMap()`, Chromium only); a key place it does not know is shown from
8
- * its name.
7
+ * (`navigator.keyboard.getLayoutMap()`, Chromium only). Only keys matched by their place use it:
8
+ * a letter, digit or punctuation key matches the character it names, so it shows that character.
9
9
  */
10
10
  export type KeyLayout = ReadonlyMap<string, string>;
11
11
  /** What a key place is called on a keyboard: `KeyQ` → `Q`, `Digit1` → `1`, `Numpad4` → `Num 4`. */
@@ -20,11 +20,14 @@ export declare function chordParts(keys: string, platform?: ShortcutPlatform, la
20
20
  export declare function formatChord(keys: string, platform?: ShortcutPlatform, layout?: KeyLayout): string;
21
21
  /**
22
22
  * The key string a press makes, for someone setting a key: exactly the modifiers held, the command
23
- * key of their platform written `Mod` (⌘ on Apple, Ctrl elsewhere) so the key works on both. `null`
24
- * while only modifiers are down, or for a press with no key place.
23
+ * key of their platform written `Mod` (⌘ on Apple, Ctrl elsewhere) so the key works on both. A letter
24
+ * or punctuation character typed is written as the key that types it on a US keyboard (`/` is
25
+ * `Slash` whichever keys typed it), the way the matcher reads it back. `null` while only modifiers
26
+ * are down, or for a press with no key place.
25
27
  */
26
28
  export declare function chordFromEvent(event: {
27
29
  code: string;
30
+ key?: string;
28
31
  shiftKey: boolean;
29
32
  altKey: boolean;
30
33
  ctrlKey: boolean;
@@ -1,6 +1,6 @@
1
1
  // A key string for people: how a chord is shown (`⌘⇧Z`, `Ctrl+Shift+Z`, `Q`), and the key string a
2
2
  // press makes when someone sets a key ("press a key to change").
3
- import { isModifierCode, parseChord } from './chord.js';
3
+ import { isModifierCode, parseChord, typedKey } from './chord.js';
4
4
  /** The platform of this browser, by its own report; `other` outside a browser. */
5
5
  export function shortcutPlatform() {
6
6
  const nav = globalThis.navigator;
@@ -18,7 +18,8 @@ const NAMED = {
18
18
  };
19
19
  /** What a key place is called on a keyboard: `KeyQ` → `Q`, `Digit1` → `1`, `Numpad4` → `Num 4`. */
20
20
  export function keyLabel(code, layout) {
21
- const printed = layout?.get(code);
21
+ const byCharacter = /^(Key[A-Z]|Digit\d)$/.test(code) || typedKey(NAMED[code])?.code === code;
22
+ const printed = byCharacter ? undefined : layout?.get(code);
22
23
  if (printed && printed.trim())
23
24
  return printed.length === 1 ? printed.toUpperCase() : printed;
24
25
  if (/^Key[A-Z]$/.test(code))
@@ -29,6 +30,11 @@ export function keyLabel(code, layout) {
29
30
  return `Num ${code.slice(6)}`;
30
31
  return NAMED[code] ?? code;
31
32
  }
33
+ /** What a US keyboard types with Shift at each punctuation key. */
34
+ const SHIFTED = {
35
+ Minus: '_', Equal: '+', BracketLeft: '{', BracketRight: '}', Backslash: '|', Semicolon: ':', Quote: '"',
36
+ Backquote: '~', Comma: '<', Period: '>', Slash: '?',
37
+ };
32
38
  const SYMBOLS = { ctrl: '⌃', alt: '⌥', shift: '⇧', meta: '⌘' };
33
39
  const WORDS = { ctrl: 'Ctrl', alt: 'Alt', shift: 'Shift', meta: 'Meta' };
34
40
  /**
@@ -55,12 +61,16 @@ export function chordParts(keys, platform = shortcutPlatform(), layout) {
55
61
  parts.push(names.ctrl);
56
62
  if (held(chord.alt))
57
63
  parts.push(platform === 'apple' ? names.alt : 'Alt');
58
- if (held(chord.shift))
64
+ // A punctuation key is the character it types, Shift included: `Shift+Slash` is `?`.
65
+ // A command (⇧⌘,) is shown as written: its Shift is a key to press, not part of a character.
66
+ const punctuation = !ctrl && !meta && typedKey(NAMED[chord.code])?.code === chord.code ? SHIFTED[chord.code] : undefined;
67
+ if (held(chord.shift) && !punctuation)
59
68
  parts.push(names.shift);
60
69
  if (meta)
61
70
  parts.push(names.meta);
62
- // The help key is `?` wherever it lets Shift be held (its place is `/`): show what people press.
63
- parts.push(chord.code === 'Slash' && chord.shift === 'any' && !layout ? '?' : keyLabel(chord.code, layout));
71
+ // The help key is `?` wherever it lets Shift be held (`/` or `?`): show what people press.
72
+ const shifted = punctuation && (held(chord.shift) || (chord.code === 'Slash' && chord.shift === 'any'));
73
+ parts.push(shifted ? punctuation : keyLabel(chord.code, layout));
64
74
  return parts;
65
75
  }
66
76
  /** One line for a chord: `⌘⇧Z` on Apple keyboards, `Ctrl+Shift+Z` elsewhere. */
@@ -74,28 +84,44 @@ export function formatChord(keys, platform = shortcutPlatform(), layout) {
74
84
  }
75
85
  /**
76
86
  * The key string a press makes, for someone setting a key: exactly the modifiers held, the command
77
- * key of their platform written `Mod` (⌘ on Apple, Ctrl elsewhere) so the key works on both. `null`
78
- * while only modifiers are down, or for a press with no key place.
87
+ * key of their platform written `Mod` (⌘ on Apple, Ctrl elsewhere) so the key works on both. A letter
88
+ * or punctuation character typed is written as the key that types it on a US keyboard (`/` is
89
+ * `Slash` whichever keys typed it), the way the matcher reads it back. `null` while only modifiers
90
+ * are down, or for a press with no key place.
79
91
  */
80
92
  export function chordFromEvent(event, platform = shortcutPlatform()) {
81
93
  if (!event.code || isModifierCode(event.code))
82
94
  return null;
95
+ const typed = typedKey(event.key);
96
+ const punctuation = typed?.shift !== undefined;
97
+ // A punctuation character carries its own Shift, and the AltGr or ⌥ that typed it is no modifier.
98
+ const altGr = punctuation && event.altKey && event.ctrlKey && !event.metaKey;
99
+ // Field by field: a browser's KeyboardEvent keeps them on its prototype, where a spread does not reach.
100
+ const press = punctuation
101
+ ? {
102
+ // With ⌘ or Ctrl some browsers report the key unshifted: the Shift held is then part of the chord.
103
+ shiftKey: Boolean(typed.shift) || (event.shiftKey && !altGr && (event.ctrlKey || event.metaKey)),
104
+ altKey: false,
105
+ ctrlKey: altGr ? false : event.ctrlKey,
106
+ metaKey: event.metaKey,
107
+ }
108
+ : event;
83
109
  const parts = [];
84
110
  // ⌘ (Ctrl elsewhere) is written Mod so the key works on both — unless Ctrl and ⌘ are both held,
85
111
  // which Mod cannot say: then both by name.
86
- if (event.ctrlKey && event.metaKey)
112
+ if (press.ctrlKey && press.metaKey)
87
113
  parts.push('Ctrl', 'Meta');
88
- else if (platform === 'apple' ? event.metaKey : event.ctrlKey)
114
+ else if (platform === 'apple' ? press.metaKey : press.ctrlKey)
89
115
  parts.push('Mod');
90
- else if (event.ctrlKey)
116
+ else if (press.ctrlKey)
91
117
  parts.push('Ctrl');
92
- else if (event.metaKey)
118
+ else if (press.metaKey)
93
119
  parts.push('Meta');
94
- if (event.altKey)
120
+ if (press.altKey)
95
121
  parts.push('Alt');
96
- if (event.shiftKey)
122
+ if (press.shiftKey)
97
123
  parts.push('Shift');
98
- parts.push(event.code);
124
+ parts.push(typed?.code ?? event.code);
99
125
  const keys = parts.join('+');
100
126
  return parseChord(keys) ? keys : null;
101
127
  }
@@ -1,4 +1,4 @@
1
- export { parseChord, parseKeys, keysOverlap, chordMatches, chordsOverlap, isCommandChord, type Chord, type ModifierRule, type ModifierState, } from './chord.js';
1
+ export { parseChord, parseKeys, keysOverlap, chordMatch, chordMatches, chordsOverlap, typedKey, isCommandChord, type Chord, type ModifierRule, type ModifierState, type ChordMatch, type ChordPress, } from './chord.js';
2
2
  export { matchShortcut, createShortcutMatcher, SEQUENCE_WAIT_MS, resolveBindings, findClashes, claimKeys, isKeyClaimed, isTypingTarget, type ShortcutAction, type ShortcutNames, type ShortcutBindings, type ShortcutOverrides, type ShortcutEvent, } from './match.js';
3
3
  export { shortcutPlatform, keyLabel, chordParts, formatChord, chordFromEvent, mergedKeyParts, recordedKeys, type ShortcutPlatform, type KeyLayout, } from './format.js';
4
4
  export { EMPTY_CATALOGUE, parseCatalogue, localName, localAppName, localPartName, shortcutApps, sectionActions, findSection, type ShortcutCatalogue, type ShortcutSection, type ShortcutNamed, type ShortcutSheetColumn, type ShortcutSheetRow, } from './catalogue.js';
@@ -1,8 +1,9 @@
1
1
  // Keyboard shortcuts, one system for every app: the reader of the shortcut catalogue (every app's
2
2
  // actions and default keys, served by the backend) and of a person's own keys, and one matcher that
3
- // says which action a key press is. Keys are named by their place on the keyboard
4
- // (`KeyboardEvent.code`). Framework-free: no React, no DOM beyond reading a key event.
5
- export { parseChord, parseKeys, keysOverlap, chordMatches, chordsOverlap, isCommandChord, } from './chord.js';
3
+ // says which action a key press is. Keys are named as a US keyboard names them
4
+ // (`KeyboardEvent.code`) and matched by the character a press types, so they work on any keyboard.
5
+ // Framework-free: no React, no DOM beyond reading a key event.
6
+ export { parseChord, parseKeys, keysOverlap, chordMatch, chordMatches, chordsOverlap, typedKey, isCommandChord, } from './chord.js';
6
7
  export { matchShortcut, createShortcutMatcher, SEQUENCE_WAIT_MS, resolveBindings, findClashes, claimKeys, isKeyClaimed, isTypingTarget, } from './match.js';
7
8
  export { shortcutPlatform, keyLabel, chordParts, formatChord, chordFromEvent, mergedKeyParts, recordedKeys, } from './format.js';
8
9
  export { EMPTY_CATALOGUE, parseCatalogue, localName, localAppName, localPartName, shortcutApps, sectionActions, findSection, } from './catalogue.js';
@@ -58,7 +58,9 @@ export declare function isKeyClaimed(event: Pick<ShortcutEvent, 'key' | 'metaKey
58
58
  export declare function isTypingTarget(target: unknown): boolean;
59
59
  /**
60
60
  * The action this press is, or `null`. Not one while the person is typing in a field, nor a key a
61
- * surface holds; a widget that reads characters keeps its characters but not the ⌘ commands.
61
+ * surface holds; a widget that reads characters keeps its characters but not the ⌘ commands. A key
62
+ * that matches by the character typed wins over one that only matches by the key's place, whatever
63
+ * their order (Shift+7 types `/` on a Spanish keyboard: the `/` action, not the `7` one).
62
64
  */
63
65
  export declare function matchShortcut(event: ShortcutEvent, bindings: ShortcutBindings): string | null;
64
66
  /** How long the second key of a sequence (`G`, then `H`) may follow the first. */
@@ -1,7 +1,7 @@
1
1
  // Which action a key press is. Each app keeps what its actions DO; this only answers which one a
2
2
  // press names, so every app reads keys the same way: the modifiers, a field being typed in, and a
3
3
  // surface that holds letters of its own are decided here, once.
4
- import { chordMatches, isCommandChord, isModifierCode, keysOverlap, parseChord, parseKeys } from './chord.js';
4
+ import { chordMatch, chordMatches, isCommandChord, isModifierCode, keysOverlap, parseChord, parseKeys } from './chord.js';
5
5
  /**
6
6
  * Every action's keys: its defaults, or the person's override (unknown actions and keys that name no
7
7
  * chord are dropped). Catalogue order is kept, so the earlier catalogue wins where two match one press.
@@ -81,23 +81,29 @@ function readsCharacters(target) {
81
81
  }
82
82
  /**
83
83
  * The action this press is, or `null`. Not one while the person is typing in a field, nor a key a
84
- * surface holds; a widget that reads characters keeps its characters but not the ⌘ commands.
84
+ * surface holds; a widget that reads characters keeps its characters but not the ⌘ commands. A key
85
+ * that matches by the character typed wins over one that only matches by the key's place, whatever
86
+ * their order (Shift+7 types `/` on a Spanish keyboard: the `/` action, not the `7` one).
85
87
  */
86
88
  export function matchShortcut(event, bindings) {
87
89
  if (!event.code || isTypingTarget(event.target) || isKeyClaimed(event))
88
90
  return null;
89
91
  const characters = readsCharacters(event.target);
92
+ let byPlace = null;
90
93
  for (const [actionId, keys] of bindings) {
91
94
  for (const key of keys) {
92
95
  const chord = parseChord(key);
93
- if (!chord || !chordMatches(chord, event.code, event))
96
+ const how = chord && chordMatch(chord, event);
97
+ if (!chord || !how)
94
98
  continue;
95
- if (characters && !isCommandChord(chord))
96
- return null;
97
- return actionId;
99
+ if (how === 'character')
100
+ return characters && !isCommandChord(chord) ? null : actionId;
101
+ byPlace ??= { id: actionId, command: isCommandChord(chord) };
98
102
  }
99
103
  }
100
- return null;
104
+ if (!byPlace || (characters && !byPlace.command))
105
+ return null;
106
+ return byPlace.id;
101
107
  }
102
108
  // ---------------------------------------------------------------------------------------------
103
109
  // Sequences
@@ -137,7 +143,7 @@ export function createShortcutMatcher(waitMs = SEQUENCE_WAIT_MS, now = Date.now)
137
143
  return id;
138
144
  }
139
145
  if (known.some(([, [one]]) => chordMatches(one, event.code, event))) {
140
- first = { code: event.code, shiftKey: event.shiftKey, altKey: event.altKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey, at: now() };
146
+ first = { code: event.code, key: event.key, shiftKey: event.shiftKey, altKey: event.altKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey, at: now() };
141
147
  return null;
142
148
  }
143
149
  return matchShortcut(event, bindings);
@@ -0,0 +1,31 @@
1
+ import * as THREE from 'three';
2
+ import type { WorldSky } from './worldSkyFile.js';
3
+ /** The band's frame: `basis` maps band space (x toward the core, y the band's pole) to the scene. */
4
+ export interface BandFrame {
5
+ basis: THREE.Matrix4;
6
+ core: THREE.Vector3;
7
+ }
8
+ /** An equatorial direction in the scene frame (y = celestial north). */
9
+ export declare function equatorial(raDeg: number, decDeg: number, out?: THREE.Vector3): THREE.Vector3;
10
+ /** A seeded band, tilted `tiltDeg` from the equator, its core at a seeded longitude. */
11
+ export declare function seededBandFrame(seed: number, tiltDeg: number): BandFrame;
12
+ /**
13
+ * The Milky Way: the galactic plane (J2000 north pole and centre). From every catalogued host it
14
+ * is the same plane to well under a degree, so it holds for every world sky.
15
+ */
16
+ export declare function galacticBandFrame(): BandFrame;
17
+ /**
18
+ * The star buffer, exactly `count` stars (the device's star budget). A world sky first: its
19
+ * catalogue stars copied as they are (brightest first, so a small budget keeps the brightest), then
20
+ * its stand-ins, then seeded faint fill; a seeded sky is `count` seeded stars. Stars gather into the
21
+ * band by `band`.
22
+ */
23
+ export declare function buildStarGeometry(opts: {
24
+ seed: number;
25
+ count: number;
26
+ band: number;
27
+ frame: BandFrame;
28
+ world?: WorldSky | null;
29
+ }): THREE.BufferGeometry;
30
+ /** The constellation figures of a world sky, straight from the file. */
31
+ export declare function buildLineGeometry(world: WorldSky): THREE.BufferGeometry;
@@ -0,0 +1,84 @@
1
+ // Star and line buffers. One layout for every sky: direction `Int16 × 3` (normalised) + colour and
2
+ // brightness `Uint8 × 4` (normalised) = 10 bytes a star, the world-sky file's own layout, so a world
3
+ // sky's catalogue stars are copied in as they are and only the seeded stars are worked out here.
4
+ import * as THREE from 'three';
5
+ import { blackbodyColor } from '../star/blackbody.js';
6
+ import { createLcg } from '../star/layout.js';
7
+ const INT16 = 32767;
8
+ /** An equatorial direction in the scene frame (y = celestial north). */
9
+ export function equatorial(raDeg, decDeg, out = new THREE.Vector3()) {
10
+ const a = THREE.MathUtils.degToRad(raDeg);
11
+ const d = THREE.MathUtils.degToRad(decDeg);
12
+ return out.set(Math.cos(d) * Math.cos(a), Math.sin(d), -Math.cos(d) * Math.sin(a));
13
+ }
14
+ /** A seeded band, tilted `tiltDeg` from the equator, its core at a seeded longitude. */
15
+ export function seededBandFrame(seed, tiltDeg) {
16
+ const basis = new THREE.Matrix4().makeRotationX(THREE.MathUtils.degToRad(tiltDeg));
17
+ const lon = createLcg(seed ^ 0x5bd1e995)() * Math.PI * 2;
18
+ basis.multiply(new THREE.Matrix4().makeRotationY(-lon));
19
+ return { basis, core: new THREE.Vector3(1, 0, 0).applyMatrix4(basis).normalize() };
20
+ }
21
+ /**
22
+ * The Milky Way: the galactic plane (J2000 north pole and centre). From every catalogued host it
23
+ * is the same plane to well under a degree, so it holds for every world sky.
24
+ */
25
+ export function galacticBandFrame() {
26
+ const pole = equatorial(192.85948, 27.12825);
27
+ const centre = equatorial(266.40499, -28.93617);
28
+ const x = centre.sub(pole.clone().multiplyScalar(centre.dot(pole))).normalize();
29
+ const z = new THREE.Vector3().crossVectors(x, pole);
30
+ return { basis: new THREE.Matrix4().makeBasis(x, pole, z), core: x.clone() };
31
+ }
32
+ /** Naked-eye stand-ins reach this brightness (0..1), under the glow tier so only real stars glow; faint fill stays under the catalogue's limit. */
33
+ const STAND_IN_BRIGHTNESS = 0.7;
34
+ const FILL_BRIGHTNESS = 0.22;
35
+ /**
36
+ * The star buffer, exactly `count` stars (the device's star budget). A world sky first: its
37
+ * catalogue stars copied as they are (brightest first, so a small budget keeps the brightest), then
38
+ * its stand-ins, then seeded faint fill; a seeded sky is `count` seeded stars. Stars gather into the
39
+ * band by `band`.
40
+ */
41
+ export function buildStarGeometry(opts) {
42
+ const { seed, band, frame, world } = opts;
43
+ const total = Math.max(0, opts.count);
44
+ const worldStars = world ? Math.min(world.starCount, total) : 0;
45
+ const standIns = world ? Math.min(world.standIns, total - worldStars) : 0;
46
+ const dir = new Int16Array(total * 3);
47
+ const cb = new Uint8Array(total * 4);
48
+ if (world) {
49
+ dir.set(world.directions.subarray(0, worldStars * 3));
50
+ cb.set(world.colourBrightness.subarray(0, worldStars * 4));
51
+ }
52
+ const rand = createLcg(seed);
53
+ const v = new THREE.Vector3();
54
+ const bandShare = 0.1 + 0.5 * band;
55
+ for (let i = worldStars; i < total; i++) {
56
+ const inBand = rand() < bandShare;
57
+ const lon = rand() * Math.PI * 2;
58
+ const lat = inBand ? (rand() + rand() + rand() - 1.5) * 0.2 : Math.asin(rand() * 2 - 1);
59
+ v.set(Math.cos(lat) * Math.cos(lon), Math.sin(lat), Math.cos(lat) * Math.sin(lon)).applyMatrix4(frame.basis);
60
+ dir[i * 3] = Math.round(v.x * INT16);
61
+ dir[i * 3 + 1] = Math.round(v.y * INT16);
62
+ dir[i * 3 + 2] = Math.round(v.z * INT16);
63
+ // Many faint, few bright. Under a world sky: stand-ins at naked-eye brightness, then faint fill.
64
+ const scale = !world ? 1 : i < worldStars + standIns ? STAND_IN_BRIGHTNESS : FILL_BRIGHTNESS;
65
+ const c = blackbodyColor(2800 + Math.pow(rand(), 2.2) * 12000);
66
+ cb[i * 4] = Math.round(c.r * 255);
67
+ cb[i * 4 + 1] = Math.round(c.g * 255);
68
+ cb[i * 4 + 2] = Math.round(c.b * 255);
69
+ cb[i * 4 + 3] = Math.round(Math.pow(rand(), 4) * scale * 255);
70
+ }
71
+ const geo = new THREE.BufferGeometry();
72
+ geo.setAttribute('position', new THREE.BufferAttribute(dir, 3, true));
73
+ geo.setAttribute('aColourBrightness', new THREE.BufferAttribute(cb, 4, true));
74
+ // Stars sit at infinity in every direction: never frustum-cull them.
75
+ geo.boundingSphere = new THREE.Sphere(new THREE.Vector3(), Infinity);
76
+ return geo;
77
+ }
78
+ /** The constellation figures of a world sky, straight from the file. */
79
+ export function buildLineGeometry(world) {
80
+ const geo = new THREE.BufferGeometry();
81
+ geo.setAttribute('position', new THREE.BufferAttribute(world.lines, 3, true));
82
+ geo.boundingSphere = new THREE.Sphere(new THREE.Vector3(), Infinity);
83
+ return geo;
84
+ }
@@ -0,0 +1,6 @@
1
+ export { createSky } from './sky.js';
2
+ export type { Sky, CreateSkyOptions } from './sky.js';
3
+ export { DEFAULT_SKY_PARAMS, SKY_PARAM_RANGES, SKY_LOOK_KEYS, SKY_SHAPE_KEYS, normalizeSkyParams, } from './params.js';
4
+ export type { SkyParams, SkySource } from './params.js';
5
+ export { parseWorldSky, WORLD_SKY_MAGIC, WORLD_SKY_FORMAT } from './worldSkyFile.js';
6
+ export type { WorldSky } from './worldSkyFile.js';
@@ -0,0 +1,6 @@
1
+ // ./sky — the sky behind a world: a parametric star field (seeded, or the world's own sky as seen
2
+ // from its host star, read from the backend's precomputed file), an optional nebula + Milky Way
3
+ // baked once per change, and constellation figures. Replaces the six-face painted skycube.
4
+ export { createSky } from './sky.js';
5
+ export { DEFAULT_SKY_PARAMS, SKY_PARAM_RANGES, SKY_LOOK_KEYS, SKY_SHAPE_KEYS, normalizeSkyParams, } from './params.js';
6
+ export { parseWorldSky, WORLD_SKY_MAGIC, WORLD_SKY_FORMAT } from './worldSkyFile.js';
@@ -0,0 +1,44 @@
1
+ /** Where the stars come from: a seed, or the world's own sky (the backend's file for its host star). */
2
+ export type SkySource = 'seeded' | 'world';
3
+ export interface SkyParams {
4
+ source: SkySource;
5
+ /** Same seed → same sky. Also seeds the stand-in stars and the nebula under a world sky. */
6
+ seed: number;
7
+ /** Stars drawn: the device's star budget. A world sky spends it on its catalogue stars first
8
+ * (brightest first), then its stand-ins, then faint fill. */
9
+ count: number;
10
+ /** Star brightness multiplier. */
11
+ brightness: number;
12
+ /** Star size in pixels at a 1080-pixel-tall frame, for the brightest ordinary star. */
13
+ size: number;
14
+ /** 0 = every star white, 1 = full blackbody colour. */
15
+ colour: number;
16
+ /** 0 = steady, 1 = full twinkle. */
17
+ twinkle: number;
18
+ /** Nebula amount, 0 = no nebula layer at all (the cheapest sky). */
19
+ nebula: number;
20
+ /** Nebula hue, degrees. */
21
+ hue: number;
22
+ /** How far the nebula's hue wanders around `hue`, degrees. */
23
+ hueSpread: number;
24
+ /** How much stars and clouds gather into a Milky Way band, 0..1. */
25
+ band: number;
26
+ /** Band tilt, degrees. Seeded skies only: a world sky's band is the galactic plane. */
27
+ bandTilt: number;
28
+ /** Constellation figures' strength, 0 = hidden. World skies only. */
29
+ constellations: number;
30
+ }
31
+ export declare const DEFAULT_SKY_PARAMS: Readonly<SkyParams>;
32
+ type NumericKey = Exclude<keyof SkyParams, 'source'>;
33
+ /** Valid range of every numeric param, for UIs and for `normalizeSkyParams`. */
34
+ export declare const SKY_PARAM_RANGES: Readonly<Record<NumericKey, {
35
+ min: number;
36
+ max: number;
37
+ }>>;
38
+ /** Params that only change uniforms: no rebuild, no re-bake. */
39
+ export declare const SKY_LOOK_KEYS: readonly ["brightness", "size", "colour", "twinkle", "constellations", "nebula", "hue", "hueSpread"];
40
+ /** Params that rebuild the star buffer (at most once a frame); all but `count` also re-bake the glow's shape. */
41
+ export declare const SKY_SHAPE_KEYS: readonly ["source", "seed", "count", "band", "bandTilt"];
42
+ /** A complete, in-range set from anything stored: missing or invalid fields take the default. */
43
+ export declare function normalizeSkyParams(input: unknown): SkyParams;
44
+ export {};
@@ -0,0 +1,52 @@
1
+ // What a world sets on its sky. Plain data, so it persists as-is; `normalizeSkyParams` is the one
2
+ // boundary that turns whatever was stored (or nothing) into a complete, in-range set.
3
+ export const DEFAULT_SKY_PARAMS = Object.freeze({
4
+ source: 'seeded',
5
+ seed: 1,
6
+ count: 40000,
7
+ brightness: 1,
8
+ size: 3,
9
+ colour: 0.7,
10
+ twinkle: 0.4,
11
+ nebula: 0.6,
12
+ hue: 255,
13
+ hueSpread: 50,
14
+ band: 0.6,
15
+ bandTilt: 60,
16
+ constellations: 0,
17
+ });
18
+ /** Valid range of every numeric param, for UIs and for `normalizeSkyParams`. */
19
+ export const SKY_PARAM_RANGES = Object.freeze({
20
+ seed: { min: 0, max: 2 ** 31 - 1 },
21
+ count: { min: 0, max: 100000 },
22
+ brightness: { min: 0, max: 3 },
23
+ size: { min: 0.5, max: 5 },
24
+ colour: { min: 0, max: 1 },
25
+ twinkle: { min: 0, max: 1 },
26
+ nebula: { min: 0, max: 1 },
27
+ hue: { min: 0, max: 360 },
28
+ hueSpread: { min: 0, max: 180 },
29
+ band: { min: 0, max: 1 },
30
+ bandTilt: { min: 0, max: 180 },
31
+ constellations: { min: 0, max: 1 },
32
+ });
33
+ /** Params that only change uniforms: no rebuild, no re-bake. */
34
+ export const SKY_LOOK_KEYS = ['brightness', 'size', 'colour', 'twinkle', 'constellations', 'nebula', 'hue', 'hueSpread'];
35
+ /** Params that rebuild the star buffer (at most once a frame); all but `count` also re-bake the glow's shape. */
36
+ export const SKY_SHAPE_KEYS = ['source', 'seed', 'count', 'band', 'bandTilt'];
37
+ /** A complete, in-range set from anything stored: missing or invalid fields take the default. */
38
+ export function normalizeSkyParams(input) {
39
+ const raw = (input && typeof input === 'object' ? input : {});
40
+ const out = { ...DEFAULT_SKY_PARAMS };
41
+ out.source = raw.source === 'world' ? 'world' : 'seeded';
42
+ for (const key of Object.keys(SKY_PARAM_RANGES)) {
43
+ const v = raw[key];
44
+ if (typeof v !== 'number' || !Number.isFinite(v))
45
+ continue;
46
+ const { min, max } = SKY_PARAM_RANGES[key];
47
+ out[key] = Math.min(max, Math.max(min, v));
48
+ }
49
+ out.seed = Math.round(out.seed);
50
+ out.count = Math.round(out.count);
51
+ return out;
52
+ }
@@ -0,0 +1,14 @@
1
+ /** Ordinary stars are never drawn bigger than this (pixels at a 1080-pixel-tall frame); glow stars 3.2×. */
2
+ export declare const MAX_STAR_SIZE = 5;
3
+ /** Brightness (0..1) above which a star joins the glow tier: ~1% of a seeded sky; on a world sky,
4
+ * every catalogue star brighter than apparent magnitude ~1. */
5
+ export declare const GLOW_SEEDED = 0.965;
6
+ export declare const GLOW_WORLD = 0.73;
7
+ export declare const STAR_VERTEX: string;
8
+ export declare const STAR_FRAGMENT = "\n varying vec3 vColour;\n varying float vAlpha;\n varying float vGlow;\n void main() {\n vec2 d = gl_PointCoord - 0.5;\n float r2 = dot(d, d) * 4.0;\n // Ordinary star: a soft core. Glow star: a tighter core plus a wide halo and a thin cross, so a\n // handful of stars read as bright without a bloom pass.\n float core = exp(-r2 * mix(3.5, 11.0, vGlow));\n float halo = vGlow * (0.4 * exp(-r2 * 3.0) + 0.3 * exp(-abs(d.x * d.y) * 700.0) * (1.0 - r2));\n // No discard (it defeats early depth on tile GPUs); additive black adds nothing.\n gl_FragColor = vec4(vColour * (core + max(halo, 0.0)) * vAlpha, 1.0);\n }\n";
9
+ export declare const NEBULA_VERTEX = "\n uniform float uFar;\n varying vec3 vDir;\n void main() {\n vDir = position;\n gl_Position = projectionMatrix * vec4(mat3(modelViewMatrix) * position * uFar * 0.5, 1.0);\n }\n";
10
+ export declare const NEBULA_FRAGMENT = "\n varying vec3 vDir;\n uniform samplerCube uNebula;\n uniform float uAmount;\n uniform float uHue;\n uniform float uSpread;\n vec3 hsv(float h, float s, float v) {\n vec3 k = clamp(abs(mod(h / 60.0 + vec3(0.0, 4.0, 2.0), 6.0) - 3.0) - 1.0, 0.0, 1.0);\n return v * mix(vec3(1.0), k, s);\n }\n void main() {\n vec3 f = textureCube(uNebula, vDir).rgb;\n vec3 c = hsv(uHue + uSpread * (f.g - 0.5) * 3.0, 0.7 - 0.3 * f.b, 1.0) * f.r * uAmount;\n // Under a pixel of dither (interleaved gradient noise): the 8-bit screen would otherwise band\n // the glow's last faint steps into black.\n float dither = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715)))) - 0.5;\n gl_FragColor = vec4(max(c + dither / 255.0, 0.0), 1.0);\n }\n";
11
+ export declare const LINE_VERTEX = "\n uniform float uFar;\n void main() {\n gl_Position = projectionMatrix * vec4(mat3(modelViewMatrix) * normalize(position) * uFar * 0.9, 1.0);\n }\n";
12
+ export declare const LINE_FRAGMENT = "\n uniform float uLines;\n void main() { gl_FragColor = vec4(vec3(0.45, 0.6, 0.9) * uLines * 0.35, 1.0); }\n";
13
+ export declare const BAKE_VERTEX = "\n varying vec3 vDir;\n void main() {\n vDir = position;\n gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);\n }\n";
14
+ export declare const BAKE_FRAGMENT = "\n varying vec3 vDir;\n uniform float uSeed;\n uniform float uBand;\n uniform mat3 uToBand;\n uniform vec3 uCoreDir;\n float hash(vec3 p) {\n p = fract(p * 0.3183099 + vec3(0.71, 0.113, 0.419) * (1.0 + uSeed * 0.137));\n p *= 17.0;\n return fract(p.x * p.y * p.z * (p.x + p.y + p.z));\n }\n float noise(vec3 x) {\n vec3 i = floor(x);\n vec3 f = fract(x);\n f = f * f * (3.0 - 2.0 * f);\n return mix(mix(mix(hash(i), hash(i + vec3(1, 0, 0)), f.x), mix(hash(i + vec3(0, 1, 0)), hash(i + vec3(1, 1, 0)), f.x), f.y),\n mix(mix(hash(i + vec3(0, 0, 1)), hash(i + vec3(1, 0, 1)), f.x), mix(hash(i + vec3(0, 1, 1)), hash(i + vec3(1, 1, 1)), f.x), f.y), f.z);\n }\n float fbm(vec3 p) {\n float a = 0.5, s = 0.0;\n for (int i = 0; i < 7; i++) { s += a * noise(p); p = p * 2.03 + vec3(1.7, 9.2, 4.1); a *= 0.5; }\n return s;\n }\n void main() {\n vec3 d = normalize(vDir);\n vec3 q = d * 2.2 + vec3(uSeed * 3.17, uSeed * 1.31, uSeed * 2.07);\n vec3 w = vec3(fbm(q), fbm(q + vec3(5.2, 1.3, 2.8)), fbm(q + vec3(9.1, 4.7, 7.3)));\n float n = fbm(q + 1.9 * w);\n float lat = (uToBand * d).y;\n float bandMask = exp(-lat * lat / 0.06);\n float density = smoothstep(0.42, 0.9, n) * mix(1.0, 0.25 + 1.6 * bandMask, uBand);\n float core = exp(-(1.0 - dot(d, uCoreDir)) * 5.0) * bandMask * uBand;\n density *= 1.0 + 1.8 * core;\n float dust = smoothstep(0.5, 0.75, fbm(q * 2.7 + w * 1.3 + 3.0));\n density *= 1.0 - 0.75 * dust * bandMask * uBand;\n density += 0.12 * uBand * bandMask * (0.6 + 0.4 * n);\n gl_FragColor = vec4(density * 0.42, w.x, bandMask * uBand, 1.0);\n }\n";