Merge branch 'main' into feature/retirement-planning

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
Juan José Mata
2026-05-24 12:14:14 +02:00
committed by GitHub
1630 changed files with 98596 additions and 7676 deletions

View File

@@ -11,13 +11,13 @@ Use the rules below when:
## Rules for AI (mandatory)
The codebase uses TailwindCSS v4.x (the newest version) with a custom design system defined in [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css)
The codebase uses TailwindCSS v4.x (the newest version) with a custom design system defined in [sure-design-system.css](mdc:app/assets/tailwind/sure-design-system.css)
- Always start by referencing [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css) to see the base primitives, functional tokens, and component tokens we use in the codebase
- Always prefer using the functional "tokens" defined in @maybe-design-system.css when possible.
- Always start by referencing [sure-design-system.css](mdc:app/assets/tailwind/sure-design-system.css) to see the base primitives, functional tokens, and component tokens we use in the codebase
- Always prefer using the functional "tokens" defined in @sure-design-system.css when possible.
- Example 1: use `text-primary` rather than `text-white`
- Example 2: use `bg-container` rather than `bg-white`
- Example 3: use `border border-primary` rather than `border border-gray-200`
- Never create new styles in [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css) or [application.css](mdc:app/assets/tailwind/application.css) without explicitly receiving permission to do so
- Never create new styles in [sure-design-system.css](mdc:app/assets/tailwind/sure-design-system.css) or [application.css](mdc:app/assets/tailwind/application.css) without explicitly receiving permission to do so
- Always generate semantic HTML

View File

@@ -3,6 +3,9 @@ FROM ruby:${RUBY_VERSION}-slim-bookworm
ENV DEBIAN_FRONTEND=noninteractive
# libvips42 supports ActiveStorage image variants through image_processing/ruby-vips
# for existing profile/avatar images. AccountStatement.original_file only stores
# PDF/CSV/XLSX originals, but the devcontainer needs this broader image stack.
RUN apt-get update -qq \
&& apt-get -y install --no-install-recommends \
apt-utils \
@@ -11,6 +14,7 @@ RUN apt-get update -qq \
git \
imagemagick \
iproute2 \
libvips42 \
libpq-dev \
libyaml-dev \
libyaml-0-2 \

View File

@@ -5,7 +5,9 @@ x-db-env: &db_env
x-rails-env: &rails_env
DB_HOST: db
HOST: "0.0.0.0"
# Bind the dev server to all interfaces inside the container so Docker's
# published port reaches it from the host. Rails reads BINDING natively.
BINDING: "0.0.0.0"
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
BUNDLE_PATH: /bundle
@@ -68,6 +70,9 @@ services:
- "7900:7900"
shm_size: 2gb
restart: unless-stopped
environment:
SE_NODE_MAX_SESSIONS: 4
SE_NODE_OVERRIDE_MAX_SESSIONS: "true"
volumes:
postgres-data:

View File

@@ -25,19 +25,34 @@ OPENAI_ACCESS_TOKEN=
OPENAI_MODEL=
OPENAI_URI_BASE=
# Optional: LLM token budget (applies to chat, auto-categorize, merchant detection, PDF processing).
# Lower these for small-context local models (Ollama, LM Studio, LocalAI).
# Defaults work for modern cloud OpenAI models without configuration.
# LLM_CONTEXT_WINDOW=2048
# LLM_MAX_RESPONSE_TOKENS=512
# LLM_MAX_HISTORY_TOKENS=
# LLM_SYSTEM_PROMPT_RESERVE=256
# LLM_MAX_ITEMS_PER_CALL=25
# Optional: OpenAI-compatible capability flags
# OPENAI_REQUEST_TIMEOUT=60 # HTTP timeout in seconds; raise for slow local models
# OPENAI_SUPPORTS_PDF_PROCESSING=true # Set to false for endpoints without vision support
# OPENAI_SUPPORTS_RESPONSES_ENDPOINT= # Override Responses-API vs chat.completions routing
# LLM_JSON_MODE= # auto | strict | json_object | none
# Optional: External AI Assistant — delegates chat to a remote AI agent
# instead of calling LLMs directly. The agent calls back to Sure's /mcp endpoint.
# See docs/hosting/ai.md for full details.
# ASSISTANT_TYPE=external
# EXTERNAL_ASSISTANT_URL=https://your-agent-host/v1/chat/completions
# EXTERNAL_ASSISTANT_TOKEN=your-api-token
# EXTERNAL_ASSISTANT_TOKEN=your-api-token # pipelock:ignore
# EXTERNAL_ASSISTANT_AGENT_ID=main
# EXTERNAL_ASSISTANT_SESSION_KEY=agent:main:main
# EXTERNAL_ASSISTANT_ALLOWED_EMAILS=user@example.com
# Optional: MCP server endpoint — enables /mcp for external AI assistants.
# Both values are required. MCP_USER_EMAIL must match an existing user's email.
# MCP_API_TOKEN=your-random-bearer-token
# MCP_API_TOKEN=your-random-bearer-token # pipelock:ignore
# MCP_USER_EMAIL=user@example.com
# Optional: Langfuse config
@@ -82,7 +97,7 @@ EMAIL_SENDER=
# Database Configuration
DB_HOST=localhost # May need to be changed to `DB_HOST=db` if using devcontainer
DB_PORT=5432
POSTGRES_PASSWORD=postgres
POSTGRES_PASSWORD=postgres # pipelock:ignore
POSTGRES_USER=postgres
# Redis configuration
@@ -94,12 +109,18 @@ REDIS_URL=redis://localhost:6379/1
# REDIS_SENTINEL_HOSTS=sentinel1:26379,sentinel2:26379,sentinel3:26379
# REDIS_SENTINEL_MASTER=mymaster
# REDIS_SENTINEL_USERNAME=default
# REDIS_PASSWORD=your-redis-password
# REDIS_PASSWORD=your-redis-password # pipelock:ignore
# App Domain
# This is the domain that your Sure instance will be hosted at. It is used to generate links in emails and other places.
APP_DOMAIN=
# WebAuthn / passkey MFA configuration
# RP ID is usually the registrable domain (example.com), not a full URL.
# Allowed origins are full HTTPS origins where users access Sure.
WEBAUTHN_RP_ID=
WEBAUTHN_ALLOWED_ORIGINS=
# OpenID Connect configuration
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
@@ -130,7 +151,7 @@ POSTHOG_HOST=
# Active Storage Configuration - responsible for storing file uploads
# ======================================================================================================
#
# * Defaults to disk storage but you can also use Amazon S3 or Cloudflare R2
# * Defaults to disk storage but you can also use Amazon S3, Cloudflare R2, or Google Cloud Storage
# * Set the appropriate environment variables to use these services.
# * Ensure libvips is installed on your system for image processing - https://github.com/libvips/libvips
#
@@ -159,3 +180,11 @@ POSTHOG_HOST=
# GENERIC_S3_BUCKET=
# GENERIC_S3_ENDPOINT=
# GENERIC_S3_FORCE_PATH_STYLE= <- defaults to false
#
# Google Cloud Storage
# ====================
# ACTIVE_STORAGE_SERVICE=google <- Enables Google Cloud Storage
# GCS_PROJECT=
# GCS_BUCKET=
# GCS_KEYFILE_JSON= <- JSON content of service account key (preferred)
# GCS_KEYFILE= <- path to service account JSON key file

View File

@@ -28,8 +28,22 @@ TWELVE_DATA_API_KEY =
OPENAI_ACCESS_TOKEN =
OPENAI_URI_BASE =
OPENAI_MODEL =
# OPENAI_REQUEST_TIMEOUT: Request timeout in seconds (default: 60)
# OPENAI_SUPPORTS_PDF_PROCESSING: Set to false for endpoints without vision support (default: true)
# LLM token budget. Applies to ALL outbound LLM calls: chat history,
# auto-categorize, merchant detection, provider enhancer, PDF processing.
# Defaults to Ollama's historical 2048-token baseline so small local models
# work out of the box — raise explicitly for cloud or larger-context models.
# LLM_CONTEXT_WINDOW = 2048 # Total tokens the model will accept
# LLM_MAX_RESPONSE_TOKENS = 512 # Reserved for the model's reply
# LLM_MAX_HISTORY_TOKENS = # Derived if unset (context - response - system_reserve)
# LLM_SYSTEM_PROMPT_RESERVE = 256 # Tokens reserved for the system prompt
# LLM_MAX_ITEMS_PER_CALL = 25 # Upper bound on auto-categorize / merchant batches
# OpenAI-compatible capability flags (custom/self-hosted providers)
# OPENAI_REQUEST_TIMEOUT = 60 # HTTP timeout in seconds; raise for slow local models
# OPENAI_SUPPORTS_PDF_PROCESSING = true # Set to false for endpoints without vision support
# OPENAI_SUPPORTS_RESPONSES_ENDPOINT = # true to force Responses API on custom providers
# LLM_JSON_MODE = # auto | strict | json_object | none
# (example: LM Studio/Docker config) OpenAI-compatible API endpoint config
# OPENAI_URI_BASE = http://host.docker.internal:1234/
@@ -41,6 +55,11 @@ OIDC_CLIENT_SECRET=
OIDC_ISSUER=
OIDC_REDIRECT_URI=http://localhost:3000/auth/openid_connect/callback
# WebAuthn / passkey MFA development defaults
# RP ID must match the domain where credentials are registered.
WEBAUTHN_RP_ID=localhost
WEBAUTHN_ALLOWED_ORIGINS=http://localhost:3000
# Langfuse config
LANGFUSE_PUBLIC_KEY =
LANGFUSE_SECRET_KEY =
@@ -81,3 +100,10 @@ AI_DEBUG_MODE =
# SSL_DEBUG: "true"
# volumes:
# - ./my-ca.crt:/certs/my-ca.crt:ro
# Active Storage Configuration
# ACTIVE_STORAGE_SERVICE=google
# GCS_PROJECT=
# GCS_BUCKET=
# GCS_KEYFILE_JSON=
# GCS_KEYFILE=

View File

@@ -26,6 +26,10 @@ OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_REDIRECT_URI=http://localhost:3000/auth/openid_connect/callback
# WebAuthn / passkey MFA test defaults
WEBAUTHN_RP_ID=www.example.com
WEBAUTHN_ALLOWED_ORIGINS=http://www.example.com
# ================
# Data Providers
# ---------------------------------------------------------------------------------

View File

@@ -6,4 +6,72 @@ linters:
rubocop_config:
Style/StringLiterals:
Enabled: true
EnforcedStyle: double_quotes
EnforcedStyle: double_quotes
DeprecatedClasses:
enabled: true
addendum: "Design tokens live in design/tokens/sure.tokens.json; semantic utilities are documented in app/assets/tailwind/sure-design-system.css."
rule_set:
- suggestion: "Use text-primary, text-secondary, text-subdued, text-inverse, text-link, or a semantic color token (text-success, text-warning, text-destructive)."
deprecated:
- 'text-gray-50'
- 'text-gray-100'
- 'text-gray-200'
- 'text-gray-500'
- 'text-gray-600'
- 'text-gray-700'
- 'text-gray-800'
- 'text-gray-900'
- 'text-white'
- suggestion: "Use bg-container, bg-container-inset, bg-surface, bg-surface-inset, bg-inverse, button-bg-primary, button-bg-secondary, or one of their hover variants."
deprecated:
- 'bg-gray-50'
- 'bg-gray-100'
- 'bg-gray-200'
- 'bg-gray-500'
- 'bg-gray-600'
- 'bg-gray-700'
- 'bg-gray-800'
- 'bg-gray-900'
- 'bg-white'
- suggestion: "Use border-primary, border-secondary, border-tertiary, border-subdued, border-inverse, or border-destructive."
deprecated:
- 'border-gray-200'
- 'border-gray-300'
- 'border-gray-500'
- 'border-gray-700'
- 'border-gray-900'
- 'border-white'
# Custom @utility tokens (bg-inverse, text-inverse, text-primary, etc.) do
# NOT support Tailwind's `/N` opacity modifier syntax — modifiers like
# `text-inverse/70` silently compile to nothing. Use `opacity-N` on the
# parent element, or migrate the token to `@theme --color-X` in
# design/tokens/sure.tokens.json so Tailwind auto-generates the
# color-mix pipeline. See #1653.
- suggestion: "Custom @utility tokens drop `/N` opacity modifiers silently. Use `opacity-N` instead, or migrate the token to @theme in design/tokens/sure.tokens.json (see #1653)."
deprecated:
- 'text-inverse\/\d+'
- 'bg-inverse\/\d+'
- 'bg-inverse-hover\/\d+'
- 'border-inverse\/\d+'
- 'text-primary\/\d+'
- 'text-secondary\/\d+'
- 'text-subdued\/\d+'
- 'border-primary\/\d+'
- 'border-secondary\/\d+'
- 'border-subdued\/\d+'
- 'border-destructive\/\d+'
- 'border-solid\/\d+'
- 'button-bg-primary\/\d+'
- 'button-bg-primary-hover\/\d+'
- 'button-bg-secondary\/\d+'
- 'button-bg-secondary-hover\/\d+'
- 'button-bg-secondary-strong\/\d+'
- 'button-bg-secondary-strong-hover\/\d+'
- 'button-bg-disabled\/\d+'
- 'button-bg-destructive\/\d+'
- 'button-bg-destructive-hover\/\d+'
- 'button-bg-ghost-hover\/\d+'
- 'button-bg-outline-hover\/\d+'
- 'tab-item-active\/\d+'
- 'tab-item-hover\/\d+'
- 'tab-bg-group\/\d+'

3
.gitattributes vendored
View File

