Compare commits
85 Commits
matiss/bro
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32498b0026 | ||
|
|
f5896fe770 | ||
|
|
dfb6612333 | ||
|
|
fcf42bef63 | ||
|
|
ee2ed81c0a | ||
|
|
0d6742664b | ||
|
|
37e807f161 | ||
|
|
c9abc62b3a | ||
|
|
234f257260 | ||
|
|
49d583e4ad | ||
|
|
460cb7b6cd | ||
|
|
1eb68c8e19 | ||
|
|
2ec592c2d6 | ||
|
|
19edbeb5c2 | ||
|
|
d8b7e45aaa | ||
|
|
f71249f510 | ||
|
|
ebf8e985ad | ||
|
|
690e2d0871 | ||
|
|
91faa2f7f1 | ||
|
|
4888d3faed | ||
|
|
3091320719 | ||
|
|
72304c6182 | ||
|
|
e4903ca6e3 | ||
|
|
d768cfa508 | ||
|
|
e0ed53c4af | ||
|
|
c3e3a258e0 | ||
|
|
f55a42d817 | ||
|
|
331aafda30 | ||
|
|
be95b9a3d5 | ||
|
|
8eadd09bfc | ||
|
|
414aa95fd8 | ||
|
|
19ed2423d4 | ||
|
|
4986e433a5 | ||
|
|
9a3415adab | ||
|
|
bcfefde4ad | ||
|
|
c4514b1fe6 | ||
|
|
7a5bfe7c20 | ||
|
|
323403b5f7 | ||
|
|
a35fdf4d18 | ||
|
|
d0a72f10b6 | ||
|
|
b651238ad2 | ||
|
|
8d1f0cf1de | ||
|
|
b50c45c9c9 | ||
|
|
8f2369d5b8 | ||
|
|
713ac88fee | ||
|
|
bcbcd6ad9f | ||
|
|
c9a0ffa91c | ||
|
|
6a9df6562c | ||
|
|
d4144f4b9c | ||
|
|
176336e7f3 | ||
|
|
de0f4e9440 | ||
|
|
65dee4c627 | ||
|
|
9376217c5e | ||
|
|
e6e108ffbd | ||
|
|
517b1b4a81 | ||
|
|
cdae09e554 | ||
|
|
59233c4786 | ||
|
|
502babd310 | ||
|
|
3c302b3af9 | ||
|
|
4c2d3e6998 | ||
|
|
e79d91b000 | ||
|
|
2457a0b454 | ||
|
|
84a1d12dae | ||
|
|
b50d48a31a | ||
|
|
9289932af9 | ||
|
|
fe624f0158 | ||
|
|
7443886856 | ||
|
|
58b1420c60 | ||
|
|
7035b32f26 | ||
|
|
ee0e7ed3e0 | ||
|
|
b6452f930b | ||
|
|
bf814a6873 | ||
|
|
81c5dd347f | ||
|
|
cd87d5a899 | ||
|
|
f5e1d5eab4 | ||
|
|
6bf119786c | ||
|
|
14d4c7748d | ||
|
|
edf61a477a | ||
|
|
0724f7eaef | ||
|
|
d01d0eacb8 | ||
|
|
bb70074f35 | ||
|
|
5860b95c9c | ||
|
|
a58bac6de6 | ||
|
|
d01f38d480 | ||
|
|
4b1f127910 |
@@ -3,7 +3,7 @@ issue_enrichment:
|
||||
enabled: false
|
||||
reviews:
|
||||
request_changes_workflow: true
|
||||
review_status: true
|
||||
review_status: false
|
||||
high_level_summary: false
|
||||
finishing_touches:
|
||||
docstrings:
|
||||
@@ -12,16 +12,6 @@ reviews:
|
||||
docstrings:
|
||||
mode: off
|
||||
enabled: false
|
||||
custom_checks:
|
||||
- mode: error
|
||||
name: 'settings'
|
||||
instructions: 'Every addition of a new setting toggle must be thoroughly evaluated against the core design principles of Actual. The settings screen is reserved for essential and foundational options only — do not introduce settings for minor UI adjustments such as sizes, paddings, colors, or margins. Prioritize preserving a simple and uncluttered user experience. Users proposing new settings must confirm in a reply to the Coderabbit comment that they have reviewed and ensured alignment with these principles. Excessive or granular UI options increase code complexity and risk confusing users, and are generally not permitted.'
|
||||
- mode: error
|
||||
name: 'linting'
|
||||
instructions: 'Do not allow any oxlint-disable lines.'
|
||||
- mode: error
|
||||
name: 'typecheck'
|
||||
instructions: 'Do not allow creating new components or utilities with the @ts-strict-ignore comment.'
|
||||
labeling_instructions:
|
||||
- label: 'suspect ai generated'
|
||||
instructions: 'This issue or PR is suspected to be generated by AI.'
|
||||
|
||||
@@ -33,11 +33,11 @@ try {
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'You are categorizing pull requests for release notes. You must respond with exactly one of these categories: "Features", "Enhancements", "Bugfix", or "Maintenance". No other text or explanation.',
|
||||
'You are categorizing pull requests for release notes. You must respond with exactly one of these categories: "Features", "Enhancements", "Bugfixes", or "Maintenance". No other text or explanation.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `PR Title: ${prDetails.title}\n\nGenerated Summary: ${summaryData.summary}\n\nCodeRabbit Analysis:\n${commentBody}\n\nCategories:\n- Features: New functionality or capabilities\n- Bugfix: Fixes for broken or incorrect behavior\n- Enhancements: Improvements to existing functionality\n- Maintenance: Code cleanup, refactoring, dependencies, etc.\n\nWhat category does this PR belong to?`,
|
||||
content: `PR Title: ${prDetails.title}\n\nGenerated Summary: ${summaryData.summary}\n\nCodeRabbit Analysis:\n${commentBody}\n\nCategories:\n- Features: New functionality or capabilities\n- Bugfixes: Fixes for broken or incorrect behavior\n- Enhancements: Improvements to existing functionality\n- Maintenance: Code cleanup, refactoring, dependencies, etc.\n\nWhat category does this PR belong to?`,
|
||||
},
|
||||
],
|
||||
max_tokens: 10,
|
||||
@@ -86,7 +86,7 @@ try {
|
||||
// Validate the category response
|
||||
const validCategories = [
|
||||
'Features',
|
||||
'Bugfix',
|
||||
'Bugfixes',
|
||||
'Enhancements',
|
||||
'Maintenance',
|
||||
];
|
||||
|
||||
9
.github/actions/docs-spelling/expect.txt
vendored
@@ -5,6 +5,7 @@ Activo
|
||||
AESUDEF
|
||||
ALZEY
|
||||
Anglais
|
||||
ANZ
|
||||
aql
|
||||
AUR
|
||||
Authentik
|
||||
@@ -40,13 +41,13 @@ COBADEFF
|
||||
CODEOWNERS
|
||||
COEP
|
||||
commerzbank
|
||||
COOP
|
||||
Copiar
|
||||
COUNTA
|
||||
COUNTBLANK
|
||||
countif
|
||||
CREGBEBB
|
||||
crt
|
||||
CZK
|
||||
Danske
|
||||
datadir
|
||||
DATEDIF
|
||||
@@ -68,7 +69,6 @@ Fineco
|
||||
Finicity
|
||||
Fintro
|
||||
Finverse
|
||||
flathub
|
||||
Flathub
|
||||
FORTUNEO
|
||||
FTNOFRP
|
||||
@@ -83,6 +83,7 @@ HABAL
|
||||
Hampel
|
||||
HELADEF
|
||||
HLOOKUP
|
||||
HUF
|
||||
IFERROR
|
||||
IFNA
|
||||
INDUSTRIEL
|
||||
@@ -116,12 +117,14 @@ LKR
|
||||
MAXA
|
||||
mbank
|
||||
mdc
|
||||
metainfo
|
||||
modals
|
||||
Moldovan
|
||||
murmurhash
|
||||
NETWORKDAYS
|
||||
nginx
|
||||
OIDC
|
||||
Okabe
|
||||
overbudgeted
|
||||
overbudgeting
|
||||
oxc
|
||||
@@ -134,7 +137,6 @@ prefs
|
||||
Primoco
|
||||
Priotecs
|
||||
proactively
|
||||
pwa
|
||||
Qatari
|
||||
QNTOFRP
|
||||
QONTO
|
||||
@@ -172,6 +174,7 @@ touchscreen
|
||||
triaging
|
||||
UAH
|
||||
ubuntu
|
||||
undici
|
||||
userinfo
|
||||
Userscripts
|
||||
UZS
|
||||
|
||||
2
.github/scripts/count-points.mjs
vendored
@@ -278,7 +278,7 @@ async function countContributorPoints() {
|
||||
|
||||
if (
|
||||
event.event === 'closed' &&
|
||||
event.state_reason === 'not_planned'
|
||||
['not_planned', 'duplicate'].includes(event.state_reason)
|
||||
) {
|
||||
const closer = event.actor.login;
|
||||
const userStats = stats.get(closer);
|
||||
|
||||
33
.github/workflows/docs-release.yml
vendored
@@ -1,33 +0,0 @@
|
||||
name: Release Docs to Github Pages
|
||||
|
||||
# Release docs on every push to master
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- 'packages/docs/**'
|
||||
- '.github/workflows/docs-spelling.yml'
|
||||
- '.github/actions/docs-spelling/**'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy Docs
|
||||
if: github.event.repository.fork == false
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
|
||||
- name: Docusaurus Deploy
|
||||
run: |
|
||||
GIT_USER=MikesGlitch \
|
||||
GIT_PASS=${{ secrets.DOCS_GITHUB_PAGES_DEPLOY }} \
|
||||
GIT_USER_NAME=github-actions[bot] \
|
||||
GIT_USER_EMAIL=github-actions[bot]@users.noreply.github.com \
|
||||
yarn deploy:docs
|
||||
48
.github/workflows/fork-pr-welcome.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
name: Fork PR Welcome
|
||||
|
||||
##########################################################################################
|
||||
# WARNING! This workflow uses the 'pull_request_target' event. That means that it will #
|
||||
# always run in the context of the main actualbudget/actual repo, even if the PR is from #
|
||||
# a fork. This is necessary to get access to a GitHub token that can post a comment on #
|
||||
# the PR. Be VERY CAREFUL about adding things to this workflow, since forks can inject #
|
||||
# arbitrary code into their branch, and can pollute the artifacts we download. Arbitrary #
|
||||
# code execution in this workflow could lead to a compromise of the main repo. #
|
||||
##########################################################################################
|
||||
# See: https://securitylab.github.com/research/github-actions-preventing-pwn-requests #
|
||||
##########################################################################################
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
welcome:
|
||||
name: Post Welcome Message
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.pull_request.head.repo.full_name != github.repository
|
||||
steps:
|
||||
- name: Post welcome comment
|
||||
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
number: ${{ github.event.pull_request.number }}
|
||||
header: fork-pr-welcome
|
||||
hide_and_recreate: true
|
||||
hide_classify: OUTDATED
|
||||
message: |
|
||||
<!-- fork-pr-welcome -->
|
||||
👋 Hello contributor!
|
||||
|
||||
We would love to review your PR! Before we can do that, please make sure:
|
||||
|
||||
- ✅ All CI checks pass
|
||||
- ✅ The PR is moved from draft to open (if applicable)
|
||||
- ✅ The "[WIP]" prefix is removed from the PR title
|
||||
- ✅ All CodeRabbit code review comments are resolved (if you disagree with anything - reply to the bot with your reasoning so we can read through it). The bot will eventually approve the PR.
|
||||
|
||||
We do this to reduce the TOIL the core contributor team has to go through for each PR and to allow for speedy reviews and merges.
|
||||
|
||||
For more information, please see our [Contributing Guide](https://actualbudget.org/docs/contributing/).
|
||||
5
.github/workflows/generate-release-pr.yml
vendored
@@ -35,7 +35,10 @@ jobs:
|
||||
pkg="${packages[$key]}"
|
||||
|
||||
if [[ -n "${{ github.event.inputs.version }}" ]]; then
|
||||
version="${{ github.event.inputs.version }}"
|
||||
version=$(node ./packages/ci-actions/bin/get-next-package-version.js \
|
||||
--package-json "./packages/$pkg/package.json" \
|
||||
--version "${{ github.event.inputs.version }}" \
|
||||
--update)
|
||||
else
|
||||
version=$(node ./packages/ci-actions/bin/get-next-package-version.js \
|
||||
--package-json "./packages/$pkg/package.json" \
|
||||
|
||||
25
.github/workflows/pr-ai-label-cleanup.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
name: Remove 'suspect ai generated' label when 'AI generated' is present
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [labeled]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
remove-suspect-label:
|
||||
if: >-
|
||||
${{ contains(github.event.pull_request.labels.*.name, 'AI generated') &&
|
||||
contains(github.event.pull_request.labels.*.name, 'suspect ai generated') }}
|
||||
runs-on: ubuntu-slim
|
||||
steps:
|
||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
name: 'suspect ai generated'
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
name: Publish nightly npm packages
|
||||
|
||||
# Nightly npm packages are built daily
|
||||
# Nightly npm packages are built daily at midnight UTC
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
|
||||
6
.github/workflows/size-compare.yml
vendored
@@ -139,7 +139,8 @@ jobs:
|
||||
--head desktop-client=./head/web-stats.json \
|
||||
--head loot-core=./head/loot-core-stats.json \
|
||||
--head api=./head/api-stats.json \
|
||||
--identifier combined > bundle-stats-comment.md
|
||||
--identifier combined \
|
||||
--format pr-body > bundle-stats-comment.md
|
||||
- name: Post combined bundle stats comment
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -148,4 +149,5 @@ jobs:
|
||||
run: |
|
||||
node packages/ci-actions/bin/update-bundle-stats-comment.mjs \
|
||||
--comment-file bundle-stats-comment.md \
|
||||
--identifier '<!--- bundlestats-action-comment key:combined --->'
|
||||
--identifier combined \
|
||||
--target pr-body
|
||||
|
||||
3
.gitignore
vendored
@@ -76,3 +76,6 @@ build/
|
||||
|
||||
# Lage cache
|
||||
.lage/
|
||||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
|
||||
@@ -4,7 +4,31 @@
|
||||
"trailingComma": "all",
|
||||
"arrowParens": "avoid",
|
||||
"printWidth": 80,
|
||||
"ignorePatterns": [
|
||||
"packages/docs/*" // TOOD: fixme; temporary
|
||||
]
|
||||
"experimentalSortImports": {
|
||||
"groups": [
|
||||
"react",
|
||||
"builtin",
|
||||
"external",
|
||||
"loot-core",
|
||||
"parent",
|
||||
"sibling",
|
||||
"index",
|
||||
"desktop-client"
|
||||
],
|
||||
"customGroups": [
|
||||
{
|
||||
"groupName": "react",
|
||||
"elementNamePattern": ["react"]
|
||||
},
|
||||
{
|
||||
"groupName": "loot-core",
|
||||
"elementNamePattern": ["loot-core"]
|
||||
},
|
||||
{
|
||||
"groupName": "desktop-client",
|
||||
"elementNamePattern": ["@desktop-client"]
|
||||
}
|
||||
],
|
||||
"newlinesBetween": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,41 +18,11 @@
|
||||
"FS": "readonly"
|
||||
},
|
||||
"rules": {
|
||||
// TODO fix all these and re-enable
|
||||
"jsx-a11y/click-events-have-key-events": "off",
|
||||
"jsx-a11y/prefer-tag-over-role": "off",
|
||||
"jsx-a11y/tabindex-no-positive": "off",
|
||||
|
||||
// Import sorting
|
||||
// TODO replace once oxfmt supports this: https://github.com/oxc-project/oxc/issues/17076
|
||||
"perfectionist/sort-imports": [
|
||||
"perfectionist/sort-named-imports": [
|
||||
"warn",
|
||||
{
|
||||
"groups": [
|
||||
"react",
|
||||
"builtin",
|
||||
"external",
|
||||
"loot-core",
|
||||
"parent",
|
||||
"sibling",
|
||||
"index",
|
||||
"desktop-client"
|
||||
],
|
||||
"customGroups": [
|
||||
{
|
||||
"groupName": "react",
|
||||
"elementNamePattern": "^react(-.*)?$"
|
||||
},
|
||||
{
|
||||
"groupName": "loot-core",
|
||||
"elementNamePattern": "^loot-core"
|
||||
},
|
||||
{
|
||||
"groupName": "desktop-client",
|
||||
"elementNamePattern": "^@desktop-client"
|
||||
}
|
||||
],
|
||||
"newlinesBetween": "always"
|
||||
"groups": ["value-import", "type-import"]
|
||||
}
|
||||
],
|
||||
|
||||
@@ -376,55 +346,9 @@
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
// TODO: fix the issues in these files
|
||||
"files": [
|
||||
"packages/component-library/src/Menu.tsx",
|
||||
"packages/desktop-client/src/components/accounts/Account.jsx",
|
||||
"packages/desktop-client/src/components/accounts/MobileAccount.jsx",
|
||||
"packages/desktop-client/src/components/accounts/MobileAccounts.jsx",
|
||||
"packages/desktop-client/src/components/budget/BudgetCategories.jsx",
|
||||
"packages/desktop-client/src/components/budget/BudgetSummaries.tsx",
|
||||
"packages/desktop-client/src/components/budget/DynamicBudgetTable.tsx",
|
||||
"packages/desktop-client/src/components/budget/envelope/HoldMenu.tsx",
|
||||
"packages/desktop-client/src/components/budget/envelope/TransferMenu.tsx",
|
||||
"packages/desktop-client/src/components/budget/index.tsx",
|
||||
"packages/desktop-client/src/components/budget/MobileBudget.tsx",
|
||||
"packages/desktop-client/src/components/FinancesApp.tsx",
|
||||
"packages/desktop-client/src/components/GlobalKeys.ts",
|
||||
"packages/desktop-client/src/components/LoggedInUser.tsx",
|
||||
"packages/desktop-client/src/components/manager/ManagementApp.jsx",
|
||||
"packages/desktop-client/src/components/manager/subscribe/common.tsx",
|
||||
"packages/desktop-client/src/components/ManageRules.tsx",
|
||||
"packages/desktop-client/src/components/mobile/MobileAmountInput.jsx",
|
||||
"packages/desktop-client/src/components/mobile/MobileNavTabs.tsx",
|
||||
"packages/desktop-client/src/components/Modals.tsx",
|
||||
"packages/desktop-client/src/components/modals/EditRule.jsx",
|
||||
"packages/desktop-client/src/components/modals/ImportTransactions.jsx",
|
||||
"packages/desktop-client/src/components/modals/ImportTransactionsModal/ImportTransactionsModal.tsx",
|
||||
"packages/desktop-client/src/components/modals/MergeUnusedPayees.jsx",
|
||||
"packages/desktop-client/src/components/Notifications.tsx",
|
||||
"packages/desktop-client/src/components/payees/ManagePayees.jsx",
|
||||
"packages/desktop-client/src/components/payees/ManagePayeesWithData.jsx",
|
||||
"packages/desktop-client/src/components/payees/PayeeTable.tsx",
|
||||
"packages/desktop-client/src/components/reports/graphs/tableGraph/ReportTable.tsx",
|
||||
"packages/desktop-client/src/components/reports/graphs/tableGraph/ReportTableTotals.tsx",
|
||||
"packages/desktop-client/src/components/reports/reports/CashFlowCard.jsx",
|
||||
"packages/desktop-client/src/components/reports/reports/CustomReport.jsx",
|
||||
"packages/desktop-client/src/components/reports/reports/CustomReport.tsx",
|
||||
"packages/desktop-client/src/components/reports/reports/NetWorthCard.jsx",
|
||||
"packages/desktop-client/src/components/reports/SaveReportName.tsx",
|
||||
"packages/desktop-client/src/components/reports/useReport.ts",
|
||||
"packages/desktop-client/src/components/schedules/ScheduleDetails.jsx",
|
||||
"packages/desktop-client/src/components/schedules/ScheduleEditModal.tsx",
|
||||
"packages/desktop-client/src/components/schedules/SchedulesTable.tsx",
|
||||
"packages/desktop-client/src/components/select/DateSelect.tsx",
|
||||
"packages/desktop-client/src/components/sidebar/Tools.tsx",
|
||||
"packages/desktop-client/src/components/sort.tsx",
|
||||
"packages/desktop-client/src/hooks/useEffectAfterMount.ts",
|
||||
"packages/desktop-client/src/hooks/useQuery.ts"
|
||||
],
|
||||
"files": ["packages/desktop-electron/**/*"],
|
||||
"rules": {
|
||||
"react/exhaustive-deps": "off"
|
||||
"react/rules-of-hooks": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -259,6 +259,10 @@ Always run `yarn typecheck` before committing.
|
||||
- Generate i18n files: `yarn generate:i18n`
|
||||
- Custom ESLint rules enforce translation usage
|
||||
|
||||
### 5. Financial Number Typography
|
||||
|
||||
- Wrap standalone financial numbers with `FinancialText` or apply `styles.tnum` directly if wrapping is not possible
|
||||
|
||||
## Code Style & Conventions
|
||||
|
||||
### TypeScript Guidelines
|
||||
@@ -553,6 +557,10 @@ When creating pull requests:
|
||||
|
||||
- **AI-Generated PRs**: If you create a PR using AI assistance, add the **"AI generated"** label to the pull request. This helps maintainers understand the nature of the contribution.
|
||||
|
||||
## Code Review Guidelines
|
||||
|
||||
When performing code reviews (especially for LLM agents): **see [CODE_REVIEW_GUIDELINES.md](./CODE_REVIEW_GUIDELINES.md)** for specific guidelines.
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- **Bundle Size**: Check with rollup-plugin-visualizer
|
||||
|
||||
94
CODE_REVIEW_GUIDELINES.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# CODE_REVIEW_GUIDELINES.md - Guidelines for LLM Agents Performing Code Reviews
|
||||
|
||||
This document provides specific guidelines for LLM agents performing code reviews on the Actual Budget codebase. These guidelines help maintain code quality, consistency, and follow the project's design principles.
|
||||
|
||||
## Settings Proliferation
|
||||
|
||||
**Do NOT add new settings for every little UI tweak.**
|
||||
|
||||
Actual Budget follows a design philosophy that prioritizes simplicity and avoids settings bloat. Before introducing code that adds new settings:
|
||||
|
||||
- Consider if the UI tweak can be achieved through existing theme/design tokens
|
||||
- Evaluate whether the setting provides meaningful value to users
|
||||
- Check if the change aligns with Actual's design guidelines
|
||||
- Prefer hardcoded values or theme-based solutions over adding user-facing settings
|
||||
|
||||
## TypeScript Strict Mode Suppressions
|
||||
|
||||
**Do NOT approve code that adds new `@ts-strict-ignore` comments.**
|
||||
|
||||
The project uses strict TypeScript checking via `typescript-strict-plugin`. Adding `@ts-strict-ignore` comments undermines type safety. Instead, review should encourage:
|
||||
|
||||
- Fixing the underlying type issue
|
||||
- Using proper type definitions
|
||||
- Refactoring code to satisfy strict type checking
|
||||
- Only in exceptional cases, document why strict checking cannot be applied and seek alternative solutions
|
||||
|
||||
## Linter Suppressions
|
||||
|
||||
**Do NOT approve code that adds new `eslint-disable` or `oxlint-disable` comments.**
|
||||
|
||||
Linter rules are in place for good reasons. Instead of suppressing them:
|
||||
|
||||
- Fix the underlying issue
|
||||
- If the rule is incorrectly flagging valid code, consider if the code can be refactored
|
||||
- Only approve suppressions if there's a documented, exceptional reason
|
||||
|
||||
## Type Assertions
|
||||
|
||||
**Prefer `x satisfies SomeType` over `x as SomeType` for type coercions.**
|
||||
|
||||
The `satisfies` operator provides better type safety by:
|
||||
|
||||
- Ensuring the value actually satisfies the type (narrowing)
|
||||
- Preserving the actual type information for better inference
|
||||
- Catching type mismatches at compile time
|
||||
|
||||
**Exception:** If you truly need to assert a type that TypeScript cannot verify (e.g., runtime type guards), use `as` but require a comment explaining why it's safe.
|
||||
|
||||
## Avoiding `any` and `unknown`
|
||||
|
||||
**Flag code that uses `any` or `unknown` unless absolutely necessary.**
|
||||
|
||||
The use of `any` or `unknown` should be rare and well-justified. Before approving:
|
||||
|
||||
- Require explicit justification for why the type cannot be determined
|
||||
- Suggest using proper type definitions or generics
|
||||
- Consider if the type can be narrowed or properly inferred
|
||||
- Look for existing type definitions in `packages/loot-core/src/types/`
|
||||
|
||||
Only approve `any` or `unknown` if there's a documented, exceptional reason (e.g., interop with untyped external libraries, gradual migration).
|
||||
|
||||
## Internationalization (i18n)
|
||||
|
||||
**All user-facing strings must be translated.**
|
||||
|
||||
The project has custom ESLint rules (`actual/no-untranslated-strings`) that enforce i18n usage, but reviewers should actively flag untranslated strings:
|
||||
|
||||
- Use `Trans` component instead of `t()` function when possible
|
||||
- All text visible to users must use i18n functions
|
||||
- Flag hardcoded strings that should be translated
|
||||
|
||||
## Test Mocking
|
||||
|
||||
**Minimize mocked dependencies; prefer real implementations.**
|
||||
|
||||
When reviewing tests, encourage the use of real implementations over mocks:
|
||||
|
||||
- Prefer real dependencies, utilities, and data structures
|
||||
- Only mock when the real implementation is impractical (e.g., external APIs, file system in unit tests)
|
||||
- Ensure mocks accurately represent real behavior
|
||||
|
||||
Over-mocking makes tests brittle and less reliable. Real implementations provide better confidence that code works correctly.
|
||||
|
||||
## Financial Number Typography
|
||||
|
||||
Standalone financial numbers should have tabular number styles applied.
|
||||
|
||||
- Standalone financial numbers should be wrapped with `FinancialText` or `styles.tnum` should be applied directly if wrapping is not possible
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- See [AGENTS.md](./AGENTS.md) for general development guidelines
|
||||
- See [CONTRIBUTING.md](./CONTRIBUTING.md) for contribution guidelines
|
||||
- Community documentation: [https://actualbudget.org/docs/contributing/](https://actualbudget.org/docs/contributing/)
|
||||
@@ -37,7 +37,7 @@ async function run() {
|
||||
choices: [
|
||||
{ title: '✨ Features', value: 'Features' },
|
||||
{ title: '👍 Enhancements', value: 'Enhancements' },
|
||||
{ title: '🐛 Bugfix', value: 'Bugfix' },
|
||||
{ title: '🐛 Bugfixes', value: 'Bugfixes' },
|
||||
{ title: '⚙️ Maintenance', value: 'Maintenance' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"start:service-plugins": "yarn workspace plugins-service watch",
|
||||
"start:browser-backend": "yarn workspace loot-core watch:browser",
|
||||
"start:browser-frontend": "yarn workspace @actual-app/web start:browser",
|
||||
"start:storybook": "yarn workspace @actual-app/components start:storybook",
|
||||
"build:browser-backend": "yarn workspace loot-core build:browser",
|
||||
"build:server": "yarn build:browser && yarn workspace @actual-app/sync-server build",
|
||||
"build:browser": "./bin/package-browser",
|
||||
@@ -40,6 +41,7 @@
|
||||
"build:plugins-service": "yarn workspace plugins-service build",
|
||||
"build:api": "yarn workspace @actual-app/api build",
|
||||
"build:docs": "yarn workspace docs build",
|
||||
"build:storybook": "yarn workspace @actual-app/components build:storybook",
|
||||
"deploy:docs": "yarn workspace docs deploy",
|
||||
"generate:i18n": "yarn workspace @actual-app/web generate:i18n",
|
||||
"generate:release-notes": "ts-node ./bin/release-note-generator.ts",
|
||||
@@ -63,6 +65,7 @@
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@types/node": "^22.19.3",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"baseline-browser-mapping": "^2.9.14",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-perfectionist": "^4.15.1",
|
||||
@@ -74,8 +77,8 @@
|
||||
"minimatch": "^10.1.1",
|
||||
"node-jq": "^6.3.1",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"oxfmt": "^0.22.0",
|
||||
"oxlint": "^1.38.0",
|
||||
"oxfmt": "^0.26.0",
|
||||
"oxlint": "^1.41.0",
|
||||
"p-limit": "^7.2.0",
|
||||
"prompts": "^2.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actual-app/api",
|
||||
"version": "26.1.0",
|
||||
"version": "26.2.0",
|
||||
"description": "An API for Actual",
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
|
||||
@@ -174,6 +174,7 @@ function parseArgs(argv) {
|
||||
return {
|
||||
sections,
|
||||
identifier: getSingleValue(args, 'identifier') ?? 'bundle-stats',
|
||||
format: getSingleValue(args, 'format') ?? 'pr-body',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -463,6 +464,12 @@ const TOTAL_HEADERS = makeHeader([
|
||||
'Total bundle size',
|
||||
'% Changed',
|
||||
]);
|
||||
const SUMMARY_HEADERS = makeHeader([
|
||||
'Bundle',
|
||||
'Files count',
|
||||
'Total bundle size',
|
||||
'% Changed',
|
||||
]);
|
||||
const TABLE_HEADERS = makeHeader(['Asset', 'File Size', '% Changed']);
|
||||
const CHUNK_TABLE_HEADERS = makeHeader(['File', 'Δ', 'Size']);
|
||||
|
||||
@@ -596,6 +603,24 @@ function printTotalAssetTable(statsDiff) {
|
||||
return `**Total**\n${TOTAL_HEADERS}\n${printAssetTableRow(statsDiff.total)}`;
|
||||
}
|
||||
|
||||
function printSummaryTable(sections) {
|
||||
if (sections.length === 0) {
|
||||
return `${SUMMARY_HEADERS}\nNo bundle stats were generated.`;
|
||||
}
|
||||
|
||||
const rows = sections.map(section => {
|
||||
const total = section.statsDiff.total;
|
||||
return [
|
||||
section.name,
|
||||
total.name,
|
||||
toFileSizeDiffCell(total),
|
||||
conditionalPercentage(total.diffPercentage),
|
||||
].join(' | ');
|
||||
});
|
||||
|
||||
return `${SUMMARY_HEADERS}\n${rows.join('\n')}`;
|
||||
}
|
||||
|
||||
function renderSection(title, statsDiff, chunkModuleDiff) {
|
||||
const { total, ...groups } = statsDiff;
|
||||
const parts = [`#### ${title}`, '', printTotalAssetTable({ total })];
|
||||
@@ -615,8 +640,30 @@ function renderSection(title, statsDiff, chunkModuleDiff) {
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
function renderSections(sections) {
|
||||
return sections
|
||||
.map(section =>
|
||||
renderSection(section.name, section.statsDiff, section.chunkDiff),
|
||||
)
|
||||
.join('\n\n---\n\n');
|
||||
}
|
||||
|
||||
function getIdentifierMarkers(key) {
|
||||
const label = 'bundlestats-action-comment';
|
||||
return {
|
||||
start: `<!--- ${label} key:${key} start --->`,
|
||||
end: `<!--- ${label} key:${key} end --->`,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv);
|
||||
const allowedFormats = new Set(['comment', 'pr-body']);
|
||||
if (!allowedFormats.has(args.format)) {
|
||||
throw new Error(
|
||||
`Invalid format "${args.format}". Use "comment" or "pr-body".`,
|
||||
);
|
||||
}
|
||||
|
||||
console.error(
|
||||
`[bundle-stats] Found ${args.sections.length} sections to process`,
|
||||
@@ -654,22 +701,29 @@ async function main() {
|
||||
});
|
||||
}
|
||||
|
||||
const identifier = `<!--- bundlestats-action-comment key:${args.identifier} --->`;
|
||||
const markers = getIdentifierMarkers(args.identifier);
|
||||
const sectionsContent = renderSections(sections);
|
||||
const summaryTable = printSummaryTable(sections);
|
||||
|
||||
const comment = [
|
||||
const detailedBody = ['### Bundle Stats', '', sectionsContent].join('\n');
|
||||
|
||||
const commentBody = [markers.start, detailedBody, '', markers.end, ''].join(
|
||||
'\n',
|
||||
);
|
||||
|
||||
const prBody = [
|
||||
markers.start,
|
||||
'### Bundle Stats',
|
||||
'',
|
||||
sections
|
||||
.map(section =>
|
||||
renderSection(section.name, section.statsDiff, section.chunkDiff),
|
||||
)
|
||||
.join('\n\n---\n\n'),
|
||||
summaryTable,
|
||||
'',
|
||||
identifier,
|
||||
`<details>\n<summary>View detailed bundle stats</summary>\n\n${sectionsContent}\n</details>`,
|
||||
'',
|
||||
markers.end,
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
process.stdout.write(comment);
|
||||
process.stdout.write(args.format === 'comment' ? commentBody : prBody);
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
|
||||
@@ -19,6 +19,10 @@ const options = {
|
||||
type: 'string', // nightly, hotfix, monthly, auto
|
||||
short: 't',
|
||||
},
|
||||
version: {
|
||||
type: 'string',
|
||||
short: 'v',
|
||||
},
|
||||
update: {
|
||||
type: 'boolean',
|
||||
short: 'u',
|
||||
@@ -44,16 +48,21 @@ try {
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
const currentVersion = packageJson.version;
|
||||
|
||||
const explicitVersion = values.version;
|
||||
let newVersion;
|
||||
try {
|
||||
newVersion = getNextVersion({
|
||||
currentVersion,
|
||||
type: values.type,
|
||||
currentDate: new Date(),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e.message);
|
||||
process.exit(1);
|
||||
if (explicitVersion) {
|
||||
newVersion = explicitVersion;
|
||||
} else {
|
||||
try {
|
||||
newVersion = getNextVersion({
|
||||
currentVersion,
|
||||
type: values.type,
|
||||
currentDate: new Date(),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(newVersion);
|
||||
|
||||
@@ -14,10 +14,14 @@ import process from 'node:process';
|
||||
|
||||
import { Octokit } from '@octokit/rest';
|
||||
|
||||
const BOT_BOUNDARY_MARKER = '<!--- actual-bot-sections --->';
|
||||
const BOT_BOUNDARY_TEXT = `${BOT_BOUNDARY_MARKER}\n<hr />`;
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
commentFile: null,
|
||||
identifier: null,
|
||||
target: 'comment',
|
||||
};
|
||||
|
||||
for (let i = 2; i < argv.length; i += 2) {
|
||||
@@ -41,6 +45,9 @@ function parseArgs(argv) {
|
||||
case '--identifier':
|
||||
args.identifier = value;
|
||||
break;
|
||||
case '--target':
|
||||
args.target = value;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown argument "${key}".`);
|
||||
}
|
||||
@@ -54,6 +61,12 @@ function parseArgs(argv) {
|
||||
throw new Error('Missing required argument "--identifier".');
|
||||
}
|
||||
|
||||
if (!['comment', 'pr-body'].includes(args.target)) {
|
||||
throw new Error(
|
||||
`Invalid value "${args.target}" for "--target". Use "comment" or "pr-body".`,
|
||||
);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
@@ -110,20 +123,123 @@ function isGitHubActionsBot(comment) {
|
||||
return comment.user?.login === 'github-actions[bot]';
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function getIdentifierMarkers(identifier) {
|
||||
if (identifier.includes('<!---')) {
|
||||
return {
|
||||
start: identifier,
|
||||
end: null,
|
||||
};
|
||||
}
|
||||
|
||||
const label = 'bundlestats-action-comment';
|
||||
return {
|
||||
start: `<!--- ${label} key:${identifier} start --->`,
|
||||
end: `<!--- ${label} key:${identifier} end --->`,
|
||||
};
|
||||
}
|
||||
|
||||
function upsertBlock(existingBody, block, markers) {
|
||||
const body = existingBody ?? '';
|
||||
|
||||
if (markers.end) {
|
||||
const pattern = new RegExp(
|
||||
`${escapeRegExp(markers.start)}[\\s\\S]*?${escapeRegExp(markers.end)}`,
|
||||
'm',
|
||||
);
|
||||
|
||||
if (pattern.test(body)) {
|
||||
return body.replace(pattern, block.trim());
|
||||
}
|
||||
}
|
||||
|
||||
if (body.trim().length === 0) {
|
||||
return block.trim();
|
||||
}
|
||||
|
||||
const separator = body.endsWith('\n') ? '\n' : '\n\n';
|
||||
const boundary = body.includes(BOT_BOUNDARY_MARKER)
|
||||
? ''
|
||||
: `${BOT_BOUNDARY_TEXT}\n\n`;
|
||||
return `${body}${separator}${boundary}${block.trim()}`;
|
||||
}
|
||||
|
||||
async function updatePullRequestBody(
|
||||
octokit,
|
||||
owner,
|
||||
repo,
|
||||
pullNumber,
|
||||
block,
|
||||
markers,
|
||||
) {
|
||||
const { data } = await octokit.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullNumber,
|
||||
});
|
||||
const nextBody = upsertBlock(data.body ?? '', block, markers);
|
||||
|
||||
await octokit.rest.pulls.update({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullNumber,
|
||||
body: nextBody,
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteExistingComment(
|
||||
octokit,
|
||||
owner,
|
||||
repo,
|
||||
issueNumber,
|
||||
markers,
|
||||
) {
|
||||
const comments = await listComments(octokit, owner, repo, issueNumber);
|
||||
const existingComment = comments.find(
|
||||
comment =>
|
||||
isGitHubActionsBot(comment) && comment.body?.includes(markers.start),
|
||||
);
|
||||
|
||||
if (existingComment) {
|
||||
await octokit.rest.issues.deleteComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: existingComment.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { commentFile, identifier } = parseArgs(process.argv);
|
||||
const { commentFile, identifier, target } = parseArgs(process.argv);
|
||||
const commentBody = await loadCommentBody(commentFile);
|
||||
const token = assertGitHubToken();
|
||||
const { owner, repo } = getRepoInfo();
|
||||
const issueNumber = getPullRequestNumber();
|
||||
const markers = getIdentifierMarkers(identifier);
|
||||
|
||||
const octokit = new Octokit({ auth: token });
|
||||
|
||||
const comments = await listComments(octokit, owner, repo, issueNumber);
|
||||
if (target === 'pr-body') {
|
||||
await updatePullRequestBody(
|
||||
octokit,
|
||||
owner,
|
||||
repo,
|
||||
issueNumber,
|
||||
commentBody,
|
||||
markers,
|
||||
);
|
||||
await deleteExistingComment(octokit, owner, repo, issueNumber, markers);
|
||||
console.log('Updated pull request body with bundle stats.');
|
||||
return;
|
||||
}
|
||||
|
||||
const comments = await listComments(octokit, owner, repo, issueNumber);
|
||||
const existingComment = comments.find(
|
||||
comment =>
|
||||
isGitHubActionsBot(comment) && comment.body?.includes(identifier),
|
||||
isGitHubActionsBot(comment) && comment.body?.includes(markers.start),
|
||||
);
|
||||
|
||||
if (existingComment) {
|
||||
@@ -134,15 +250,16 @@ async function main() {
|
||||
body: commentBody,
|
||||
});
|
||||
console.log('Updated existing bundle stats comment.');
|
||||
} else {
|
||||
await octokit.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
body: commentBody,
|
||||
});
|
||||
console.log('Created new bundle stats comment.');
|
||||
return;
|
||||
}
|
||||
|
||||
await octokit.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
body: commentBody,
|
||||
});
|
||||
console.log('Created new bundle stats comment.');
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getNextVersion } from './get-next-package-version';
|
||||
|
||||
|
||||
3
packages/component-library/.oxlintrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"jsPlugins": ["eslint-plugin-storybook"]
|
||||
}
|
||||
43
packages/component-library/.storybook/main.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import type { StorybookConfig } from '@storybook/react-vite';
|
||||
import viteTsconfigPaths from 'vite-tsconfig-paths';
|
||||
|
||||
/**
|
||||
* This function is used to resolve the absolute path of a package.
|
||||
* It is needed in projects that use Yarn PnP or are set up within a monorepo.
|
||||
*/
|
||||
function getAbsolutePath(value: string) {
|
||||
return dirname(fileURLToPath(import.meta.resolve(`${value}/package.json`)));
|
||||
}
|
||||
const config: StorybookConfig = {
|
||||
stories: [
|
||||
'../src/Introduction.mdx',
|
||||
'../src/**/*.mdx',
|
||||
'../src/**/*.stories.@(js|jsx|mjs|ts|tsx)',
|
||||
],
|
||||
addons: [
|
||||
getAbsolutePath('@chromatic-com/storybook'),
|
||||
getAbsolutePath('@storybook/addon-a11y'),
|
||||
getAbsolutePath('@storybook/addon-docs'),
|
||||
],
|
||||
framework: getAbsolutePath('@storybook/react-vite'),
|
||||
core: {
|
||||
disableTelemetry: true,
|
||||
},
|
||||
async viteFinal(config) {
|
||||
const { mergeConfig } = await import('vite');
|
||||
|
||||
return mergeConfig(config, {
|
||||
// Telling Vite how to resolve path aliases
|
||||
plugins: [viteTsconfigPaths({ root: '../..' })],
|
||||
esbuild: {
|
||||
// Needed to handle JSX in .ts/.tsx files
|
||||
jsx: 'automatic',
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
74
packages/component-library/.storybook/manager.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { addons } from 'storybook/manager-api';
|
||||
import { create } from 'storybook/theming/create';
|
||||
|
||||
// Colors from the Actual Budget light theme palette
|
||||
const purple500 = '#8719e0';
|
||||
const purple400 = '#9a3de8';
|
||||
const navy900 = '#102a43';
|
||||
const navy700 = '#334e68';
|
||||
const navy600 = '#486581';
|
||||
const navy150 = '#d9e2ec';
|
||||
const navy100 = '#e8ecf0';
|
||||
const white = '#ffffff';
|
||||
|
||||
// Create a custom Storybook theme matching Actual Budget's light theme
|
||||
const theme = create({
|
||||
base: 'light',
|
||||
brandTitle: 'Actual Budget',
|
||||
brandUrl: 'https://actualbudget.org',
|
||||
brandImage: 'https://actualbudget.org/img/actual.webp',
|
||||
brandTarget: '_blank',
|
||||
|
||||
// UI colors
|
||||
colorPrimary: purple500,
|
||||
colorSecondary: purple400,
|
||||
|
||||
// App chrome
|
||||
appBg: navy100,
|
||||
appContentBg: white,
|
||||
appPreviewBg: white,
|
||||
appBorderColor: navy150,
|
||||
appBorderRadius: 4,
|
||||
|
||||
// Fonts
|
||||
fontBase:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
|
||||
fontCode: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace',
|
||||
|
||||
// Text colors
|
||||
textColor: navy900,
|
||||
textInverseColor: white,
|
||||
textMutedColor: navy600,
|
||||
|
||||
// Toolbar
|
||||
barTextColor: navy700,
|
||||
barHoverColor: purple500,
|
||||
barSelectedColor: purple500,
|
||||
barBg: white,
|
||||
|
||||
// Form colors
|
||||
buttonBg: white,
|
||||
buttonBorder: navy900,
|
||||
booleanBg: navy150,
|
||||
booleanSelectedBg: purple500,
|
||||
inputBg: white,
|
||||
inputBorder: navy900,
|
||||
inputTextColor: navy900,
|
||||
inputBorderRadius: 4,
|
||||
});
|
||||
|
||||
addons.setConfig({
|
||||
theme,
|
||||
enableShortcuts: true,
|
||||
isFullscreen: false,
|
||||
isToolshown: true,
|
||||
sidebar: {
|
||||
collapsedRoots: [],
|
||||
filters: {
|
||||
patterns: item => {
|
||||
// Hide stories that are marked as internal
|
||||
return !item.tags?.includes('internal');
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
88
packages/component-library/.storybook/preview.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
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
|
||||
import * as darkTheme from '../../desktop-client/src/style/themes/dark';
|
||||
import * as developmentTheme from '../../desktop-client/src/style/themes/development';
|
||||
import * as lightTheme from '../../desktop-client/src/style/themes/light';
|
||||
import * as midnightTheme from '../../desktop-client/src/style/themes/midnight';
|
||||
|
||||
const THEMES = {
|
||||
light: lightTheme,
|
||||
dark: darkTheme,
|
||||
midnight: midnightTheme,
|
||||
development: developmentTheme,
|
||||
} as const;
|
||||
|
||||
type ThemeName = keyof typeof THEMES;
|
||||
|
||||
const ThemedStory = ({
|
||||
themeName,
|
||||
children,
|
||||
}: {
|
||||
themeName?: ThemeName;
|
||||
children?: ReactNode;
|
||||
}) => {
|
||||
if (!themeName || !THEMES[themeName]) {
|
||||
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>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const preview: Preview = {
|
||||
decorators: [
|
||||
(Story, { globals }) => {
|
||||
const themeName = globals.theme;
|
||||
|
||||
return (
|
||||
<ThemedStory themeName={themeName}>
|
||||
<Story />
|
||||
</ThemedStory>
|
||||
);
|
||||
},
|
||||
],
|
||||
globalTypes: {
|
||||
theme: {
|
||||
name: 'Theme',
|
||||
description: 'Global theme for components',
|
||||
defaultValue: 'light',
|
||||
toolbar: {
|
||||
icon: 'circlehollow',
|
||||
items: [
|
||||
{ value: 'light', title: 'Light' },
|
||||
{ value: 'dark', title: 'Dark' },
|
||||
{ value: 'midnight', title: 'Midnight' },
|
||||
{ value: 'development', title: 'Development' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
|
||||
a11y: {
|
||||
// 'todo' - show a11y violations in the test UI only
|
||||
// 'error' - fail CI on a11y violations
|
||||
// 'off' - skip a11y checks entirely
|
||||
test: 'todo',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
||||
@@ -37,7 +37,9 @@
|
||||
"scripts": {
|
||||
"generate:icons": "rm src/icons/*/*.tsx; cd src/icons && svgr --template template.ts --index-template index-template.ts --typescript --expand-props start -d . .",
|
||||
"test": "npm-run-all -cp 'test:*'",
|
||||
"test:web": "ENV=web vitest --run -c vitest.web.config.ts"
|
||||
"test:web": "ENV=web vitest --run -c vitest.web.config.ts",
|
||||
"start:storybook": "storybook dev -p 6006",
|
||||
"build:storybook": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/css": "^11.13.5",
|
||||
@@ -45,10 +47,16 @@
|
||||
"usehooks-ts": "^3.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^5.0.0",
|
||||
"@storybook/addon-a11y": "^10.2.0",
|
||||
"@storybook/addon-docs": "^10.2.0",
|
||||
"@storybook/react-vite": "^10.2.0",
|
||||
"@svgr/cli": "^8.1.0",
|
||||
"@types/react": "^19.2.5",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"eslint-plugin-storybook": "^10.2.0",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"storybook": "^10.2.0",
|
||||
"vitest": "^4.0.16"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ComponentProps, type ReactNode, type CSSProperties } from 'react';
|
||||
import { type ComponentProps, type CSSProperties, type ReactNode } from 'react';
|
||||
|
||||
import { Block } from './Block';
|
||||
import { View } from './View';
|
||||
|
||||
100
packages/component-library/src/Button.stories.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { fn } from 'storybook/test';
|
||||
|
||||
import { Button } from './Button';
|
||||
|
||||
const meta = {
|
||||
title: 'Button',
|
||||
component: Button,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
onClick: { action: 'clicked' },
|
||||
},
|
||||
args: { onClick: fn() },
|
||||
} satisfies Meta<typeof Button>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
|
||||
export const Primary: Story = {
|
||||
args: {
|
||||
variant: 'primary',
|
||||
bounce: false,
|
||||
children: 'Button Text',
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: `
|
||||
Primary button variant uses the following theme CSS variables:
|
||||
- \`--color-buttonPrimaryText\`
|
||||
- \`--color-buttonPrimaryTextHover\`
|
||||
- \`--color-buttonPrimaryBackground\`
|
||||
- \`--color-buttonPrimaryBackgroundHover\`
|
||||
- \`--color-buttonPrimaryBorder\`
|
||||
- \`--color-buttonPrimaryShadow\`
|
||||
- \`--color-buttonPrimaryDisabledText\`
|
||||
- \`--color-buttonPrimaryDisabledBackground\`
|
||||
- \`--color-buttonPrimaryDisabledBorder\`
|
||||
`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Normal: Story = {
|
||||
args: {
|
||||
variant: 'normal',
|
||||
bounce: false,
|
||||
children: 'Button Text',
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: `
|
||||
Normal button variant uses the following theme CSS variables:
|
||||
- \`--color-buttonNormalText\`
|
||||
- \`--color-buttonNormalTextHover\`
|
||||
- \`--color-buttonNormalBackground\`
|
||||
- \`--color-buttonNormalBackgroundHover\`
|
||||
- \`--color-buttonNormalBorder\`
|
||||
- \`--color-buttonNormalShadow\`
|
||||
- \`--color-buttonNormalSelectedText\`
|
||||
- \`--color-buttonNormalSelectedBackground\`
|
||||
- \`--color-buttonNormalDisabledText\`
|
||||
- \`--color-buttonNormalDisabledBackground\`
|
||||
- \`--color-buttonNormalDisabledBorder\`
|
||||
`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Bare: Story = {
|
||||
args: {
|
||||
variant: 'bare',
|
||||
bounce: false,
|
||||
children: 'Button Text',
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: `
|
||||
Bare button variant uses the following theme CSS variables:
|
||||
- \`--color-buttonBareText\`
|
||||
- \`--color-buttonBareTextHover\`
|
||||
- \`--color-buttonBareBackground\`
|
||||
- \`--color-buttonBareBackgroundHover\`
|
||||
- \`--color-buttonBareBackgroundActive\`
|
||||
- \`--color-buttonBareDisabledText\`
|
||||
- \`--color-buttonBareDisabledBackground\`
|
||||
`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -2,8 +2,8 @@ import React, {
|
||||
forwardRef,
|
||||
useMemo,
|
||||
type ComponentPropsWithoutRef,
|
||||
type ReactNode,
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { Button as ReactAriaButton } from 'react-aria-components';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ComponentProps, forwardRef } from 'react';
|
||||
import { forwardRef, type ComponentProps } from 'react';
|
||||
|
||||
import { theme } from './theme';
|
||||
import { View } from './View';
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { type ChangeEvent, type ReactNode } from 'react';
|
||||
import {
|
||||
ColorPicker as AriaColorPicker,
|
||||
type ColorPickerProps as AriaColorPickerProps,
|
||||
ColorSwatch as AriaColorSwatch,
|
||||
ColorSwatchPicker as AriaColorSwatchPicker,
|
||||
ColorField,
|
||||
ColorSwatchPickerItem,
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
ColorSwatch as AriaColorSwatch,
|
||||
type ColorSwatchProps,
|
||||
ColorSwatchPicker as AriaColorSwatchPicker,
|
||||
ColorSwatchPickerItem,
|
||||
ColorField,
|
||||
parseColor,
|
||||
type ColorPickerProps as AriaColorPickerProps,
|
||||
type ColorSwatchProps,
|
||||
} from 'react-aria-components';
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ReactNode, type CSSProperties } from 'react';
|
||||
import { type CSSProperties, type ReactNode } from 'react';
|
||||
|
||||
import { View } from './View';
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@ import {
|
||||
Children,
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
useEffect,
|
||||
useRef,
|
||||
type ReactElement,
|
||||
type Ref,
|
||||
type RefObject,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
type InitialFocusProps<T extends HTMLElement> = {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, {
|
||||
type ChangeEvent,
|
||||
type ComponentPropsWithRef,
|
||||
type KeyboardEvent,
|
||||
type FocusEvent,
|
||||
type KeyboardEvent,
|
||||
} from 'react';
|
||||
import { Input as ReactAriaInput } from 'react-aria-components';
|
||||
|
||||
|
||||
27
packages/component-library/src/Introduction.mdx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
<Meta title="Introduction" />
|
||||
|
||||
# Actual Budget Component Library
|
||||
|
||||
Welcome to the **Actual Budget Component Library**. Explore our UI components, see how they look across different themes, and learn how to use them in your code.
|
||||
|
||||
### What you can do here
|
||||
|
||||
- ✨ **Browse components** in the sidebar
|
||||
- 🎨 **Switch themes** using the toolbar above
|
||||
- 📚 **Read documentation** and see code examples
|
||||
- 🔍 **Test variations** and component states
|
||||
- ♿ **Check accessibility** compliance
|
||||
|
||||
### Getting Started
|
||||
|
||||
Select a component from the sidebar to explore its documentation, variants, and interactive controls.
|
||||
|
||||
---
|
||||
|
||||
### Useful Links
|
||||
|
||||
- [Actual Budget Website](https://actualbudget.org)
|
||||
- [Documentation](https://actualbudget.org/docs)
|
||||
- [GitHub Repository](https://github.com/actualbudget/actual)
|
||||
@@ -1,4 +1,4 @@
|
||||
import { forwardRef, type ReactNode, type CSSProperties } from 'react';
|
||||
import { forwardRef, type CSSProperties, type ReactNode } from 'react';
|
||||
|
||||
import { styles } from './styles';
|
||||
import { Text } from './Text';
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import {
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentProps,
|
||||
type ComponentType,
|
||||
type SVGProps,
|
||||
type CSSProperties,
|
||||
type KeyboardEvent,
|
||||
useEffect,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
type SVGProps,
|
||||
} from 'react';
|
||||
|
||||
import { Button } from './Button';
|
||||
@@ -152,7 +152,7 @@ export function Menu<const NameType = string>({
|
||||
<View
|
||||
className={className}
|
||||
style={{ outline: 'none', borderRadius: 4, overflow: 'hidden', ...style }}
|
||||
tabIndex={1}
|
||||
tabIndex={0}
|
||||
onKeyDown={onKeyDown}
|
||||
innerRef={elRef}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ComponentProps, useCallback, useEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef, type ComponentProps } from 'react';
|
||||
import { Popover as ReactAriaPopover } from 'react-aria-components';
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, {
|
||||
type HTMLProps,
|
||||
type Ref,
|
||||
type ReactNode,
|
||||
forwardRef,
|
||||
type HTMLProps,
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
} from 'react';
|
||||
|
||||
import { css, cx } from '@emotion/css';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { type SVGProps, useState } from 'react';
|
||||
import React, { useState, type SVGProps } from 'react';
|
||||
|
||||
export const SvgLoading = (props: SVGProps<SVGSVGElement>) => {
|
||||
const { color = 'currentColor' } = props;
|
||||
|
||||
@@ -162,4 +162,11 @@ export const styles: Record<string, any> = {
|
||||
padding: 16,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
tableContainer: {
|
||||
flex: 1,
|
||||
border: '1px solid ' + theme.tableBorder,
|
||||
borderTopLeftRadius: 6,
|
||||
borderTopRightRadius: 6,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -13,6 +13,9 @@ export const theme = {
|
||||
pageTextPositive: 'var(--color-pageTextPositive)',
|
||||
pageTextLink: 'var(--color-pageTextLink)',
|
||||
pageTextLinkLight: 'var(--color-pageTextLinkLight)',
|
||||
numberPositive: 'var(--color-numberPositive)',
|
||||
numberNegative: 'var(--color-numberNegative)',
|
||||
numberNeutral: 'var(--color-numberNeutral)',
|
||||
cardBackground: 'var(--color-cardBackground)',
|
||||
cardBorder: 'var(--color-cardBorder)',
|
||||
cardShadow: 'var(--color-cardShadow)',
|
||||
@@ -43,6 +46,7 @@ export const theme = {
|
||||
sidebarItemBackgroundHover: 'var(--color-sidebarItemBackgroundHover)',
|
||||
sidebarItemText: 'var(--color-sidebarItemText)',
|
||||
sidebarItemTextSelected: 'var(--color-sidebarItemTextSelected)',
|
||||
sidebarBudgetName: 'var(--color-sidebarBudgetName)',
|
||||
menuBackground: 'var(--color-menuBackground)',
|
||||
menuItemBackground: 'var(--color-menuItemBackground)',
|
||||
menuItemBackgroundHover: 'var(--color-menuItemBackgroundHover)',
|
||||
@@ -186,6 +190,19 @@ export const theme = {
|
||||
reportsGray: 'var(--color-reportsGray)',
|
||||
reportsLabel: 'var(--color-reportsLabel)',
|
||||
reportsInnerLabel: 'var(--color-reportsInnerLabel)',
|
||||
reportsChartFill: 'var(--color-reportsChartFill)',
|
||||
reportsNumberPositive: 'var(--color-reportsNumberPositive)',
|
||||
reportsNumberNegative: 'var(--color-reportsNumberNegative)',
|
||||
reportsNumberNeutral: 'var(--color-reportsNumberNeutral)',
|
||||
budgetNumberPositive: 'var(--color-budgetNumberPositive)',
|
||||
budgetNumberNegative: 'var(--color-budgetNumberNegative)',
|
||||
budgetNumberNeutral: 'var(--color-budgetNumberNeutral)',
|
||||
budgetNumberZero: 'var(--color-budgetNumberZero)',
|
||||
toBudgetPositive: 'var(--color-toBudgetPositive)',
|
||||
toBudgetZero: 'var(--color-toBudgetZero)',
|
||||
toBudgetNegative: 'var(--color-toBudgetNegative)',
|
||||
templateNumberFunded: 'var(--color-templateNumberFunded)',
|
||||
templateNumberUnderFunded: 'var(--color-templateNumberUnderFunded)',
|
||||
noteTagBackground: 'var(--color-noteTagBackground)',
|
||||
noteTagBackgroundHover: 'var(--color-noteTagBackgroundHover)',
|
||||
noteTagDefault: 'var(--color-noteTagDefault)',
|
||||
@@ -201,4 +218,5 @@ export const theme = {
|
||||
tooltipBackground: 'var(--color-tooltipBackground)',
|
||||
tooltipBorder: 'var(--color-tooltipBorder)',
|
||||
calendarCellBackground: 'var(--color-calendarCellBackground)',
|
||||
overlayBackground: 'var(--color-overlayBackground)',
|
||||
};
|
||||
|
||||
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 166 KiB After Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |