Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .cursor/rules/initialize-state-and-variables.mdc
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
---
description: Initialize variables and NgRx state with concrete defaults
globs: **/*.{ts,tsx}
alwaysApply: false
description: Initialize variables and NgRx state with concrete defaults; avoid optional chaining smell
alwaysApply: true
---

# Initialize variables and NgRx state

Initialize primitives and objects at declaration whenever a sensible default exists. The goal is typed, predictable values—not defensive `?.` and `??` scattered through selectors, templates, and components.

Treat widespread optional chaining (`?.`) and nullish coalescing (`??`) as a code smell: they often hide missing initialization or the wrong selector. Prefer concrete defaults and explicit control flow (`if` / early return) so optionality is intentional, not lazy.

## General TypeScript

- Give local variables an initial value when one is known at declaration time.
Expand Down
18 changes: 18 additions & 0 deletions .cursor/rules/max-function-parameters.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
description: Prefer options objects when a function needs more than three parameters
alwaysApply: true
---

# Function parameters

Functions and methods with more than three parameters are hard to maintain. Prefer an options/payload object once the call needs four or more values.

```typescript
// ❌ BAD
createKeymapWithMacroAssignment(abbr, name, layerId, moduleId, keyId, macroId)

// ✅ GOOD
createKeymapWithMacroAssignment({ abbreviation, name, layerId, moduleId, keyId, macroId })
```

Three-or-fewer scalar params are fine when the meaning stays obvious at the call site.
29 changes: 29 additions & 0 deletions .cursor/rules/prefer-existing-styles.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
description: Prefer Bootstrap and existing styles over new custom CSS
alwaysApply: true
---

# Prefer existing styles

Do not add custom CSS when Bootstrap utilities or existing shared styles already cover spacing, typography, and layout.

## Prefer

- Bootstrap utilities: `my-2`, `mb-3`, `me-1`, `small`, `text-nowrap`, `float-end`, …
- Patterns already used nearby (e.g. `back-to` uses `class="my-2"`)

## Avoid

- New BEM blocks for one-off spacing/font-size/weight that utilities already express
- Growing component SCSS with unexplained overrides that later confuse deletions

```html
<!-- ❌ BAD -->
<div class="macro__assignments">…</div>
<!-- plus .macro__assignments { font-size: 0.875rem; padding-top: 1.25em; } -->

<!-- ✅ GOOD -->
<div class="my-2 small">…</div>
```

Only add custom CSS when no existing utility/shared rule fits, and keep it minimal.
34 changes: 34 additions & 0 deletions .cursor/rules/smart-dumb-components.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
description: Prefer smart containers / dumb leaf components with store-side calculations
alwaysApply: true
---

# Smart / dumb components

Follow the smart/dumb (container/presentational) pattern already used in this codebase.

## Responsibilities

- **Store / selectors / smart parents**: derive view models, sort/filter/map domain data, combine streams.
- **Leaf components**: receive `@Input()`s, render them, emit `@Output()`s / dispatch obvious user intents. Avoid reading the store just to compute display data.

```typescript
// ❌ BAD — deep leaf component selects and calculates
class MacroHeaderComponent {
constructor(store: Store) {
store.select(getKeymaps).subscribe(keymaps => this.assignments = map(keymaps));
}
}

// ✅ GOOD — smart parent selects; leaf renders
// macro-edit (smart)
assignments$ = this.store.select(getSelectedMacroKeyAssignments);

// template
<macro-header [macro]="macro" [assignments]="assignments$ | async"></macro-header>

// macro-header (dumb regarding this data)
@Input() assignments: MacroKeyAssignmentViewModel[] = [];
```

Existing leaf components that already inject `Store` only to `dispatch` actions are acceptable when inherited; do not expand them with new selectors or calculation logic.
29 changes: 29 additions & 0 deletions .cursor/rules/uhk-package-boundaries.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
description: Keep UI and presentation code out of uhk-common
alwaysApply: true
---

# Package boundaries: uhk-common vs uhk-web

`uhk-common` is for shared, UI-agnostic domain logic (config serializers, USB helpers, pure data transforms). Do **not** put presentation-oriented helpers there.

## Belong in `uhk-web` (or uhk-agent), not `uhk-common`

- Display labels, breadcrumbs, menu grouping, view-model mappers
- Anything that formats data for Angular templates
- Code that only Agent UI consumers need, even if it looks "pure"

## Belong in `uhk-common`

- Config/device models and serialization
- Algorithms that both Electron main and renderer need without UI assumptions

```typescript
// ❌ BAD — lives in uhk-common only because tests were convenient there
export function findMacroKeyAssignments(...): MacroKeyAssignment[] { /* for macro page UI */ }

// ✅ GOOD — presentation helper next to the UI that uses it
// packages/uhk-web/src/app/util/find-macro-key-assignments.ts
```

If you want unit tests for UI helpers and `uhk-web` has no runner yet, still put the code in `uhk-web` and add tests there; do not grow UI surface area in `uhk-common` for convenience.
4 changes: 3 additions & 1 deletion packages/uhk-web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@
"server:renderer": "ng build --project=uhk-renderer --watch",
"lint": "run-p -snc lint:*",
"lint:ng": "ng lint",
"lint:style": "stylelint \"src/**/*.scss\""
"lint:style": "stylelint \"src/**/*.scss\"",
"test": "node --loader=ts-node/esm --test \"**/*.test.ts\""
},
"private": true,
"type": "module",
"license": "See in LICENSE",
"devDependencies": {
"@angular-devkit/build-angular": "20.3.28",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@ import { Subscription } from 'rxjs';
standalone: false,
template: `
<div *ngIf="backUrl" class="my-2">
Back to <a [routerLink]="[backUrl]" [queryParams]="queryParams">{{backText}}</a>
Back to <a [routerLink]="[backUrl]" [queryParams]="queryParams">{{ backText }}</a>{{ backSuffix }}
</div>
`,
})
export class BackToComponent implements OnDestroy {
backUrl: string | undefined;
backText: string;
backSuffix = '';
queryParams = {};

private routeSubscription: Subscription;
Expand All @@ -25,6 +26,7 @@ export class BackToComponent implements OnDestroy {
const backUrl = new URL(params.backUrl as string, window.location.origin);
this.backUrl = backUrl.pathname;
this.backText = params.backText;
this.backSuffix = params.backSuffix || '';
this.queryParams = {};
for (const key of backUrl.searchParams.keys()) {
this.queryParams[key] = backUrl.searchParams.get(key);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ export class KeymapAddComponent implements OnDestroy, OnInit {
navigateToModuleSettings(moduleId: number): void {
this.store.dispatch(new NavigateToModuleSettings({
backUrl: `/add-keymap/${encodeURIComponent(this.keymap.abbreviation)}`,
backText: `new "${this.keymap.name}" keymap`,
backText: `new ${this.keymap.name}`,
backSuffix: ' keymap',
moduleId
}));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,8 @@ export class KeymapEditComponent implements OnDestroy {
navigateToModuleSettings(moduleId: number): void {
this.store.dispatch(new NavigateToModuleSettings({
backUrl: `/keymap/${encodeURIComponent(this.keymap.abbreviation)}?layer=${this.selectedLayer}`,
backText: `"${this.keymap.name}" keymap`,
backText: this.keymap.name,
backSuffix: ' keymap',
moduleId,
}));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
[macro]="macro"
[isNew]="isNew$ | async"
[maxMacroCountReached]="maxMacroCountReached$ | async"
[assignments]="assignments"
></macro-header>
<macro-list
[macro]="macro"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,24 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, ViewChild } from '@angular/core';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { faCaretDown } from '@fortawesome/free-solid-svg-icons';
import { SplitGutterInteractionEvent } from 'angular-split';
import { isEqual } from 'lodash';
import { Macro, MacroAction } from 'uhk-common';

import { Observable, Subscription } from 'rxjs';
import { combineLatest, Observable, Subscription } from 'rxjs';

import {
AddMacroActionAction,
DuplicateMacroActionAction,
DeleteMacroActionAction,
ReorderMacroActionAction,
SaveMacroActionAction,
SelectMacroActionAction
} from '../../../store/actions/macro';
DuplicateMacroActionPayload,
MacroKeyAssignmentViewModel,
SelectedMacroAction,
SelectedMacroActionIdModel
} from '../../../models';
import { MapperService } from '../../../services/mapper.service';
import {
AppState,
getDefaultUserConfiguration,
getKeymaps,
getSelectedMacro,
getSelectedMacroAction,
getSmartMacroPanelVisibility,
Expand All @@ -28,9 +29,16 @@ import {
maxMacroCountReached,
selectSmartMacroDocUrl
} from '../../../store';

import { DuplicateMacroActionPayload, SelectedMacroAction, SelectedMacroActionIdModel } from '../../../models';
import {
AddMacroActionAction,
DeleteMacroActionAction,
DuplicateMacroActionAction,
ReorderMacroActionAction,
SaveMacroActionAction,
SelectMacroActionAction
} from '../../../store/actions/macro';
import { PanelSizeChangedAction, TogglePanelVisibilityAction } from '../../../store/actions/smart-macro-doc.action';
import { buildMacroKeyAssignmentViewModels } from '../../../util/build-macro-key-assignment-view-models';

@Component({
selector: 'macro-edit',
Expand All @@ -45,6 +53,7 @@ import { PanelSizeChangedAction, TogglePanelVisibilityAction } from '../../../st
export class MacroEditComponent implements OnDestroy {
faCaretDown = faCaretDown;
macro: Macro;
assignments: MacroKeyAssignmentViewModel[] = [];
isNew$: Observable<boolean>;
macroId: number;
macroPlaybackSupported$: Observable<boolean>;
Expand All @@ -59,20 +68,31 @@ export class MacroEditComponent implements OnDestroy {
left: 100,
right: 0
};

private backUrl: string;
private backUrlText: string;
private subscriptions = new Subscription();

constructor(private store: Store<AppState>,
private cdRef: ChangeDetectorRef,
private route: ActivatedRoute,
private router: Router) {
this.subscriptions.add(store.select(getSelectedMacro)
.subscribe((macro: Macro) => {
private router: Router,
private mapper: MapperService) {
this.subscriptions.add(
combineLatest([
store.select(getSelectedMacro),
store.select(getKeymaps),
store.select(getDefaultUserConfiguration),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We load the default configuration into the store when opening the Add Keymap menu.
I do it to save some memory because it is not frequently used feature. We could load it and app start and refresh when new device is connected, but I haven't did it, because little bit confusing me to see the macro assigned on a non qwerty keymap and we so the qwerty key name.
For example I assign the macro to the Dvorak O that is Qwerty S and I see Dvorak for PC -> Base -> S instead of Dvorak for PC -> Base -> O

]).subscribe(([macro, keymaps, defaultUserConfiguration]) => {
this.macro = macro;
this.assignments = macro
? buildMacroKeyAssignmentViewModels({
keymaps,
macroId: macro.id,
defaultUserConfiguration,
mapper: this.mapper,
})
: [];
this.cdRef.markForCheck();
}));
})
);

this.isNew$ = this.store.select(isSelectedMacroNew);
this.isMacroCommandSupported$ = this.store.select(isMacroCommandSupported);
Expand All @@ -90,9 +110,6 @@ export class MacroEditComponent implements OnDestroy {
this.smartMacroDocUrl$ = store.select(selectSmartMacroDocUrl);
this.smartMacroPanelVisibility$ = store.select(getSmartMacroPanelVisibility);
this.subscriptions.add(this.route.queryParams.subscribe(params => {
this.backUrl = params.backUrl;
this.backUrlText = params.backText;

if (params.actionIndex) {
if (params.actionIndex === 'new') {
this.selectedMacroActionIdModel = {
Expand Down Expand Up @@ -150,8 +167,6 @@ export class MacroEditComponent implements OnDestroy {
this.router.navigate([], {
queryParams: {
actionIndex: model?.id,
backText: this.backUrlText,
backUrl: this.backUrl,
inlineEdit: model?.inlineEdit
}
});
Expand All @@ -173,10 +188,7 @@ export class MacroEditComponent implements OnDestroy {
private hideActiveEditor(): void {
if (!this.selectedMacroActionIdModel?.inlineEdit) {
this.router.navigate([], {
queryParams: {
backText: this.backUrlText,
backUrl: this.backUrl,
},
queryParams: {},
});
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,11 @@
placement="bottom-right"
(click)="duplicateMacro()"></fa-icon>
</uhk-header>
<back-to/>
<div *ngIf="assignments.length" class="mt-3 mb-2">
<span class="me-1">Used on:</span>
<ng-container *ngFor="let assignment of assignments; let last = last">
<a class="text-nowrap"
[routerLink]="['/keymap', assignment.keymapAbbreviation]"
[queryParams]="{ layer: assignment.layerId, module: assignment.moduleId, key: assignment.keyId }">{{ assignment.label }}</a><span *ngIf="!last">, </span>
</ng-container>
</div>
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

.macro {
&__remove {
font-size: 0.75em;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Macro, MAX_ALLOWED_MACROS_TOOLTIP } from 'uhk-common';

import { DuplicateMacroAction, EditMacroNameAction, RemoveMacroAction } from '../../../store/actions/macro';
import { AppState } from '../../../store';
import { MacroKeyAssignmentViewModel } from '../../../models';
import * as util from '../../../util';
import { AutoGrowInputComponent } from '../../auto-grow-input';

Expand All @@ -26,6 +27,7 @@ export class MacroHeaderComponent implements OnChanges {
@Input() macro: Macro;
@Input() isNew: boolean;
@Input() maxMacroCountReached: boolean;
@Input() assignments: MacroKeyAssignmentViewModel[] = [];

@ViewChild(AutoGrowInputComponent, { static: true }) macroName: AutoGrowInputComponent;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@
}

.col-controls {
min-width: 130px;
min-width: 145px;
text-align: right;
white-space: nowrap;

&.arguments {
fa-icon {
Expand Down
1 change: 1 addition & 0 deletions packages/uhk-web/src/app/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export * from './firmware-upgrade-steps';
export * from './last-edited-key';
export * from './layer-option';
export * from './load-user-configuration-from-file-payload';
export * from './macro-key-assignment-view-model';
export * from './macro-menu-item';
export * from './modify-color-of-backlighting-color-palette-payload';
export * from './navigation-payload';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { LayerName } from 'uhk-common';

export interface MacroKeyAssignmentViewModel {
keymapAbbreviation: string;
layerId: LayerName;
moduleId: number;
keyId: number;
label: string;
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export interface NavigateToModuleSettingsPayload {
backSuffix?: string;
backText: string;
backUrl: string;
moduleId: number;
Expand Down
Loading