mirror of
https://github.com/we-promise/sure.git
synced 2026-05-29 23:39:03 +00:00
Merge branch 'main' into feature/retirement-planning
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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:
|
||||
|
||||
39
.env.example
39
.env.example
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------------
|
||||
|
||||
@@ -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
3
.gitattributes
vendored
@@ -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
|
||||
|
||||
4
.github/copilot-instructions.md
vendored
4
.github/copilot-instructions.md
vendored
@@ -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`
|
||||
|
||||
18
.github/workflows/chart-ci.yml
vendored
18
.github/workflows/chart-ci.yml
vendored
@@ -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: |
|
||||
|
||||
16
.github/workflows/chart-release.yml
vendored
16
.github/workflows/chart-release.yml
vendored
@@ -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 }}
|
||||
|
||||
64
.github/workflows/ci.yml
vendored
64
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
18
.github/workflows/flutter-build.yml
vendored
18
.github/workflows/flutter-build.yml
vendored
@@ -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
108
.github/workflows/google-play-upload.yml
vendored
Normal 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 }}
|
||||
8
.github/workflows/helm-publish.yml
vendored
8
.github/workflows/helm-publish.yml
vendored
@@ -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
|
||||
|
||||
106
.github/workflows/ios-testflight.yml
vendored
106
.github/workflows/ios-testflight.yml
vendored
@@ -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
457
.github/workflows/llm-evals.yml
vendored
Normal 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 }}"
|
||||
8
.github/workflows/mobile-build.yml
vendored
8
.github/workflows/mobile-build.yml
vendored
@@ -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
|
||||
|
||||
91
.github/workflows/mobile-release.yml
vendored
91
.github/workflows/mobile-release.yml
vendored
@@ -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
|
||||
|
||||
3
.github/workflows/pipelock.yml
vendored
3
.github/workflows/pipelock.yml
vendored
@@ -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
216
.github/workflows/preview-cleanup.yml
vendored
Normal 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
189
.github/workflows/preview-deploy.yml
vendored
Normal 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
|
||||
50
.github/workflows/publish.yml
vendored
50
.github/workflows/publish.yml
vendored
@@ -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
2
.gitignore
vendored
@@ -123,4 +123,4 @@ scripts/
|
||||
.auto-claude-status
|
||||
.claude_settings.json
|
||||
.security-key
|
||||
logs/security/
|
||||
logs/security/
|
||||
|
||||
@@ -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
1
.sure-version
Normal file
@@ -0,0 +1 @@
|
||||
0.7.1-alpha.10
|
||||
15
AGENTS.md
15
AGENTS.md
@@ -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
|
||||
|
||||
@@ -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
247
Dockerfile.preview
Normal 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"]
|
||||
2
Gemfile
2
Gemfile
@@ -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"
|
||||
|
||||
80
Gemfile.lock
80
Gemfile.lock
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
11
README.md
11
README.md
@@ -1,6 +1,7 @@
|
||||
[](https://deepwiki.com/we-promise/sure)
|
||||
[](https://oss.skylight.io/app/applications/s6PEZSKwcklL)
|
||||
[](https://app.dosu.dev/a72bdcfd-15f5-4edc-bd85-ea0daa6c3adc/ask)
|
||||
[](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 2021–2022 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 2021–2022 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
|
||||
|
||||
[](https://www.pikapods.com/pods?run=sure)
|
||||
|
||||
[](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.
|
||||
|
||||

|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
12
app/assets/tailwind/sure-design-system.css
Normal file
12
app/assets/tailwind/sure-design-system.css
Normal 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';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
37
app/assets/tailwind/sure-design-system/base.css
Normal file
37
app/assets/tailwind/sure-design-system/base.css
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
148
app/assets/tailwind/sure-design-system/components.css
Normal file
148
app/assets/tailwind/sure-design-system/components.css
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
24
app/assets/tailwind/sure-design-system/prose.css
Normal file
24
app/assets/tailwind/sure-design-system/prose.css
Normal 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;
|
||||
}
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<% end %>
|
||||
|
||||
<% unless icon_only? %>
|
||||
<%= text %>
|
||||
<span class="min-w-0 truncate"><%= text %></span>
|
||||
<% end %>
|
||||
|
||||
<% if icon && icon_position == :right %>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 %>">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) %>
|
||||
|
||||
@@ -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",
|
||||
|
||||
20
app/components/DS/pill.html.erb
Normal file
20
app/components/DS/pill.html.erb
Normal 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
131
app/components/DS/pill.rb
Normal 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
|
||||
32
app/components/DS/popover.html.erb
Normal file
32
app/components/DS/popover.html.erb
Normal 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 %>
|
||||
62
app/components/DS/popover.rb
Normal file
62
app/components/DS/popover.rb
Normal 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
|
||||
140
app/components/DS/popover_controller.js
Normal file
140
app/components/DS/popover_controller.js
Normal 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: "",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
17
app/components/DS/search_input.html.erb
Normal file
17
app/components/DS/search_input.html.erb
Normal 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 %>
|
||||
78
app/components/DS/search_input.rb
Normal file
78
app/components/DS/search_input.rb
Normal 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
|
||||
@@ -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] %>">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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, " ".html_safe, class: label_classes, for: id %>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"] %>
|
||||
|
||||
25
app/components/settings/provider_card.html.erb
Normal file
25
app/components/settings/provider_card.html.erb
Normal 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 %>
|
||||
47
app/components/settings/provider_card.rb
Normal file
47
app/components/settings/provider_card.rb
Normal 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
|
||||
211
app/controllers/account_statements_controller.rb
Normal file
211
app/controllers/account_statements_controller.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
76
app/controllers/api/v1/balances_controller.rb
Normal file
76
app/controllers/api/v1/balances_controller.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
63
app/controllers/api/v1/budget_categories_controller.rb
Normal file
63
app/controllers/api/v1/budget_categories_controller.rb
Normal 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
|
||||
47
app/controllers/api/v1/budgets_controller.rb
Normal file
47
app/controllers/api/v1/budgets_controller.rb
Normal 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
|
||||
@@ -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
Reference in New Issue
Block a user