@@ -3,6 +3,9 @@
# Mark the database schema as having been generated.
db/schema.rb linguist-generated
# Mark generated design system CSS (built from tokens/sure.tokens.json).
app/assets/tailwind/sure-design-system/_generated.css linguist-generated
# Mark any vendored files as having been vendored.
vendor/* linguist-vendored
config/credentials/*.yml.enc diff=rails_credentials

View File

@@ -86,7 +86,7 @@ Sidekiq handles asynchronous tasks:
- **Stimulus Controllers**: Handle interactivity, organized alongside components
- **Charts**: D3.js for financial visualizations (time series, donut, sankey)
- **Styling**: Tailwind CSS v4.x with custom design system
- Design system defined in `app/assets/tailwind/maybe-design-system.css`
- Design system defined in `app/assets/tailwind/sure-design-system.css`
- Always use functional tokens (e.g., `text-primary` not `text-white`)
- Prefer semantic HTML elements over JS components
- Use `icon` helper for icons, never `lucide_icon` directly
@@ -261,7 +261,7 @@ end
## TailwindCSS Design System
### Design System Rules
- **Always reference `app/assets/tailwind/maybe-design-system.css`** for primitives and tokens
- **Always reference `app/assets/tailwind/sure-design-system.css`** for primitives and tokens
- **Use functional tokens** defined in design system:
- `text-primary` instead of `text-white`
- `bg-container` instead of `bg-white`

View File

@@ -4,14 +4,14 @@ on:
pull_request:
paths:
- 'charts/**'
- 'config/initializers/version.rb'
- '.sure-version'
- '.github/workflows/chart-ci.yml'
push:
branches:
- main
paths:
- 'charts/**'
- 'config/initializers/version.rb'
- '.sure-version'
- '.github/workflows/chart-ci.yml'
jobs:
@@ -20,22 +20,22 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Check version alignment
shell: bash
run: |
set -euo pipefail
RAILS_VERSION=$(grep -oP '"\K[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?' config/initializers/version.rb | head -n 1 || true)
RAILS_VERSION=$(cat .sure-version | tr -d '[:space:]')
if [ -z "$RAILS_VERSION" ]; then
echo "::error::Could not extract a version string from config/initializers/version.rb — ensure it contains a quoted semver like VERSION = \"1.2.3\""
echo "::error::Could not read version from .sure-version"
exit 1
fi
CHART_VERSION=$(sed -n 's/^version: //p' charts/sure/Chart.yaml | head -n 1)
APP_VERSION=$(sed -n 's/^appVersion: "\{0,1\}\([^"]*\)"\{0,1\}/\1/p' charts/sure/Chart.yaml | head -n 1)
echo "Rails version (version.rb): $RAILS_VERSION"
echo "App version (.sure-version): $RAILS_VERSION"
echo "Helm chart version (Chart.yaml): $CHART_VERSION"
echo "Helm appVersion (Chart.yaml): $APP_VERSION"
@@ -53,7 +53,7 @@ jobs:
if [ "$ERRORS" -gt 0 ]; then
echo ""
echo "To fix: ensure version in config/initializers/version.rb matches"
echo "To fix: ensure version in .sure-version matches"
echo "both 'version' and 'appVersion' in charts/sure/Chart.yaml"
exit 1
fi
@@ -64,10 +64,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Install Helm
uses: azure/setup-helm@v4.3.1
uses: azure/setup-helm@v5
- name: Add chart dependencies repositories
run: |

View File

@@ -18,7 +18,7 @@ jobs:
app_version: ${{ steps.tag.outputs.app_version }}
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0
@@ -28,20 +28,20 @@ jobs:
run: |
set -euo pipefail
# Read the canonical version from the Rails app (single source of truth)
APP_SEMVER=$(grep -oP '"\K[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?' config/initializers/version.rb | head -n 1 || true)
# Read the canonical version from .sure-version (single source of truth)
APP_SEMVER=$(cat .sure-version | tr -d '[:space:]')
if [ -z "$APP_SEMVER" ]; then
echo "::error::Could not extract version from config/initializers/version.rb"
echo "::error::Could not read version from .sure-version"
exit 1
fi
echo "App version from version.rb: $APP_SEMVER"
echo "App version from .sure-version: $APP_SEMVER"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
# Use the app version as the chart version (monorepo: versions stay in sync)
TAG_NAME="chart-v${APP_SEMVER}"
if git rev-parse "refs/tags/${TAG_NAME}" >/dev/null 2>&1; then
echo "::error::Tag ${TAG_NAME} already exists. Bump the version in config/initializers/version.rb and charts/sure/Chart.yaml first."
echo "::error::Tag ${TAG_NAME} already exists. Bump the version in .sure-version and charts/sure/Chart.yaml first."
exit 1
fi
@@ -79,13 +79,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Download Helm chart artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
name: helm-chart-package
path: ${{ runner.temp }}/helm-artifacts
- name: Create chart GitHub Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ needs.prepare_release.outputs.tag_name }}
name: ${{ needs.prepare_release.outputs.tag_name }}

View File

@@ -9,7 +9,7 @@ jobs:
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up Ruby
uses: ruby/setup-ruby@v1
@@ -25,7 +25,7 @@ jobs:
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up Ruby
uses: ruby/setup-ruby@v1
@@ -41,7 +41,7 @@ jobs:
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up Ruby
uses: ruby/setup-ruby@v1
@@ -57,12 +57,12 @@ jobs:
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Node.js environment
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: "20"
node-version: "24"
cache: "npm"
- name: Install dependencies
@@ -72,7 +72,7 @@ jobs:
- name: Lint/Format js code
run: npm run lint
test:
test_unit:
runs-on: ubuntu-latest
timeout-minutes: 10
@@ -104,7 +104,7 @@ jobs:
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y google-chrome-stable curl libvips postgresql-client libpq-dev
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up Ruby
uses: ruby/setup-ruby@v1
@@ -121,11 +121,57 @@ jobs:
- name: Unit and integration tests
run: bin/rails test
test_system:
runs-on: ubuntu-latest
timeout-minutes: 10
env:
PLAID_CLIENT_ID: foo
PLAID_SECRET: bar
DATABASE_URL: postgres://postgres:postgres@localhost:5432
REDIS_URL: redis://localhost:6379
RAILS_ENV: test
services:
postgres:
image: postgres
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3
redis:
image: redis
ports:
- 6379:6379
options: --health-cmd="redis-cli ping" --health-interval=10s --health-timeout=5s --health-retries=3
steps:
- name: Install packages
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y google-chrome-stable curl libvips postgresql-client libpq-dev
- name: Checkout code
uses: actions/checkout@v5
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version
bundler-cache: true
- name: DB setup and smoke test
run: |
bin/rails db:create
bin/rails db:schema:load
bin/rails db:seed
- name: System tests
run: DISABLE_PARALLELIZATION=true bin/rails test:system
- name: Keep screenshots from failed system tests
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
if: failure()
with:
name: screenshots

View File

@@ -2,6 +2,10 @@ name: Flutter Mobile Build
on:
workflow_call:
outputs:
has_app_release_aab:
description: "Whether a signed release AAB artifact was produced"
value: ${{ jobs.build-android.outputs.has_app_release_aab }}
workflow_dispatch:
permissions:
@@ -12,13 +16,15 @@ jobs:
name: Build Android APK
runs-on: ubuntu-latest
timeout-minutes: 30
outputs:
has_app_release_aab: ${{ steps.check_secrets.outputs.has_keystore }}
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up Java
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '17'
@@ -87,7 +93,7 @@ jobs:
fi
- name: Upload APK artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: app-release-apk
path: |
@@ -103,7 +109,7 @@ jobs:
- name: Upload AAB artifact
if: steps.check_secrets.outputs.has_keystore == 'true'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: app-release-aab
path: mobile/build/app/outputs/bundle/release/app-release.aab
@@ -116,7 +122,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up Flutter
uses: subosito/flutter-action@v2
@@ -161,7 +167,7 @@ jobs:
echo "For distribution, you need to configure code signing with Apple certificates" >> build/ios-build-info.txt
- name: Upload iOS build artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: ios-build-unsigned
path: |

108
.github/workflows/google-play-upload.yml vendored Normal file
View File

@@ -0,0 +1,108 @@
name: Google Play Upload
on:
workflow_call:
inputs:
notes:
description: "Google Play release notes"
required: false
type: string
track:
description: "Google Play track (internal, alpha, beta, production)"
required: false
default: "internal"
type: string
secrets:
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64:
required: false
permissions:
contents: read
jobs:
upload:
name: Upload Android AAB to Google Play
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Check Google Play credentials
id: check_prereqs
env:
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 }}
run: |
set -eu
missing=()
if [ -z "${GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64-}" ]; then
missing+=("GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64")
fi
if [ "${#missing[@]}" -eq 0 ]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "enabled=false" >> "$GITHUB_OUTPUT"
{
echo "Missing required Google Play secrets:"
printf " - %s\n" "${missing[@]}"
} >> "$GITHUB_STEP_SUMMARY"
- name: Skip Google Play upload
if: ${{ steps.check_prereqs.outputs.enabled != 'true' }}
run: |
echo "Skipping Google Play upload because required credentials are not configured."
- name: Download Android AAB artifact
if: ${{ steps.check_prereqs.outputs.enabled == 'true' }}
uses: actions/download-artifact@v7
with:
name: app-release-aab
path: ${{ runner.temp }}/android-aab
- name: Prepare Google Play credentials
id: play_creds
if: ${{ steps.check_prereqs.outputs.enabled == 'true' }}
env:
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 }}
run: |
set -euo pipefail
CREDENTIALS_PATH="$RUNNER_TEMP/google-play-service-account.json"
echo "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64" | base64 --decode > "$CREDENTIALS_PATH"
echo "credentials-path=$CREDENTIALS_PATH" >> "$GITHUB_OUTPUT"
- name: Resolve AAB path
id: aab
if: ${{ steps.check_prereqs.outputs.enabled == 'true' }}
run: |
set -euo pipefail
AAB_PATH="$(find "${{ runner.temp }}/android-aab" -name '*.aab' | head -n 1)"
if [ -z "$AAB_PATH" ]; then
echo "::error::No Android App Bundle (.aab) found in downloaded artifacts"
exit 1
fi
echo "aab-path=$AAB_PATH" >> "$GITHUB_OUTPUT"
- name: Create release notes file
id: notes
if: ${{ steps.check_prereqs.outputs.enabled == 'true' && inputs.notes != '' }}
env:
NOTES: ${{ inputs.notes }}
run: |
set -euo pipefail
NOTES_DIR="$RUNNER_TEMP/google-play-whatsnew"
mkdir -p "$NOTES_DIR"
printf '%s\n' "$NOTES" > "$NOTES_DIR/whatsnew-en-US"
echo "notes-dir=$NOTES_DIR" >> "$GITHUB_OUTPUT"
- name: Upload to Google Play
if: ${{ steps.check_prereqs.outputs.enabled == 'true' }}
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJson: ${{ steps.play_creds.outputs.credentials-path }}
packageName: am.sure.mobile
releaseFiles: ${{ steps.aab.outputs.aab-path }}
tracks: ${{ inputs.track }}
status: completed
whatsNewDirectory: ${{ steps.notes.outputs.notes-dir }}

View File

@@ -29,12 +29,12 @@ jobs:
app_version: ${{ steps.version.outputs.app_version }}
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Install Helm
uses: azure/setup-helm@v4.3.1
uses: azure/setup-helm@v5
- name: Resolve chart and app versions
id: version
@@ -88,7 +88,7 @@ jobs:
helm package charts/sure -d .cr-release-packages
- name: Upload packaged chart artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: helm-chart-package
path: .cr-release-packages/*.tgz
@@ -98,7 +98,7 @@ jobs:
- name: Checkout gh-pages
if: ${{ inputs.update_gh_pages }}
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
ref: gh-pages
path: gh-pages

View File

@@ -15,7 +15,7 @@ on:
type: string
push:
tags:
- 'ios-v*'
- 'v*'
permissions:
contents: read
@@ -23,12 +23,12 @@ permissions:
jobs:
build-and-upload:
name: Build signed IPA and upload to TestFlight
runs-on: macos-latest
runs-on: macos-26
timeout-minutes: 120
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Check TestFlight credentials
id: check_prereqs
@@ -82,6 +82,14 @@ jobs:
run: |
echo "Skipping TestFlight upload because required credentials are not configured."
- name: Select Xcode 26
if: ${{ steps.check_prereqs.outputs.enabled == 'true' }}
run: |
set -euo pipefail
sudo xcode-select -s /Applications/Xcode_26.4.app/Contents/Developer
xcodebuild -version
xcrun --sdk iphoneos --show-sdk-version
- name: Set up Flutter
uses: subosito/flutter-action@v2
if: ${{ steps.check_prereqs.outputs.enabled == 'true' }}
@@ -95,10 +103,6 @@ jobs:
if: ${{ steps.check_prereqs.outputs.enabled == 'true' }}
run: flutter pub get
- name: Copy app icon source
if: ${{ steps.check_prereqs.outputs.enabled == 'true' }}
run: cp public/android-chrome-512x512.png mobile/assets/icon/app_icon.png
- name: Generate app icons
working-directory: mobile
if: ${{ steps.check_prereqs.outputs.enabled == 'true' }}
@@ -154,7 +158,44 @@ jobs:
run: |
set -euo pipefail
IOS_VERSION="$(tr -d '[:space:]' < ../.sure-version | sed 's/-.*$//')"
IOS_BUILD_NUMBER="$(date -u +%Y%m%d%H%M)"
ARCHIVE_PATH="$RUNNER_TEMP/Runner.xcarchive"
EXPORT_PATH="$PWD/build/ios/ipa"
EXPORT_PLIST="$RUNNER_TEMP/ExportOptions.plist"
python3 <<'PY'
import os
import re
from pathlib import Path
path = Path("ios/Runner.xcodeproj/project.pbxproj")
text = path.read_text()
team = os.environ["IOS_TEAM_ID"]
profile = os.environ["PROFILE_NAME"]
identity = os.environ["IOS_DISTRIBUTION_CERT_NAME"]
def patch_block(match):
block = match.group(0)
if "PRODUCT_BUNDLE_IDENTIFIER = am.sure.mobile;" not in block:
return block
if "CODE_SIGN_STYLE = Manual;" not in block:
block = block.replace("CURRENT_PROJECT_VERSION = \"$(FLUTTER_BUILD_NUMBER)\";", "CURRENT_PROJECT_VERSION = \"$(FLUTTER_BUILD_NUMBER)\";\n\t\t\t\tCODE_SIGN_STYLE = Manual;")
if '"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Distribution";' not in block:
block = re.sub(r'DEVELOPMENT_TEAM = .*?;', f'"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "{identity}";\n\t\t\t\tDEVELOPMENT_TEAM = {team};', block, count=1)
else:
block = re.sub(r'"CODE_SIGN_IDENTITY\[sdk=iphoneos\*\]" = ".*?";', f'"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "{identity}";', block)
block = re.sub(r'DEVELOPMENT_TEAM = .*?;', f'DEVELOPMENT_TEAM = {team};', block)
if "PROVISIONING_PROFILE_SPECIFIER = " in block:
block = re.sub(r'PROVISIONING_PROFILE_SPECIFIER = .*?;', f'PROVISIONING_PROFILE_SPECIFIER = "{profile}";', block)
else:
block = block.replace(f'DEVELOPMENT_TEAM = {team};', f'DEVELOPMENT_TEAM = {team};\n\t\t\t\tPROVISIONING_PROFILE_SPECIFIER = "{profile}";')
return block
text = re.sub(r'isa = XCBuildConfiguration;\n\s+baseConfigurationReference = .*?;\n\s+buildSettings = \{.*?\n\s+name = (?:Debug|Release|Profile);\n\s+\};', patch_block, text, flags=re.S)
path.write_text(text)
PY
cat > "$EXPORT_PLIST" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -181,11 +222,37 @@ jobs:
</plist>
EOF
CODE_SIGN_IDENTITY="${IOS_DISTRIBUTION_CERT_NAME}" \
CODE_SIGN_STYLE=Manual \
DEVELOPMENT_TEAM="${IOS_TEAM_ID}" \
PROVISIONING_PROFILE_SPECIFIER="${PROFILE_NAME}" \
flutter build ipa --release --export-options-plist="$EXPORT_PLIST"
if [ -z "$IOS_VERSION" ]; then
echo "::error::.sure-version is empty or unreadable"
exit 1
fi
echo "Using iOS version: $IOS_VERSION"
echo "Using iOS build number: $IOS_BUILD_NUMBER"
flutter build ios \
--release \
--no-codesign \
--build-name="$IOS_VERSION" \
--build-number="$IOS_BUILD_NUMBER"
xcodebuild \
-workspace ios/Runner.xcworkspace \
-scheme Runner \
-configuration Release \
-archivePath "$ARCHIVE_PATH" \
-destination 'generic/platform=iOS' \
MARKETING_VERSION="$IOS_VERSION" \
CURRENT_PROJECT_VERSION="$IOS_BUILD_NUMBER" \
archive
mkdir -p "$EXPORT_PATH"
xcodebuild \
-exportArchive \
-archivePath "$ARCHIVE_PATH" \
-exportPath "$EXPORT_PATH" \
-exportOptionsPlist "$EXPORT_PLIST"
- name: Prepare TestFlight auth key
id: testflight_key
@@ -195,8 +262,11 @@ jobs:
APP_STORE_CONNECT_API_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_KEY_BASE64 }}
run: |
set -euo pipefail
KEY_FILE="$RUNNER_TEMP/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8"
KEY_DIR="$HOME/.appstoreconnect/private_keys"
mkdir -p "$KEY_DIR"
KEY_FILE="$KEY_DIR/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8"
echo "$APP_STORE_CONNECT_API_KEY_BASE64" | base64 --decode > "$KEY_FILE"
chmod 600 "$KEY_FILE"
echo "key-file=$KEY_FILE" >> "$GITHUB_OUTPUT"
- name: Upload IPA to TestFlight
@@ -219,12 +289,11 @@ jobs:
--file "$IPA_PATH" \
--type ios \
--apiKey "$APP_STORE_CONNECT_API_KEY_ID" \
--apiIssuer "$APP_STORE_CONNECT_API_ISSUER_ID" \
--apiPrivateKey "$APP_STORE_CONNECT_API_KEY_FILE"
--apiIssuer "$APP_STORE_CONNECT_API_ISSUER_ID"
- name: Upload build artifact
if: ${{ steps.check_prereqs.outputs.enabled == 'true' }}
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: ios-ipa-testflight
path: mobile/build/ios/ipa/*.ipa
@@ -232,9 +301,14 @@ jobs:
- name: Cleanup signing keychain
if: ${{ always() && steps.check_prereqs.outputs.enabled == 'true' }}
env:
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
run: |
set -euo pipefail
KEYCHAIN_PATH="${{ steps.signing.outputs.keychain-path }}"
if [ -n "$KEYCHAIN_PATH" ] && [ -f "$KEYCHAIN_PATH" ]; then
security delete-keychain "$KEYCHAIN_PATH"
fi
KEY_FILE="$HOME/.appstoreconnect/private_keys/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8"
rm -f "$KEY_FILE"

457
.github/workflows/llm-evals.yml vendored Normal file
View File

@@ -0,0 +1,457 @@
name: LLM Evals
on:
push:
tags:
- 'v*'
permissions:
contents: read
env:
EVAL_MODELS: gpt-4.1
RAILS_ENV: test
DATABASE_URL: postgres://postgres:postgres@localhost:5432
REDIS_URL: redis://localhost:6379
PLAID_CLIENT_ID: foo
PLAID_SECRET: bar
jobs:
check_openai:
name: Check OpenAI credentials
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.gate.outputs.should_run }}
reason: ${{ steps.gate.outputs.reason }}
steps:
- name: Validate OpenAI token and quota
id: gate
env:
OPENAI_ACCESS_TOKEN: ${{ secrets.OPENAI_ACCESS_TOKEN }}
shell: bash
run: |
set -euo pipefail
if [ -z "${OPENAI_ACCESS_TOKEN:-}" ]; then
echo "OpenAI token is not configured; skipping eval workflow."
echo "should_run=false" >> "$GITHUB_OUTPUT"
echo "reason=OPENAI_ACCESS_TOKEN secret is missing" >> "$GITHUB_OUTPUT"
exit 0
fi
TEST_MODEL=$(printf '%s' "$EVAL_MODELS" | cut -d',' -f1 | xargs)
if [ -z "$TEST_MODEL" ]; then
TEST_MODEL="gpt-4.1"
fi
echo "Checking API access with model: ${TEST_MODEL}"
RESPONSE_FILE="$(mktemp)"
STATUS_CODE=$(curl -sS -o "$RESPONSE_FILE" -w "%{http_code}" \
https://api.openai.com/v1/chat/completions \
-H "Authorization: Bearer ${OPENAI_ACCESS_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"model\":\"${TEST_MODEL}\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}],\"max_tokens\":1}")
if [ "$STATUS_CODE" = "200" ]; then
echo "OpenAI token check passed."
echo "should_run=true" >> "$GITHUB_OUTPUT"
echo "reason=ok" >> "$GITHUB_OUTPUT"
exit 0
fi
ERROR_MESSAGE=$(ruby -rjson -e '
body = File.read(ARGV[0]) rescue ""
data = JSON.parse(body) rescue {}
message = data.dig("error", "message") || data["message"] || "unknown error"
puts message.gsub(/\s+/, " ").strip
' "$RESPONSE_FILE")
echo "OpenAI check failed (${STATUS_CODE}): ${ERROR_MESSAGE}"
echo "should_run=false" >> "$GITHUB_OUTPUT"
echo "reason=OpenAI token invalid or insufficient quota (${STATUS_CODE})" >> "$GITHUB_OUTPUT"
exit 0
discover_datasets:
name: Discover eval datasets
needs: check_openai
if: needs.check_openai.outputs.should_run == 'true'
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3
redis:
image: redis:7.2
ports:
- 6379:6379
options: --health-cmd="redis-cli ping" --health-interval=10s --health-timeout=5s --health-retries=3
outputs:
datasets: ${{ steps.datasets.outputs.datasets }}
models: ${{ steps.models.outputs.models }}
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version
bundler-cache: true
- name: Prepare database
run: |
bin/rails db:create
bin/rails db:schema:load
- name: Import eval datasets
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
dataset_files=(db/eval_data/*.yml)
if [ ${#dataset_files[@]} -eq 0 ]; then
echo "::error::No eval dataset files found under db/eval_data/*.yml"
exit 1
fi
for dataset_file in "${dataset_files[@]}"; do
echo "Importing ${dataset_file}"
bundle exec rake "evals:import_dataset[${dataset_file}]"
done
- name: Resolve eval models
id: models
shell: bash
run: |
set -euo pipefail
MODELS_JSON=$(bin/rails runner '
models = ENV.fetch("EVAL_MODELS", "").split(",").map(&:strip).reject(&:blank?)
puts models.to_json
')
if [ "$MODELS_JSON" = "[]" ]; then
echo "::error::EVAL_MODELS is empty. Set at least one model, for example: EVAL_MODELS=gpt-4.1"
exit 1
fi
{
echo "models<<EOF"
echo "$MODELS_JSON"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Resolve available eval datasets
id: datasets
shell: bash
run: |
set -euo pipefail
DATASETS_JSON=$(bin/rails runner 'puts Eval::Dataset.order(:name).pluck(:name).to_json')
if [ "$DATASETS_JSON" = "[]" ]; then
echo "::error::No eval datasets found. Import one first with: rake evals:import_dataset[path/to/file.yml]"
exit 1
fi
{
echo "datasets<<EOF"
echo "$DATASETS_JSON"
echo "EOF"
} >> "$GITHUB_OUTPUT"
run_evals:
name: Run eval for ${{ matrix.dataset }} on ${{ matrix.model }}
needs: [check_openai, discover_datasets]
if: needs.check_openai.outputs.should_run == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
dataset: ${{ fromJson(needs.discover_datasets.outputs.datasets) }}
model: ${{ fromJson(needs.discover_datasets.outputs.models) }}
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3
redis:
image: redis:7.2
ports:
- 6379:6379
options: --health-cmd="redis-cli ping" --health-interval=10s --health-timeout=5s --health-retries=3
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version
bundler-cache: true
- name: Prepare database
run: |
bin/rails db:create
bin/rails db:schema:load
- name: Import eval datasets
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
dataset_files=(db/eval_data/*.yml)
if [ ${#dataset_files[@]} -eq 0 ]; then
echo "::error::No eval dataset files found under db/eval_data/*.yml"
exit 1
fi
for dataset_file in "${dataset_files[@]}"; do
echo "Importing ${dataset_file}"
bundle exec rake "evals:import_dataset[${dataset_file}]"
done
- name: Prepare dataset artifact names
id: dataset_slug
env:
DATASET: ${{ matrix.dataset }}
MODEL: ${{ matrix.model }}
shell: bash
run: |
set -euo pipefail
slug=$(printf '%s' "$DATASET" \
| tr '[:upper:]' '[:lower:]' \
| sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//; s/-{2,}/-/g')
if [ -z "$slug" ]; then
echo "::error::Could not generate dataset slug from '$DATASET'"
exit 1
fi
echo "slug=$slug" >> "$GITHUB_OUTPUT"
model_slug=$(printf '%s' "$MODEL" \
| tr '[:upper:]' '[:lower:]' \
| sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//; s/-{2,}/-/g')
if [ -z "$model_slug" ]; then
echo "::error::Could not generate model slug from '$MODEL'"
exit 1
fi
echo "model_slug=$model_slug" >> "$GITHUB_OUTPUT"
echo "log_path=tmp/evals/${slug}-${model_slug}.log" >> "$GITHUB_OUTPUT"
echo "json_path=tmp/evals/${slug}-${model_slug}.json" >> "$GITHUB_OUTPUT"
- name: Verify dataset exists
env:
DATASET: ${{ matrix.dataset }}
MODEL: ${{ matrix.model }}
run: |
bin/rails runner 'dataset = Eval::Dataset.find_by(name: ENV.fetch("DATASET")); abort("Dataset not found: #{ENV.fetch("DATASET")}") if dataset.nil?'
- name: Run eval
env:
DATASET: ${{ matrix.dataset }}
MODEL: ${{ matrix.model }}
OPENAI_ACCESS_TOKEN: ${{ secrets.OPENAI_ACCESS_TOKEN }}
run: |
set -euo pipefail
mkdir -p tmp/evals
bundle exec rake "evals:run[${DATASET},${MODEL}]" | tee "${{ steps.dataset_slug.outputs.log_path }}"
- name: Export run summary
id: export_summary
env:
DATASET: ${{ matrix.dataset }}
MODEL: ${{ matrix.model }}
JSON_PATH: ${{ steps.dataset_slug.outputs.json_path }}
run: |
set -euo pipefail
mkdir -p "$(dirname "$JSON_PATH")"
bin/rails runner '
dataset = Eval::Dataset.find_by!(name: ENV.fetch("DATASET"))
run = Eval::Run.where(dataset: dataset, model: ENV.fetch("MODEL")).order(created_at: :desc).first
abort("No eval run found for dataset #{dataset.name} and model #{ENV.fetch("MODEL")}") if run.nil?
payload = {
dataset: dataset.name,
dataset_metadata: dataset.metadata,
model: ENV.fetch("MODEL"),
run_id: run.id,
status: run.status,
created_at: run.created_at,
completed_at: run.completed_at,
total_prompt_tokens: run.total_prompt_tokens,
total_completion_tokens: run.total_completion_tokens,
total_cost: run.total_cost,
metrics: run.metrics,
accuracy: run.accuracy || 0.0,
duration_seconds: run.duration_seconds
}
File.write(ENV.fetch("JSON_PATH"), JSON.pretty_generate(payload))
'
echo "accuracy=$(jq -r '.accuracy // 0' "$JSON_PATH")" >> "$GITHUB_OUTPUT"
echo "status=$(jq -r '.status' "$JSON_PATH")" >> "$GITHUB_OUTPUT"
- name: Upload eval artifact
uses: actions/upload-artifact@v6
with:
name: llm-evals-${{ steps.dataset_slug.outputs.slug }}-${{ steps.dataset_slug.outputs.model_slug }}
path: |
${{ steps.dataset_slug.outputs.log_path }}
${{ steps.dataset_slug.outputs.json_path }}
if-no-files-found: error
retention-days: 30
- name: Output eval result
shell: bash
run: |
echo "### Eval Result: ${{ matrix.dataset }} / ${{ matrix.model }}" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "- **Status**: ${{ steps.export_summary.outputs.status }}" >> "$GITHUB_STEP_SUMMARY"
echo "- **Accuracy**: ${{ steps.export_summary.outputs.accuracy }}%" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
summarize_evals:
name: Summarize LLM Evals
needs: [check_openai, run_evals]
if: always() && needs.check_openai.outputs.should_run == 'true'
runs-on: ubuntu-latest
steps:
- name: Download all artifacts
uses: actions/download-artifact@v7
with:
path: eval-artifacts
pattern: llm-evals-*
- name: Generate summary
shell: bash
run: |
set -euo pipefail
echo "# 🧪 LLM Evals Results" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
printf "Triggered by: \`%s\`\n" "$GITHUB_REF" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "---" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
# Find all JSON result files
shopt -s globstar nullglob
json_files=(eval-artifacts/**/*.json)
if [ ${#json_files[@]} -eq 0 ]; then
echo "⚠️ No eval results found." >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
# Table header
echo "| Dataset | Model | Status | Accuracy | Cost | Duration |" >> "$GITHUB_STEP_SUMMARY"
echo "|---------|-------|--------|----------|------|----------|" >> "$GITHUB_STEP_SUMMARY"
all_passed=true
accuracy_threshold=70
for json_file in "${json_files[@]}"; do
dataset=$(jq -r '.dataset' "$json_file")
model=$(jq -r '.model' "$json_file")
status=$(jq -r '.status' "$json_file")
accuracy=$(jq -r '.accuracy // 0' "$json_file")
cost=$(jq -r '.total_cost // 0' "$json_file")
duration=$(jq -r '.duration_seconds // 0' "$json_file")
if [ "$status" = "completed" ] && awk -v accuracy="$accuracy" -v threshold="$accuracy_threshold" 'BEGIN { exit !((accuracy + 0) >= threshold) }'; then
icon="✅"
else
icon="❌"
all_passed=false
fi
printf '| %s | %s | %s %s | %s%% | \\$%s | %ss |\n' \
"$dataset" "$model" "$icon" "$status" "$accuracy" "$cost" "$duration" >> "$GITHUB_STEP_SUMMARY"
done
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "---" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
if [ "$all_passed" = "true" ]; then
echo "✅ **All evals passed!**" >> "$GITHUB_STEP_SUMMARY"
else
echo "❌ **Some evals failed. Check the details above.**" >> "$GITHUB_STEP_SUMMARY"
fi
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "📦 Artifacts with full logs are available for download." >> "$GITHUB_STEP_SUMMARY"
- name: Check eval thresholds
shell: bash
run: |
set -euo pipefail
shopt -s globstar nullglob
json_files=(eval-artifacts/**/*.json)
failed=0
accuracy_threshold=70
for json_file in "${json_files[@]}"; do
status=$(jq -r '.status' "$json_file")
accuracy=$(jq -r '.accuracy // 0' "$json_file")
dataset=$(jq -r '.dataset' "$json_file")
model=$(jq -r '.model' "$json_file")
if [ "$status" != "completed" ]; then
echo "::error::Eval for $dataset / $model did not complete successfully"
failed=$((failed + 1))
fi
# Fail if accuracy is below 70%
if awk -v accuracy="$accuracy" -v threshold="$accuracy_threshold" 'BEGIN { exit !((accuracy + 0) < threshold) }'; then
echo "::error::Accuracy for $dataset / $model is below threshold: ${accuracy}%"
failed=$((failed + 1))
fi
done
if [ $failed -gt 0 ]; then
echo "::error::$failed eval(s) failed or below threshold"
exit 1
fi
echo "All evals passed with acceptable accuracy."
skip_evals:
name: Skip evals (no valid OpenAI token/quota)
needs: check_openai
if: needs.check_openai.outputs.should_run != 'true'
runs-on: ubuntu-latest
steps:
- name: Report skip reason
run: |
echo "LLM evals were skipped gracefully."
echo "Reason: ${{ needs.check_openai.outputs.reason }}"

View File

@@ -64,21 +64,21 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
ref: ${{ github.ref }}
fetch-depth: 0
- name: Download Android APK artifact
continue-on-error: true
uses: actions/download-artifact@v4.3.0
uses: actions/download-artifact@v7
with:
name: app-release-apk
path: ${{ runner.temp }}/mobile-artifacts
- name: Download iOS build artifact
continue-on-error: true
uses: actions/download-artifact@v4.3.0
uses: actions/download-artifact@v7
with:
name: ios-build-unsigned
path: ${{ runner.temp }}/ios-build
@@ -170,7 +170,7 @@ jobs:
${{ runner.temp }}/release-assets/*
- name: Checkout gh-pages branch
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
ref: gh-pages
path: gh-pages

View File

@@ -23,7 +23,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0
@@ -71,12 +71,22 @@ jobs:
uses: ./.github/workflows/flutter-build.yml
secrets: inherit
play-store:
name: Upload Android to Google Play
if: ${{ needs.build.outputs.has_app_release_aab == 'true' }}
needs: [build, prepare_release]
uses: ./.github/workflows/google-play-upload.yml
with:
notes: "Mobile release ${{ needs.prepare_release.outputs.tag_name }}"
track: internal
secrets: inherit
testflight:
name: Upload iOS to TestFlight
needs: [build, release]
needs: [build, prepare_release]
uses: ./.github/workflows/ios-testflight.yml
with:
notes: "Mobile release ${{ needs.release.outputs.tag_name }}"
notes: "Mobile release ${{ needs.prepare_release.outputs.tag_name }}"
secrets: inherit
release:
@@ -102,13 +112,13 @@ jobs:
echo "Extracted version: $VERSION"
- name: Download Android APK artifact
uses: actions/download-artifact@v4.3.0
uses: actions/download-artifact@v7
with:
name: app-release-apk
path: ${{ runner.temp }}/mobile-artifacts
- name: Download iOS build artifact
uses: actions/download-artifact@v4.3.0
uses: actions/download-artifact@v7
with:
name: ios-build-unsigned
path: ${{ runner.temp }}/ios-build
@@ -160,13 +170,23 @@ jobs:
exit 1
fi
- name: Create GitHub Release
- name: Create or update GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
VERSION="${{ steps.version.outputs.version }}"
TAG="${{ steps.version.outputs.tag_name }}"
REPO="${{ github.repository }}"
mapfile -d '' -t RELEASE_ASSETS < <(
find "${{ runner.temp }}/release-assets" -maxdepth 1 -type f -print0
)
if [[ "${#RELEASE_ASSETS[@]}" -eq 0 ]]; then
echo "::error::No release assets were produced"
exit 1
fi
cat > /tmp/release-notes.md <<NOTES
## Mobile-Only Release: ${VERSION}
@@ -184,20 +204,61 @@ jobs:
# Strip heredoc indentation
sed -i 's/^ //' /tmp/release-notes.md
PRERELEASE_FLAG=""
RELEASE_ARGS=(
"--repo"
"$REPO"
"--title"
"$TAG"
"--notes-file"
/tmp/release-notes.md
)
if [[ "$TAG" == *"alpha"* ]] || [[ "$TAG" == *"beta"* ]] || [[ "$TAG" == *"rc"* ]]; then
PRERELEASE_FLAG="--prerelease"
RELEASE_ARGS+=("--prerelease")
fi
gh release create "$TAG" \
--repo "${{ github.repository }}" \
--title "$TAG" \
--notes-file /tmp/release-notes.md \
$PRERELEASE_FLAG \
${{ runner.temp }}/release-assets/*
if release_view_output="$(gh release view "$TAG" --repo "$REPO" 2>&1)"; then
is_immutable="$(gh release view "$TAG" --repo "$REPO" --json isImmutable --jq '.isImmutable')"
existing_assets="$(gh release view "$TAG" --repo "$REPO" --json assets --jq '.assets[].name')"
echo "Release ${TAG} already exists. Updating metadata."
gh release edit "$TAG" "${RELEASE_ARGS[@]}"
if [[ "$is_immutable" == "true" ]]; then
echo "Release is immutable. Verifying existing release assets are already present."
missing_assets=0
for asset in "${RELEASE_ASSETS[@]}"; do
asset_name="$(basename "$asset")"
if ! grep -Fxq "$asset_name" <<<"$existing_assets"; then
echo "::error::Immutable release ${TAG} is missing asset: ${asset_name}"
missing_assets=1
fi
done
if [[ "$missing_assets" != "0" ]]; then
echo "::error::Release assets cannot be changed on immutable releases. Recreate the release to update assets."
exit 1
fi
echo "Immutable release already has required assets. Skipping asset upload."
exit 0
fi
else
if ! echo "$release_view_output" | grep -Eiq "(not found|404)"; then
echo "::error::Failed to inspect existing release ${TAG}."
echo "$release_view_output"
exit 1
fi
echo "Creating release ${TAG} with attached assets."
gh release create "$TAG" "${RELEASE_ARGS[@]}" "${RELEASE_ASSETS[@]}"
exit 0
fi
for asset in "${RELEASE_ASSETS[@]}"; do
gh release upload "$TAG" "$asset" --repo "$REPO" --clobber
done
- name: Checkout gh-pages branch
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
ref: gh-pages
path: gh-pages

View File

@@ -11,7 +11,7 @@ jobs:
security-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
fetch-depth: 0
persist-credentials: false
@@ -29,3 +29,4 @@ jobs:
config/locales/views/reports/
docs/hosting/ai.md
app/models/provider/binance.rb
workers/preview/package-lock.json

216
.github/workflows/preview-cleanup.yml vendored Normal file
View File

@@ -0,0 +1,216 @@
name: Cleanup PR Previews
on:
# Run hourly to check for expired previews
schedule:
- cron: '0 * * * *'
# Immediately cleanup when PR is closed
pull_request:
types: [closed, unlabeled]
# Allow manual trigger
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to cleanup (optional, cleans all expired if empty)'
required: false
type: string
permissions:
contents: read
deployments: write
jobs:
cleanup-on-close:
name: Cleanup closed PR preview
if: github.event_name == 'pull_request' && (github.event.action == 'closed' || (github.event.action == 'unlabeled' && github.event.label.name == 'preview-cf'))
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: "24"
- name: Install Wrangler
run: npm install -g wrangler
- name: Delete preview Worker
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
WORKER_NAME="sure-preview-${{ github.event.pull_request.number }}"
echo "Deleting Worker: $WORKER_NAME"
# Delete the worker (this also stops any running containers)
wrangler delete --name "$WORKER_NAME" --force || echo "Worker may not exist"
- name: Delete GitHub Deployment
uses: actions/github-script@v7
with:
script: |
const environment = `preview-pr-${{ github.event.pull_request.number }}`;
const description = context.payload.action === 'closed'
? 'PR closed - preview deleted'
: 'preview-cf label removed - preview deleted';
try {
// Get deployments for this environment
const { data: deployments } = await github.rest.repos.listDeployments({
owner: context.repo.owner,
repo: context.repo.repo,
environment: environment
});
// Mark all deployments as inactive
for (const deployment of deployments) {
await github.rest.repos.createDeploymentStatus({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: deployment.id,
state: 'inactive',
description
});
}
console.log(`Marked ${deployments.length} deployments as inactive`);
} catch (error) {
console.log('No deployments to cleanup or error:', error.message);
}
cleanup-expired:
name: Cleanup expired previews
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: "24"
- name: Install Wrangler
run: npm install -g wrangler
- name: Cleanup expired previews
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_INPUT: ${{ inputs.pr_number }}
run: |
# If specific PR number provided, only cleanup that one
if [ -n "$PR_INPUT" ]; then
if [[ "$PR_INPUT" =~ ^[1-9][0-9]*$ ]]; then
PR_NUM="$PR_INPUT"
WORKER_NAME="sure-preview-$PR_NUM"
echo "Manually deleting Worker: $WORKER_NAME"
wrangler delete --name "$WORKER_NAME" --force || echo "Worker may not exist"
# Cleanup GitHub deployment for this PR
echo "Cleaning up GitHub deployment for PR #$PR_NUM"
gh api \
-X GET "/repos/${{ github.repository }}/deployments?environment=preview-pr-$PR_NUM" \
--jq '.[].id' 2>/dev/null | while read -r DEPLOY_ID; do
if [ -n "$DEPLOY_ID" ]; then
gh api \
-X POST "/repos/${{ github.repository }}/deployments/$DEPLOY_ID/statuses" \
-f state=inactive \
-f description="Preview manually deleted" || true
fi
done || echo "No deployments to cleanup or error occurred"
else
echo "Invalid PR number input '$PR_INPUT'; skipping manual cleanup"
fi
exit 0
fi
# Get list of all preview workers
echo "Fetching list of preview workers..."
# Use Cloudflare API to list workers and read modified_on from the list response.
# The per-script endpoint returns raw script content, not JSON metadata.
WORKERS_RESPONSE=$(curl -fsS -X GET \
"https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/scripts" \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
-H "Content-Type: application/json") || {
echo "Failed to fetch preview worker list from Cloudflare"
exit 1
}
if ! echo "$WORKERS_RESPONSE" | jq -e '.success == true and (.result | type == "array")' >/dev/null 2>&1; then
echo "Cloudflare API returned an invalid worker list response"
echo "$WORKERS_RESPONSE" | jq -c '.errors // .'
exit 1
fi
WORKERS=$(echo "$WORKERS_RESPONSE" | jq -r '
.result[]
| select(.id | startswith("sure-preview-"))
| [.id, (.modified_on // "")]
| @tsv
')
if [ -z "$WORKERS" ]; then
echo "No preview workers found"
exit 0
fi
echo "Found preview workers:"
echo "$WORKERS" | cut -f1
# Check each worker's deployment time
CUTOFF_TIME=$(date -d '24 hours ago' +%s)
while IFS=$'\t' read -r WORKER MODIFIED_ON; do
[ -n "$WORKER" ] || continue
echo "Checking $WORKER..."
if [ -z "$MODIFIED_ON" ]; then
echo "No modified_on timestamp for $WORKER; skipping"
continue
fi
if ! MODIFIED_TS=$(date -d "$MODIFIED_ON" +%s 2>/dev/null); then
echo "Invalid modified_on timestamp for $WORKER ($MODIFIED_ON); skipping"
continue
fi
if [ "$MODIFIED_TS" -lt "$CUTOFF_TIME" ]; then
echo "Worker $WORKER is older than 24 hours, deleting..."
if wrangler delete --name "$WORKER" --force; then
# Extract PR number and cleanup GitHub deployment
PR_NUM=$(echo "$WORKER" | sed 's/sure-preview-//')
if [[ "$PR_NUM" =~ ^[1-9][0-9]*$ ]]; then
echo "Cleaning up GitHub deployment for PR #$PR_NUM"
gh api \
-X GET "/repos/${{ github.repository }}/deployments?environment=preview-pr-$PR_NUM" \
--jq '.[].id' 2>/dev/null | while read -r DEPLOY_ID; do
gh api \
-X POST "/repos/${{ github.repository }}/deployments/$DEPLOY_ID/statuses" \
-f state=inactive \
-f description="Preview expired after 24 hours" || true
done || echo "No deployments to cleanup or error occurred"
else
echo "Could not extract a valid PR number from $WORKER; skipping deployment cleanup"
fi
else
echo "Failed to delete $WORKER; skipping deployment status update"
fi
else
echo "Worker $WORKER is still within 24-hour window, keeping..."
fi
done <<< "$WORKERS"
echo "Cleanup complete"

189
.github/workflows/preview-deploy.yml vendored Normal file
View File

@@ -0,0 +1,189 @@
name: Deploy PR Preview
on:
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled]
paths-ignore:
- 'charts/**'
- 'docs/**'
- '*.md'
jobs:
deploy-preview:
if: contains(github.event.pull_request.labels.*.name, 'preview-cf')
name: Deploy to Cloudflare Containers
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
actions: read
contents: read
pull-requests: write
deployments: write
steps:
- name: Wait for PR CI to pass
uses: actions/github-script@v7
with:
script: |
const headSha = context.payload.pull_request.head.sha;
const timeoutMs = 10 * 60 * 1000;
const pollMs = 15 * 1000;
const startedAt = Date.now();
let lastState = 'not found';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
while (Date.now() - startedAt < timeoutMs) {
const { data } = await github.rest.actions.listWorkflowRunsForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
event: 'pull_request',
head_sha: headSha,
per_page: 20,
});
const prRun = data.workflow_runs.find((run) => run.name === 'Pull Request' && run.head_sha === headSha);
if (prRun) {
lastState = `${prRun.status}/${prRun.conclusion ?? 'pending'}`;
core.info(`Pull Request workflow ${prRun.id}: ${lastState}`);
if (prRun.status === 'completed') {
if (prRun.conclusion === 'success') {
return;
}
core.setFailed(`Pull Request workflow concluded with ${prRun.conclusion}`);
return;
}
}
await sleep(pollMs);
}
core.setFailed(`Timed out waiting for Pull Request workflow for ${headSha}. Last state: ${lastState}`);
- name: Checkout code
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "24"
- name: Install Wrangler dependencies
working-directory: workers/preview
run: npm install
- name: Configure preview files for this PR
working-directory: workers/preview
run: |
sed -i "s/\${PR_NUMBER}/${{ github.event.pull_request.number }}/g" wrangler.toml
sed -i "s/\${PR_NUMBER}/${{ github.event.pull_request.number }}/g" src/index.ts
cat wrangler.toml
- name: Create GitHub Deployment
id: deployment
uses: actions/github-script@v7
with:
script: |
const deployment = await github.rest.repos.createDeployment({
owner: context.repo.owner,
repo: context.repo.repo,
ref: context.payload.pull_request.head.sha,
environment: `preview-pr-${{ github.event.pull_request.number }}`,
auto_merge: false,
required_contexts: [],
description: 'PR Preview Deployment'
});
return deployment.data.id;
result-encoding: string
- name: Deploy to Cloudflare Containers
id: deploy
working-directory: workers/preview
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
npx wrangler deploy --var "PR_NUMBER:${{ github.event.pull_request.number }}"
# Get the deployment URL
PREVIEW_URL="https://sure-preview-${{ github.event.pull_request.number }}.${{ secrets.CLOUDFLARE_WORKERS_SUBDOMAIN }}.workers.dev"
echo "preview_url=${PREVIEW_URL}" >> "$GITHUB_OUTPUT"
- name: Warm preview container
env:
PREVIEW_URL: ${{ steps.deploy.outputs.preview_url }}
run: |
echo "Triggering preview wake-up..."
curl -fsS "$PREVIEW_URL/" >/dev/null || true
- name: Update Deployment Status
if: always() && steps.deployment.outputs.result
uses: actions/github-script@v7
with:
script: |
const state = '${{ job.status }}' === 'success' ? 'success' : 'failure';
await github.rest.repos.createDeploymentStatus({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: ${{ steps.deployment.outputs.result }},
state: state,
environment_url: state === 'success' ? '${{ steps.deploy.outputs.preview_url }}' : undefined,
description: state === 'success' ? 'Preview deployed successfully' : 'Preview deployment failed'
});
- name: Comment on PR
if: success()
uses: actions/github-script@v7
with:
script: |
const previewUrl = '${{ steps.deploy.outputs.preview_url }}';
const commentBody = `## 🚀 Preview Deployment Ready
Your preview environment has been deployed to Cloudflare Containers with the PR's Docker image.
**Preview URL:** ${previewUrl}
> ⏰ This preview is intended to be cleaned up after **24 hours** of the last deployment once the cleanup workflow is live on the default branch.
> 💤 The container will sleep after 30 minutes of inactivity and wake on the next request.
---
<sub>Deployed from commit ${{ github.event.pull_request.head.sha }}</sub>`;
// Find existing comment
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: ${{ github.event.pull_request.number }}
});
const botComment = comments.find(comment =>
comment.user.type === 'Bot' &&
comment.body.includes('Preview Deployment Ready')
);
if (botComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: commentBody
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: ${{ github.event.pull_request.number }},
body: commentBody
});
}
- name: Store cleanup metadata
if: success()
uses: actions/upload-artifact@v6
with:
name: preview-cleanup-pr-${{ github.event.pull_request.number }}
path: |
workers/preview/wrangler.toml
retention-days: 2

View File

@@ -55,9 +55,14 @@ jobs:
fail-fast: false
matrix:
platform: [amd64, arm64]
include:
- platform: amd64
runs-on: ubuntu-24.04
- platform: arm64
runs-on: ubuntu-24.04-arm
timeout-minutes: 60
runs-on: ${{ matrix.platform == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }}
runs-on: ${{ matrix.runs-on }}
outputs:
tags: ${{ steps.meta.outputs.tags }}
@@ -68,15 +73,15 @@ jobs:
steps:
- name: Check out the repo
uses: actions/checkout@v4.2.0
uses: actions/checkout@v5
with:
ref: ${{ github.event.inputs.ref || github.ref }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.10.0
uses: docker/setup-buildx-action@v4
- name: Log in to the container registry
uses: docker/login-action@v3.3.0
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -98,7 +103,6 @@ jobs:
BASE_CONFIG+=$'\n'"type=raw,value=latest"
else
BASE_CONFIG+=$'\n'"type=raw,value=stable"
BASE_CONFIG+=$'\n'"type=raw,value=latest"
fi
fi
fi
@@ -114,7 +118,7 @@ jobs:
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5.6.0
uses: docker/metadata-action@v6
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
flavor: latest=false
@@ -128,7 +132,7 @@ jobs:
org.opencontainers.image.description=A multi-arch Docker image for the Sure Rails app
- name: Publish 'linux/${{ matrix.platform }}' image by digest
uses: docker/build-push-action@v6.16.0
uses: docker/build-push-action@v7
id: build
with:
context: .
@@ -154,7 +158,7 @@ jobs:
- name: Upload the Docker image digest
if: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || github.event_name == 'schedule' || github.event.inputs.push }}
uses: actions/upload-artifact@v4.6.2
uses: actions/upload-artifact@v6
with:
name: digest-${{ matrix.platform }}
path: ${{ runner.temp }}/digests/*
@@ -174,17 +178,17 @@ jobs:
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.10.0
uses: docker/setup-buildx-action@v4
- name: Download Docker image digests
uses: actions/download-artifact@v4.3.0
uses: actions/download-artifact@v7
with:
path: ${{ runner.temp }}/digests
pattern: digest-*
merge-multiple: true
- name: Log in to the container registry
uses: docker/login-action@v3.3.0
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -271,19 +275,19 @@ jobs:
steps:
- name: Download Android APK artifact
uses: actions/download-artifact@v4.3.0
uses: actions/download-artifact@v7
with:
name: app-release-apk
path: ${{ runner.temp }}/mobile-artifacts
- name: Download iOS build artifact
uses: actions/download-artifact@v4.3.0
uses: actions/download-artifact@v7
with:
name: ios-build-unsigned
path: ${{ runner.temp }}/ios-build
- name: Download Helm chart artifact
uses: actions/download-artifact@v4.3.0
uses: actions/download-artifact@v7
with:
name: helm-chart-package
path: ${{ runner.temp }}/helm-artifacts
@@ -334,7 +338,7 @@ jobs:
ls -la "${{ runner.temp }}/release-assets/"
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
@@ -421,14 +425,14 @@ jobs:
echo "branch=$SOURCE_BRANCH" >> $GITHUB_OUTPUT
- name: Check out source branch
uses: actions/checkout@v4.2.0
uses: actions/checkout@v5
with:
ref: ${{ steps.source_branch.outputs.branch }}
token: ${{ secrets.GH_PAT }}
token: ${{ github.token }}
- name: Bump pre-release version
run: |
VERSION_FILE="config/initializers/version.rb"
VERSION_FILE=".sure-version"
CHART_FILE="charts/sure/Chart.yaml"
# Ensure version file exists
@@ -444,7 +448,7 @@ jobs:
fi
# Extract current version
CURRENT_VERSION=$(grep -oP '"\K[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc)\.[0-9]+' "$VERSION_FILE")
CURRENT_VERSION=$(cat "$VERSION_FILE" | tr -d '[:space:]')
if [ -z "$CURRENT_VERSION" ]; then
echo "ERROR: Could not extract version from $VERSION_FILE"
exit 1
@@ -475,11 +479,11 @@ jobs:
echo "New version: $NEW_VERSION"
# Update the version file
sed -i "s/\"$CURRENT_VERSION\"/\"$NEW_VERSION\"/" "$VERSION_FILE"
echo "$NEW_VERSION" > "$VERSION_FILE"
# Verify the change
echo "Updated version.rb:"
grep "semver" "$VERSION_FILE"
echo "Updated .sure-version:"
cat "$VERSION_FILE"
# Update Helm chart version and appVersion
sed -i -E "s/^version: .*/version: ${NEW_VERSION}/" "$CHART_FILE"
@@ -496,7 +500,7 @@ jobs:
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add config/initializers/version.rb
git add .sure-version
git add charts/sure/Chart.yaml
# Check if there are changes to commit

2
.gitignore vendored
View File

@@ -123,4 +123,4 @@ scripts/
.auto-claude-status
.claude_settings.json
.security-key
logs/security/
logs/security/

View File

@@ -470,14 +470,14 @@ Use the rules below when:
## Rules for AI (mandatory)
The codebase uses TailwindCSS v4.x (the newest version) with a custom design system defined in [maybe-design-system.css](app/assets/tailwind/maybe-design-system.css)
The codebase uses TailwindCSS v4.x (the newest version) with a custom design system defined in [sure-design-system.css](app/assets/tailwind/sure-design-system.css)
- Always start by referencing [maybe-design-system.css](app/assets/tailwind/maybe-design-system.css) to see the base primitives, functional tokens, and component tokens we use in the codebase
- Always prefer using the functional "tokens" defined in @maybe-design-system.css when possible.
- Always start by referencing [sure-design-system.css](app/assets/tailwind/sure-design-system.css) to see the base primitives, functional tokens, and component tokens we use in the codebase
- Always prefer using the functional "tokens" defined in @sure-design-system.css when possible.
- Example 1: use `text-primary` rather than `text-white`
- Example 2: use `bg-container` rather than `bg-white`
- Example 3: use `border border-primary` rather than `border border-gray-200`
- Never create new styles in [maybe-design-system.css](app/assets/tailwind/maybe-design-system.css) or [application.css](app/assets/tailwind/application.css) without explicitly receiving permission to do so
- Never create new styles in [sure-design-system.css](app/assets/tailwind/sure-design-system.css) or [application.css](app/assets/tailwind/application.css) without explicitly receiving permission to do so
- Always generate semantic HTML
```

1
.sure-version Normal file
View File

@@ -0,0 +1 @@
0.7.1-alpha.10

View File

@@ -48,6 +48,21 @@ When adding or modifying API endpoints in `app/controllers/api/v1/`, you **MUST*
### Post-commit API consistency (LLM checklist)
After every API endpoint commit, ensure: (1) **Minitest** behavioral coverage in `test/controllers/api/v1/{resource}_controller_test.rb` (no behavioral assertions in rswag); (2) **rswag** remains docs-only (no `expect`/`assert_*` in `spec/requests/api/v1/`); (3) **rswag auth** uses the same API key pattern everywhere (`X-Api-Key`, not OAuth/Bearer). Full checklist: [.cursor/rules/api-endpoint-consistency.mdc](.cursor/rules/api-endpoint-consistency.mdc).
## Design System Hygiene (UI PRs)
When a PR touches `.erb`, view components, or `.css`:
1. **Tokens, not palette.** Use functional tokens from `app/assets/tailwind/sure-design-system.css` (`bg-warning/10`, `text-destructive`, `bg-container`, `text-primary`, `border-primary`). No raw Tailwind palette (`bg-blue-50`, `text-red-500`, hex literals).
2. **Reach for `DS::*` first.** Check `app/components/DS/` (`DS::Alert`, `DS::Button`, `DS::Disclosure`, `DS::Dialog`, `DS::Menu`, etc.) before writing an alert, badge, button, disclosure, dialog, or input shape.
3. **Two copies → lift to DS.** Same hand-rolled shape ≥2× in a diff with no DS equivalent → propose a new `DS::*` primitive before the second copy lands.
4. **Conventions.** Use the `icon` helper (never `lucide_icon` directly), no raw SVG outside DS primitives, user-facing strings via `t()`, avoid arbitrary `*-[Npx]` values when a scale token fits.
Reviewers escalate violations of (2)(3) to close/rewrite; (1) and (4) are request-changes.
## Securities Providers
If you need to add a new securities price provider (Tiingo, EODHD, Binance-style crypto, etc.), see [adding-a-securities-provider.md](./docs/llm-guides/adding-a-securities-provider.md) for the full walkthrough — provider class, registry wiring, MIC handling, settings UI, locales, and tests.
## Providers: Pending Transactions and FX Metadata (SimpleFIN/Plaid/Lunchflow)
- Pending detection

View File

@@ -138,7 +138,7 @@ Sidekiq handles asynchronous tasks:
- **Stimulus Controllers**: Handle interactivity, organized alongside components
- **Charts**: D3.js for financial visualizations (time series, donut, sankey)
- **Styling**: Tailwind CSS v4.x with custom design system
- Design system defined in `app/assets/tailwind/maybe-design-system.css`
- Design system defined in `app/assets/tailwind/sure-design-system.css`
- Always use functional tokens (e.g., `text-primary` not `text-white`)
- Prefer semantic HTML elements over JS components
- Use `icon` helper for icons, never `lucide_icon` directly
@@ -222,7 +222,7 @@ Sidekiq handles asynchronous tasks:
## TailwindCSS Design System
### Design System Rules
- **Always reference `app/assets/tailwind/maybe-design-system.css`** for primitives and tokens
- **Always reference `app/assets/tailwind/sure-design-system.css`** for primitives and tokens
- **Use functional tokens** defined in design system:
- `text-primary` instead of `text-white`
- `bg-container` instead of `bg-white`

247
Dockerfile.preview Normal file
View File

@@ -0,0 +1,247 @@
# syntax = docker/dockerfile:1
# Preview Dockerfile for Cloudflare Containers
# Includes PostgreSQL and Redis for self-contained development testing
ARG RUBY_VERSION=3.4.7
FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim AS base
WORKDIR /rails
# Install base packages including PostgreSQL and Redis servers
RUN apt-get update -qq \
&& apt-get install --no-install-recommends -y \
curl libvips postgresql postgresql-client redis-server libyaml-0-2 procps sudo openssl strace \
&& rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Set development environment
ARG BUILD_COMMIT_SHA
ENV RAILS_ENV="development" \
BUNDLE_PATH="/usr/local/bundle" \
BUILD_COMMIT_SHA=${BUILD_COMMIT_SHA}
# Build stage
FROM base AS build
RUN apt-get update -qq \
&& apt-get install --no-install-recommends -y build-essential libpq-dev git pkg-config libyaml-dev \
&& rm -rf /var/lib/apt/lists /var/cache/apt/archives
COPY .ruby-version Gemfile Gemfile.lock ./
RUN bundle install \
&& rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git \
&& bundle exec bootsnap precompile --gemfile -j 0
COPY . .
RUN bundle exec bootsnap precompile -j 0 app/ lib/
# Precompile assets
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
# Final stage
FROM base
# Create rails user and configure PostgreSQL/Redis permissions
RUN groupadd --system --gid 1000 rails && \
useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \
echo "rails ALL=(ALL) NOPASSWD: /usr/bin/pg_ctlcluster, /usr/bin/redis-server" > /etc/sudoers.d/rails && \
chmod 0440 /etc/sudoers.d/rails
# Configure PostgreSQL to allow local connections
RUN PG_HBA=$(find /etc/postgresql -name pg_hba.conf 2>/dev/null | head -1) && \
if [ -n "$PG_HBA" ]; then \
echo "local all all trust" > "$PG_HBA" && \
echo "host all all 127.0.0.1/32 trust" >> "$PG_HBA" && \
echo "host all all ::1/128 trust" >> "$PG_HBA"; \
fi
# Create database directory with correct permissions
RUN mkdir -p /var/run/postgresql && \
chown -R postgres:postgres /var/run/postgresql && \
chmod 2775 /var/run/postgresql
# Copy built artifacts
COPY --chown=rails:rails --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --chown=rails:rails --from=build /rails /rails
# Create preview entrypoint script inline
RUN cat > /rails/bin/preview-entrypoint << 'ENTRYPOINT_EOF'
#!/bin/bash
set -e
cd /rails
emit_status() {
if [ -n "$PREVIEW_ORIGIN" ]; then
local stage="$1"
local detail="$2"
local payload
payload=$(STAGE="$stage" DETAIL="$detail" ruby -rjson -e 'print JSON.generate({stage: ENV.fetch("STAGE"), detail: ENV.fetch("DETAIL", "")})' 2>/dev/null) || return 0
curl -fsS -X POST "$PREVIEW_ORIGIN/_container_event" \
-H 'content-type: application/json' \
--data "$payload" >/dev/null || true
fi
}
trap 'emit_status failed "preview-entrypoint failed on line ${LINENO}"' ERR
emit_status boot "preview-entrypoint started"
REDIS_READY=0
POSTGRES_READY=0
# Start Redis
echo "Starting Redis..."
emit_status redis-start "starting redis"
sudo redis-server --daemonize yes --bind 127.0.0.1
# Wait for Redis to be ready
echo "Waiting for Redis to be ready..."
for i in {1..10}; do
if redis-cli ping > /dev/null 2>&1; then
echo "Redis is ready"
emit_status redis-ready "redis is ready"
REDIS_READY=1
break
fi
sleep 1
done
if [ "$REDIS_READY" -ne 1 ]; then
echo "Redis did not become ready in time"
exit 1
fi
# Start PostgreSQL
echo "Starting PostgreSQL..."
emit_status postgres-start "starting postgres"
PG_VERSION=$(ls /etc/postgresql/ | sort -V | tail -1)
if [ -z "$PG_VERSION" ]; then
echo "Could not determine installed PostgreSQL version"
exit 1
fi
if sudo pg_ctlcluster --skip-systemctl-redirect "$PG_VERSION" main status > /dev/null 2>&1; then
emit_status postgres-already-running "postgres cluster already running"
else
sudo pg_ctlcluster --skip-systemctl-redirect "$PG_VERSION" main start
fi
# Wait for PostgreSQL to be ready
echo "Waiting for PostgreSQL to be ready..."
for i in {1..30}; do
if pg_isready -h localhost -U postgres > /dev/null 2>&1; then
echo "PostgreSQL is ready"
emit_status postgres-ready "postgres is ready"
POSTGRES_READY=1
break
fi
sleep 1
done
if [ "$POSTGRES_READY" -ne 1 ]; then
echo "PostgreSQL did not become ready in time"
exit 1
fi
# Create database user and database if they don't exist
echo "Setting up database..."
emit_status db-setup "setting up database"
psql -h localhost -U postgres -tc "SELECT 1 FROM pg_roles WHERE rolname='rails'" | grep -q 1 || \
psql -h localhost -U postgres -c "CREATE USER rails WITH SUPERUSER PASSWORD 'rails';"
psql -h localhost -U postgres -tc "SELECT 1 FROM pg_database WHERE datname='sure_development'" | grep -q 1 || \
psql -h localhost -U postgres -c "CREATE DATABASE sure_development OWNER rails;"
# Set DATABASE_URL if not already set
export DATABASE_URL="${DATABASE_URL:-postgres://rails:rails@localhost:5432/sure_development}"
# Set REDIS_URL if not already set
export REDIS_URL="${REDIS_URL:-redis://localhost:6379/0}"
# Generate SECRET_KEY_BASE if not set
export SECRET_KEY_BASE="${SECRET_KEY_BASE:-$(openssl rand -hex 64)}"
# Run database migrations
echo "Running database migrations..."
emit_status db-prepare "running rails db:prepare"
/rails/bin/rails db:prepare
emit_status db-prepare-done "rails db:prepare finished"
# Defer all demo-data creation until after Rails is up so preview can boot first
echo "Checking demo dataset..."
emit_status demo-data-check "checking for default demo user"
DEMO_EMAIL="${DEMO_USER_EMAIL:-user@example.com}"
DEMO_EMAIL_SQL=${DEMO_EMAIL//\'/\'\'}
DEMO_SEED="${DEMO_DATA_SEED:-880}"
DEMO_HAS_USER=0
DEMO_HAS_DATA=0
if psql "$DATABASE_URL" -tAc "SELECT 1 FROM users WHERE email = '${DEMO_EMAIL_SQL}' LIMIT 1" | grep -q 1; then
DEMO_HAS_USER=1
emit_status demo-data-user-present "default demo user already exists"
fi
if psql "$DATABASE_URL" -tAc "SELECT 1 FROM accounts a JOIN users u ON u.family_id = a.family_id WHERE u.email = '${DEMO_EMAIL_SQL}' LIMIT 1" | grep -q 1; then
DEMO_HAS_DATA=1
emit_status demo-data-skip "demo financial data already exists"
else
emit_status demo-data-deferred "deferring demo data creation until after rails boot"
fi
# Execute the main command with an internal readiness probe
echo "Starting Rails server..."
emit_status rails-start "starting rails server"
"$@" > /tmp/rails.log 2>&1 &
RAILS_PID=$!
for i in {1..180}; do
if curl -fsS http://127.0.0.1:3000/up > /dev/null 2>&1; then
emit_status rails-up-ready "rails responded on localhost:3000/up"
if [ "$DEMO_HAS_USER" -ne 1 ] || [ "$DEMO_HAS_DATA" -ne 1 ]; then
emit_status demo-data-load "creating/backfilling demo dataset in background (seed=${DEMO_SEED})"
(
(
DEMO_USER_EMAIL="$DEMO_EMAIL" DEMO_DATA_SEED="$DEMO_SEED" /rails/bin/rails runner '
email = ENV.fetch("DEMO_USER_EMAIL")
generator = Demo::Generator.new(seed: ENV.fetch("DEMO_DATA_SEED"))
user = User.find_by(email: email)
unless user
generator.generate_empty_data!(skip_clear: true)
user = User.find_by!(email: email)
end
has_accounts = user.family.accounts.exists?
generator.generate_new_user_data_for!(user.family, email: user.email) unless has_accounts
'
) > /tmp/demo-data.log 2>&1 && \
emit_status demo-data-ready "default demo dataset loaded in background" || \
emit_status demo-data-failed "background demo dataset load failed"
) &
fi
break
fi
sleep 1
done
if ! curl -fsS http://127.0.0.1:3000/up > /dev/null 2>&1; then
emit_status rails-up-timeout "rails did not answer localhost:3000/up in time"
emit_status rails-process-status "$(ps -o pid=,ppid=,stat=,comm=,args= -p "$RAILS_PID" 2>/dev/null | tr -s ' ' | sed 's/^ //')"
emit_status rails-process-wchan "$(cat /proc/$RAILS_PID/wchan 2>/dev/null | tr '\n' ' ' | cut -c 1-200)"
emit_status rails-process-children "$(ps -o pid=,ppid=,stat=,comm=,args= --ppid "$RAILS_PID" 2>/dev/null | tail -n +2 | tr '\n' '|' | cut -c 1-600)"
emit_status rails-socket-state "$(ruby -e 'hex="0BB8"; rows=File.readlines("/proc/net/tcp")+File.readlines("/proc/net/tcp6"); hits=rows.select{|l| l.include?(":#{hex} ")}.map{|l| l.strip.split[3] rescue nil}.compact; puts(hits.empty? ? "no-listener" : hits.join(","))' 2>&1 | tr '\n' ' ' | cut -c 1-400)"
emit_status rails-log-tail "$(tail -n 40 /tmp/rails.log 2>&1 | sed 's/"/'"'"'/g' | tr '\n' ' ' | cut -c 1-1200)"
fi
wait "$RAILS_PID"
ENTRYPOINT_EOF
RUN chmod 755 /rails/bin/preview-entrypoint && chown rails:rails /rails/bin/preview-entrypoint
USER 1000:1000
ENTRYPOINT ["/rails/bin/preview-entrypoint"]
EXPOSE 3000
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]

View File

@@ -48,6 +48,7 @@ gem "skylight", groups: [ :production ]
# Active Storage
gem "aws-sdk-s3", "~> 1.208.0", require: false
gem "google-cloud-storage", "~> 1.59", require: false
gem "image_processing", ">= 1.2"
# Other
@@ -81,6 +82,7 @@ gem "snaptrade", "~> 2.0"
gem "httparty"
gem "rotp", "~> 6.3"
gem "rqrcode", "~> 3.0"
gem "webauthn", "~> 3.4"
gem "activerecord-import"
gem "rubyzip", "~> 2.3"
gem "pdf-reader", "~> 2.12"

View File

@@ -86,6 +86,7 @@ GEM
after_commit_everywhere (1.6.0)
activerecord (>= 4.2)
activesupport
android_key_attestation (0.3.0)
ast (2.4.3)
attr_required (1.0.2)
aws-eventstream (1.4.0)
@@ -135,6 +136,7 @@ GEM
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
cbor (0.5.10.2)
cgi (0.5.1)
childprocess (5.1.0)
logger (~> 1.5)
@@ -142,6 +144,9 @@ GEM
climate_control (1.2.0)
concurrent-ruby (1.3.6)
connection_pool (2.5.5)
cose (1.3.1)
cbor (~> 0.5.9)
openssl-signature_algorithm (~> 1.0)
countries (8.0.3)
unaccent (~> 0.3)
crack (1.0.0)
@@ -158,6 +163,7 @@ GEM
debug (1.11.0)
irb (~> 1.10)
reline (>= 0.3.8)
declarative (0.0.20)
derailed_benchmarks (2.2.1)
base64
benchmark-ips (~> 2)
@@ -177,6 +183,8 @@ GEM
ruby2_keywords
thor (>= 0.19, < 2)
diff-lcs (1.6.2)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
docile (1.4.1)
doorkeeper (5.8.2)
railties (>= 5)
@@ -231,6 +239,43 @@ GEM
ffi (~> 1.0)
globalid (1.3.0)
activesupport (>= 6.1)
google-apis-core (1.0.2)
addressable (~> 2.8, >= 2.8.7)
faraday (~> 2.13)
faraday-follow_redirects (~> 0.3)
googleauth (~> 1.14)
mini_mime (~> 1.1)
representable (~> 3.0)
retriable (~> 3.1)
google-apis-iamcredentials_v1 (0.26.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.61.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.8.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (2.3.1)
base64 (~> 0.2)
faraday (>= 1.0, < 3.a)
google-cloud-errors (1.6.0)
google-cloud-storage (1.59.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-core (>= 0.18, < 2)
google-apis-iamcredentials_v1 (~> 0.18)
google-apis-storage_v1 (>= 0.42)
google-cloud-core (~> 1.6)
googleauth (~> 1.9)
mini_mime (~> 1.0)
google-logging-utils (0.2.0)
googleauth (1.16.2)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.2)
google-logging-utils (~> 0.1)
jwt (>= 1.4, < 4.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
hashdiff (1.2.0)
hashery (2.1.2)
hashie (5.0.0)
@@ -361,6 +406,7 @@ GEM
mocha (2.7.1)
ruby2_keywords (>= 0.0.5)
msgpack (1.8.0)
multi_json (1.20.1)
multi_xml (0.8.0)
bigdecimal (>= 3.1, < 5)
multipart-post (2.4.1)
@@ -441,6 +487,10 @@ GEM
tzinfo
validate_url
webfinger (~> 2.0)
openssl (4.0.1)
openssl-signature_algorithm (1.3.0)
openssl (> 2.0)
os (1.1.4)
ostruct (0.6.2)
pagy (9.3.5)
parallel (1.27.0)
@@ -500,7 +550,7 @@ GEM
base64 (>= 0.1.0)
logger (>= 1.6.0)
rack (>= 3.0.0, < 4)
rack-session (2.1.1)
rack-session (2.1.2)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.2.0)
@@ -563,6 +613,11 @@ GEM
regexp_parser (2.10.0)
reline (0.6.1)
io-console (~> 0.5)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.4.1)
rexml (3.4.2)
rotp (6.3.0)
rouge (4.5.2)
@@ -647,6 +702,8 @@ GEM
logger
ruby2_keywords (0.0.5)
rubyzip (2.4.1)
safety_net_attestation (0.5.0)
jwt (>= 2.0, < 4.0)
sawyer (0.9.2)
addressable (>= 2.3.5)
faraday (>= 0.17.3, < 3)
@@ -681,6 +738,11 @@ GEM
concurrent-ruby (~> 1.0, >= 1.0.5)
sidekiq (>= 7.0.0, < 9.0.0)
thor (>= 1.0, < 3.0)
signet (0.21.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 4.0)
multi_json (~> 1.10)
simplecov (0.22.0)
docile (~> 1.1)
simplecov-html (~> 0.11)
@@ -720,6 +782,11 @@ GEM
unicode-display_width (>= 1.1.1, < 4)
thor (1.4.0)
timeout (0.6.1)
tpm-key_attestation (0.14.1)
bindata (~> 2.4)
openssl (> 2.0)
openssl-signature_algorithm (~> 1.0)
trailblazer-option (0.1.2)
tsort (0.2.0)
ttfunk (1.8.0)
bigdecimal (~> 3.1)
@@ -728,6 +795,7 @@ GEM
railties (>= 7.1.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
uber (0.1.0)
unaccent (0.4.0)
unicode (0.4.4.5)
unicode-display_width (3.1.4)
@@ -751,6 +819,14 @@ GEM
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
webauthn (3.4.3)
android_key_attestation (~> 0.3.0)
bindata (~> 2.4)
cbor (~> 0.5.9)
cose (~> 1.1)
openssl (>= 2.2)
safety_net_attestation (~> 0.5.0)
tpm-key_attestation (~> 0.14.0)
webfinger (2.1.3)
activesupport
faraday (~> 2.0)
@@ -804,6 +880,7 @@ DEPENDENCIES
faraday-multipart
faraday-retry
foreman
google-cloud-storage (~> 1.59)
hotwire-livereload
hotwire_combobox
httparty
@@ -874,6 +951,7 @@ DEPENDENCIES
vernier
view_component
web-console
webauthn (~> 3.4)
webmock
RUBY VERSION

View File

@@ -1,3 +1,3 @@
web: bundle exec ${DEBUG:+rdbg -O -n -c --} bin/rails server -b 0.0.0.0
web: bundle exec ${DEBUG:+rdbg -O -n -c --} bin/rails server
css: bundle exec bin/rails tailwindcss:watch 2>/dev/null
worker: bundle exec sidekiq

View File

@@ -1,6 +1,7 @@
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/we-promise/sure)
[![View performance data on Skylight](https://badges.skylight.io/typical/s6PEZSKwcklL.svg)](https://oss.skylight.io/app/applications/s6PEZSKwcklL)
[![Dosu](https://raw.githubusercontent.com/dosu-ai/assets/main/dosu-badge.svg)](https://app.dosu.dev/a72bdcfd-15f5-4edc-bd85-ea0daa6c3adc/ask)
[![Pipelock Security Scan](https://github.com/we-promise/sure/actions/workflows/pipelock.yml/badge.svg)](https://github.com/we-promise/sure/actions/workflows/pipelock.yml)
<img width="1270" height="1140" alt="sure_shot" src="https://github.com/user-attachments/assets/9c6e03cc-3490-40ab-9a68-52e042c51293" />
@@ -27,7 +28,7 @@ involved: [Discord](https://discord.gg/36ZGBsxYEK) • [Website](https://sure.am
## Backstory
The Maybe Finance team spent most of 20212022 building a full-featured personal finance and wealth management app. It even included an “Ask an Advisor” feature that connected users with a real CFP/CFA — all included with your subscription.
The [Maybe Finance](https://github.com/maybe-finance/maybe) (archived/abandoned repo) team spent most of 20212022 building a full-featured personal finance and wealth management app. It even included an “Ask an Advisor” feature that connected users with a real CFP/CFA — all included with your subscription.
The business end of things didn't work out, and so they stopped developing the app in mid-2023.
@@ -100,12 +101,16 @@ For further instructions, see guides below.
- [Windows dev setup](https://github.com/we-promise/sure/wiki/Windows-Dev-Setup-Guide)
- Dev containers - visit [this guide](https://code.visualstudio.com/docs/devcontainers/containers)
### One-click
### One-click Install
[![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=sure)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/T_draF?referralCode=CW_fPQ)
### Managed OpenClaw for Sure Finances
<a href="https://kilocode.pxf.io/repo-readme"><img src="https://kilo.ai/kiloclaw/partner-resources/kiloclaw-logo-yellow-bg-typography.png" alt="Managed OpenClaw for Sure Finances" width="185"/></a>
## License and Trademarks
@@ -113,3 +118,5 @@ Maybe and Sure are both distributed under
an [AGPLv3 license](https://github.com/we-promise/sure/blob/main/LICENSE).
- "Maybe" is a trademark of Maybe Finance, Inc.
- "Sure" is not, and refers to this community fork.
![Alt](https://repobeats.axiom.co/api/embed/3a9753cff07501fba8a6749d0ebd567ff63848c8.svg "Repobeats analytics image")

View File

@@ -1,6 +1,6 @@
@import 'tailwindcss';
@import "./maybe-design-system.css";
@import "./sure-design-system.css";
@import "./geist-font.css";
@import "./geist-mono-font.css";
@@ -56,7 +56,7 @@
}
.hw-combobox__label {
@apply block text-xs text-gray-500 peer-disabled:text-gray-400;
@apply block text-xs text-secondary peer-disabled:text-subdued;
}
.hw-combobox__option {
@@ -155,12 +155,12 @@
}
::-webkit-scrollbar-thumb {
background: #d6d6d6;
background: var(--color-gray-300);
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: #a6a6a6;
background: var(--color-gray-400);
}
}
@@ -170,12 +170,12 @@
}
&::-webkit-scrollbar-thumb {
background: #d6d6d6;
background: var(--color-gray-300);
border-radius: 10px;
}
&::-webkit-scrollbar-thumb:hover {
background: #a6a6a6;
background: var(--color-gray-400);
}
}
@@ -203,3 +203,30 @@
.turbo-progress-bar {
margin-top: env(safe-area-inset-top);
}
.table-divider {
position: relative;
}
.table-divider {
background-image: linear-gradient(
to right,
transparent 1rem,
var(--color-alpha-black-100) 1rem,
var(--color-alpha-black-100) calc(100% - 1rem),
transparent calc(100% - 1rem)
);
background-repeat: no-repeat;
background-size: 100% 1px;
background-position: bottom;
}
[data-theme="dark"] .table-divider {
background-image: linear-gradient(
to right,
transparent 1rem,
var(--color-alpha-white-200) 1rem,
var(--color-alpha-white-200) calc(100% - 1rem),
transparent calc(100% - 1rem)
);
}

View File

@@ -1,91 +0,0 @@
@utility bg-surface {
@apply bg-gray-50;
@variant theme-dark {
@apply bg-black;
}
}
@utility bg-surface-hover {
@apply bg-gray-100;
@variant theme-dark {
@apply bg-gray-800;
}
}
@utility bg-surface-inset {
@apply bg-gray-100;
@variant theme-dark {
@apply bg-gray-800;
}
}
@utility bg-surface-inset-hover {
@apply bg-gray-200;
@variant theme-dark {
@apply bg-gray-800;
}
}
@utility bg-container {
@apply bg-white;
@variant theme-dark {
@apply bg-gray-900;
}
}
@utility bg-container-hover {
@apply bg-gray-50;
@variant theme-dark {
@apply bg-gray-800;
}
}
@utility bg-container-inset {
@apply bg-gray-50;
@variant theme-dark {
@apply bg-gray-800;
}
}
@utility bg-container-inset-hover {
@apply bg-gray-100;
@variant theme-dark {
@apply bg-gray-700;
}
}
@utility bg-inverse {
@apply bg-gray-800;
@variant theme-dark {
@apply bg-white;
}
}
@utility bg-inverse-hover {
@apply bg-gray-700;
@variant theme-dark {
@apply bg-gray-100;
}
}
@utility bg-overlay {
background-color: --alpha(var(--color-gray-100) / 50%);
@variant theme-dark {
background-color: var(--color-alpha-black-900);
}
}
@utility bg-loader {
@apply bg-surface-inset animate-pulse;
}

View File

@@ -1,92 +0,0 @@
/* Custom shadow borders used for surfaces / containers */
@utility shadow-border-xs {
box-shadow: var(--shadow-xs), 0px 0px 0px 1px var(--color-alpha-black-50);
@variant theme-dark {
box-shadow: var(--shadow-xs), 0px 0px 0px 1px var(--color-alpha-white-50);
}
}
@utility shadow-border-sm {
box-shadow: var(--shadow-sm), 0px 0px 0px 1px var(--color-alpha-black-50);
@variant theme-dark {
box-shadow: var(--shadow-sm), 0px 0px 0px 1px var(--color-alpha-white-50);
}
}
@utility shadow-border-md {
box-shadow: var(--shadow-md), 0px 0px 0px 1px var(--color-alpha-black-50);
@variant theme-dark {
box-shadow: var(--shadow-md), 0px 0px 0px 1px var(--color-alpha-white-50);
}
}
@utility shadow-border-lg {
box-shadow: var(--shadow-lg), 0px 0px 0px 1px var(--color-alpha-black-50);
@variant theme-dark {
box-shadow: var(--shadow-lg), 0px 0px 0px 1px var(--color-alpha-white-50);
}
}
@utility shadow-border-xl {
box-shadow: var(--shadow-xl), 0px 0px 0px 1px var(--color-alpha-black-50);
@variant theme-dark {
box-shadow: var(--shadow-xl), 0px 0px 0px 1px var(--color-alpha-white-50);
}
}
@utility border-primary {
@apply border-alpha-black-300;
@variant theme-dark {
@apply border-alpha-white-400;
}
}
@utility border-secondary {
@apply border-alpha-black-200;
@variant theme-dark {
@apply border-alpha-white-300;
}
}
@utility border-tertiary {
@apply border-alpha-black-100;
@variant theme-dark {
@apply border-alpha-white-200;
}
}
@utility border-divider {
@apply border-tertiary;
}
@utility border-subdued {
@apply border-alpha-black-50;
@variant theme-dark {
@apply border-alpha-white-100;
}
}
@utility border-solid {
@apply border-black;
@variant theme-dark {
@apply border-white;
}
}
@utility border-destructive {
@apply border-red-500;
@variant theme-dark {
@apply border-red-400;
}
}

View File

@@ -1,109 +0,0 @@
/* Button Backgrounds */
@utility button-bg-primary {
@apply bg-gray-900;
/* Maps to fg-primary light */
@variant theme-dark {
@apply bg-white;
/* Maps to fg-primary dark */
}
}
@utility button-bg-primary-hover {
@apply bg-gray-800;
/* Maps to fg-primary-variant light */
@variant theme-dark {
@apply bg-gray-50;
/* Maps to fg-primary-variant dark */
}
}
@utility button-bg-secondary {
@apply bg-gray-50; /* Maps to fg-secondary light */
@variant theme-dark {
@apply bg-gray-700; /* Maps to fg-secondary dark */
}
}
@utility button-bg-secondary-hover {
@apply bg-gray-100; /* Maps to fg-secondary-variant light */
@variant theme-dark {
@apply bg-gray-600; /* Maps to fg-secondary-variant dark */
}
}
@utility button-bg-disabled {
@apply bg-gray-50;
@variant theme-dark {
@apply bg-gray-700;
}
}
@utility button-bg-destructive {
@apply bg-red-500;
@variant theme-dark {
@apply bg-red-400;
}
}
@utility button-bg-destructive-hover {
@apply bg-red-600;
@variant theme-dark {
@apply bg-red-500;
}
}
@utility button-bg-ghost-hover {
@apply bg-gray-50;
@variant theme-dark {
@apply bg-gray-800 fg-inverse;
}
}
@utility button-bg-outline-hover {
@apply bg-gray-100;
@variant theme-dark {
@apply bg-gray-700;
}
}
/* Tab Styles */
@utility tab-item-active {
@apply bg-white;
@variant theme-dark {
@apply bg-gray-700;
}
}
@utility tab-item-hover {
@apply bg-gray-200;
@variant theme-dark {
@apply bg-gray-800;
}
}
@utility tab-bg-group {
@apply bg-gray-50;
@variant theme-dark {
@apply bg-alpha-black-700;
}
}
@utility bg-nav-indicator {
@apply bg-black;
@variant theme-dark {
@apply bg-white;
}
}

View File

@@ -1,63 +0,0 @@
@utility fg-gray {
@apply text-gray-500;
@variant theme-dark {
@apply text-gray-400;
}
}
@utility fg-contrast {
@apply text-gray-400;
@variant theme-dark {
@apply text-gray-500;
}
}
@utility fg-inverse {
@apply text-white;
@variant theme-dark {
@apply text-gray-900;
}
}
@utility fg-primary {
@apply text-gray-900;
@variant theme-dark {
@apply text-white;
}
}
@utility fg-primary-variant {
@apply text-gray-800;
@variant theme-dark {
@apply text-gray-50;
}
}
@utility fg-secondary {
@apply text-gray-50;
@variant theme-dark {
@apply text-gray-400;
}
}
@utility fg-secondary-variant {
@apply text-gray-100;
@variant theme-dark {
@apply text-gray-500;
}
}
@utility fg-subdued {
@apply text-gray-400;
@variant theme-dark {
@apply text-gray-500;
}
}

View File

@@ -1,39 +0,0 @@
@utility text-primary {
@apply text-gray-900;
@variant theme-dark {
@apply text-white;
}
}
@utility text-inverse {
@apply text-white;
@variant theme-dark {
@apply text-gray-900;
}
}
@utility text-secondary {
@apply text-gray-500;
@variant theme-dark {
@apply text-gray-300;
}
}
@utility text-subdued {
@apply text-gray-400;
@variant theme-dark {
@apply text-gray-500;
}
}
@utility text-link {
@apply text-blue-600;
@variant theme-dark {
@apply text-blue-500;
}
}

View File

@@ -2,10 +2,13 @@
html.privacy-mode .privacy-sensitive {
filter: blur(8px);
user-select: none;
pointer-events: none;
transition: filter 0.2s ease;
}
html.privacy-mode .privacy-sensitive:not(.privacy-sensitive-interactive) {
pointer-events: none;
}
html:not(.privacy-mode) .privacy-sensitive {
transition: filter 0.2s ease;
}
}

View File

@@ -0,0 +1,12 @@
/*
* Sure design system entry.
* Tokens (theme, dark overrides, utilities) are generated from design/tokens/sure.tokens.json: see _generated.css.
* Element resets, components, and prose overrides remain hand-written.
*/
@custom-variant theme-dark (&:where([data-theme=dark], [data-theme=dark] *));
@import './sure-design-system/_generated.css';
@import './sure-design-system/base.css';
@import './sure-design-system/components.css';
@import './sure-design-system/prose.css';

View File

@@ -1,37 +1,32 @@
/*
This file contains all of the Figma design tokens, components, etc. that
are used globally across the app.
One-off styling (3rd party overrides, etc.) should be done in the application.css file.
*/
@import './maybe-design-system/background-utils.css';
@import './maybe-design-system/foreground-utils.css';
@import './maybe-design-system/text-utils.css';
@import './maybe-design-system/border-utils.css';
@import './maybe-design-system/component-utils.css';
@custom-variant theme-dark (&:where([data-theme=dark], [data-theme=dark] *));
/*
* GENERATED do not edit by hand.
* Source: design/tokens/sure.tokens.json
* Build: npm run tokens:build
*/
@theme {
/* Font families */
--font-sans: 'Geist', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
/* Base colors */
--color-white: #ffffff;
--color-black: #0B0B0B;
--color-success: var(--color-green-600);
--color-success: var(--color-green-700);
--color-warning: var(--color-yellow-600);
--color-destructive: var(--color-red-600);
--color-info: var(--color-blue-600);
--color-shadow: --alpha(var(--color-black) / 6%);
/* Colors used in Stimulus controllers with SVGs (easier to define light/dark mode here than toggle within the controllers) */
/* See @layer base block below for dark mode overrides */
--budget-unused-fill: var(--color-gray-200);
--budget-unallocated-fill: var(--color-gray-50);
/* Gray scale */
--color-link: var(--color-blue-600);
--color-tertiary: var(--color-alpha-black-100);
--color-surface: var(--color-gray-50);
--color-surface-hover: var(--color-gray-100);
--color-surface-inset: var(--color-gray-100);
--color-surface-inset-hover: var(--color-gray-200);
--color-container: var(--color-white);
--color-container-hover: var(--color-gray-50);
--color-container-inset: var(--color-gray-50);
--color-container-inset-hover: var(--color-gray-100);
--color-nav-indicator: var(--color-black);
--color-toggle-track: var(--color-gray-100);
--color-destructive-subtle: var(--color-red-200);
--color-gray-25: #FAFAFA;
--color-gray-50: #F7F7F7;
--color-gray-100: #F0F0F0;
@@ -46,8 +41,6 @@
--color-gray: var(--color-gray-500);
--color-gray-tint-5: --alpha(var(--color-gray-500) / 5%);
--color-gray-tint-10: --alpha(var(--color-gray-500) / 10%);
/* Alpha colors */
--color-alpha-white-25: --alpha(var(--color-white) / 3%);
--color-alpha-white-50: --alpha(var(--color-white) / 5%);
--color-alpha-white-100: --alpha(var(--color-white) / 8%);
@@ -59,7 +52,6 @@
--color-alpha-white-700: --alpha(var(--color-white) / 50%);
--color-alpha-white-800: --alpha(var(--color-white) / 70%);
--color-alpha-white-900: --alpha(var(--color-white) / 85%);
--color-alpha-black-25: --alpha(var(--color-black) / 3%);
--color-alpha-black-50: --alpha(var(--color-black) / 5%);
--color-alpha-black-100: --alpha(var(--color-black) / 8%);
@@ -71,8 +63,6 @@
--color-alpha-black-700: --alpha(var(--color-black) / 50%);
--color-alpha-black-800: --alpha(var(--color-black) / 70%);
--color-alpha-black-900: --alpha(var(--color-black) / 85%);
/* Red scale */
--color-red-25: #FFFBFB;
--color-red-50: #FFF1F0;
--color-red-100: #FFDEDB;
@@ -86,8 +76,6 @@
--color-red-900: #7E0707;
--color-red-tint-5: --alpha(var(--color-red-500) / 5%);
--color-red-tint-10: --alpha(var(--color-red-500) / 10%);
/* Green scale */
--color-green-25: #F6FEF9;
--color-green-50: #ECFDF3;
--color-green-100: #D1FADF;
@@ -101,8 +89,6 @@
--color-green-900: #054F31;
--color-green-tint-5: --alpha(var(--color-green-500) / 5%);
--color-green-tint-10: --alpha(var(--color-green-500) / 10%);
/* Yellow scale */
--color-yellow-25: #FFFCF5;
--color-yellow-50: #FFFAEB;
--color-yellow-100: #FEF0C7;
@@ -116,8 +102,6 @@
--color-yellow-900: #7A2E0E;
--color-yellow-tint-5: --alpha(var(--color-yellow-500) / 5%);
--color-yellow-tint-10: --alpha(var(--color-yellow-500) / 10%);
/* Cyan scale */
--color-cyan-25: #F5FEFF;
--color-cyan-50: #ECFDFF;
--color-cyan-100: #CFF9FE;
@@ -128,11 +112,9 @@
--color-cyan-600: #088AB2;
--color-cyan-700: #0E7090;
--color-cyan-800: #155B75;
--color-cyan-900: #155B75;
--color-cyan-900: #164E63;
--color-cyan-tint-5: --alpha(var(--color-cyan-500) / 5%);
--color-cyan-tint-10: --alpha(var(--color-cyan-500) / 10%);
/* Blue scale */
--color-blue-25: #F5FAFF;
--color-blue-50: #EFF8FF;
--color-blue-100: #D1E9FF;
@@ -146,8 +128,6 @@
--color-blue-900: #194185;
--color-blue-tint-5: --alpha(var(--color-blue-500) / 5%);
--color-blue-tint-10: --alpha(var(--color-blue-500) / 10%);
/* Indigo scale */
--color-indigo-25: #F5F8FF;
--color-indigo-50: #EFF4FF;
--color-indigo-100: #E0EAFF;
@@ -161,8 +141,6 @@
--color-indigo-900: #2D3282;
--color-indigo-tint-5: --alpha(var(--color-indigo-500) / 5%);
--color-indigo-tint-10: --alpha(var(--color-indigo-500) / 10%);
/* Violet scale */
--color-violet-25: #FBFAFF;
--color-violet-50: #F5F3FF;
--color-violet-100: #ECE9FE;
@@ -174,8 +152,6 @@
--color-violet-700: #6927DA;
--color-violet-tint-5: --alpha(var(--color-violet-500) / 5%);
--color-violet-tint-10: --alpha(var(--color-violet-500) / 10%);
/* Fuchsia scale */
--color-fuchsia-25: #FEFAFF;
--color-fuchsia-50: #FDF4FF;
--color-fuchsia-100: #FBE8FF;
@@ -189,8 +165,6 @@
--color-fuchsia-900: #6F1877;
--color-fuchsia-tint-5: --alpha(var(--color-fuchsia-500) / 5%);
--color-fuchsia-tint-10: --alpha(var(--color-fuchsia-500) / 10%);
/* Pink scale */
--color-pink-25: #FFFAFC;
--color-pink-50: #FEF0F7;
--color-pink-100: #FFD1E2;
@@ -204,8 +178,6 @@
--color-pink-900: #840B45;
--color-pink-tint-5: --alpha(var(--color-pink-500) / 5%);
--color-pink-tint-10: --alpha(var(--color-pink-500) / 10%);
/* Orange scale */
--color-orange-25: #FFF9F5;
--color-orange-50: #FFF4ED;
--color-orange-100: #FFE6D5;
@@ -219,243 +191,313 @@
--color-orange-900: #771A0D;
--color-orange-tint-5: --alpha(var(--color-orange-500) / 5%);
--color-orange-tint-10: --alpha(var(--color-orange-500) / 10%);
/* Border radius overrides */
--budget-unused-fill: var(--color-gray-200);
--budget-unallocated-fill: var(--color-gray-50);
--border-radius-md: 8px;
--border-radius-lg: 10px;
--shadow-xs: 0px 1px 2px 0px --alpha(var(--color-black) / 6%);
--shadow-sm: 0px 1px 6px 0px --alpha(var(--color-black) / 6%);
--shadow-md: 0px 4px 8px -2px --alpha(var(--color-black) / 6%);
--shadow-lg: 0px 12px 16px -4px --alpha(var(--color-black) / 6%);
--shadow-xl: 0px 20px 24px -4px --alpha(var(--color-black) / 6%);
--animate-stroke-fill: stroke-fill 3s 300ms forwards;
@keyframes stroke-fill {
0% {
stroke-dashoffset: 43.9822971503;
}
100% {
stroke-dashoffset: 0;
}
}
}
/* Specific override for strong tags in prose under dark mode */
.prose:where([data-theme=dark], [data-theme=dark] *) strong {
color: theme(colors.white) !important;
}
/* Specific override for headings in prose under dark mode */
.prose:where([data-theme=dark], [data-theme=dark] *) h1,
.prose:where([data-theme=dark], [data-theme=dark] *) h2,
.prose:where([data-theme=dark], [data-theme=dark] *) h3,
.prose:where([data-theme=dark], [data-theme=dark] *) h4,
.prose:where([data-theme=dark], [data-theme=dark] *) h5,
.prose:where([data-theme=dark], [data-theme=dark] *) h6,
.prose:where([data-theme=dark], [data-theme=dark] *) blockquote,
.prose:where([data-theme=dark], [data-theme=dark] *) thead th {
color: theme(colors.white) !important;
0% { stroke-dashoffset: 43.9822971503; }
100% { stroke-dashoffset: 0; }
}
}
@layer base {
[data-theme="dark"] {
--color-success: var(--color-green-500);
[data-theme="dark"] {
--color-success: var(--color-green-400);
--color-warning: var(--color-yellow-400);
--color-destructive: var(--color-red-400);
--color-info: var(--color-blue-500);
--color-shadow: --alpha(var(--color-white) / 8%);
/* Dark mode overrides for colors used in Stimulus controllers with SVGs */
--color-link: var(--color-blue-500);
--color-tertiary: var(--color-alpha-white-200);
--color-surface: var(--color-black);
--color-surface-hover: var(--color-gray-800);
--color-surface-inset: var(--color-gray-800);
--color-surface-inset-hover: var(--color-gray-800);
--color-container: var(--color-gray-900);
--color-container-hover: var(--color-gray-800);
--color-container-inset: var(--color-gray-800);
--color-container-inset-hover: var(--color-gray-700);
--color-nav-indicator: var(--color-white);
--color-toggle-track: var(--color-gray-700);
--color-destructive-subtle: var(--color-red-800);
--budget-unused-fill: var(--color-gray-500);
--budget-unallocated-fill: var(--color-gray-700);
--shadow-xs: 0px 1px 2px 0px --alpha(var(--color-white) / 8%);
--shadow-sm: 0px 1px 6px 0px --alpha(var(--color-white) / 8%);
--shadow-md: 0px 4px 8px -2px --alpha(var(--color-white) / 8%);
--shadow-lg: 0px 12px 16px -4px --alpha(var(--color-white) / 8%);
--shadow-xl: 0px 20px 24px -4px --alpha(var(--color-white) / 8%);
}
}
button {
@apply cursor-pointer focus-visible:outline-gray-900;
}
@utility bg-inverse {
@apply bg-gray-800;
hr {
@apply text-gray-200;
}
/* We control the sizing through DialogComponent, so reset this value */
dialog:modal {
max-width: 100dvw;
max-height: 100dvh;
}
details>summary::-webkit-details-marker {
@apply hidden;
}
details>summary {
@apply list-none;
}
input[type='radio'] {
@apply border-gray-300 text-indigo-600 focus:ring-indigo-600;
/* Default light mode */
@variant theme-dark {
/* Dark mode radio button base and checked styles */
@apply border-gray-600 bg-gray-700 checked:bg-blue-500 focus:ring-blue-500 focus:ring-offset-gray-800;
}
@variant theme-dark {
@apply bg-white;
}
}
@layer components {
/* Forms */
.form-field {
@apply flex flex-col gap-1 relative px-3 py-2 rounded-md border bg-container border-secondary shadow-xs w-full;
@apply focus-within:border-secondary focus-within:shadow-none focus-within:ring-4 focus-within:ring-alpha-black-200;
@apply transition-all duration-300;
@utility bg-inverse-hover {
@apply bg-gray-700;
@variant theme-dark {
@apply focus-within:ring-alpha-white-300;
}
/* Add styles for multiple select within form fields */
select[multiple] {
@apply py-2 pr-2 space-y-0.5 overflow-y-auto;
option {
@apply py-2 rounded-md;
}
option:checked {
@apply after:content-['\2713'] bg-container-inset after:text-gray-500 after:ml-2;
}
option:active,
option:focus {
@apply bg-container-inset;
}
}
@variant theme-dark {
@apply bg-gray-100;
}
}
/* New form field structure components */
.form-field__header {
@apply flex items-center justify-between gap-2;
@utility bg-overlay {
background-color: --alpha(var(--color-gray-100) / 50%);
@variant theme-dark {
background-color: var(--color-alpha-black-900);
}
}
.form-field__body {
@apply flex flex-col gap-1;
@utility bg-loader {
@apply bg-surface-inset animate-pulse;
}
@utility text-primary {
@apply text-gray-900;
@variant theme-dark {
@apply text-white;
}
}
.form-field__actions {
@apply flex items-center gap-1;
@utility text-inverse {
@apply text-white;
@variant theme-dark {
@apply text-gray-900;
}
}
.form-field__label {
@apply block text-xs text-secondary peer-disabled:text-subdued;
@utility text-secondary {
@apply text-gray-500;
@variant theme-dark {
@apply text-gray-300;
}
}
.form-field__input {
@apply text-primary border-none bg-container text-sm opacity-100 w-full p-0;
@apply focus:opacity-100 focus:outline-hidden focus:ring-0;
@apply placeholder-shown:opacity-50;
@apply disabled:text-subdued;
@apply text-ellipsis overflow-hidden whitespace-nowrap;
@apply transition-opacity duration-300;
@apply placeholder:text-subdued;
@utility text-subdued {
@apply text-gray-400;
@variant theme-dark {
&::-webkit-calendar-picker-indicator {
filter: invert(1);
cursor: pointer;
}
}
@variant theme-dark {
@apply text-gray-500;
}
}
textarea.form-field__input {
@apply whitespace-normal overflow-auto;
text-overflow: clip;
@utility shadow-border-xs {
box-shadow: var(--shadow-xs), 0px 0px 0px 1px var(--color-alpha-black-50);
@variant theme-dark {
box-shadow: var(--shadow-xs), 0px 0px 0px 1px var(--color-alpha-white-50);
}
select.form-field__input,
button.form-field__input {
@apply pr-10 appearance-none;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
background-position: right -0.15rem center;
background-repeat: no-repeat;
background-size: 1.25rem 1.25rem;
text-align: left;
}
@utility shadow-border-sm {
box-shadow: var(--shadow-sm), 0px 0px 0px 1px var(--color-alpha-black-50);
@variant theme-dark {
box-shadow: var(--shadow-sm), 0px 0px 0px 1px var(--color-alpha-white-50);
}
}
.form-field__radio {
@apply text-primary;
@utility shadow-border-md {
box-shadow: var(--shadow-md), 0px 0px 0px 1px var(--color-alpha-black-50);
@variant theme-dark {
box-shadow: var(--shadow-md), 0px 0px 0px 1px var(--color-alpha-white-50);
}
}
.form-field__submit {
@apply cursor-pointer rounded-lg bg-surface p-3 text-center text-white hover:bg-surface-hover;
@utility shadow-border-lg {
box-shadow: var(--shadow-lg), 0px 0px 0px 1px var(--color-alpha-black-50);
@variant theme-dark {
box-shadow: var(--shadow-lg), 0px 0px 0px 1px var(--color-alpha-white-50);
}
}
/* Checkboxes */
.checkbox {
&[type='checkbox'] {
@apply rounded-sm;
@apply transition-colors duration-300;
}
@utility shadow-border-xl {
box-shadow: var(--shadow-xl), 0px 0px 0px 1px var(--color-alpha-black-50);
@variant theme-dark {
box-shadow: var(--shadow-xl), 0px 0px 0px 1px var(--color-alpha-white-50);
}
}
.checkbox--light {
&[type='checkbox'] {
@apply border-alpha-black-200 checked:bg-gray-900 checked:ring-gray-900 focus:ring-gray-900 focus-visible:ring-gray-900 checked:hover:bg-gray-300 hover:bg-gray-300;
}
@utility border-primary {
@apply border-alpha-black-300;
&[type='checkbox']:disabled {
@apply cursor-not-allowed opacity-80 bg-gray-50 border-gray-200 checked:bg-gray-400 checked:ring-gray-400;
}
@variant theme-dark {
&[type='checkbox'] {
@apply ring-gray-900 checked:text-white;
background-color: var(--color-gray-100);
}
&[type='checkbox']:disabled {
@apply cursor-not-allowed opacity-80;
background-color: var(--color-gray-600);
}
&[type='checkbox']:checked {
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='%23808080' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
background-color: var(--color-gray-100);
}
}
@variant theme-dark {
@apply border-alpha-white-400;
}
}
.checkbox--dark {
&[type='checkbox'] {
@apply ring-gray-900 checked:text-white;
}
@utility border-secondary {
@apply border-alpha-black-200;
&[type='checkbox']:disabled {
@apply cursor-not-allowed opacity-80 ring-gray-600;
}
&[type='checkbox']:checked {
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='%23111827' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
}
@variant theme-dark {
@apply border-alpha-white-300;
}
}
/* Tooltips */
.tooltip {
@apply hidden absolute;
}
@utility border-divider {
@apply border-tertiary;
}
.qrcode svg path {
fill: var(--color-black);
@variant theme-dark {
fill: var(--color-white);
}
@utility border-subdued {
@apply border-alpha-black-50;
@variant theme-dark {
@apply border-alpha-white-100;
}
}
}
@utility border-solid {
@apply border-black;
@variant theme-dark {
@apply border-white;
}
}
@utility border-destructive {
@apply border-red-500;
@variant theme-dark {
@apply border-red-400;
}
}
@utility border-inverse {
@apply border-alpha-white-200;
@variant theme-dark {
@apply border-alpha-black-300;
}
}
@utility button-bg-primary {
@apply bg-gray-900;
@variant theme-dark {
@apply bg-white;
}
}
@utility button-bg-primary-hover {
@apply bg-gray-800;
@variant theme-dark {
@apply bg-gray-50;
}
}
@utility button-bg-secondary {
@apply bg-gray-50;
@variant theme-dark {
@apply bg-gray-700;
}
}
@utility button-bg-secondary-hover {
@apply bg-gray-100;
@variant theme-dark {
@apply bg-gray-600;
}
}
@utility button-bg-secondary-strong {
@apply bg-gray-200;
@variant theme-dark {
@apply bg-gray-700;
}
}
@utility button-bg-secondary-strong-hover {
@apply bg-gray-300;
@variant theme-dark {
@apply bg-gray-600;
}
}
@utility button-bg-disabled {
@apply bg-gray-50;
@variant theme-dark {
@apply bg-gray-700;
}
}
@utility button-bg-destructive {
@apply bg-red-500;
@variant theme-dark {
@apply bg-red-400;
}
}
@utility button-bg-destructive-hover {
@apply bg-red-600;
@variant theme-dark {
@apply bg-red-500;
}
}
@utility button-bg-ghost-hover {
@apply bg-gray-50;
@variant theme-dark {
@apply bg-gray-800 text-inverse;
}
}
@utility button-bg-outline-hover {
@apply bg-gray-100;
@variant theme-dark {
@apply bg-gray-700;
}
}
@utility tab-item-active {
@apply bg-white;
@variant theme-dark {
@apply bg-gray-700;
}
}
@utility tab-item-hover {
@apply bg-gray-200;
@variant theme-dark {
@apply bg-gray-800;
}
}
@utility tab-bg-group {
@apply bg-gray-50;
@variant theme-dark {
@apply bg-alpha-black-700;
}
}

View File

@@ -0,0 +1,37 @@
@layer base {
button {
@apply cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-alpha-black-300;
@variant theme-dark {
@apply focus-visible:ring-alpha-white-300;
}
}
hr {
@apply text-gray-200;
}
/* We control the sizing through DialogComponent, so reset this value */
dialog:modal {
max-width: 100dvw;
max-height: 100dvh;
}
details>summary::-webkit-details-marker {
@apply hidden;
}
details>summary {
@apply list-none;
}
input[type='radio'] {
@apply border-gray-300 text-indigo-600 focus:ring-indigo-600;
/* Default light mode */
@variant theme-dark {
/* Dark mode radio button base and checked styles */
@apply border-gray-600 bg-gray-700 checked:bg-blue-500 focus:ring-blue-500 focus:ring-offset-gray-800;
}
}
}

View File

@@ -0,0 +1,148 @@
@layer components {
/* Forms */
.form-field {
@apply flex flex-col gap-1 relative px-3 py-2 rounded-md border bg-container border-secondary shadow-xs w-full;
@apply focus-within:border-secondary focus-within:shadow-none focus-within:ring-4 focus-within:ring-alpha-black-200;
@apply transition-all duration-300;
@variant theme-dark {
@apply focus-within:ring-alpha-white-300;
}
/* Add styles for multiple select within form fields */
select[multiple] {
@apply py-2 pr-2 space-y-0.5 overflow-y-auto;
option {
@apply py-2 rounded-md;
}
option:checked {
@apply after:content-['\2713'] bg-container-inset after:text-gray-500 after:ml-2;
}
option:active,
option:focus {
@apply bg-container-inset;
}
}
}
/* New form field structure components */
.form-field__header {
@apply flex items-center justify-between gap-2;
}
.form-field__body {
@apply flex flex-col gap-1;
}
.form-field__actions {
@apply flex items-center gap-1;
}
.form-field__label {
@apply block text-xs text-secondary peer-disabled:text-subdued;
}
.form-field__input {
@apply text-primary border-none bg-container text-sm opacity-100 w-full p-0;
@apply focus:opacity-100 focus:outline-hidden focus:ring-0;
@apply placeholder-shown:opacity-50;
@apply disabled:text-subdued;
@apply text-ellipsis overflow-hidden whitespace-nowrap;
@apply transition-opacity duration-300;
@apply placeholder:text-subdued;
@variant theme-dark {
&::-webkit-calendar-picker-indicator {
filter: invert(1);
cursor: pointer;
}
}
}
textarea.form-field__input {
@apply whitespace-normal overflow-auto;
text-overflow: clip;
}
select.form-field__input,
button.form-field__input {
@apply pr-10 appearance-none;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
background-position: right -0.15rem center;
background-repeat: no-repeat;
background-size: 1.25rem 1.25rem;
text-align: left;
}
.form-field__radio {
@apply text-primary;
}
.form-field__submit {
@apply cursor-pointer rounded-lg bg-surface p-3 text-center text-white hover:bg-surface-hover;
}
/* Checkboxes */
.checkbox {
&[type='checkbox'] {
@apply rounded-sm;
@apply transition-colors duration-300;
}
}
.checkbox--light {
&[type='checkbox'] {
@apply border-alpha-black-200 checked:bg-gray-900 checked:ring-gray-900 focus:ring-gray-900 focus-visible:ring-gray-900 checked:hover:bg-gray-300 hover:bg-gray-300;
}
&[type='checkbox']:disabled {
@apply cursor-not-allowed opacity-80 bg-gray-50 border-gray-200 checked:bg-gray-400 checked:ring-gray-400;
}
@variant theme-dark {
&[type='checkbox'] {
@apply ring-gray-900 checked:text-white;
background-color: var(--color-gray-100);
}
&[type='checkbox']:disabled {
@apply cursor-not-allowed opacity-80;
background-color: var(--color-gray-600);
}
&[type='checkbox']:checked {
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='%23808080' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
background-color: var(--color-gray-100);
}
}
}
.checkbox--dark {
&[type='checkbox'] {
@apply ring-gray-900 checked:text-white;
}
&[type='checkbox']:disabled {
@apply cursor-not-allowed opacity-80 ring-gray-600;
}
&[type='checkbox']:checked {
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='%23111827' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
}
}
/* Tooltips */
.tooltip {
@apply hidden absolute;
}
.qrcode svg path {
fill: var(--color-black);
@variant theme-dark {
fill: var(--color-white);
}
}
}

View File

@@ -0,0 +1,24 @@
/* Specific override for strong tags in prose under dark mode */
.prose:where([data-theme=dark], [data-theme=dark] *) strong {
color: theme(colors.white) !important;
}
/* Specific override for headings in prose under dark mode */
.prose:where([data-theme=dark], [data-theme=dark] *) h1,
.prose:where([data-theme=dark], [data-theme=dark] *) h2,
.prose:where([data-theme=dark], [data-theme=dark] *) h3,
.prose:where([data-theme=dark], [data-theme=dark] *) h4,
.prose:where([data-theme=dark], [data-theme=dark] *) h5,
.prose:where([data-theme=dark], [data-theme=dark] *) h6,
.prose:where([data-theme=dark], [data-theme=dark] *) blockquote,
.prose:where([data-theme=dark], [data-theme=dark] *) thead th {
color: theme(colors.white) !important;
}
/* Inline code in prose under dark mode. Without this the Tailwind Typography
plugin's default near-black foreground falls through and disappears against
the dark page background. */
.prose:where([data-theme=dark], [data-theme=dark] *) code {
color: theme(colors.white) !important;
background-color: theme(colors.gray.800) !important;
}

View File

@@ -1,7 +1,37 @@
<div class="<%= container_classes %>">
<%= helpers.icon icon_name, size: "sm", color: icon_color, class: "shrink-0" %>
<%= tag.div(class: container_classes, role: aria_role, "aria-labelledby": (aria_role && title.present?) ? title_id : nil) do %>
<% if title.present? %>
<div class="flex items-center gap-3">
<%= helpers.icon icon_name, size: "sm", color: icon_color, class: "shrink-0 -mt-0.5" %>
<p id="<%= title_id %>" class="text-sm font-semibold text-primary flex-1 leading-5">
<span class="sr-only"><%= variant_label %>:</span>
<%= title %>
</p>
</div>
<div class="flex-1 text-sm">
<%= message %>
</div>
</div>
<% if content.present? || message.present? %>
<div class="text-sm text-primary mt-2 pl-7 space-y-1">
<% if content.present? %>
<%= content %>
<% else %>
<%= message %>
<% end %>
</div>
<% end %>
<% elsif content.present? %>
<div class="flex items-start gap-3">
<%= helpers.icon icon_name, size: "sm", color: icon_color, class: "shrink-0" %>
<div class="flex-1 text-sm text-primary space-y-1">
<span class="sr-only"><%= variant_label %>:</span>
<%= content %>
</div>
</div>
<% elsif message.present? %>
<div class="flex items-center gap-3">
<%= helpers.icon icon_name, size: "sm", color: icon_color, class: "shrink-0 -mt-0.5" %>
<p class="text-sm text-primary flex-1 leading-5">
<span class="sr-only"><%= variant_label %>:</span>
<%= message %>
</p>
</div>
<% end %>
<% end %>

View File

@@ -1,24 +1,44 @@
class DS::Alert < DesignSystemComponent
def initialize(message:, variant: :info)
VARIANTS = %i[info success warning error destructive].freeze
LIVE_MODES = %i[none status alert].freeze
def initialize(message: nil, title: nil, variant: :info, live: :none)
@message = message
@variant = variant
@title = title
@variant = normalize_variant(variant)
@live = normalize_live(live)
end
private
attr_reader :message, :variant
attr_reader :message, :title, :variant, :live
def normalize_variant(raw)
sym = raw.respond_to?(:to_sym) ? raw.to_sym : nil
VARIANTS.include?(sym) ? sym : :info
end
def normalize_live(raw)
sym = raw.respond_to?(:to_sym) ? raw.to_sym : nil
case sym
when :polite then :status
when :assertive then :alert
when *LIVE_MODES then sym
else :none
end
end
def container_classes
base_classes = "flex items-start gap-3 p-4 rounded-lg border"
base_classes = "p-4 rounded-lg border"
variant_classes = case variant
when :info
"bg-blue-50 text-blue-700 border-blue-200 theme-dark:bg-blue-900/20 theme-dark:text-blue-400 theme-dark:border-blue-800"
"bg-info/10 border-info/20"
when :success
"bg-green-50 text-green-700 border-green-200 theme-dark:bg-green-900/20 theme-dark:text-green-400 theme-dark:border-green-800"
"bg-success/10 border-success/20"
when :warning
"bg-yellow-50 text-yellow-700 border-yellow-200 theme-dark:bg-yellow-900/20 theme-dark:text-yellow-400 theme-dark:border-yellow-800"
"bg-warning/10 border-warning/20"
when :error, :destructive
"bg-red-50 text-red-700 border-red-200 theme-dark:bg-red-900/20 theme-dark:text-red-400 theme-dark:border-red-800"
"bg-destructive/10 border-destructive-subtle"
end
"#{base_classes} #{variant_classes}"
@@ -46,7 +66,22 @@ class DS::Alert < DesignSystemComponent
when :error, :destructive
"destructive"
else
"blue-600"
"info"
end
end
def aria_role
case live
when :status then "status"
when :alert then "alert"
end
end
def variant_label
I18n.t("ds.alert.variants.#{variant}")
end
def title_id
@title_id ||= "DS-alert-title-#{SecureRandom.hex(4)}"
end
end

View File

@@ -4,7 +4,7 @@
<% end %>
<% unless icon_only? %>
<%= text %>
<span class="min-w-0 truncate"><%= text %></span>
<% end %>
<% if icon && icon_position == :right %>

View File

@@ -22,7 +22,6 @@ class DS::Button < DS::Buttonish
def merged_opts
merged_opts = opts.dup || {}
extra_classes = merged_opts.delete(:class)
href = merged_opts.delete(:href)
data = merged_opts.delete(:data) || {}
if confirm.present?
@@ -33,6 +32,28 @@ class DS::Button < DS::Buttonish
data = data.merge(turbo_frame: frame)
end
# `content_tag(:button, ...)` defaults to `type="submit"` per the HTML
# spec — meaning a DS::Button rendered inside a form will steal Enter-key
# submission from the first text input. Default to `type="button"` so
# callers must opt into submit behavior explicitly. `button_to` (href
# branch) wraps the button in its own form, so submit there is correct
# and we leave its default alone.
if href.blank?
merged_opts[:type] ||= "button"
end
# Icon-only buttons have no visible text node, so screen readers fall
# back to announcing "button" with no name. Derive a humanized fallback
# from the icon key so AT users hear *something* meaningful; explicit
# `aria: { label: }` on the caller still wins.
if icon_only? && icon.present?
aria = (merged_opts[:aria] || {}).symbolize_keys
if aria[:label].blank? && merged_opts[:"aria-label"].blank?
aria[:label] = icon.to_s.tr("-_", " ").capitalize
merged_opts[:aria] = aria
end
end
merged_opts.merge(
class: class_names(container_classes, extra_classes),
data: data

View File

@@ -2,35 +2,35 @@ class DS::Buttonish < DesignSystemComponent
VARIANTS = {
primary: {
container_classes: "text-inverse bg-inverse hover:bg-inverse-hover disabled:bg-gray-500 theme-dark:disabled:bg-gray-400",
icon_classes: "fg-inverse"
icon_classes: "text-inverse"
},
secondary: {
container_classes: "text-primary bg-gray-200 theme-dark:bg-gray-700 hover:bg-gray-300 theme-dark:hover:bg-gray-600 disabled:bg-gray-200 theme-dark:disabled:bg-gray-600",
icon_classes: "fg-primary"
icon_classes: "text-primary"
},
destructive: {
container_classes: "text-inverse bg-red-500 theme-dark:bg-red-400 hover:bg-red-600 theme-dark:hover:bg-red-500 disabled:bg-red-200 theme-dark:disabled:bg-red-600",
icon_classes: "fg-white"
icon_classes: "text-inverse"
},
outline: {
container_classes: "text-primary border border-secondary bg-transparent hover:bg-surface-hover",
icon_classes: "fg-gray"
icon_classes: "text-secondary"
},
outline_destructive: {
container_classes: "text-destructive border border-secondary bg-transparent hover:bg-gray-100 theme-dark:hover:bg-gray-700",
icon_classes: "fg-gray"
container_classes: "text-destructive border border-secondary bg-transparent hover:bg-container-inset-hover",
icon_classes: "text-secondary"
},
ghost: {
container_classes: "text-primary bg-transparent hover:bg-gray-100 theme-dark:hover:bg-gray-700",
icon_classes: "fg-gray"
container_classes: "text-primary bg-transparent hover:bg-container-inset-hover",
icon_classes: "text-secondary"
},
icon: {
container_classes: "hover:bg-gray-100 theme-dark:hover:bg-gray-700",
icon_classes: "fg-gray"
container_classes: "hover:bg-container-inset-hover",
icon_classes: "text-secondary"
},
icon_inverse: {
container_classes: "bg-inverse hover:bg-inverse-hover",
icon_classes: "fg-inverse"
icon_classes: "text-inverse"
}
}.freeze
@@ -43,13 +43,13 @@ class DS::Buttonish < DesignSystemComponent
},
md: {
container_classes: "px-3 py-2",
icon_container_classes: "inline-flex items-center justify-center w-9 h-9",
icon_container_classes: "inline-flex items-center justify-center w-11 h-11",
radius_classes: "rounded-lg",
text_classes: "text-sm"
},
lg: {
container_classes: "px-4 py-3",
icon_container_classes: "inline-flex items-center justify-center w-10 h-10",
icon_container_classes: "inline-flex items-center justify-center w-12 h-12",
radius_classes: "rounded-xl",
text_classes: "text-base"
}

View File

@@ -1,5 +1,23 @@
<%= wrapper_element do %>
<%= tag.dialog class: "w-full h-full bg-transparent theme-dark:backdrop:bg-alpha-black-900 backdrop:bg-overlay pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)] #{(drawer? || responsive?) ? "lg:p-3" : "lg:p-1"}", **merged_opts do %>
<%# `role="dialog"` + `aria-modal="true"` is redundant with `<dialog>`
in modern browsers, but Safari and screen readers older than the
2024 WAI-ARIA Mapping still benefit from the explicit role.
`aria-labelledby` is always emitted with the stable `title_id`. When
the header slot rendered an auto-title (the common case), AT
resolves the reference and announces the title. When no title is
rendered (`custom_header: true`, body-only dialogs), the dangling
reference is silently ignored per the WAI-ARIA spec, and callers
can supply `aria-label` / `aria-labelledby` via `**opts` (last-wins)
for an explicit accessible name. The conditional `if has_auto_title?`
gate was a no-op here: the `renders_one :header` slot lambda is
evaluated lazily at slot-render time (after the `<dialog>` opening
tag attributes are computed), so the flag was never `true` when
this attribute was read. %>
<%= tag.dialog class: "w-full h-full bg-transparent text-primary theme-dark:backdrop:bg-alpha-black-900 backdrop:bg-overlay pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)] #{(drawer? || responsive?) ? "lg:p-3" : "lg:p-1"}",
role: "dialog",
"aria-modal": "true",
"aria-labelledby": title_id,
**merged_opts do %>
<%= tag.div class: dialog_outer_classes do %>
<%= tag.div class: dialog_inner_classes, data: { DS__dialog_target: "content" } do %>
<div class="grow py-4 space-y-4 flex flex-col <%= "overflow-auto" if scrollable %>">

View File

@@ -2,7 +2,11 @@ class DS::Dialog < DesignSystemComponent
renders_one :header, ->(title: nil, subtitle: nil, custom_header: false, **opts, &block) do
content_tag(:header, class: "px-4 flex flex-col gap-2", **opts) do
title_div = content_tag(:div, class: "flex items-center justify-between gap-2") do
title = content_tag(:h2, title, class: class_names("font-medium text-primary", drawer? ? "text-lg" : "")) if title
# `id: title_id` lets the host `<dialog>` reference the title via
# `aria-labelledby` so AT users hear the title when focus lands
# in the dialog. `content_tag("h#{heading_level}", ...)` builds an
# h2/h3/etc based on the caller's `heading_level:`.
title = content_tag("h#{heading_level}", title, id: title_id, class: class_names("font-medium text-primary", drawer? ? "text-lg" : "")) if title
close_icon = close_button unless custom_header
safe_join([ title, close_icon ].compact)
end
@@ -33,7 +37,7 @@ class DS::Dialog < DesignSystemComponent
end
end
attr_reader :variant, :auto_open, :reload_on_close, :width, :disable_frame, :content_class, :disable_click_outside, :opts, :responsive, :scrollable
attr_reader :variant, :auto_open, :reload_on_close, :width, :disable_frame, :content_class, :disable_click_outside, :opts, :responsive, :scrollable, :heading_level, :title_id
VARIANTS = %w[modal drawer].freeze
WIDTHS = {
@@ -42,8 +46,13 @@ class DS::Dialog < DesignSystemComponent
lg: "lg:max-w-[700px]",
full: "lg:max-w-full"
}.freeze
VALID_HEADING_LEVELS = (1..6).freeze
def initialize(variant: "modal", auto_open: true, reload_on_close: false, width: "md", frame: nil, disable_frame: false, content_class: nil, disable_click_outside: false, responsive: false, scrollable: true, heading_level: 2, **opts)
unless heading_level.is_a?(Integer) && VALID_HEADING_LEVELS.cover?(heading_level)
raise ArgumentError, "heading_level must be an Integer between 1 and 6, got: #{heading_level.inspect}"
end
def initialize(variant: "modal", auto_open: true, reload_on_close: false, width: "md", frame: nil, disable_frame: false, content_class: nil, disable_click_outside: false, responsive: false, scrollable: true, **opts)
@variant = variant.to_sym
@auto_open = auto_open
@reload_on_close = reload_on_close
@@ -54,6 +63,8 @@ class DS::Dialog < DesignSystemComponent
@disable_click_outside = disable_click_outside
@responsive = responsive
@scrollable = scrollable
@heading_level = heading_level
@title_id = "dialog-title-#{SecureRandom.hex(4)}"
@opts = opts
end
@@ -133,8 +144,8 @@ class DS::Dialog < DesignSystemComponent
variant: "icon",
class: classes,
icon: "x",
title: I18n.t("common.close"),
aria_label: I18n.t("common.close"),
title: I18n.t("ds.dialog.close"),
aria_label: I18n.t("ds.dialog.close"),
data: { action: "DS--dialog#close" }
)
end

View File

@@ -1,13 +1,25 @@
<details class="group" <%= "open" if open %>>
<%= tag.summary class: class_names(
"px-3 py-2 rounded-xl cursor-pointer flex items-center justify-between bg-surface"
) do %>
<%= tag.details class: details_classes, open: open, **details_opts do %>
<%= tag.summary class: summary_classes do %>
<% if summary_content? %>
<%= summary_content %>
<% if variant == :default %>
<%# `:default` summary is already `flex justify-between`, so
caller-provided sibling divs get distributed directly.
Wrapping would collapse them into a single flex child and
kill the justify-between distribution. %>
<%= summary_content %>
<% else %>
<%# Non-default summaries are `list-item` (no flex), so a flex
caller div would shrink-wrap to content width and any
`justify-between` inside has nothing to distribute. Wrap
in `w-full` so caller flex rows stretch across the card. %>
<div class="w-full">
<%= summary_content %>
</div>
<% end %>
<% else %>
<div class="flex items-center gap-3">
<% if align == :left %>
<%= helpers.icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
<%= helpers.icon "chevron-right", class: "group-open:rotate-90 motion-safe:transition-transform motion-safe:duration-150" %>
<% end %>
<%= tag.span class: class_names("font-medium", align == :left ? "text-sm text-primary" : "text-xs uppercase text-secondary") do %>
@@ -16,7 +28,7 @@
</div>
<% if align == :right %>
<%= helpers.icon "chevron-down", class: "group-open:transform group-open:rotate-180" %>
<%= helpers.icon "chevron-down", class: "group-open:rotate-180 motion-safe:transition-transform motion-safe:duration-150" %>
<% end %>
<% end %>
<% end %>
@@ -24,4 +36,4 @@
<div class="mt-2">
<%= content %>
</div>
</details>
<% end %>

View File

@@ -1,12 +1,79 @@
class DS::Disclosure < DesignSystemComponent
renders_one :summary_content
attr_reader :title, :align, :open, :opts
VARIANTS = %i[default card card_inset inline].freeze
def initialize(title: nil, align: "right", open: false, **opts)
attr_reader :title, :align, :open, :variant, :opts
# `:default` — bg-surface summary, no chrome on the `<details>`. Use
# for inline expanders that sit inside a parent card (the summary
# itself reads as the surface).
#
# `:card` — `<details>` itself becomes a `bg-container shadow-border-xs
# rounded-xl` card; the summary inherits the container (no own bg).
# Use for provider-item rows (binance, lunchflow, plaid, etc.) where
# each card is the surface and the summary is custom rich content.
#
# `:card_inset` — `<details>` is `bg-surface-inset rounded-xl` (no
# shadow). Use for inset sub-panels inside a parent card surface
# (e.g. the IBKR flex-query "report details" panel embedded inside
# the IBKR settings flow). Same summary contract as `:card`.
#
# `:inline` — no surface, no padding, no shadow. The disclosure reads
# as a plain text-link-style toggle (e.g. "Alternative auth" inside
# a form, or a "Manage connections" lazy-load opener). Caller provides
# the summary text (and optional chevron) via the `summary_content`
# slot.
#
# In card / inline variants, callers should pass their own
# `summary_content` slot; the built-in title rendering assumes the
# `:default` shape.
def initialize(title: nil, align: "right", open: false, variant: :default, **opts)
@title = title
@align = align.to_sym
@open = open
@variant = variant&.to_sym
@opts = opts
raise ArgumentError, "Invalid variant: #{@variant.inspect}. Must be one of #{VARIANTS.inspect}" unless VARIANTS.include?(@variant)
end
def details_classes
base = case variant
when :card
"group bg-container p-4 shadow-border-xs rounded-xl"
when :card_inset
"group bg-surface-inset rounded-xl p-4"
else
"group"
end
class_names(base, opts[:class])
end
# `opts` minus the `:class` key, since `details_classes` merges that
# separately to avoid duplicate-keyword collisions when forwarding to
# `tag.details`.
def details_opts
opts.except(:class)
end
def summary_classes
case variant
when :card, :card_inset
# Card variants: no bg on summary — the parent details *is* the
# surface. Keep cursor + focus-visible ring + flex baseline.
# Ring token matches `settings/provider_card.html.erb` (the
# established focus pattern on container cards).
"list-none cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-alpha-black-300 rounded-xl"
when :inline
# Inline variant: no surface, no padding — the summary reads as
# plain text-link copy. Caller markup (text + optional chevron)
# provides the visual. Keep cursor + focus-visible ring + matching
# alpha-black-300 token used by the card variants for consistency.
"list-none cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-alpha-black-300 rounded-sm"
else
"px-3 py-2 rounded-xl cursor-pointer flex items-center justify-between bg-surface focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-alpha-black-300"
end
end
end

View File

@@ -1,5 +1,8 @@
<%= tag.div style: transparent? ? container_styles : nil,
class: container_classes do %>
class: container_classes,
role: (!aria_hidden && description.present?) ? "img" : nil,
"aria-label": aria_hidden ? nil : description.presence,
"aria-hidden": aria_hidden ? "true" : nil do %>
<% if icon %>
<%= helpers.icon(icon, size: icon_size, color: "current") %>
<% elsif text %>

View File

@@ -1,5 +1,5 @@
class DS::FilledIcon < DesignSystemComponent
attr_reader :icon, :text, :hex_color, :size, :rounded, :variant
attr_reader :icon, :text, :hex_color, :size, :rounded, :variant, :description, :aria_hidden
VARIANTS = %i[default text surface container inverse].freeze
@@ -24,13 +24,26 @@ class DS::FilledIcon < DesignSystemComponent
}
}.freeze
def initialize(variant: :default, icon: nil, text: nil, hex_color: nil, size: "md", rounded: false)
# `description:` makes the icon meaningful — emits `role="img"` +
# `aria-label=description` so AT users hear it. Without `description:`,
# the wrapper defaults to `aria-hidden="true"` (decorative) on the
# assumption that adjacent DOM carries the accessible name. Pass
# `aria_hidden: false` if you want the visual exposed but the name
# already lives in surrounding text (rare).
#
# NOTE on the `:text` variant: only `text.first` is rendered (e.g.
# "Apple" → "A"). The single letter is decorative — relying on AT
# users to infer "Apple" from "A" is broken. Use `description:` to
# surface the full label, or ensure the adjacent text node carries it.
def initialize(variant: :default, icon: nil, text: nil, hex_color: nil, size: "md", rounded: false, description: nil, aria_hidden: nil)
@variant = variant.to_sym
@icon = icon
@text = text
@hex_color = hex_color
@size = size.to_sym
@rounded = rounded
@description = description.presence
@aria_hidden = aria_hidden.nil? ? @description.blank? : aria_hidden
end
def container_classes

