Skip to content

feat(shortcuts): app-wide keyboard shortcuts and the help registry (#243) - #276

Merged
daniilperkin merged 7 commits into
devfrom
feature/243-app-wide-keyboard-shortcuts
Sep 29, 2026
Merged

daniilperkin merged 7 commits into
devfrom
feature/243-app-wide-keyboard-shortcuts

Conversation

@daniilperkin

Copy link
Copy Markdown
Collaborator

Closes #243 · base dev · branch feature/243-app-wide-keyboard-shortcuts

Every keyboard chord in the app, defined once and answered wherever it makes sense — plus one help dialog that lists them all.

Keys Action Where it works
Alt+H · Alt+B · Alt+C · Alt+U · Alt+K · Alt+P · Alt+, Dashboard · Board · Chat · Buddy · Knowledge Base · PM Dashboard · Settings anywhere outside a text field
? this help anywhere outside a text field
Ctrl/Cmd+K Switch project anywhere (existing, now part of the registry)
Alt+N New conversation Chat & Buddy, deliberately even while composing
Alt+S Toggle the sidebar the drawer below lg (#244 will give the same chord the desktop collapse)
/ · Esc Focus the message box (Chat) · close a dialog or menu documented, answered by their owners

Sidebar entries show their chord on hover/focus (chip + title for screen readers), and the help is discoverable by pressing ?.

Why these chords

  • Alt, not Ctrl. Every desktop browser owns the Ctrl/Cmd combinations this wanted (Ctrl+N opens a window and a page cannot refuse it). The Alt row is free for H/B/C/U/K/P/S/, on Chrome, Edge and Firefox on Windows. Named trade: on macOS Option+N/Option+U are dead keys (ñ/ü) — documented in the code, acceptable for a Windows-first team.
  • Punctuation matches the produced character. ? is Shift+ß on a German keyboard (code: "Minus") and Shift+/ on a US one (code: "Slash") — a code-based ? is dead for half this team. Same for / and ,. Letter chords keep the app's event.code convention.
  • AltGr can never fire a shortcut. Windows reports AltGr as Ctrl+Alt; a bare altKey check fires on every {, [, }, @ a European layout types.
  • Text fields own their keys, except Alt+N, which fires while composing on purpose: the moment somebody most wants a fresh conversation is halfway through typing into the wrong one.
  • Permissions are canAccessRoute. A hire pressing Alt+P is refused exactly like one typing the URL — the same rule the sidebar and the manager guard use, and the same filter hides the row from the help.

What the review of the handed-over plan changed

  1. ? on code:"Slash" → matched as key:"?" (dead on QWERTZ).
  2. requiresPermission(profile, canManage) callbacks → dropped in favour of path + canAccessRoute.
  3. Ctrl/Cmd+K and Alt+N were listed as "existing" shortcuts while implemented by components — both now listen through the shared registry matcher, so their hints and handlers cannot drift.
  4. Esc stays documented-only: 30+ dialogs and popovers already own it locally (several checking event.defaultPrevented), and a global handler would break layered cases.
  5. bg-app-surface-raised does not exist in the token set — the keycaps use real tokens.
  6. No sidebar entry navigates to /buddy (it lights up Chat), so Buddy is help-only; Chat — the busiest entry — got the Alt+C the plan left it without.
  7. The plan's git checkout dev && git pull in the shared checkout → worktree (workspace rule).

Base dev, not #268: #268 touches AddSourceFlow.tsx and its test — no shared file with this branch. Stacking on an open hotfix would couple our mergeability to it for zero shared code.

Not in this PR

  • Desktop sidebar collapse — Alt+S works the drawer; Add a collapsible and resizable sidebar #244 owns the collapse and this chord will land on it without changes.
  • Chords for every PM/Admin sub-page — the issue asks for case-by-case; chord space is finite.

Verification

  • npm run try green: format check, build, eslint, unit suite, a11y suite.
  • New: registry/matcher unit tests (11), GlobalShortcuts integration — navigation, PM gating for USER vs PM, typing/AltGr/repeat guards, help open/close, no stacking on another dialog (10), help modal incl. permission filtering and keycap rendering (7), a11y with axe + focus return (2), sidebar hint and Alt+S drawer tests, sidebar-link hint tests.
  • One existing test updated: the project switcher's Ctrl+K test dispatched a code-less KeyboardEvent; browsers always set code, so it now sends a realistic event (the synthetic-event guard moved into the matcher).

Issue #243 asked for shortcuts "wherever they make sense" without a
listener per page. What existed was three separate conventions: the
new-conversation hook had its own idea of a press (`event.code`, refusing
Ctrl/Cmd), the project switcher compared `event.key` and shipped its own
chord string, and the chat page matched `/` inline. Two spellings of one
rule is how a chord starts disagreeing with the hint that advertises it,
so the matching, the labels and the catalogue live here from now on —
`isShortcutPress`, `shortcutChord`, `SHORTCUTS` — and the components
listen through them.

Decisions worth naming:

- `code` for letter chords, the produced character for punctuation. The
  handed-over plan matched `?` on `code: "Slash"`, which is dead on a
  German keyboard: `?` is Shift+ß there and `code: "Slash"` belongs to
  `-`. Character-matched chords (`?`, `/`, `,`) work on whichever layout
  the person brought, which is the entire point of a help key.

- AltGr is refused by refusing Ctrl+Alt — how Windows reports it — because
  a bare `altKey` check fires on every `{`, `[`, `}` and `@` a European
  layout types with it.

- Shift must match exactly on physical-key chords and is ignored on
  character chords: producing `?` requires Shift on every layout that has
  it, so the character is the whole claim there.

- `scope: "global" | "surface"` says who answers. The destinations and `?`
  are the shortcuts layer's own; Ctrl/Cmd+K, Alt+N, Alt+S, `/` and Esc are
  answered where they act — the switcher, the chat, the sidebar, dialogs —
  but are registered here all the same, so the help view cannot document a
  chord the app does not answer or miss one it does.

- Destination entries carry a `path`, not a permission callback: the gate
  is `canAccessRoute`, the rule the sidebar and the manager guard already
  use, instead of the plan's second spelling of the same rule.

- `Alt+C` for Chat is new. The plan gave the buddy (`Alt+U`) a chord while
  the sidebar's busiest entry stayed unlabelled, even though `/buddy` is
  Chat's own second half.

Part of #243.
`GlobalShortcuts` is mounted once in the signed-in shell: one window
listener answers every `scope: "global"` entry — the seven destinations
and `?` — rather than a listener per page, because a shortcut that only
works on the page you are already on is not a shortcut.

Destinations go through `canAccessRoute` before navigating: a chord is a
convenience, never a way around the access policy, so a hire pressing
`Alt+P` is refused like a hire typing the URL. The help view filters the
same way, since advertising a shortcut the profile may not use is the same
leak in a friendlier font.

The help itself is `ui/Modal`, so the focus trap, Escape-to-close, scroll
lock and focus restoration are the ones every other dialog already has.
The chords render as real text — a help nobody can hear is not the help
this dialog exists to be (the hover chip stays `aria-hidden`, this does
not) — and the surface-owned chords are listed alongside the global ones,
because the dialog is the one place to learn they exist.

`?` is ignored while any other dialog is open: two `z-50` overlays would
otherwise appear at once and a single Escape would close both.

`App` mounts the layer only while signed in — the login screen has no
destinations to jump between, and the help would advertise routes a
visitor cannot reach.

Part of #243.
- `SidebarNavLink` takes a `shortcut` and draws `ui/ShortcutHint` next to
  the label. The entries get their chord from `navigationShortcut(path)`
  rather than strings typed at the call site, so an entry's hint and the
  keypress that answers it read the same line of the registry. The chip
  stays `aria-hidden` and the link's own `title` carries the copy a screen
  reader hears — once. The icon-only Settings button has no room for a
  chip, so its title carries `Alt + ,`, the arrangement the project
  switcher's trigger already uses.

- `Alt+S` works the drawer the mobile header's button works — one toggle
  for both ways in. There is no desktop collapse to reach yet (#244), and
  inventing a second behaviour for wide screens now would only have to be
  undone there.

- `ProjectSwitcher` and `useNewConversationShortcut` lose their bespoke
  listeners: the chord and what counts as a press come from the registry
  through `useShortcutListener`. The switcher's old synthetic-event guard
  becomes the matcher's own refusal of events without a `code` (a real
  KeyboardEvent always has one; `new Event("keydown")` from a browser
  extension never will). Each component keeps its own gate —
  `isSwitcherEnabled`, the assistant's `enabled` — because those say what
  is on screen, which is knowledge the registry does not have.

Closes #243.
@daniilperkin daniilperkin linked an issue Sep 28, 2026 that may be closed by this pull request
6 tasks
…modals

Review of #276 turned up four places where the registry did not yet do what the
PR claimed. All four trace back to the matcher being stricter than the behaviour
it replaced, not to the registry idea itself.

Ctrl/Cmd+K in a text field
  The dialog's chord came out of a hand-rolled window listener that never asked
  where the keystroke landed, so it worked mid-typing; the shared matcher's
  typing guard silently narrowed that. SWITCH_PROJECT_SHORTCUT now declares
  `allowInInput`, which restores the old behaviour and keeps the PR's "anywhere"
  promise honest. A text field can never produce Ctrl/Cmd+K itself, so nothing
  is stolen from a composer, and the guard still keeps plain character chords
  out of one.

Global chords while a modal surface is up
  Alt+H mid-form unmounted the dialog being filled in, because the dialog check
  only guarded `?`. Every global chord now stops at an open `aria-modal` element
  — the precise line between modal surfaces (`ui/Modal`, the canvas covers, the
  side panel, the celebration overlays) and the deliberately non-modal overlays
  (the buddy dock, the popover-style dialogs) whose whole point is living
  alongside the page. Keying off `role="dialog"` alone would freeze every chord
  whenever the buddy dock sat open, which is most of a session.

Denied chords are still owned by the app
  `canAccessRoute` returned before `preventDefault`, so a hire pressing Alt+P
  had the keystroke fall through to whatever else was listening, browser
  included. A matched chord is now consumed first and only then discarded — the
  "spoken for either way" the comment always claimed.

Alt+, reaches macOS
  NAVIGATE_SETTINGS matched the character alone, and macOS turns Option+, into a
  literal "≤" — the chord was dead there. A shortcut may now declare both a
  `code` and a `key` and either spelling answers: the character where the layout
  produces it, the physical key where a modifier rewrites it. The AZERTY note
  gets a correction too — the comma moves to the `KeyM` position there, which is
  exactly why the character has to stay the primary spelling.

Tests cover each fix at the level it broke: a bubbling keypress at an input for
`allowInInput`, a modal-vs-popover stand-in for the surface guard, the
`!defaultPrevented` observable for key consumption, and the "≤"/`Comma` pair
for the both-spellings matcher.
… ChatPage

The second half of the #276 review: places where the registry described
behaviour it did not own, and two accessibility gaps in what it renders.

ChatPage answers FOCUS_COMPOSER_SHORTCUT through useShortcutListener now
  The registry row described a listener ChatPage kept privately, with its own
  copy of the typing guard — editing the item changed nothing. The item is
  exported (barrel included) and ChatPage listens through `useShortcutListener`,
  so the chord, the guard and the help row read one definition.

The chord rides in the accessible name, not only in the title
  `aria-label` wins the accessible-name computation, so the Settings button and
  the project switcher trigger announced without their chords while the `title`
  reached only the mouse tooltip. Both now carry the chord inside their
  `aria-label`; sidebar links keep the `title` arrangement, their name already
  comes from their content.

Help dialog columns balance at two columns
  Navigation holds the left column alone and Actions + General stack beside it,
  instead of a flat map that left the fourth grid cell empty under a lone
  General on every screen wide enough for two columns.

KEY_LABELS loses its speculative rows
  Only `Escape` is ever looked up: character chords are labelled from their own
  `key`, and no registered chord uses `Period`/`Semicolon`/`Backquote`/`Space`
  or a digit code. Deleted rather than kept "just in case" — a wrong label in
  the help view is worse than a plain one, and entries can come back the day a
  chord actually needs them.
@daniilperkin
daniilperkin marked this pull request as ready for review September 28, 2026 13:38
@daniilperkin
daniilperkin requested review from DavidLeuter and kiranfin and a lite review from Copilot and removed request for Copilot September 28, 2026 13:39

@kiranfin kiranfin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

The registry as a single source for listener, hint and help dialog is a good structure, and isShortcutPress (AltGr refusal, auto-repeat guard, key for ?, both spellings for Alt+,) is well thought through. The tests for the registry and the modal are thorough. The main problem is the modal guard: it currently disables every global chord on the Board, because a closed drawer still counts as an open modal. The remaining concerns are about chords acting while an overlay is open, text-field detection, and the old Ctrl+K behaviour changing quietly.

🔴 Should be resolved before merge

1. The modal guard treats a closed, always-mounted SidePanel as an open modal, so no global chord works on the Board (src/features/shortcuts/hooks/useGlobalShortcuts.ts:18,57, src/components/ui/SidePanel.tsx:195, src/pages/BoardPage.tsx:1349)
useGlobalShortcuts returns as soon as document.querySelector('[aria-modal="true"]') matches anything. SidePanel sets aria-modal="true" unconditionally and is kept mounted while closed so the backdrop can fade (it is only aria-hidden and inert then). BoardPage mounts <BoardChainPanel> permanently, and that always renders a SidePanel (isOpen={cardId !== null && subject !== null}). On the Board the selector therefore always matches, and every scope: "global" chord (Alt+H/B/C/U/K/P/, and ?) is swallowed (preventDefault runs before the check) without doing anything. Any other page that keeps a SidePanel or DetailsSideDrawer mounted while closed has the same problem; only drawers wrapped in PanelPresence (for example the Starter Work issues sheet) are safe.
Suggested fix: render aria-modal on SidePanel only while open (aria-modal={isOpen ? "true" : undefined}; a closed, aria-hidden panel is not a modal anyway), and make the selector defensive as well: [aria-modal="true"]:not([aria-hidden="true"]):not([inert]). Add a test that mounts a closed SidePanel and asserts a navigation chord still works.

🟡 Worth a look

2. Only scope: "global" chords respect an open modal, so surface chords still act behind or on top of one (useGlobalShortcuts.ts:46-57, src/features/shortcuts/hooks/useShortcutListener.ts:20-30)
The aria-modal guard lives only in useGlobalShortcuts. useShortcutListener has no such check, so Ctrl/Cmd+K, Alt+N, Alt+S and / all fire while a dialog is open. The concrete case is the one the guard's own comment wants to prevent: open the help with ?, press Ctrl+K, and the project switcher opens on top of it. ui/Modal registers its Escape handler on document per open modal, so one Escape closes both. Alt+N behind a modal starts a new conversation under the dialog in the same way. Either apply the modal check inside useShortcutListener (with an opt-out for Ctrl/Cmd+K if opening the switcher over a dialog is intended) or document the split explicitly.

3. Text-field detection is broader than "typing", so chords die on checkboxes, radios and sliders (src/features/easter-eggs/lib/keyTargets.ts, used by isShortcutPress)
isTypingTarget matches every INPUT, not only text-like ones. After clicking a checkbox, radio button or range slider, focus stays on that input and Alt+… chords and ? are ignored although nothing is being typed. The helper was written for the easter-egg games, where that was fine. For the shortcuts, restrict the guard to text-like input types (text, search, email, url, password, number, tel, and so on) plus textarea and contenteditable. A <select> is currently not treated as a text field, which is fine.

4. Ctrl/Cmd+K moves from event.key to event.code, which changes behaviour for non-QWERTY layouts (src/features/shortcuts/shortcuts.ts, SWITCH_PROJECT_SHORTCUT, and ProjectSwitcher.tsx)
The old handler matched the produced character k. The registry now matches code: "KeyK". On Dvorak or Colemak the key labelled K is elsewhere, so the advertised "Ctrl + K" stops working, and the physical KeyK position triggers the switcher (and swallows the event) for whatever letter sits there. The same applies to all the Alt+letter chords, but there it is a new feature, while for Ctrl+K it is a regression of an existing shortcut. Consider declaring both code and key for it (the matcher already accepts either), the way Alt+, does.

5. Alt+S toggles the mobile drawer state on desktop widths too (src/components/layout/SideBar.tsx:476-480)
The listener flips isMobileSidebarOpen regardless of viewport. On a wide screen nothing visible happens, but the state is now true, so resizing below lg shows the drawer already open with its overlay. The chord is also preventDefaulted for nothing on desktop (it is the History menu accelerator in Firefox). Gate the listener on the drawer actually being reachable (a matchMedia check, or only when the header button is displayed), or leave it out of the help until #244 exists.

6. Modifier handling is looser than the docs say for key-matched chords (src/features/shortcuts/shortcuts.ts:321)
if (!shortcut.key && …) skips the Shift check for every chord that declares a key. That is intended for ?, but it also applies to Alt+, (which declares key: ","), so Alt+Shift+, navigates to Settings. Harmless in practice, but the comment above the check only justifies it for ?. Restrict the Shift exemption to chords without altKey/ctrlOrMeta, or state the wider behaviour.

🟢 Nits / cleanup

  • Behaviour with overlays, for the record. Modal or SidePanel open: global chords are blocked as intended (see finding 2 for the surface chords). Buddy dock: not modal on purpose, chords work. Mobile sidebar drawer: not modal, chords work, but a navigation chord changes the route and leaves the drawer and its overlay open, because only link clicks call onNavigate (SideBar.tsx:468). Closing the drawer on pathname change would fix it.
  • A ui/Modal that is playing its exit animation stays in the DOM through AnimatePresence and keeps aria-modal="true", so chords are briefly blocked right after closing a dialog. Short, but it will feel like a dropped keypress if someone presses a chord immediately after Escape.
  • useGlobalShortcuts calls preventDefault() before the modal and access checks. This is documented and reasonable, but it also means a PM-only chord like Alt+P is silently swallowed for a hire with no feedback. Fine to keep, maybe worth a line in the help ("only for roles that can open it").
  • Tests: there is no case for a closed SidePanel in the DOM (finding 1), a surface chord while a modal is open (finding 2), a chord with focus on a checkbox or radio (finding 3), Ctrl+K on a layout where key and code differ (finding 4), or Alt+S at desktop width (finding 5).

Overall this is clean and well documented, but finding 1 makes the feature unusable on the Board and possibly on other pages, so I would treat it as a blocker. Findings 2 and 3 are worth fixing here as well, since they concern the same guard. Findings 4 to 6 can be accepted as follow-ups.

Found in review of #276: the guard asked "is any aria-modal element in the
document?" — and `ui/SidePanel` keeps a closed panel mounted so its backdrop
can fade. It was `aria-hidden` and `inert`, but the selector did not care, and
the Board mounts one for the whole visit. Every global chord (Alt+H/B/C/U/K/P/,
and `?`) was matched, preventDefaulted and then dropped for as long as the page
stayed open — silent, total, on the page people live in.

Two layers, because either alone could rot:

- `ui/SidePanel` now renders `aria-modal` only while it is actually open. A
  closed, inert panel is not a modal — saying so was wrong before it was ever
  harmful.
- The guard moved to `lib/modalSurface.ts` and its selector is defensive as
  well: `[aria-modal="true"]:not([aria-hidden="true"]):not([inert])`.

The same review also showed the guard covered only the global chords, so
Ctrl/Cmd+K could still stack the project switcher on the help dialog, and Alt+N
could start a conversation under a dialog still asking its question. The check
now lives in `useShortcutListener` too — one rule, both listeners, no opt-outs.

Two smaller findings in the matcher:

- The typing guard used the games' `isTypingTarget`, which calls every `<input>`
  a text field. Focus parked on a settings checkbox or slider silenced the
  page's chords until focus moved on. Shortcuts now use a stricter
  `isTextEntryTarget` (text-like types, textarea, contenteditable); the games
  keep the wider predicate, because arrows belong to a focused radio group and
  Space to a focused switch.
- Shift was exempt for every chord with a `key`, not only bare character chords
  like `?`, so `Alt+Shift+,` was also `Alt+,`. The exemption now requires a
  chord with no modifiers of its own.

And the switcher chord gets its character spelling back: the pre-registry
listener matched the produced `k`; `code: "KeyK"` alone answers whichever
letter now sits at that position on Dvorak/Colemak — and swallows it. Declaring
both (the matcher's either-spelling rule) restores the old behaviour without
losing the layout-proof code; the label still prints `Ctrl + K`.

Tests: a closed panel in the document (the real SidePanel's rendered shape plus
a defensive stand-in), a surface chord with a dialog open, focus on
checkbox/radio/range vs text-like inputs, the Dvorak key/code split, and the
tightened Shift rule.
…ndows

The tail of the #276 review — chords and attributes acting on things that are
not there.

Alt+S listened at every width, but the drawer only exists below `lg`. On a wide
screen the chord flipped state nobody could see and left it flipped, so the
next resize opened the drawer uninvited — and on Firefox, where Alt+S is the
History menu, the swallow was taken from the browser for a no-op. The listener
now runs only while the drawer is reachable, so above `lg` the keystroke falls
through to the browser instead of being consumed.

A navigation chord moves the route without any link being clicked, so nothing
ran `onNavigate` and the mobile drawer stayed open — with its overlay — over
the new page. The drawer now closes when the pathname changes, deferred to a
microtask to stay clear of `react-hooks/set-state-in-effect`.

`ui/Modal` now drops `aria-modal` for the length of its exit animation: during
the fade the node is still in the DOM (AnimatePresence), and a stale
`aria-modal` kept global chords dead for exactly that window — a dropped
keypress for whoever types fast right after Escape. `ui/SidePanel` got the same
treatment in the previous commit, for the harder reason.

Tests: Alt+S at desktop width (event falls through unconsumed, drawer state
untouched, viewport mock restored for the rest of the suite) and the drawer
closing on a chord-style route change.

@kiranfin kiranfin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review

Checked the two new commits against my previous findings. All of them are addressed:

  • Board blocker: SidePanel and Modal only set aria-modal while open, and the shared selector in modalSurface.ts additionally ignores aria-hidden/inert overlays. Both the cause and the guard are covered.
  • Surface chords (Ctrl/Cmd+K, Alt+N, Alt+S, /) now stand down behind an open modal through useShortcutListener.
  • isTextEntryTarget no longer treats checkboxes, radios and sliders as text fields.
  • Ctrl/Cmd+K declares code and key again, and Alt+Shift+, no longer matches Alt+,.
  • Alt+S only listens below lg, and the drawer closes on route change.

tsc -b and eslint on the touched files are clean. I could not run the new SideBar tests locally (localStorage is undefined in my Node 26 environment, the same tests fail on dev), so I have not verified those.

Small remaining nits, none blocking:

  • TEXT_INPUT_TYPES does not include date, time and datetime-local. The date fields in ChatComposer would let / focus the composer while someone is typing a date.
  • Ctrl/Cmd+K still also answers on the physical KeyK position for Dvorak/Colemak. It works on the labelled key now, so this is cosmetic.
  • The Promise.resolve().then(...) in the SideBar effect only sidesteps react-hooks/set-state-in-effect. The render-time state pattern used elsewhere in the repo would be cleaner.

@daniilperkin
daniilperkin merged commit 2d73b5f into dev Sep 29, 2026
4 checks passed
@daniilperkin
daniilperkin deleted the feature/243-app-wide-keyboard-shortcuts branch September 29, 2026 13:58
daniilperkin added a commit that referenced this pull request Sep 29, 2026
…y-escalation-preview-edit (#235)

David's second backmerge. `311` now carries dev's app-wide keyboard-shortcut
work (#276, #268, and the focus/shortcut fixes that followed) plus the
sidebar/modal touches that came with them — a **clean merge, no conflicts**:
none of those commits touch the buddy files #235 reworks, so our wiring,
the card's own dismissal call sites and the reset-site clears all stay as
they were after e0d1d31.

Contents: 26 files, +1,867/−83, dominated by the new
`features/shortcuts/` registry, its modal and their tests.

Nothing of ours was rewritten for this one — the resolution work of the
previous backmerge (their files + our wiring, the memo-safe row props, both
reset behaviours) already meets this base.

Gate on the merged tree — `npm run try` green on Node 22: format:check,
build (`tsc -b` included), lint; unit 344 files / 3321 tests; a11y 57 files
/ 78 tests. Buddy + a11y + the incoming shortcuts suite alone: 87 files /
307 tests, so #235's own suites still pass against their memoised rows and
the dismissal API.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add keyboard shortcuts app-wide, wherever they make sense

2 participants