View File

@@ -10,4 +10,8 @@
<% if icon && icon_position == "right" %>
<%= helpers.icon(icon, size: size, color: icon_color) %>
<% end %>
<% if opens_in_new_tab? %>
<span class="sr-only"><%= t("ds.link.opens_in_new_tab", default: "(opens in new tab)") %></span>
<% end %>
<% end %>

View File

@@ -5,8 +5,14 @@ class DS::Link < DS::Buttonish
VARIANTS = VARIANTS.reverse_merge(
default: {
container_classes: "",
icon_classes: "fg-gray"
# Underline + `text-link` so the link is distinguishable by more
# than color alone (WCAG 1.4.1). Focus ring uses the established
# alpha-ring DS pattern (also used by DS::Toggle, DS::Tooltip,
# provider_card, form-field) so theming stays centralized.
container_classes: "text-link underline underline-offset-2 hover:no-underline " \
"focus-visible:ring-2 focus-visible:ring-alpha-black-300 " \
"theme-dark:focus-visible:ring-alpha-white-300",
icon_classes: "text-secondary"
}
).freeze
@@ -18,12 +24,48 @@ class DS::Link < DS::Buttonish
data = data.merge(turbo_frame: frame)
end
# External link hardening: `target="_blank"` without `rel="noopener"`
# exposes window.opener to the new tab (reverse-tabnabbing). Always
# set `noopener noreferrer` when we send the user off-tab. Authors
# can override by passing `rel:` explicitly.
if merged_opts[:target].to_s == "_blank"
merged_opts[:rel] ||= "noopener noreferrer"
end
# Icon-only links have no visible text node, so screen readers fall
# back to announcing the href. Derive a humanized fallback from the
# icon key so AT users hear *something* meaningful; explicit
# `aria: { label: }` on the caller still wins. Mirrors DS::Button.
#
# When the link also opens in a new tab, fold the cue into the
# generated `aria-label` itself — `aria-label` overrides the
# descendant accessible name, so the sr-only "(opens in new tab)"
# span in the template would otherwise be masked.
if icon_only? && icon.present?
aria = (merged_opts[:aria] || {}).symbolize_keys
if aria[:label].blank? && merged_opts[:"aria-label"].blank?
label = icon.to_s.tr("-_", " ").humanize
if merged_opts[:target].to_s == "_blank"
label = "#{label} #{I18n.t("ds.link.opens_in_new_tab", default: "(opens in new tab)")}"
end
aria[:label] = label
merged_opts[:aria] = aria
end
end
merged_opts.merge(
class: class_names(container_classes, extra_classes),
data: data
)
end
# Render an sr-only suffix when the link opens in a new tab so AT
# users hear "(opens in new tab)" — visual is a separate concern
# (callers can render a `external-link` icon if they want a glyph).
def opens_in_new_tab?
opts[:target].to_s == "_blank"
end
private
def container_size_classes
super unless variant == :default

View File

@@ -1,26 +1,16 @@
<%= tag.div data: { controller: "DS--menu", DS__menu_placement_value: placement, DS__menu_offset_value: offset, DS__menu_mobile_fullwidth_value: mobile_fullwidth, testid: testid } do %>
<% if variant == :icon %>
<%= render DS::Button.new(variant: "icon", icon: icon_vertical ? "more-vertical" : "more-horizontal", data: { DS__menu_target: "button" }) %>
<% if icon_only? %>
<%= render DS::Button.new(variant: "icon", size: icon_button_size, icon: icon_vertical ? "more-vertical" : "more-horizontal", aria: { haspopup: "menu", expanded: "false", controls: menu_id }, data: { DS__menu_target: "button" }) %>
<% elsif variant == :button %>
<%= button %>
<% elsif variant == :avatar %>
<button data-DS--menu-target="button">
<div class="w-9 h-9 cursor-pointer">
<%= render "settings/user_avatar", avatar_url: avatar_url, initials: initials %>
</div>
</button>
<% end %>
<div data-DS--menu-target="content" class="px-2 lg:px-0 max-w-full hidden z-50">
<div id="<%= menu_id %>" data-DS--menu-target="content" class="px-2 lg:px-0 max-w-full hidden z-50" role="menu">
<%= tag.div class: "mx-auto min-w-[200px] shadow-border-xs bg-container rounded-lg", style: ("max-width: #{max_width}" if max_width) do %>
<%= header %>
<%= tag.div class: class_names("py-1" => !no_padding) do %>
<% items.each do |item| %>
<%= item %>
<% end %>
<%= custom_content %>
<% end %>
<% end %>
</div>

View File

@@ -1,32 +1,40 @@
# frozen_string_literal: true
# `DS::Menu` is a strict action-list primitive. Children are `DS::MenuItem`
# (link / button / divider) only; the container announces as `role="menu"`,
# items as `role="menuitem"`, dividers as `role="separator"`. Arrow Up/Down
# and Home/End move focus across items (roving tabindex). Use **only** for
# flat clickable-action lists.
#
# Need a panel that hosts forms, pickers, headings, or user-account
# content? Use `DS::Popover` — `role="menu"` restricts AT users to
# menuitem-only navigation and breaks anything that isn't an action.
class DS::Menu < DesignSystemComponent
attr_reader :variant, :avatar_url, :initials, :placement, :offset, :icon_vertical, :no_padding, :testid, :mobile_fullwidth, :max_width
attr_reader :variant, :placement, :offset, :icon_vertical, :no_padding, :testid, :mobile_fullwidth, :max_width, :menu_id
renders_one :button, ->(**button_options, &block) do
options_with_target = button_options.merge(data: { DS__menu_target: "button" })
options_with_target = button_options.deep_dup
options_with_target[:data] = (options_with_target[:data] || {}).merge(DS__menu_target: "button")
options_with_target[:aria] = (options_with_target[:aria] || {}).merge(
haspopup: "menu",
expanded: "false",
controls: menu_id
)
if block
options_with_target[:type] ||= "button"
content_tag(:button, **options_with_target, &block)
else
DS::Button.new(**options_with_target)
end
end
renders_one :header, ->(&block) do
content_tag(:div, class: "border-b border-tertiary", &block)
end
renders_one :custom_content
renders_many :items, DS::MenuItem
VARIANTS = %i[icon button avatar].freeze
VARIANTS = %i[icon icon_sm button].freeze
def initialize(variant: "icon", avatar_url: nil, initials: nil, placement: "bottom-end", offset: 12, icon_vertical: false, no_padding: false, testid: nil, mobile_fullwidth: true, max_width: nil)
def initialize(variant: "icon", placement: "bottom-end", offset: 12, icon_vertical: false, no_padding: false, testid: nil, mobile_fullwidth: true, max_width: nil)
@variant = variant.to_sym
@avatar_url = avatar_url
@initials = initials
@placement = placement
@offset = offset
@icon_vertical = icon_vertical
@@ -34,7 +42,26 @@ class DS::Menu < DesignSystemComponent
@testid = testid
@mobile_fullwidth = mobile_fullwidth
@max_width = max_width
@menu_id = "menu-#{SecureRandom.hex(4)}"
raise ArgumentError, "Invalid variant: #{@variant}" unless VARIANTS.include?(@variant)
raise ArgumentError, "Invalid variant: #{@variant}. DS::Menu is for action lists only; use DS::Popover for mixed content (forms, pickers, account dropdowns)." unless VARIANTS.include?(@variant)
end
# `:icon_sm` renders the dropdown trigger as a 32x32 icon button (DS::Button
# `size: :sm`) instead of the default 44x44 `:md`. Use for action menus
# embedded in dense lists (e.g. the category dropdown row trigger) where the
# 44x44 enhanced-touch-target trigger introduced in #1840 makes every row
# ~8px taller and the cumulative list height regresses visibly.
#
# Trade-off: the `sm` icon button is 32x32, which meets WCAG 2.5.8 AA
# (24x24) but not 2.5.5 AAA enhanced (44x44). Acceptable for compact
# dropdown rows that aren't primary touch surfaces; do not use on
# standalone toolbar / row-action triggers where 44x44 should remain.
def icon_only?
variant == :icon || variant == :icon_sm
end
def icon_button_size
variant == :icon_sm ? "sm" : "md"
end
end

View File

@@ -8,7 +8,10 @@ import {
import { Controller } from "@hotwired/stimulus";
/**
* A "menu" can contain arbitrary content including non-clickable items, links, buttons, and forms.
* Strict action-list menu. Container is `role="menu"`, items are
* `role="menuitem"`. Arrow Up/Down moves focus between items, Home/End
* jumps to first/last, Escape closes the menu and returns focus to the
* trigger. Use DS::Popover for mixed-content panels (forms, pickers).
*/
export default class extends Controller {
static targets = ["button", "content"];
@@ -59,31 +62,71 @@ export default class extends Controller {
if (event.key === "Escape") {
this.close();
this.buttonTarget.focus();
return;
}
if (!this.show) return;
const items = this.#menuItems();
if (items.length === 0) return;
const currentIndex = items.indexOf(event.target);
// Activate the focused item on Enter / Space (ARIA menu pattern).
// Without this, link-based menuitems can't be activated by keyboard
// once focus has moved off the native default.
if (event.key === "Enter" || event.key === " ") {
if (currentIndex < 0) return;
event.preventDefault();
items[currentIndex].click();
return;
}
let nextIndex = null;
switch (event.key) {
case "ArrowDown":
nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % items.length;
break;
case "ArrowUp":
nextIndex = currentIndex < 0 ? items.length - 1 : (currentIndex - 1 + items.length) % items.length;
break;
case "Home":
nextIndex = 0;
break;
case "End":
nextIndex = items.length - 1;
break;
default:
return;
}
event.preventDefault();
items.forEach((item, i) => item.setAttribute("tabindex", i === nextIndex ? "0" : "-1"));
items[nextIndex].focus();
};
toggle = () => {
this.show = !this.show;
this.contentTarget.classList.toggle("hidden", !this.show);
this.buttonTarget.setAttribute("aria-expanded", this.show.toString());
if (this.show) {
this.update();
this.focusFirstElement();
this.#focusFirstMenuItem();
}
};
close() {
this.show = false;
this.contentTarget.classList.add("hidden");
this.buttonTarget.setAttribute("aria-expanded", "false");
}
focusFirstElement() {
const focusableElements =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const firstFocusableElement =
this.contentTarget.querySelectorAll(focusableElements)[0];
if (firstFocusableElement) {
firstFocusableElement.focus({ preventScroll: true });
}
#menuItems() {
return Array.from(this.contentTarget.querySelectorAll('[role="menuitem"]'));
}
#focusFirstMenuItem() {
const items = this.#menuItems();
if (items.length === 0) return;
items.forEach((item, i) => item.setAttribute("tabindex", i === 0 ? "0" : "-1"));
items[0].focus({ preventScroll: true });
}
startAutoUpdate() {

View File

@@ -1,7 +1,9 @@
<% if variant == :divider %>
<%= render "shared/ruler", classes: "my-1" %>
<div role="separator">
<%= render "shared/ruler", classes: "my-1" %>
</div>
<% else %>
<div class="px-1">
<div class="px-1" role="none">
<%= wrapper do %>
<% if icon %>
<%= helpers.icon(icon, color: destructive? ? :destructive : :default) %>

View File

@@ -1,9 +1,14 @@
class DS::MenuItem < DesignSystemComponent
VARIANTS = %i[link button divider].freeze
attr_reader :variant, :text, :icon, :href, :method, :destructive, :confirm, :frame, :opts
attr_reader :variant, :text, :icon, :href, :method, :destructive, :confirm, :frame, :roving, :opts
def initialize(variant:, text: nil, icon: nil, href: nil, method: :post, destructive: false, confirm: nil, frame: nil, **opts)
# `roving: true` (default) emits `tabindex="-1"` and `role="menuitem"` — correct
# for `DS::Menu`, which provides arrow-key roving and announces `role="menu"`.
# `roving: false` omits both so items stay in the normal Tab order — required
# inside `DS::Popover`, which has no roving handler and is not a `role="menu"`
# container.
def initialize(variant:, text: nil, icon: nil, href: nil, method: :post, destructive: false, confirm: nil, frame: nil, roving: true, **opts)
@variant = variant.to_sym
@text = text
@icon = icon
@@ -12,15 +17,22 @@ class DS::MenuItem < DesignSystemComponent
@destructive = destructive
@confirm = confirm
@frame = frame
@roving = roving
@opts = opts
raise ArgumentError, "Invalid variant: #{@variant}" unless VARIANTS.include?(@variant)
end
def wrapper(&block)
# When roving is on, `menuitem_attrs` is part of the `DS::Menu` ARIA contract
# and must win — strip any caller overrides of `role`/`tabindex` from
# `merged_opts` before splatting, so a stray `role: :button` or
# `tabindex: 0` can't downgrade keyboard/AT semantics.
html_opts = roving ? merged_opts.except(:role, :tabindex) : merged_opts
if variant == :button
button_to href, method: method, class: container_classes, **merged_opts, &block
button_to href, method: method, class: container_classes, **html_opts, **menuitem_attrs, &block
elsif variant == :link
link_to href, class: container_classes, **merged_opts, &block
link_to href, class: container_classes, **html_opts, **menuitem_attrs, &block
else
nil
end
@@ -38,6 +50,10 @@ class DS::MenuItem < DesignSystemComponent
end
private
def menuitem_attrs
roving ? { role: "menuitem", tabindex: "-1" } : {}
end
def container_classes
[
"flex items-center gap-2 p-2 rounded-md w-full",

View File

@@ -0,0 +1,20 @@
<% if dot_only %>
<%# Compact dot — no label, no border, just the colored marker. Used on the
collapsed sidebar nav. Keeps tone semantics without taking up label
width. %>
<span role="img"
class="inline-block shrink-0 align-middle rounded-full"
style="width: <%= dot_size_px %>px; height: <%= dot_size_px %>px; background-color: <%= dot_color %>;"
aria-label="<%= title || t("ds.pill.aria_label", label: label) %>"
title="<%= title || t("ds.pill.aria_label", label: label) %>"></span>
<% else %>
<span class="<%= container_classes %>" style="<%= container_styles %>" title="<%= title || label %>">
<% if icon %>
<%= helpers.icon(icon, size: "xs", color: "current") %>
<% elsif show_dot %>
<span class="inline-block shrink-0 rounded-full"
style="width: <%= dot_size_px %>px; height: <%= dot_size_px %>px; background-color: <%= dot_color %>;"></span>
<% end %>
<%= label %>
</span>
<% end %>

131
app/components/DS/pill.rb Normal file
View File

@@ -0,0 +1,131 @@
class DS::Pill < DesignSystemComponent
TONES = %i[violet indigo fuchsia amber green gray red].freeze
STYLES = %i[soft filled outline].freeze
SIZES = %i[sm md].freeze
# Semantic-name → visual-tone aliases. Lets callers say
# `tone: :success` instead of binding to the underlying palette name.
# The aliases live here (not on the caller) so the visual palette can
# be retuned without touching every callsite.
SEMANTIC_TONE_ALIASES = {
success: :green,
warning: :amber,
error: :red,
destructive: :red,
info: :indigo,
neutral: :gray
}.freeze
attr_reader :label, :tone, :style, :size, :show_dot, :dot_only, :title, :icon, :marker
# Generic inline pill primitive. Two modes:
#
# - `marker: true` (default) — the original shape from #1829: uppercase
# 10/11px text, tracking-wide. Reads as a stage marker (Beta, Canary,
# NEW, PRO, EXPERIMENTAL, …).
#
# - `marker: false` — normal case, snaps to the DS text scale
# (`text-xs` / `text-sm`). Reads as a status / category badge.
# Pair with semantic tones (`:success`, `:warning`, `:error`,
# `:info`, `:neutral`) for status badges; pair with visual tones
# (`:violet`, `:indigo`, etc.) for category tags.
#
# Other options:
#
# - `dot_only: true` renders only the colored dot (no label, no border).
# Use on the collapsed sidebar nav, where there's no room for the label.
# - `icon:` overrides the dot with a Lucide icon (sized xs, current color).
# Useful for status pills that benefit from a glyph (circle-check,
# triangle-alert, pause, etc.) rather than the generic dot.
# - Tones accept both visual names (`:violet`, `:amber`, …) and
# semantic aliases (`:success`, `:warning`, `:error`,
# `:destructive`, `:neutral`, `:info`). Aliases resolve via
# `SEMANTIC_TONE_ALIASES`.
# - Sure has full violet / indigo / fuchsia / amber / green / gray /
# red ramps in the design system; this component picks named tokens
# at render time. No raw hex.
def initialize(label: nil, tone: :violet, style: :soft, size: :sm, show_dot: true, dot_only: false, title: nil, icon: nil, marker: true)
resolved_tone = SEMANTIC_TONE_ALIASES.fetch(tone.to_sym, tone.to_sym)
@label = label || I18n.t("ds.pill.default_label", default: "Beta")
@tone = TONES.include?(resolved_tone) ? resolved_tone : :violet
@style = STYLES.include?(style.to_sym) ? style.to_sym : :soft
@size = SIZES.include?(size.to_sym) ? size.to_sym : :sm
@show_dot = show_dot
@dot_only = dot_only
@title = title
@icon = icon
@marker = marker
end
def palette
{
violet: { bg: "var(--color-violet-50)", bg_dark: "var(--color-violet-tint-10)", text: "var(--color-violet-700)", text_dark: "var(--color-violet-200)", border: "var(--color-violet-200)", dot: "var(--color-violet-500)", fill: "var(--color-violet-500)" },
indigo: { bg: "var(--color-indigo-50)", bg_dark: "var(--color-indigo-tint-10)", text: "var(--color-indigo-700)", text_dark: "var(--color-indigo-200)", border: "var(--color-indigo-200)", dot: "var(--color-indigo-500)", fill: "var(--color-indigo-500)" },
fuchsia: { bg: "var(--color-fuchsia-50)", bg_dark: "var(--color-fuchsia-tint-10)", text: "var(--color-fuchsia-700)", text_dark: "var(--color-fuchsia-200)", border: "var(--color-fuchsia-200)", dot: "var(--color-fuchsia-500)", fill: "var(--color-fuchsia-500)" },
amber: { bg: "var(--color-yellow-50)", bg_dark: "var(--color-yellow-tint-10)", text: "var(--color-yellow-700)", text_dark: "var(--color-yellow-200)", border: "var(--color-yellow-200)", dot: "var(--color-yellow-500)", fill: "var(--color-yellow-500)" },
green: { bg: "var(--color-green-50)", bg_dark: "var(--color-green-tint-10)", text: "var(--color-green-700)", text_dark: "var(--color-green-200)", border: "var(--color-green-200)", dot: "var(--color-green-500)", fill: "var(--color-green-500)" },
gray: { bg: "var(--color-gray-100)", bg_dark: "var(--color-gray-tint-10)", text: "var(--color-gray-700)", text_dark: "var(--color-gray-200)", border: "var(--color-gray-200)", dot: "var(--color-gray-500)", fill: "var(--color-gray-500)" },
red: { bg: "var(--color-red-50)", bg_dark: "var(--color-red-tint-10)", text: "var(--color-red-700)", text_dark: "var(--color-red-200)", border: "var(--color-red-200)", dot: "var(--color-red-500)", fill: "var(--color-red-500)" }
}[tone]
end
def dot_size_px
size == :md ? 6 : 5
end
def container_styles
p = palette
case style
when :filled
<<~CSS.strip.gsub(/\s+/, " ")
background-color: #{p[:fill]};
color: var(--color-white);
border-color: transparent;
CSS
when :outline
<<~CSS.strip.gsub(/\s+/, " ")
background-color: transparent;
color: light-dark(#{p[:text]}, #{p[:text_dark]});
border-color: light-dark(#{p[:border]}, color-mix(in oklab, #{p[:dot]} 40%, transparent));
CSS
else # :soft
<<~CSS.strip.gsub(/\s+/, " ")
background-color: light-dark(#{p[:bg]}, #{p[:bg_dark]});
color: light-dark(#{p[:text]}, #{p[:text_dark]});
border-color: light-dark(#{p[:border]}, color-mix(in oklab, #{p[:dot]} 20%, transparent));
CSS
end
end
def dot_color
style == :filled ? "rgba(255,255,255,0.85)" : palette[:dot]
end
def container_classes
base = [
"inline-flex items-center align-middle font-medium whitespace-nowrap shrink-0",
"border leading-none"
]
if marker
# Marker mode (Beta / Canary / NEW): rounded-md (slight chip
# shape), uppercase, sub-12px text, wider tracking.
# text-[10/11px] stays as arbitrary values — the pill is
# intentionally sub-12px (Sure's smallest scale token is text-xs
# / 12px) so it reads as a marker, not a label. Padding / gap /
# tracking snap to Tailwind's scale to satisfy the design-system
# "no arbitrary values" rule.
base << "rounded-md uppercase"
base << (size == :md ? "px-2 py-0.5 text-[11px] tracking-wide gap-1" : "px-1.5 py-0.5 text-[10px] tracking-wider gap-1")
else
# Badge mode (Pending / Active / Past due / category tag):
# rounded-full pill shape (matches the existing convention used
# by `settings/providers/_status_pill`, `_maturity_badge`, and
# the inline transaction badges). Normal case, snaps to the
# design-system text scale (`text-xs` / `text-sm`).
base << "rounded-full"
base << (size == :md ? "px-2 py-0.5 text-sm gap-1.5" : "px-1.5 py-0.5 text-xs gap-1")
end
class_names(*base)
end
end

View File

@@ -0,0 +1,32 @@
<%= tag.div data: { controller: "DS--popover", DS__popover_placement_value: placement, DS__popover_offset_value: offset, DS__popover_mobile_fullwidth_value: mobile_fullwidth, testid: testid } do %>
<% if variant == :icon %>
<%= render DS::Button.new(variant: "icon", icon: icon, aria_label: trigger_aria_label, aria: { haspopup: "dialog", expanded: "false", controls: panel_id }, data: { DS__popover_target: "button" }) %>
<% elsif variant == :button %>
<%= button %>
<% elsif variant == :avatar %>
<%# Avatar trigger needs an explicit accessible name — the inner
avatar image is decorative. Caller must pass `aria_label:` or
the fallback `ds.popover.avatar_default_label` is used. %>
<button type="button"
data-DS--popover-target="button"
class="inline-flex items-center justify-center w-11 h-11 cursor-pointer rounded-full focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-900 theme-dark:focus-visible:outline-white"
aria-label="<%= trigger_aria_label %>"
aria-haspopup="dialog"
aria-expanded="false"
aria-controls="<%= panel_id %>">
<div class="w-9 h-9">
<%= render "settings/user_avatar", avatar_url: avatar_url, initials: initials %>
</div>
</button>
<% end %>
<div id="<%= panel_id %>" data-DS--popover-target="content" class="px-2 lg:px-0 max-w-full hidden z-50">
<%= tag.div class: "mx-auto min-w-[200px] shadow-border-xs bg-container rounded-lg", style: ("max-width: #{max_width}" if max_width) do %>
<%= header %>
<%= tag.div class: class_names("py-1" => !no_padding) do %>
<%= custom_content %>
<% end %>
<% end %>
</div>
<% end %>

View File

@@ -0,0 +1,62 @@
# frozen_string_literal: true
# `DS::Popover` is a positioned panel for **mixed, non-action-list** content:
# user-account menus, picker forms, filter forms, embedded controls. The
# panel hosts arbitrary markup and **does not** announce as a `role="menu"`
# — that role restricts AT users to menuitem-only navigation, which breaks
# any panel containing form inputs, headings, or generic groupings.
#
# Use `DS::Menu` instead when the panel is a flat list of clickable actions.
class DS::Popover < DesignSystemComponent
attr_reader :variant, :avatar_url, :initials, :placement, :offset, :icon, :no_padding, :testid, :mobile_fullwidth, :max_width, :panel_id
renders_one :button, ->(**button_options, &block) do
options_with_target = button_options.deep_dup
options_with_target[:data] = (options_with_target[:data] || {}).merge(DS__popover_target: "button")
options_with_target[:aria] = {
haspopup: "dialog",
expanded: "false",
controls: panel_id
}.merge(options_with_target[:aria] || {})
if block
options_with_target[:type] ||= "button"
content_tag(:button, **options_with_target, &block)
else
DS::Button.new(**options_with_target)
end
end
renders_one :header, ->(&block) do
content_tag(:div, class: "border-b border-tertiary", &block)
end
renders_one :custom_content
VARIANTS = %i[icon button avatar].freeze
def initialize(variant: "icon", avatar_url: nil, initials: nil, placement: "bottom-end", offset: 12, icon: "more-horizontal", no_padding: false, testid: nil, mobile_fullwidth: true, max_width: nil, aria_label: nil)
@variant = variant.to_sym
@avatar_url = avatar_url
@initials = initials
@placement = placement
@offset = offset
@icon = icon
@no_padding = no_padding
@testid = testid
@mobile_fullwidth = mobile_fullwidth
@max_width = max_width
@aria_label = aria_label
@panel_id = "popover-#{SecureRandom.hex(4)}"
raise ArgumentError, "Invalid variant: #{@variant}" unless VARIANTS.include?(@variant)
end
# Accessible name for the trigger button. The `:avatar` variant has no
# visible text, so the caller MUST pass `aria_label:`. `:icon` and
# `:button` variants fall back to DS::Button's icon-derived label and
# the slot's own text respectively.
def trigger_aria_label
@aria_label || (variant == :avatar ? I18n.t("ds.popover.avatar_default_label", default: "Open menu") : nil)
end
end

View File

@@ -0,0 +1,140 @@
import {
autoUpdate,
computePosition,
flip,
offset,
shift,
} from "@floating-ui/dom";
import { Controller } from "@hotwired/stimulus";
/**
* Positioned panel for mixed content (forms, pickers, account menus).
* Mirrors DS--menu's positioning + open/close lifecycle but skips the
* `role="menu"` / arrow-key navigation that's specific to action lists.
* Wiring `aria-expanded` on the trigger so AT users hear "expanded" /
* "collapsed" as the panel opens / closes.
*/
export default class extends Controller {
static targets = ["button", "content"];
static values = {
show: Boolean,
placement: { type: String, default: "bottom-end" },
offset: { type: Number, default: 6 },
mobileFullwidth: { type: Boolean, default: true },
};
connect() {
this.show = this.showValue;
this.boundUpdate = this.update.bind(this);
this.addEventListeners();
this.startAutoUpdate();
}
disconnect() {
this.removeEventListeners();
this.stopAutoUpdate();
this.close();
}
addEventListeners() {
this.buttonTarget.addEventListener("click", this.toggle);
this.element.addEventListener("keydown", this.handleKeydown);
document.addEventListener("click", this.handleOutsideClick);
document.addEventListener("turbo:load", this.handleTurboLoad);
}
removeEventListeners() {
this.buttonTarget.removeEventListener("click", this.toggle);
this.element.removeEventListener("keydown", this.handleKeydown);
document.removeEventListener("click", this.handleOutsideClick);
document.removeEventListener("turbo:load", this.handleTurboLoad);
}
handleTurboLoad = () => {
if (!this.show) this.close();
};
handleOutsideClick = (event) => {
if (this.show && !this.element.contains(event.target)) this.close();
};
handleKeydown = (event) => {
if (event.key === "Escape") {
this.close();
this.buttonTarget.focus();
}
};
toggle = () => {
this.show = !this.show;
this.contentTarget.classList.toggle("hidden", !this.show);
this.buttonTarget.setAttribute("aria-expanded", this.show.toString());
if (this.show) {
this.update();
this.focusFirstElement();
}
};
close() {
this.show = false;
this.contentTarget.classList.add("hidden");
this.buttonTarget.setAttribute("aria-expanded", "false");
}
focusFirstElement() {
const focusableElements =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const firstFocusableElement =
this.contentTarget.querySelectorAll(focusableElements)[0];
if (firstFocusableElement) {
firstFocusableElement.focus({ preventScroll: true });
}
}
startAutoUpdate() {
if (!this._cleanup) {
this._cleanup = autoUpdate(
this.buttonTarget,
this.contentTarget,
this.boundUpdate,
);
}
}
stopAutoUpdate() {
if (this._cleanup) {
this._cleanup();
this._cleanup = null;
}
}
update() {
if (!this.buttonTarget || !this.contentTarget) return;
const isSmallScreen = !window.matchMedia("(min-width: 768px)").matches;
const useMobileFullwidth = isSmallScreen && this.mobileFullwidthValue;
computePosition(this.buttonTarget, this.contentTarget, {
placement: useMobileFullwidth ? "bottom" : this.placementValue,
middleware: [offset(this.offsetValue), flip({ padding: 5 }), shift({ padding: 5 })],
strategy: "fixed",
}).then(({ x, y }) => {
if (useMobileFullwidth) {
Object.assign(this.contentTarget.style, {
position: "fixed",
left: "0px",
width: "100vw",
top: `${y}px`,
});
} else {
Object.assign(this.contentTarget.style, {
position: "fixed",
left: `${x}px`,
top: `${y}px`,
width: "",
});
}
});
}
}

View File

@@ -0,0 +1,17 @@
<%= tag.div class: container_classes do %>
<%= tag.input type: "search",
name: name,
value: value,
placeholder: placeholder,
"aria-label": aria_label,
autocomplete: "off",
class: input_classes,
**opts %>
<% if variant == :embedded %>
<%= helpers.icon("search", class: "absolute inset-0 ml-2 transform top-1/2 -translate-y-1/2") %>
<% else %>
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<%= helpers.icon("search", class: "text-secondary") %>
</div>
<% end %>
<% end %>

View File

@@ -0,0 +1,78 @@
# frozen_string_literal: true
# `DS::SearchInput` is the search-field primitive.
#
# Two variants:
#
# - `:standalone` (default) — top-of-list filter inputs (Preferences
# currency search, Settings/Bank Sync provider filter). Bordered
# bg-container surface, icon-on-left, full token-backed focus ring.
#
# - `:embedded` — search-inside-a-panel (DS::Select internal search,
# splits category filter, any future DS::Popover that hosts a filter).
# No border / no own focus ring — the parent panel provides the
# chrome, so adding ring + outline here would compete with the
# parent's focus-within state.
#
# For `form.search_field :foo` inside a `styled_form_with` block,
# keep using the form helper — it routes through `StyledFormBuilder`'s
# form-field CSS, which is a different visual contract.
class DS::SearchInput < DesignSystemComponent
VARIANTS = %i[standalone embedded].freeze
attr_reader :variant, :name, :placeholder, :value, :aria_label, :extra_classes, :opts
def initialize(variant: :standalone, name: nil, placeholder: nil, value: nil, aria_label: nil, class: nil, **opts)
@variant = variant.to_sym
@name = name
@placeholder = placeholder
@value = value
@aria_label = aria_label || placeholder
@extra_classes = binding.local_variable_get(:class)
@opts = opts
raise ArgumentError, "Invalid variant: #{@variant}. Must be one of #{VARIANTS.inspect}" unless VARIANTS.include?(@variant)
end
def container_classes
class_names("relative", extra_classes)
end
def input_classes
# `text-base sm:text-sm` — keep the base font at 16px so iOS Safari
# does not zoom the viewport when the input is focused. Shrink to
# 14px from `sm:` upward. The previous unconditional `text-sm`
# triggered the mobile zoom regression.
case variant
when :embedded
# No own focus ring — the parent panel handles focus chrome via
# `focus-within`. `focus:outline-hidden focus:ring-0` neutralizes
# the browser default so it doesn't compete with the panel's
# state.
"bg-container text-primary text-base sm:text-sm placeholder:text-secondary font-normal " \
"h-10 pl-10 w-full border-none rounded-lg " \
"focus:outline-hidden focus:ring-0"
else
# `focus-visible:outline-*` matches the focus-ring pattern from
# DS::Button (base.css) so every interactive surface in the design
# system uses the same ring token. Replaces the broken
# `focus:ring-gray-500` from the inline callsites — that utility
# had no backing token and rendered invisibly on the bordered
# bg-container surface.
"block w-full border border-secondary rounded-md py-2.5 pl-10 pr-3 bg-container text-base sm:text-sm " \
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-900 " \
"theme-dark:focus-visible:outline-white"
end
end
def icon_classes
variant == :embedded ? "absolute inset-0 ml-2 transform top-1/2 -translate-y-1/2" : "text-secondary"
end
def icon_wrapper_classes
# Standalone variant wraps the icon in a positioned div; embedded
# places the icon as an absolutely-positioned sibling so the parent
# panel can stay in control of vertical alignment.
variant == :embedded ? nil : "absolute inset-0 ml-2 top-1/2 -translate-y-1/2 pointer-events-none"
end
end

View File

@@ -1,36 +1,47 @@
<%# locals: form:, method:, collection:, options: {} %>
<div class="relative" data-controller="select <%= "list-filter" if searchable %> form-dropdown" data-action="dropdown:select->form-dropdown#onSelect">
<div class="relative" data-controller="select <%= "list-filter" if searchable %> form-dropdown" data-select-menu-placement-value="<%= menu_placement %>" data-action="dropdown:select->form-dropdown#onSelect">
<div class="form-field <%= options[:container_class] %>">
<div class="form-field__body">
<%= form.label method, options[:label], class: "form-field__label" if options[:label].present? %>
<%= form.label method, options[:label], class: "form-field__label", id: "#{method}_label" if options[:label].present? %>
<%= form.hidden_field method,
value: @selected_value,
data: {
"form-dropdown-target": "input",
"auto-submit-target": "auto"
"auto-submit-target": "auto",
**(options.dig(:html_options, :data) || {})
} %>
<%# `aria-expanded` reflects MENU open/closed state — managed by the
select controller's openMenu/close. Init as "false"; previously
this incorrectly mirrored whether a value was selected.
`aria-labelledby` points at BOTH the visible label and the
trigger button itself so AT users hear "<label> <selected
value>" — referencing only the label would override the
button's text node and suppress the current value. %>
<button type="button"
id="<%= method %>_trigger"
class="form-field__input w-full"
data-select-target="button"
data-action="click->select#toggle"
aria-haspopup="listbox"
aria-expanded="<%= @selected_value.present? ? "true" : "false" %>"
aria-labelledby="<%= "#{method}_label" %>">
aria-expanded="false"
<%= "aria-labelledby=\"#{method}_label #{method}_trigger\"".html_safe if options[:label].present? %>>
<%= selected_item&.dig(:label) || @placeholder %>
</button>
</div>
</div>
<div class="absolute z-50 p-1.5 w-full min-w-32 rounded-lg shadow-lg shadow-border-xs bg-container mt-1.5 transition duration-150 ease-out -translate-y-1 opacity-0 hidden" data-select-target="menu">
<% if searchable %>
<div class="relative flex items-center bg-container border border-secondary rounded-lg mb-1">
<input type="search"
placeholder="<%= t("helpers.select.search_placeholder") %>"
autocomplete="off"
class="bg-container text-sm placeholder:text-secondary font-normal h-10 pl-10 w-full border-none rounded-lg focus:outline-hidden focus:ring-0"
data-list-filter-target="input"
data-action="list-filter#filter">
<%= helpers.icon("search", class: "absolute inset-0 ml-2 transform top-1/2 -translate-y-1/2") %>
<div class="flex items-center bg-container border border-secondary rounded-lg mb-1 focus-within:ring-4 focus-within:ring-alpha-black-200 theme-dark:focus-within:ring-alpha-white-300 transition-shadow">
<%= render DS::SearchInput.new(
variant: :embedded,
placeholder: t("helpers.select.search_placeholder"),
data: {
list_filter_target: "input",
action: "input->list-filter#filter input->select#syncTabindex"
}
) %>
</div>
<% end %>
<div data-list-filter-target="list" data-select-target="content" class="flex flex-col gap-0.5 max-h-64 overflow-auto"
@@ -39,10 +50,14 @@
<% is_selected = item[:value] == selected_value %>
<% obj = item[:object] %>
<div class="filterable-item text-sm cursor-pointer flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-container-inset-hover <%= "bg-container-inset" if is_selected %>"
<%# Roving tabindex: selected option is in tab order (`0`); others
are reachable only via ArrowUp/Down (`-1`). WAI-ARIA APG
listbox keyboard pattern. %>
<div class="filterable-item text-primary text-sm cursor-pointer flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-container-inset-hover <%= "bg-container-inset" if is_selected %>"
role="option"
tabindex="0"
tabindex="<%= is_selected ? "0" : "-1" %>"
aria-selected="<%= is_selected %>"
data-select-target="option"
data-action="click->select#select"
data-value="<%= item[:value] %>"
data-filter-name="<%= item[:label] %>">

View File

@@ -1,18 +1,20 @@
module DS
class Select < ViewComponent::Base
attr_reader :form, :method, :items, :selected_value, :placeholder, :variant, :searchable, :options
attr_reader :form, :method, :items, :selected_value, :placeholder, :variant, :searchable, :menu_placement, :options
VARIANTS = %i[simple logo badge].freeze
MENU_PLACEMENTS = %w[auto down up].freeze
HEX_COLOR_REGEX = /\A#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?\z/
RGB_COLOR_REGEX = /\Argb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\)\z/
DEFAULT_COLOR = "#737373"
def initialize(form:, method:, items:, selected: nil, placeholder: I18n.t("helpers.select.default_label"), variant: :simple, include_blank: nil, searchable: false, **options)
def initialize(form:, method:, items:, selected: nil, placeholder: I18n.t("helpers.select.default_label"), variant: :simple, include_blank: nil, searchable: false, menu_placement: :auto, **options)
@form = form
@method = method
@placeholder = placeholder
@variant = variant
@searchable = searchable
@menu_placement = normalize_menu_placement(menu_placement)
@options = options
normalized_items = normalize_items(items)
@@ -61,6 +63,11 @@ module DS
private
def normalize_menu_placement(value)
normalized = value.to_s.downcase
MENU_PLACEMENTS.include?(normalized) ? normalized : "auto"
end
def normalize_items(collection)
collection.map do |item|
case item

View File

@@ -5,6 +5,7 @@ class DS::Tabs < DesignSystemComponent
active_btn_classes: active_btn_classes,
inactive_btn_classes: inactive_btn_classes,
btn_classes: base_btn_classes,
dom_prefix: dom_prefix,
classes: unstyled? ? classes : class_names(nav_container_classes, classes)
)
end
@@ -13,16 +14,35 @@ class DS::Tabs < DesignSystemComponent
content_tag(
:div,
class: ("hidden" unless tab_id == active_tab),
role: "tabpanel",
id: panel_dom_id(tab_id),
"aria-labelledby": tab_dom_id(tab_id),
tabindex: "0",
data: { id: tab_id, DS__tabs_target: "panel" },
&block
)
end
# Scope tab/panel DOM ids to this component instance so multiple
# `DS::Tabs` widgets on the same page (which often reuse generic
# tab ids like "all" or "overview") don't collide and break the
# `aria-controls` / `aria-labelledby` associations.
def tab_dom_id(tab_id)
"#{dom_prefix}-tab-#{tab_id}"
end
def panel_dom_id(tab_id)
"#{dom_prefix}-panel-#{tab_id}"
end
VARIANTS = {
default: {
active_btn_classes: "bg-white theme-dark:bg-gray-700 text-primary shadow-sm",
# `tab-item-active` is a Sure token utility (white light / gray-700 dark).
# Swapping out the raw `bg-white theme-dark:bg-gray-700` removes the
# last raw-palette reference in DS::Tabs.
active_btn_classes: "tab-item-active text-primary shadow-sm",
inactive_btn_classes: "text-secondary hover:bg-surface-inset-hover",
base_btn_classes: "w-full inline-flex justify-center items-center text-sm font-medium px-2 py-1 rounded-md transition-colors duration-200",
base_btn_classes: "w-full inline-flex justify-center items-center text-sm font-medium px-2 py-1 rounded-md motion-safe:transition-colors motion-safe:duration-200",
nav_container_classes: "flex bg-surface-inset p-1 rounded-lg mb-4"
}
}
@@ -48,6 +68,10 @@ class DS::Tabs < DesignSystemComponent
end
private
def dom_prefix
@dom_prefix ||= "tabs-#{object_id}"
end
def unstyled?
variant == :unstyled
end

View File

@@ -1,6 +1,16 @@
class DS::Tabs::Nav < DesignSystemComponent
erb_template <<~ERB
<%= tag.nav class: classes do %>
<%# Neutral `<div>` host for `role="tablist"`. Per ARIA-in-HTML,
`<nav>` has a fixed landmark role and may not be repurposed as
a tablist — some AT implementations ignore the override and
the child `role="tab"` elements end up parentless. The tab
pattern is its own widget per WAI-ARIA APG; keyboard nav
(ArrowLeft/Right, Home, End, Enter/Space) is driven by the
Stimulus controller with the manual-activation pattern
(focus moves first, activate on Enter/Space). %>
<%= tag.div class: classes,
role: "tablist",
"aria-orientation": "horizontal" do %>
<% btns.each do |btn| %>
<%= btn %>
<% end %>
@@ -8,19 +18,25 @@ class DS::Tabs::Nav < DesignSystemComponent
ERB
renders_many :btns, ->(id:, label:, classes: nil, &block) do
is_active = id == active_tab
content_tag(
:button, label, id: id,
:button, label, id: "#{dom_prefix}-tab-#{id}",
type: "button",
class: class_names(btn_classes, id == active_tab ? active_btn_classes : inactive_btn_classes, classes),
data: { id: id, action: "DS--tabs#show", DS__tabs_target: "navBtn" },
class: class_names(btn_classes, is_active ? active_btn_classes : inactive_btn_classes, classes),
role: "tab",
"aria-selected": is_active.to_s,
"aria-controls": "#{dom_prefix}-panel-#{id}",
tabindex: is_active ? "0" : "-1",
data: { id: id, action: "click->DS--tabs#show keydown->DS--tabs#handleKeydown", DS__tabs_target: "navBtn" },
&block
)
end
attr_reader :active_tab, :classes, :active_btn_classes, :inactive_btn_classes, :btn_classes
attr_reader :active_tab, :classes, :active_btn_classes, :inactive_btn_classes, :btn_classes, :dom_prefix
def initialize(active_tab:, classes: nil, active_btn_classes: nil, inactive_btn_classes: nil, btn_classes: nil)
def initialize(active_tab:, dom_prefix:, classes: nil, active_btn_classes: nil, inactive_btn_classes: nil, btn_classes: nil)
@active_tab = active_tab
@dom_prefix = dom_prefix
@classes = classes
@active_btn_classes = active_btn_classes
@inactive_btn_classes = inactive_btn_classes

View File

@@ -11,13 +11,19 @@ export default class extends Controller {
const selectedTabId = btn.dataset.id;
this.navBtnTargets.forEach((navBtn) => {
if (navBtn.dataset.id === selectedTabId) {
const isSelected = navBtn.dataset.id === selectedTabId;
if (isSelected) {
navBtn.classList.add(...this.navBtnActiveClasses);
navBtn.classList.remove(...this.navBtnInactiveClasses);
} else {
navBtn.classList.add(...this.navBtnInactiveClasses);
navBtn.classList.remove(...this.navBtnActiveClasses);
}
// Roving tabindex per WAI-ARIA APG: only the active tab is in
// the tab order. ArrowLeft/Right (see handleKeydown) moves focus
// across the tablist; Tab moves past the widget.
navBtn.setAttribute("aria-selected", isSelected.toString());
navBtn.setAttribute("tabindex", isSelected ? "0" : "-1");
});
this.panelTargets.forEach((panel) => {
@@ -38,7 +44,43 @@ export default class extends Controller {
if (this.sessionKeyValue) {
this.#updateSessionPreference(selectedTabId);
}
}
}
// WAI-ARIA APG "Tabs with Manual Activation" — arrow keys move
// focus, Enter/Space activates. Prevents accidental tab swap when
// tabbing through, which is important here because some tab
// contents trigger Turbo fetches.
handleKeydown(e) {
const navBtns = this.navBtnTargets;
const currentIndex = navBtns.indexOf(e.target);
if (currentIndex === -1) return;
let nextIndex = null;
switch (e.key) {
case "ArrowRight":
nextIndex = (currentIndex + 1) % navBtns.length;
break;
case "ArrowLeft":
nextIndex = (currentIndex - 1 + navBtns.length) % navBtns.length;
break;
case "Home":
nextIndex = 0;
break;
case "End":
nextIndex = navBtns.length - 1;
break;
case "Enter":
case " ":
e.preventDefault();
this.show(e);
return;
default:
return;
}
e.preventDefault();
navBtns[nextIndex].focus();
}
#updateSessionPreference(selectedTabId) {
fetch("/current_session", {

View File

@@ -1,5 +1,9 @@
<div class="relative inline-block select-none">
<%# Paired hidden field carries the off-state value when the checkbox is unchecked. Do NOT add an external `hidden_field_tag` with the same `name` in the caller view — it causes ID/label collisions and duplicate params. %>
<%= hidden_field_tag name, unchecked_value, id: nil %>
<%= check_box_tag name, checked_value, checked, class: "sr-only peer", disabled: disabled, id: id, **opts %>
<%# `role="switch"` upgrades the underlying checkbox so AT users hear
"switch, on" / "switch, off" instead of "checkbox, checked". The
visual already reads as a switch — semantics now match. %>
<%= check_box_tag name, checked_value, checked, class: "sr-only peer", disabled: disabled, id: id, role: "switch", **opts %>
<%= label_tag name, "&nbsp;".html_safe, class: label_classes, for: id %>
</div>

View File

@@ -13,13 +13,25 @@ class DS::Toggle < DesignSystemComponent
def label_classes
class_names(
"block w-9 h-5 cursor-pointer",
"rounded-full bg-gray-100 theme-dark:bg-gray-700",
"transition-colors duration-300",
"relative block w-9 h-5 cursor-pointer",
# `bg-toggle-track` lifts the dark-mode off-track to gray-700 so the
# switch keeps WCAG 1.4.11 contrast against the surrounding
# bg-container (gray-900). `bg-surface-inset` resolves to gray-800
# in dark mode and dropped to ~1.5:1 against the container,
# making the toggle nearly invisible inside modals.
"rounded-full bg-toggle-track",
# `motion-safe:` gates the bg + thumb-translate transitions on
# `prefers-reduced-motion`; reduced-motion users get a snap.
"motion-safe:transition-colors motion-safe:duration-300",
"after:content-[''] after:block after:bg-white after:absolute after:rounded-full",
"after:top-0.5 after:left-0.5 after:w-4 after:h-4",
"after:transition-transform after:duration-300 after:ease-in-out",
"peer-checked:bg-green-600 peer-checked:after:translate-x-4",
"motion-safe:after:transition-transform motion-safe:after:duration-300 motion-safe:after:ease-in-out",
"peer-checked:bg-success peer-checked:after:translate-x-4",
# Focus ring driven from the sr-only input via `peer-focus-visible:`.
# Offset places the ring outside the track so it lands on the
# surrounding chrome regardless of theme.
"peer-focus-visible:ring-2 peer-focus-visible:ring-offset-2",
"peer-focus-visible:ring-alpha-black-300 theme-dark:peer-focus-visible:ring-alpha-white-300",
"peer-disabled:opacity-70 peer-disabled:cursor-not-allowed"
)
end

View File

@@ -1,8 +1,39 @@
<span data-controller="DS--tooltip" data-DS--tooltip-placement-value="<%= placement %>" data-DS--tooltip-offset-value="<%= offset %>" data-DS--tooltip-cross-axis-value="<%= cross_axis %>" class="inline-flex">
<%= helpers.icon icon_name, size: size, color: color %>
<% if as == :button %>
<%# Wrap the trigger icon in a focusable `<button>` so AT users (and
keyboard-only users) can land on the tooltip anchor. The Lucide
icon itself carries `aria-hidden`, so the button's accessible
name comes from `aria-label`, and the tooltip text is exposed
via `aria-describedby`. %>
<button type="button"
class="inline-flex items-center cursor-default focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-alpha-black-300 theme-dark:focus-visible:ring-alpha-white-300 rounded"
aria-describedby="<%= tooltip_id %>"
aria-label="<%= t("ds.tooltip.trigger_label", default: "More info") %>">
<%= helpers.icon icon_name, size: size, color: color %>
</button>
<% else %>
<%# `as: :span` — used when the tooltip is rendered inside an
already-focusable interactive ancestor (e.g. `<summary>`),
where the HTML spec forbids nested interactive content.
Keyboard reveal is wired in the controller, which (in addition
to listening on the outer span for the standalone case) also
binds `focusin/focusout/keydown` on the closest `<summary>`
ancestor — because `focusin` only bubbles UP, a listener on
this descendant span would never fire when the ancestor
disclosure receives focus. %>
<span class="inline-flex items-center"
aria-describedby="<%= tooltip_id %>">
<%= helpers.icon icon_name, size: size, color: color %>
</span>
<% end %>
<div role="tooltip" data-DS--tooltip-target="tooltip" class="hidden absolute z-50 bg-gray-700 text-sm px-1.5 py-1 rounded-md">
<div class="fg-inverse font-normal max-w-[200px]">
<div role="tooltip"
id="<%= tooltip_id %>"
data-DS--tooltip-target="tooltip"
class="hidden absolute z-50 bg-inverse text-sm px-1.5 py-1 rounded-md">
<%# `max-w-[20rem]` scales with the root font-size so AT users with
a larger base font don't see the tooltip clipped. %>
<div class="text-inverse font-normal max-w-[20rem]">
<%= tooltip_content %>
</div>
</div>

View File

@@ -1,7 +1,26 @@
class DS::Tooltip < ApplicationComponent
attr_reader :placement, :offset, :cross_axis, :icon_name, :size, :color
AS_OPTIONS = %i[button span].freeze
attr_reader :placement, :offset, :cross_axis, :icon_name, :size, :color, :tooltip_id, :as
# NOTE: tooltip content must be non-interactive — no buttons, links,
# or form controls inside. Tooltips are exposed via `aria-describedby`,
# which announces the content as a description but does not expose
# interactive descendants to AT. Use a popover/menu primitive when
# the surface needs to host actions.
#
# `as:` controls the trigger element.
# :button (default) — renders `<button type="button">`, focusable on
# its own. Use for tooltips placed in standalone, non-interactive
# surrounding markup.
# :span — renders `<span>` with no `tabindex`. Use when the tooltip
# sits inside an already-focusable interactive ancestor (most
# commonly `<summary>`, where the HTML spec forbids nested
# interactive content). The ancestor's focus still triggers the
# tooltip because `focusin` bubbles up to the Stimulus controller.
def initialize(text: nil, placement: "top", offset: 10, cross_axis: 0, icon: "info", size: "sm", color: "default", as: :button)
raise ArgumentError, "as: must be one of #{AS_OPTIONS.inspect}" unless AS_OPTIONS.include?(as)
def initialize(text: nil, placement: "top", offset: 10, cross_axis: 0, icon: "info", size: "sm", color: "default")
@text = text
@placement = placement
@offset = offset
@@ -9,6 +28,8 @@ class DS::Tooltip < ApplicationComponent
@icon_name = icon
@size = size
@color = color
@as = as
@tooltip_id = "tooltip-#{SecureRandom.hex(4)}"
end
def tooltip_content

View File

@@ -29,11 +29,44 @@ export default class extends Controller {
addEventListeners() {
this.element.addEventListener("mouseenter", this.show);
this.element.addEventListener("mouseleave", this.hide);
// Keyboard parity: keyboard users hit the trigger via Tab + focus,
// not hover. Without these the tooltip never appears for them.
this.element.addEventListener("focusin", this.show);
this.element.addEventListener("focusout", this.hide);
// Esc-to-dismiss matches the WAI-ARIA Authoring Practices for the
// tooltip pattern.
this.element.addEventListener("keydown", this.handleKeydown);
// `as: :span` renders a non-focusable trigger inside an
// already-focusable ancestor (typically `<summary>`). When the
// ancestor receives keyboard focus the `focusin` event fires on
// *it* and bubbles UP to the document — it never reaches a
// descendant span. Without a listener on the ancestor itself,
// the tooltip stays hidden for keyboard users on in-summary rows.
// Bind the same handlers on the closest `<summary>` (if any) so
// focusing the disclosure reveals the tooltip and Esc still
// dismisses it.
this.summaryAncestor = this.element.closest("summary");
if (this.summaryAncestor) {
this.summaryAncestor.addEventListener("focusin", this.show);
this.summaryAncestor.addEventListener("focusout", this.hide);
this.summaryAncestor.addEventListener("keydown", this.handleKeydown);
}
}
removeEventListeners() {
this.element.removeEventListener("mouseenter", this.show);
this.element.removeEventListener("mouseleave", this.hide);
this.element.removeEventListener("focusin", this.show);
this.element.removeEventListener("focusout", this.hide);
this.element.removeEventListener("keydown", this.handleKeydown);
if (this.summaryAncestor) {
this.summaryAncestor.removeEventListener("focusin", this.show);
this.summaryAncestor.removeEventListener("focusout", this.hide);
this.summaryAncestor.removeEventListener("keydown", this.handleKeydown);
this.summaryAncestor = null;
}
}
show = () => {
@@ -47,6 +80,12 @@ export default class extends Controller {
this.stopAutoUpdate();
};
handleKeydown = (event) => {
if (event.key === "Escape" && !this.tooltipTarget.classList.contains("hidden")) {
this.hide();
}
};
startAutoUpdate() {
if (!this._cleanup) {
const reference = this.element.querySelector("[data-icon]");

View File

@@ -20,8 +20,8 @@
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<span class="font-medium"><%= end_balance_money.format %></span>
<%= render DS::Tooltip.new(text: "The end of day balance, after all transactions and adjustments", placement: "left", size: "sm") %>
<span class="font-medium privacy-sensitive"><%= end_balance_money.format %></span>
<%= render DS::Tooltip.new(text: t(".balance_tooltip"), placement: "left", size: "sm", as: :span) %>
</div>
<%= helpers.icon "chevron-down", class: "group-open:rotate-180" %>
</div>
@@ -32,7 +32,7 @@
<% if balance %>
<%= render UI::Account::BalanceReconciliation.new(balance: balance, account: account) %>
<% else %>
<p class="text-sm text-secondary">No balance data available for this date</p>
<p class="text-sm text-secondary"><%= t(".no_balance_data") %></p>
<% end %>
</div>
</details>

View File

@@ -54,7 +54,7 @@
data: { controller: "auto-submit-form" } do |form| %>
<div class="flex gap-2 mb-4">
<div class="grow">
<div class="flex items-center px-3 py-2 gap-2 border border-secondary rounded-lg focus-within:ring-gray-100 focus-within:border-gray-900">
<div class="flex items-center px-3 py-2 gap-2 border border-secondary rounded-lg focus-within:border-primary">
<%= helpers.icon("search") %>
<%= hidden_field_tag :account_id, account.id %>
@@ -67,8 +67,8 @@
</div>
</div>
<%= render DS::Menu.new(variant: "button", no_padding: true) do |menu| %>
<% menu.with_button(
<%= render DS::Popover.new(variant: "button", no_padding: true) do |popover| %>
<% popover.with_button(
id: "activity-status-filter-button",
type: "button",
text: t("accounts.show.activity.filter"),
@@ -76,7 +76,7 @@
icon: "list-filter"
) %>
<% menu.with_custom_content do %>
<% popover.with_custom_content do %>
<div class="p-3 space-y-3 min-w-[160px]">
<p class="text-xs font-medium text-secondary uppercase"><%= t("accounts.show.activity.status") %></p>
<div class="flex items-center gap-3">

View File

@@ -27,105 +27,108 @@ class UI::Account::BalanceReconciliation < ApplicationComponent
private
def t_label(key)
I18n.t("UI.account.balance_reconciliation.labels.#{key}")
end
def t_tooltip(key)
I18n.t("UI.account.balance_reconciliation.tooltips.#{key}")
end
def default_items
items = [
{ label: "Start balance", value: balance.start_balance_money, tooltip: "The account balance at the beginning of this day", style: :start },
{ label: "Net cash flow", value: net_cash_flow, tooltip: "Net change in balance from all transactions during the day", style: :flow }
{ label: t_label(:start_balance), value: balance.start_balance_money, tooltip: t_tooltip(:start_balance), style: :start },
{ label: t_label(:net_cash_flow), value: net_cash_flow, tooltip: t_tooltip(:net_cash_flow), style: :flow }
]
if has_adjustments?
items << { label: "End balance", value: end_balance_before_adjustments, tooltip: "The calculated balance after all transactions", style: :subtotal }
items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual reconciliations or other adjustments", style: :adjustment }
items << { label: t_label(:end_balance), value: end_balance_before_adjustments, tooltip: t_tooltip(:end_balance), style: :subtotal }
items << { label: t_label(:adjustments), value: total_adjustments, tooltip: t_tooltip(:adjustments), style: :adjustment }
end
items << { label: "Final balance", value: balance.end_balance_money, tooltip: "The final account balance for the day", style: :final }
items << { label: t_label(:final_balance), value: balance.end_balance_money, tooltip: t_tooltip(:final_balance), style: :final }
items
end
def credit_card_items
items = [
{ label: "Start balance", value: balance.start_balance_money, tooltip: "The balance owed at the beginning of this day", style: :start },
{ label: "Charges", value: balance.cash_outflows_money, tooltip: "New charges made during the day", style: :flow },
{ label: "Payments", value: balance.cash_inflows_money * -1, tooltip: "Payments made to the card during the day", style: :flow }
{ label: t_label(:start_balance), value: balance.start_balance_money, tooltip: t_tooltip(:start_balance_credit), style: :start },
{ label: t_label(:charges), value: balance.cash_outflows_money, tooltip: t_tooltip(:charges), style: :flow },
{ label: t_label(:payments), value: balance.cash_inflows_money * -1, tooltip: t_tooltip(:payments), style: :flow }
]
if has_adjustments?
items << { label: "End balance", value: end_balance_before_adjustments, tooltip: "The calculated balance after all transactions", style: :subtotal }
items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual reconciliations or other adjustments", style: :adjustment }
items << { label: t_label(:end_balance), value: end_balance_before_adjustments, tooltip: t_tooltip(:end_balance), style: :subtotal }
items << { label: t_label(:adjustments), value: total_adjustments, tooltip: t_tooltip(:adjustments), style: :adjustment }
end
items << { label: "Final balance", value: balance.end_balance_money, tooltip: "The final balance owed for the day", style: :final }
items << { label: t_label(:final_balance), value: balance.end_balance_money, tooltip: t_tooltip(:final_balance_credit), style: :final }
items
end
def investment_items
items = [
{ label: "Start balance", value: balance.start_balance_money, tooltip: "The total portfolio value at the beginning of this day", style: :start }
{ label: t_label(:start_balance), value: balance.start_balance_money, tooltip: t_tooltip(:start_balance_investment), style: :start }
]
# Change in brokerage cash (includes deposits, withdrawals, and cash from trades)
items << { label: "Change in brokerage cash", value: net_cash_flow, tooltip: "Net change in cash from deposits, withdrawals, and trades", style: :flow }
# Change in holdings from trading activity
items << { label: "Change in holdings (buys/sells)", value: net_non_cash_flow, tooltip: "Impact on holdings from buying and selling securities", style: :flow }
# Market price changes
items << { label: "Change in holdings (market price activity)", value: balance.net_market_flows_money, tooltip: "Change in holdings value from market price movements", style: :flow }
items << { label: t_label(:change_in_brokerage_cash), value: net_cash_flow, tooltip: t_tooltip(:change_in_brokerage_cash), style: :flow }
items << { label: t_label(:change_in_holdings_trades), value: net_non_cash_flow, tooltip: t_tooltip(:change_in_holdings_trades), style: :flow }
items << { label: t_label(:change_in_holdings_market), value: balance.net_market_flows_money, tooltip: t_tooltip(:change_in_holdings_market), style: :flow }
if has_adjustments?
items << { label: "End balance", value: end_balance_before_adjustments, tooltip: "The calculated balance after all activity", style: :subtotal }
items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual reconciliations or other adjustments", style: :adjustment }
items << { label: t_label(:end_balance), value: end_balance_before_adjustments, tooltip: t_tooltip(:end_balance_investment), style: :subtotal }
items << { label: t_label(:adjustments), value: total_adjustments, tooltip: t_tooltip(:adjustments), style: :adjustment }
end
items << { label: "Final balance", value: balance.end_balance_money, tooltip: "The final portfolio value for the day", style: :final }
items << { label: t_label(:final_balance), value: balance.end_balance_money, tooltip: t_tooltip(:final_balance_investment), style: :final }
items
end
def loan_items
items = [
{ label: "Start principal", value: balance.start_balance_money, tooltip: "The principal balance at the beginning of this day", style: :start },
{ label: "Net principal change", value: net_non_cash_flow, tooltip: "Principal payments and new borrowing during the day", style: :flow }
{ label: t_label(:start_principal), value: balance.start_balance_money, tooltip: t_tooltip(:start_principal), style: :start },
{ label: t_label(:net_principal_change), value: net_non_cash_flow, tooltip: t_tooltip(:net_principal_change), style: :flow }
]
if has_adjustments?
items << { label: "End principal", value: end_balance_before_adjustments, tooltip: "The calculated principal after all transactions", style: :subtotal }
items << { label: "Adjustments", value: balance.non_cash_adjustments_money, tooltip: "Manual reconciliations or other adjustments", style: :adjustment }
items << { label: t_label(:end_principal), value: end_balance_before_adjustments, tooltip: t_tooltip(:end_principal), style: :subtotal }
items << { label: t_label(:adjustments), value: balance.non_cash_adjustments_money, tooltip: t_tooltip(:adjustments), style: :adjustment }
end
items << { label: "Final principal", value: balance.end_balance_money, tooltip: "The final principal balance for the day", style: :final }
items << { label: t_label(:final_principal), value: balance.end_balance_money, tooltip: t_tooltip(:final_principal), style: :final }
items
end
def asset_items # Property/Vehicle
def asset_items
items = [
{ label: "Start value", value: balance.start_balance_money, tooltip: "The asset value at the beginning of this day", style: :start },
{ label: "Net value change", value: net_total_flow, tooltip: "All value changes including improvements and depreciation", style: :flow }
{ label: t_label(:start_value), value: balance.start_balance_money, tooltip: t_tooltip(:start_value), style: :start },
{ label: t_label(:net_value_change), value: net_total_flow, tooltip: t_tooltip(:net_value_change), style: :flow }
]
if has_adjustments?
items << { label: "End value", value: end_balance_before_adjustments, tooltip: "The calculated value after all changes", style: :subtotal }
items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual value adjustments or appraisals", style: :adjustment }
items << { label: t_label(:end_value), value: end_balance_before_adjustments, tooltip: t_tooltip(:end_value), style: :subtotal }
items << { label: t_label(:adjustments), value: total_adjustments, tooltip: t_tooltip(:adjustments_asset), style: :adjustment }
end
items << { label: "Final value", value: balance.end_balance_money, tooltip: "The final asset value for the day", style: :final }
items << { label: t_label(:final_value), value: balance.end_balance_money, tooltip: t_tooltip(:final_value), style: :final }
items
end
def crypto_items
items = [
{ label: "Start balance", value: balance.start_balance_money, tooltip: "The crypto holdings value at the beginning of this day", style: :start }
{ label: t_label(:start_balance), value: balance.start_balance_money, tooltip: t_tooltip(:start_balance_crypto), style: :start }
]
items << { label: "Buys", value: balance.cash_outflows_money * -1, tooltip: "Crypto purchases during the day", style: :flow } if balance.cash_outflows != 0
items << { label: "Sells", value: balance.cash_inflows_money, tooltip: "Crypto sales during the day", style: :flow } if balance.cash_inflows != 0
items << { label: "Market changes", value: balance.net_market_flows_money, tooltip: "Value changes from market price movements", style: :flow } if balance.net_market_flows != 0
items << { label: t_label(:buys), value: balance.cash_outflows_money * -1, tooltip: t_tooltip(:buys), style: :flow } if balance.cash_outflows != 0
items << { label: t_label(:sells), value: balance.cash_inflows_money, tooltip: t_tooltip(:sells), style: :flow } if balance.cash_inflows != 0
items << { label: t_label(:market_changes), value: balance.net_market_flows_money, tooltip: t_tooltip(:market_changes), style: :flow } if balance.net_market_flows != 0
if has_adjustments?
items << { label: "End balance", value: end_balance_before_adjustments, tooltip: "The calculated balance after all activity", style: :subtotal }
items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual reconciliations or other adjustments", style: :adjustment }
items << { label: t_label(:end_balance), value: end_balance_before_adjustments, tooltip: t_tooltip(:end_balance_crypto), style: :subtotal }
items << { label: t_label(:adjustments), value: total_adjustments, tooltip: t_tooltip(:adjustments), style: :adjustment }
end
items << { label: "Final balance", value: balance.end_balance_money, tooltip: "The final crypto holdings value for the day", style: :final }
items << { label: t_label(:final_balance), value: balance.end_balance_money, tooltip: t_tooltip(:final_balance_crypto), style: :final }
items
end

View File

@@ -21,7 +21,7 @@
<div class="flex items-center gap-2">
<% if account.investment? %>
<%= form.select :chart_view,
[["Total value", "balance"], ["Holdings", "holdings_balance"], ["Cash", "cash_balance"]],
[[t(".views.total_value"), "balance"], [t(".views.holdings"), "holdings_balance"], [t(".views.cash"), "cash_balance"]],
{ selected: view },
class: "bg-container border border-secondary rounded-lg text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0",
data: { "auto-submit-form-target": "auto" } %>
@@ -50,7 +50,7 @@
data-time-series-chart-data-value="<%= series.to_json %>"></div>
<% else %>
<div class="w-full h-full flex items-center justify-center">
<p class="text-secondary text-sm">No data available</p>
<p class="text-secondary text-sm"><%= t(".no_data_available") %></p>
</div>
<% end %>
</div>

View File

@@ -31,20 +31,22 @@ class UI::Account::Chart < ApplicationComponent
when "Investment", "Crypto"
case view
when "balance"
"Total account value"
I18n.t("UI.account.chart.title.total_account_value")
when "holdings_balance"
"Holdings value"
I18n.t("UI.account.chart.title.holdings_value")
when "cash_balance"
"Cash value"
I18n.t("UI.account.chart.title.cash_value")
end
when "Property", "Vehicle"
"Estimated #{account.accountable_type.humanize.downcase} value"
when "Property"
I18n.t("UI.account.chart.title.estimated_property_value")
when "Vehicle"
I18n.t("UI.account.chart.title.estimated_vehicle_value")
when "CreditCard", "OtherLiability"
"Debt balance"
I18n.t("UI.account.chart.title.debt_balance")
when "Loan"
"Remaining principal balance"
I18n.t("UI.account.chart.title.remaining_principal_balance")
else
"Balance"
I18n.t("UI.account.chart.title.balance")
end
end
@@ -55,7 +57,11 @@ class UI::Account::Chart < ApplicationComponent
def converted_balance_money
return nil unless foreign_currency?
account.balance_money.exchange_to(account.family.currency, fallback_rate: 1)
begin
account.balance_money.exchange_to(account.family.currency)
rescue Money::ConversionError
nil
end
end
def view
@@ -75,7 +81,7 @@ class UI::Account::Chart < ApplicationComponent
return period.comparison_label if start_date.blank?
if start_date > period.start_date
"vs. available history"
I18n.t("UI.account.chart.vs_available_history")
else
period.comparison_label
end

View File

@@ -6,12 +6,21 @@
<%= render UI::Account::Chart.new(account: account, period: chart_period, view: chart_view) %>
<% if (fx_coverage_start = fx_coverage_start_date).present? %>
<div class="px-3">
<%= render DS::Alert.new(
message: t("accounts.show.limited_fx_history_warning", date: l(fx_coverage_start, format: :long)),
variant: :warning
) %>
</div>
<% end %>
<div data-testid="account-details">
<% if tabs.count > 1 %>
<%= render DS::Tabs.new(active_tab: active_tab, url_param_key: "tab") do |tabs_container| %>
<% tabs_container.with_nav(classes: "max-w-fit") do |nav| %>
<% tabs.each do |tab| %>
<% nav.with_btn(id: tab, label: tab.to_s.humanize, classes: "px-6") %>
<% nav.with_btn(id: tab, label: t("accounts.show.tabs.#{tab}", default: tab.to_s.humanize), classes: "px-6") %>
<% end %>
<% end %>

View File

@@ -1,13 +1,19 @@
class UI::AccountPage < ApplicationComponent
attr_reader :account, :chart_view, :chart_period
attr_reader :account, :chart_view, :chart_period, :statement_coverage, :statements, :reconciliation_statuses,
:can_manage_statements
renders_one :activity_feed, ->(feed_data:, pagy:, search:) { UI::Account::ActivityFeed.new(feed_data: feed_data, pagy: pagy, search: search) }
def initialize(account:, chart_view: nil, chart_period: nil, active_tab: nil)
def initialize(account:, chart_view: nil, chart_period: nil, active_tab: nil, statement_coverage: nil, statements: [],
reconciliation_statuses: {}, can_manage_statements: false)
@account = account
@chart_view = chart_view
@chart_period = chart_period
@active_tab = active_tab
@statement_coverage = statement_coverage
@statements = statements
@reconciliation_statuses = reconciliation_statuses
@can_manage_statements = can_manage_statements
end
def id
@@ -37,7 +43,7 @@ class UI::AccountPage < ApplicationComponent
end
def tabs
case account.accountable_type
base_tabs = case account.accountable_type
when "Investment", "Crypto"
[ :activity, :holdings ]
when "Property", "Vehicle", "Loan"
@@ -45,6 +51,25 @@ class UI::AccountPage < ApplicationComponent
else
[ :activity ]
end
base_tabs + [ :statements ]
end
def fx_coverage_start_date
return @fx_coverage_start_date if defined?(@fx_coverage_start_date)
result = nil
if account.family.present? && account.currency != account.family.currency
pair = ExchangeRatePair.for_pair(from: account.currency, to: account.family.currency)
if pair.first_provider_rate_on.present?
oldest_entry = account.entries.minimum(:date)
if oldest_entry.present? && oldest_entry < pair.first_provider_rate_on
result = pair.first_provider_rate_on
end
end
end
@fx_coverage_start_date = result
end
def tab_content_for(tab)
@@ -54,6 +79,32 @@ class UI::AccountPage < ApplicationComponent
when :holdings, :overview
# Accountable is responsible for implementing the partial in the correct folder
render "#{account.accountable_type.downcase.pluralize}/tabs/#{tab}", account: account
when :statements
render_statement_tab
end
end
def render_statement_tab
return render "accounts/show/statements_frame", **statement_tab_locals if statement_tab_loaded?
turbo_frame_tag statement_tab_frame_id, src: helpers.account_path(account, tab: "statements"), loading: :lazy
end
def statement_tab_loaded?
statement_coverage.present?
end
def statement_tab_frame_id
dom_id(account, :statements_tab)
end
def statement_tab_locals
{
account: account,
coverage: statement_coverage,
statements: statements,
reconciliation_statuses: reconciliation_statuses,
can_manage_statements: can_manage_statements
}
end
end

View File

@@ -1,4 +1,4 @@
<details class="group bg-surface rounded-lg border border-surface-inset/50">
<details class="group bg-surface rounded-lg border border-secondary">
<summary class="flex items-center justify-between gap-2 p-3 cursor-pointer">
<div class="flex items-center gap-2">
<%= helpers.icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
@@ -123,7 +123,7 @@
<% if error_details.any? %>
<details class="mt-1">
<summary class="text-xs cursor-pointer text-secondary hover:text-primary"><%= t("provider_sync_summary.health.view_error_details") %></summary>
<div class="mt-1 pl-2 border-l-2 border-destructive/30 space-y-1">
<div class="mt-1 pl-2 border-l-2 border-destructive-subtle space-y-1">
<% error_details.each do |detail| %>
<p class="text-xs text-destructive">
<% if detail["name"].present? %><strong><%= detail["name"] %>:</strong> <% end %><%= detail["message"] %>

View File

@@ -0,0 +1,25 @@
<%= link_to connect_path,
class: "bg-container shadow-border-xs hover:bg-surface-inset rounded-xl p-4 flex flex-col gap-2.5 text-primary transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-alpha-black-300",
data: { turbo_frame: "drawer", turbo_prefetch: "false" }.merge(filter_data) do %>
<div class="flex items-start gap-2.5">
<div class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0 <%= logo_bg %>">
<span class="text-xs font-bold text-inverse"><%= logo_text %></span>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-sm font-medium text-primary"><%= name %></span>
<%= render "settings/providers/maturity_badge", label: maturity_label %>
</div>
<% if meta_line.present? %>
<p class="text-xs text-secondary mt-0.5"><%= meta_line %></p>
<% end %>
</div>
</div>
<% if tagline.present? %>
<p class="text-sm text-secondary grow leading-snug"><%= tagline %></p>
<% end %>
<div class="flex items-center justify-end gap-1.5 text-sm font-medium text-primary">
<%= t("settings.providers.connect") %>
<%= helpers.icon "arrow-right", size: "sm", class: "!w-3.5 !h-3.5" %>
</div>
<% end %>

View File

@@ -0,0 +1,47 @@
class Settings::ProviderCard < ApplicationComponent
MATURITY_LABELS = {
beta: "settings.providers.maturity.beta",
alpha: "settings.providers.maturity.alpha"
}.freeze
def self.maturity_label(maturity)
key = MATURITY_LABELS[maturity&.to_sym]
I18n.t(key) if key
end
def initialize(provider_key:, name:, tagline: nil, region: nil, kind: nil, tier: nil,
maturity: :stable, logo_bg: "bg-gray-500", logo_text: nil)
@provider_key = provider_key
@name = name
@tagline = tagline
@region = region
@kind = kind
@tier = tier
@maturity = maturity.to_sym
@logo_bg = logo_bg
@logo_text = logo_text || name.first(2).upcase
end
attr_reader :name, :tagline, :logo_bg, :logo_text
def maturity_label
self.class.maturity_label(@maturity)
end
def meta_line
[ @region, @kind, @tier ].compact.join(" · ")
end
def connect_path
helpers.connect_form_settings_providers_path(provider_key: @provider_key)
end
def filter_data
{
providers_filter_target: "card",
provider_name: @name.to_s.downcase,
provider_region: @region.to_s.downcase,
provider_kind: @kind.to_s.downcase
}
end
end

View File

@@ -0,0 +1,211 @@
# frozen_string_literal: true
class AccountStatementsController < ApplicationController
before_action :set_statement, only: %i[show update destroy link unlink reject]
before_action :ensure_statement_manager!, only: %i[index create update destroy link unlink reject]
def index
accessible_account_ids = Current.user.accessible_accounts.select(:id)
account_statements = Current.family.account_statements
.with_attached_original_file
.includes(:account, :suggested_account)
.ordered
visible_storage_scope = Current.family.account_statements
.where(account_id: nil)
.or(Current.family.account_statements.where(account_id: accessible_account_ids))
linked_statement_scope = account_statements.with_account.where(account_id: accessible_account_ids)
@unmatched_pagy, @unmatched_statements = pagy(account_statements.unmatched, limit: safe_per_page, page_param: :unmatched_page)
@linked_pagy, @linked_statements = pagy(linked_statement_scope, limit: safe_per_page, page_param: :linked_page)
@total_storage_bytes = visible_storage_scope.sum(:byte_size)
@accounts = Current.user.accessible_accounts.visible.alphabetically
@breadcrumbs = [
[ t("breadcrumbs.home"), root_path ],
[ t("account_statements.index.title"), account_statements_path ]
]
render layout: "settings"
end
def show
@accounts = Current.user.accessible_accounts.visible.alphabetically
@can_manage_statement = @statement.manageable_by?(Current.user)
@reconciliation_checks = @statement.reconciliation_checks
@breadcrumbs = [
[ t("breadcrumbs.home"), root_path ],
[ t("account_statements.index.title"), account_statements_path ],
[ @statement.filename, nil ]
]
render layout: "settings"
end
def create
files = Array(statement_upload_params[:files]).reject(&:blank?).select { |file| file.respond_to?(:read) }
account = target_account
if files.empty?
redirect_back_or_to account_statements_path, alert: t("account_statements.create.no_files")
return
end
return if account && !require_account_permission!(account)
created = []
duplicates = []
validation_errors = []
files.each do |file|
prepared_upload = AccountStatement.prepare_upload!(file)
created << AccountStatement.create_from_prepared_upload!(family: Current.family, account: account, prepared_upload: prepared_upload)
rescue AccountStatement::InvalidUploadError
validation_errors << t("account_statements.create.invalid_file_type")
rescue AccountStatement::DuplicateUploadError => e
duplicates << e.statement
rescue ActiveRecord::RecordInvalid => e
validation_errors << e.record.errors.full_messages.to_sentence
end
redirect_to redirect_after_create(account, created.first || duplicates.first),
flash_for_upload(created:, duplicates:, validation_errors:)
end
def update
return if @statement.account && !require_account_permission!(@statement.account)
target = statement_account_id.present? ? Current.user.accessible_accounts.find(statement_account_id) : nil
return if target && !require_account_permission!(target)
attrs = statement_params.to_h
attrs[:account] = target if statement_account_id_provided?
@statement.assign_attributes(attrs)
@statement.assign_account_match if @statement.account.nil? && !@statement.rejected?
if @statement.save
redirect_to account_statement_path(@statement), notice: t("account_statements.update.success")
else
@accounts = Current.user.accessible_accounts.visible.alphabetically
@can_manage_statement = @statement.manageable_by?(Current.user)
@reconciliation_checks = @statement.reconciliation_checks
flash.now[:alert] = @statement.errors.full_messages.to_sentence
render :show, status: :unprocessable_entity, layout: "settings"
end
end
def link
return if @statement.account && !require_account_permission!(@statement.account)
account_id = params[:account_id].presence || @statement.suggested_account_id
if account_id.blank?
redirect_to account_statement_path(@statement), alert: t("account_statements.link.no_account")
return
end
account = Current.user.accessible_accounts.find(account_id)
return unless require_account_permission!(account)
@statement.link_to_account!(account)
redirect_to post_link_path(@statement), notice: t("account_statements.link.success", account: account.name)
end
def unlink
return if @statement.account && !require_account_permission!(@statement.account)
@statement.unlink!
redirect_to account_statement_path(@statement), notice: t("account_statements.unlink.success")
end
def reject
return if @statement.account && !require_account_permission!(@statement.account)
@statement.reject_match!
redirect_to account_statements_path, notice: t("account_statements.reject.success")
end
def destroy
return if @statement.account && !require_account_permission!(@statement.account)
redirect_path = @statement.account ? account_path(@statement.account, tab: "statements") : account_statements_path
if @statement.destroy
redirect_to redirect_path, notice: t("account_statements.destroy.success")
else
redirect_back_or_to redirect_path, alert: t("account_statements.destroy.failure")
end
end
private
def set_statement
@statement = Current.family.account_statements
.with_attached_original_file
.includes(:account, :suggested_account)
.find(params[:id])
raise ActiveRecord::RecordNotFound unless @statement.viewable_by?(Current.user)
end
def ensure_statement_manager!
return if AccountStatement.statement_manager?(Current.user)
redirect_to accounts_path, alert: t("accounts.not_authorized")
end
def statement_upload_params
params.fetch(:account_statement, ActionController::Parameters.new).permit(files: [])
end
def statement_params
params.require(:account_statement).permit(
:institution_name_hint,
:account_name_hint,
:account_last4_hint,
:period_start_on,
:period_end_on,
:opening_balance,
:closing_balance,
:currency
)
end
def target_account
account_id = statement_account_id.presence
return nil if account_id.blank?
Current.user.accessible_accounts.find(account_id)
end
def statement_account_id
params.fetch(:account_statement, ActionController::Parameters.new)[:account_id]
end
def statement_account_id_provided?
params.fetch(:account_statement, ActionController::Parameters.new).key?(:account_id)
end
def redirect_after_create(account, statement = nil)
if account
account_path(account, tab: "statements")
elsif statement
account_statement_path(statement)
else
account_statements_path
end
end
def post_link_path(statement)
statement.account ? account_path(statement.account, tab: "statements") : account_statement_path(statement)
end
def flash_for_upload(created:, duplicates:, validation_errors: [])
alerts = []
alerts << t("account_statements.create.duplicates", count: duplicates.size) if duplicates.any?
alerts.concat(validation_errors.compact_blank)
if created.any?
flash = { notice: t("account_statements.create.success", count: created.size) }
flash[:alert] = alerts.to_sentence if alerts.any?
flash
else
{ alert: alerts.to_sentence }
end
end
end

View File

@@ -43,8 +43,14 @@ class AccountableSparklinesController < ApplicationController
).balance_series
end
# balance_type is derived purely from accountable_type, so only Investment/Crypto
# can yield :investment. Short-circuit to avoid an N+1 `account.linked?` check
# on every account for non-investment accountable types (loan, credit_card, etc).
# The `Account.linked` scope is the SQL-level mirror of `Account#linked?`.
def requires_normalized_aggregation?
accounts.any? { |account| account.linked? && account.balance_type == :investment }
return false unless %w[Investment Crypto].include?(@accountable.name)
accounts.linked.exists?
end
def aggregate_normalized_series

View File

@@ -10,6 +10,7 @@ class AccountsController < ApplicationController
@manual_accounts = family.accounts
.listable_manual
.where(id: @accessible_account_ids)
.includes(:accountable, :account_providers, :plaid_account, :simplefin_account)
.order(:name)
@plaid_items = visible_provider_items(family.plaid_items.ordered.includes(:syncs, :plaid_accounts))
@simplefin_items = visible_provider_items(family.simplefin_items.ordered.includes(:syncs))
@@ -17,9 +18,12 @@ class AccountsController < ApplicationController
@enable_banking_items = visible_provider_items(family.enable_banking_items.ordered.includes(:syncs))
@coinstats_items = visible_provider_items(family.coinstats_items.ordered.includes(:coinstats_accounts, :accounts, :syncs))
@mercury_items = visible_provider_items(family.mercury_items.ordered.includes(:syncs, :mercury_accounts))
@brex_items = visible_provider_items(family.brex_items.ordered.includes(:accounts, :syncs, brex_accounts: :account_provider))
@coinbase_items = visible_provider_items(family.coinbase_items.ordered.includes(:coinbase_accounts, :accounts, :syncs))
@snaptrade_items = visible_provider_items(family.snaptrade_items.ordered.includes(:syncs, :snaptrade_accounts))
@ibkr_items = visible_provider_items(family.ibkr_items.ordered.includes(:syncs, :ibkr_accounts))
@indexa_capital_items = visible_provider_items(family.indexa_capital_items.ordered.includes(:syncs, :indexa_capital_accounts))
@sophtron_items = visible_provider_items(family.sophtron_items.ordered.includes(:syncs, :sophtron_accounts))
# Build sync stats maps for all providers
build_sync_stats_maps
@@ -45,13 +49,18 @@ class AccountsController < ApplicationController
@chart_view = params[:chart_view] || "balance"
@tab = params[:tab]
@q = params.fetch(:q, {}).permit(:search, status: [])
entries = @account.entries.where(excluded: false).search(@q).reverse_chronological
entries = @account.entries.where(excluded: false).search(@q).reverse_chronological.includes(:entryable)
if statement_tab_active?
build_statement_tab_data
return render_statement_tab_frame if statement_tab_frame_request?
end
@pagy, @entries = pagy(
entries,
limit: safe_per_page,
params: request.query_parameters.except("tab").merge("tab" => "activity")
)
Transaction::ActivitySecurityPreloader.new(@entries).preload
@activity_feed_data = Account::ActivityFeedData.new(@account, @entries)
end
@@ -232,6 +241,39 @@ class AccountsController < ApplicationController
end
end
def build_statement_tab_data
return unless statement_tab_active?
@statement_coverage = AccountStatement::Coverage.for_year(@account, params[:statement_year])
@account_statements = @account.account_statements.with_attached_original_file.ordered.to_a
@statement_reconciliation_statuses = AccountStatement.reconciliation_statuses_for(@account_statements, account: @account)
permission = @account.permission_for(Current.user)
@can_manage_statements = AccountStatement.statement_manager?(Current.user) &&
permission.in?([ :owner, :full_control ])
end
def statement_tab_frame_request?
turbo_frame_request? && request.headers["Turbo-Frame"] == helpers.dom_id(@account, :statements_tab)
end
def render_statement_tab_frame
render partial: "accounts/show/statements_frame", locals: statement_tab_locals, layout: false
end
def statement_tab_locals
{
account: @account,
coverage: @statement_coverage,
statements: @account_statements,
reconciliation_statuses: @statement_reconciliation_statuses,
can_manage_statements: @can_manage_statements
}
end
def statement_tab_active?
@tab == "statements"
end
# Builds sync stats maps for all provider types to avoid N+1 queries in views
def build_sync_stats_maps
# SimpleFIN sync stats
@@ -299,6 +341,13 @@ class AccountsController < ApplicationController
@coinstats_sync_stats_map[item.id] = latest_sync&.sync_stats || {}
end
# Sophtron sync stats
@sophtron_sync_stats_map = {}
@sophtron_items.each do |item|
latest_sync = item.syncs.ordered.first
@sophtron_sync_stats_map[item.id] = latest_sync&.sync_stats || {}
end
# Mercury sync stats
@mercury_sync_stats_map = {}
@mercury_items.each do |item|
@@ -306,6 +355,27 @@ class AccountsController < ApplicationController
@mercury_sync_stats_map[item.id] = latest_sync&.sync_stats || {}
end
# Brex sync stats
@brex_sync_stats_map = {}
@brex_account_counts_map = {}
@brex_institutions_count_map = {}
@brex_items.each do |item|
latest_sync = item.syncs.ordered.first
@brex_sync_stats_map[item.id] = latest_sync&.sync_stats || {}
brex_accounts = item.brex_accounts.to_a
linked_count = brex_accounts.count { |brex_account| brex_account.account_provider.present? }
total_count = brex_accounts.count
@brex_account_counts_map[item.id] = {
linked: linked_count,
unlinked: total_count - linked_count,
total: total_count
}
@brex_institutions_count_map[item.id] = brex_accounts
.filter_map(&:institution_metadata)
.uniq { |institution| institution["name"] || institution["institution_name"] }
.count
end
# Coinbase sync stats
@coinbase_sync_stats_map = {}
@coinbase_unlinked_count_map = {}

View File

@@ -7,53 +7,66 @@ class Api::V1::AccountsController < Api::V1::BaseController
before_action :ensure_read_scope
def index
# Test with Pagy pagination
family = current_resource_owner.family
accounts_query = family.accounts.accessible_by(current_resource_owner).visible.alphabetically
# Handle pagination with Pagy
@pagy, @accounts = pagy(
accounts_query,
page: safe_page_param,
limit: safe_per_page_param
)
@per_page = safe_per_page_param
# Rails will automatically use app/views/api/v1/accounts/index.json.jbuilder
@pagy, @accounts = pagy(
accounts_scope.alphabetically,
page: safe_page_param,
limit: @per_page
)
render :index
rescue => e
Rails.logger.error "AccountsController error: #{e.message}"
Rails.logger.error "AccountsController#index error: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
render json: {
error: "internal_server_error",
message: "Error: #{e.message}"
message: "An unexpected error occurred"
}, status: :internal_server_error
end
private
def ensure_read_scope
authorize_scope!(:read)
end
def safe_page_param
page = params[:page].to_i
page > 0 ? page : 1
end
def safe_per_page_param
per_page = params[:per_page].to_i
# Default to 25, max 100
case per_page
when 1..100
per_page
else
25
end
end
end
def show
unless valid_uuid?(params[:id])
render json: {
error: "not_found",
message: "Account not found"
}, status: :not_found
return
end
@account = accounts_scope.find(params[:id])
render :show
rescue ActiveRecord::RecordNotFound
render json: {
error: "not_found",
message: "Account not found"
}, status: :not_found
rescue => e
Rails.logger.error "AccountsController#show error: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
render json: {
error: "internal_server_error",
message: "An unexpected error occurred"
}, status: :internal_server_error
end
private
def ensure_read_scope
authorize_scope!(:read)
end
def accounts_scope
scope = current_resource_owner.family.accounts
.accessible_by(current_resource_owner)
.includes(:accountable, account_providers: :provider)
include_disabled_accounts? ? scope : scope.visible
end
def include_disabled_accounts?
ActiveModel::Type::Boolean.new.cast(params[:include_disabled])
end
end

View File

@@ -45,23 +45,28 @@ module Api
user.family = family
user.role = User.role_for_new_family_creator
if user.save
# Claim invite code if provided
InviteCode.claim!(params[:invite_code]) if params[:invite_code].present?
# Create device and OAuth token
begin
# Atomic: user creation, invite-code claim, and device/token issuance
# either all commit or none do. Without this, a post-commit device
# failure (e.g., racing uniqueness) would leave the user/invite/family
# committed while the client got a 422 "Failed to register device".
token_response = nil
begin
ActiveRecord::Base.transaction do
unless user.save
render json: { errors: user.errors.full_messages }, status: :unprocessable_entity
raise ActiveRecord::Rollback
end
InviteCode.claim!(params[:invite_code]) if params[:invite_code].present?
device = MobileDevice.upsert_device!(user, device_params)
token_response = device.issue_token!
rescue ActiveRecord::RecordInvalid => e
render json: { error: "Failed to register device: #{e.message}" }, status: :unprocessable_entity
return
end
render json: token_response.merge(user: mobile_user_payload(user)), status: :created
else
render json: { errors: user.errors.full_messages }, status: :unprocessable_entity
rescue ActiveRecord::RecordInvalid => e
Rails.logger.error("[Auth] Device registration failed: #{e.class} - #{e.message}")
render json: { error: "Failed to register device" }, status: :unprocessable_entity
return
end
render json: token_response.merge(user: mobile_user_payload(user)), status: :created if token_response
end
def login
@@ -90,7 +95,8 @@ module Api
device = MobileDevice.upsert_device!(user, device_params)
token_response = device.issue_token!
rescue ActiveRecord::RecordInvalid => e
render json: { error: "Failed to register device: #{e.message}" }, status: :unprocessable_entity
Rails.logger.error("[Auth] Device registration failed: #{e.message}")
render json: { error: "Failed to register device" }, status: :unprocessable_entity
return
end
@@ -312,7 +318,20 @@ module Api
return false if device.nil?
required_fields = %w[device_id device_name device_type os_version app_version]
required_fields.all? { |field| device[field].present? }
return false unless required_fields.all? { |field| device[field].present? }
# Run MobileDevice's attribute-level validations up front (e.g.,
# device_type must be ios/android/web) so a misconfigured client
# is rejected BEFORE signup commits user/family/invite. Skip
# errors we can't evaluate without a user: the :user belongs_to
# presence check, and device_id uniqueness scoped to user_id
# (upsert_device! treats collisions as updates anyway).
preview = MobileDevice.new(device_params)
preview.valid?
relevant_errors = preview.errors.errors.reject do |err|
err.type == :taken || err.attribute == :user
end
relevant_errors.empty?
end
def device_params
@@ -373,7 +392,8 @@ module Api
render json: token_response.merge(user: mobile_user_payload(user))
rescue ActiveRecord::RecordInvalid => e
render json: { error: "Failed to register device: #{e.message}" }, status: :unprocessable_entity
Rails.logger.error("[Auth] Device registration failed: #{e.message}")
render json: { error: "Failed to register device" }, status: :unprocessable_entity
end
def ensure_write_scope

View File

@@ -0,0 +1,76 @@
# frozen_string_literal: true
class Api::V1::BalancesController < Api::V1::BaseController
include Pagy::Backend
before_action :ensure_read_scope
before_action :set_balance, only: :show
helper_method :format_money, :money_to_minor_units
def index
balances_query = apply_filters(balances_scope).order(date: :desc, created_at: :desc)
@per_page = safe_per_page_param
@pagy, @balances = pagy(
balances_query,
page: safe_page_param,
limit: @per_page
)
render :index
rescue InvalidFilterError => e
render json: {
error: "validation_failed",
message: e.message,
errors: [ e.message ]
}, status: :unprocessable_entity
end
def show
render :show
end
private
def set_balance
raise ActiveRecord::RecordNotFound unless valid_uuid?(params[:id])
@balance = balances_scope.find(params[:id])
end
def ensure_read_scope
authorize_scope!(:read)
end
def balances_scope
Balance
.joins(:account)
.where(accounts: { id: accessible_account_ids })
.includes(:account)
end
def accessible_account_ids
@accessible_account_ids ||= current_resource_owner.family.accounts.accessible_by(current_resource_owner).select(:id)
end
def apply_filters(query)
if params[:account_id].present?
raise InvalidFilterError, "account_id must be a valid UUID" unless valid_uuid?(params[:account_id])
query = query.where(account_id: params[:account_id])
end
query = query.where(currency: params[:currency].to_s.upcase) if params[:currency].present?
query = query.where("balances.date >= ?", parse_date_param(:start_date)) if params[:start_date].present?
query = query.where("balances.date <= ?", parse_date_param(:end_date)) if params[:end_date].present?
query
end
def format_money(money)
money&.format
end
def money_to_minor_units(money)
(money.amount * money.currency.minor_unit_conversion).round(0).to_i if money
end
end

View File

@@ -3,6 +3,14 @@
class Api::V1::BaseController < ApplicationController
include Doorkeeper::Rails::Helpers
InvalidFilterError = Class.new(StandardError)
class << self
def valid_uuid?(value)
UuidFormat.valid?(value)
end
end
# Skip regular session-based authentication for API
skip_authentication
@@ -30,6 +38,7 @@ class Api::V1::BaseController < ApplicationController
rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found
rescue_from Doorkeeper::Errors::DoorkeeperError, with: :handle_unauthorized
rescue_from ActionController::ParameterMissing, with: :handle_bad_request
rescue_from InvalidFilterError, with: :handle_invalid_filter
private
@@ -56,7 +65,7 @@ class Api::V1::BaseController < ApplicationController
# Check token validity and scope (read_write includes read access)
has_sufficient_scope = access_token&.scopes&.include?("read") || access_token&.scopes&.include?("read_write")
unless access_token && !access_token.expired? && has_sufficient_scope
unless access_token&.accessible? && has_sufficient_scope
render_json({ error: "unauthorized", message: "Access token is invalid, expired, or missing required scope" }, status: :unauthorized)
return false
end
@@ -204,11 +213,41 @@ class Api::V1::BaseController < ApplicationController
true
end
def ensure_read_scope
authorize_scope!(:read)
end
# Consistent JSON response method
def render_json(data, status: :ok)
render json: data, status: status
end
def valid_uuid?(value)
self.class.valid_uuid?(value)
end
def safe_page_param
page = params[:page].to_i
page > 0 ? page : 1
end
def safe_per_page_param
per_page = params[:per_page].to_i
case per_page
when 1..100 then per_page
when (101..) then 100
else 25
end
end
def render_validation_error(message)
render_json({
error: "validation_failed",
message: message,
errors: [ message ]
}, status: :unprocessable_entity)
end
# Error handlers
def handle_not_found(exception)
Rails.logger.warn "API Record Not Found: #{exception.message}"
@@ -225,6 +264,16 @@ class Api::V1::BaseController < ApplicationController
render_json({ error: "bad_request", message: "Required parameters are missing or invalid" }, status: :bad_request)
end
def handle_invalid_filter(exception)
render_validation_error(exception.message)
end
def parse_date_param(key)
Date.iso8601(params[key].to_s)
rescue ArgumentError
raise InvalidFilterError, "#{key} must be an ISO 8601 date"
end
# Log API access for monitoring and debugging
def log_api_access
return unless current_resource_owner

View File

@@ -0,0 +1,63 @@
# frozen_string_literal: true
class Api::V1::BudgetCategoriesController < Api::V1::BaseController
include Pagy::Backend
before_action :ensure_read_scope
before_action :set_budget_category, only: :show
def index
budget_categories_query = apply_filters(budget_categories_scope)
.order("budgets.start_date DESC", "categories.name ASC")
@per_page = safe_per_page_param
@pagy, @budget_categories = pagy(
budget_categories_query,
page: safe_page_param,
limit: @per_page
)
render :index
end
def show
render :show
end
private
def set_budget_category
raise ActiveRecord::RecordNotFound unless valid_uuid?(params[:id])
@budget_category = budget_categories_scope.find(params[:id])
end
def ensure_read_scope
authorize_scope!(:read)
end
def budget_categories_scope
BudgetCategory
.joins(:budget, :category)
.where(budgets: { family_id: current_resource_owner.family_id })
.includes({ budget: { budget_categories: { category: :parent } } }, category: :parent)
end
def apply_filters(query)
if params[:budget_id].present?
raise InvalidFilterError, "budget_id must be a valid UUID" unless valid_uuid?(params[:budget_id])
query = query.where(budget_id: params[:budget_id])
end
if params[:category_id].present?
raise InvalidFilterError, "category_id must be a valid UUID" unless valid_uuid?(params[:category_id])
query = query.where(category_id: params[:category_id])
end
query = query.where("budgets.start_date >= ?", parse_date_param(:start_date)) if params[:start_date].present?
query = query.where("budgets.end_date <= ?", parse_date_param(:end_date)) if params[:end_date].present?
query
end
end

View File

@@ -0,0 +1,47 @@
# frozen_string_literal: true
class Api::V1::BudgetsController < Api::V1::BaseController
include Pagy::Backend
before_action :ensure_read_scope
before_action :set_budget, only: :show
def index
budgets_query = apply_filters(budgets_scope).order(start_date: :desc)
@per_page = safe_per_page_param
@pagy, @budgets = pagy(
budgets_query,
page: safe_page_param,
limit: @per_page
)
render :index
end
def show
render :show
end
private
def set_budget
raise ActiveRecord::RecordNotFound unless valid_uuid?(params[:id])
@budget = budgets_scope.find(params[:id])
end
def ensure_read_scope
authorize_scope!(:read)
end
def apply_filters(query)
query = query.where("budgets.start_date >= ?", parse_date_param(:start_date)) if params[:start_date].present?
query = query.where("budgets.end_date <= ?", parse_date_param(:end_date)) if params[:end_date].present?
query
end
def budgets_scope
current_resource_owner.family.budgets.includes(budget_categories: :category)
end
end

View File

@@ -3,7 +3,8 @@
class Api::V1::CategoriesController < Api::V1::BaseController
include Pagy::Backend
before_action :ensure_read_scope
before_action :ensure_read_scope, only: %i[index show]
before_action :ensure_write_scope, only: :create
before_action :set_category, only: :show
def index
@@ -29,7 +30,7 @@ class Api::V1::CategoriesController < Api::V1::BaseController
render json: {
error: "internal_server_error",
message: "Error: #{e.message}"
message: "An unexpected error occurred"
}, status: :internal_server_error
end
@@ -41,10 +42,34 @@ class Api::V1::CategoriesController < Api::V1::BaseController
render json: {
error: "internal_server_error",
message: "Error: #{e.message}"
message: "An unexpected error occurred"
}, status: :internal_server_error
end
def create
family = current_resource_owner.family
attrs = category_params
if attrs[:parent_id].present? && !family.categories.exists?(id: attrs[:parent_id])
return render json: {
error: "unprocessable_entity",
message: "Parent must be a category in your family"
}, status: :unprocessable_entity
end
@category = family.categories.new(attrs)
@category.lucide_icon = Category.suggested_icon(@category.name) if @category.lucide_icon.blank?
if @category.save
render :show, status: :created
else
render json: {
error: "unprocessable_entity",
message: @category.errors.full_messages.join(", ")
}, status: :unprocessable_entity
end
end
private
def set_category
@@ -61,6 +86,17 @@ class Api::V1::CategoriesController < Api::V1::BaseController
authorize_scope!(:read)
end
def ensure_write_scope
authorize_scope!(:read_write)
end
def category_params
permitted = params.require(:category).permit(:name, :color, :icon, :parent_id)
icon = permitted.delete(:icon)
permitted[:lucide_icon] = icon if icon.present?
permitted
end
def apply_filters(query)
# Filter for root categories only (no parent)
if params[:roots_only].present? && ActiveModel::Type::Boolean.new.cast(params[:roots_only])

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