Feature/streamline #4

Merged
JustinZeus merged 2 commits from feature/streamline into main 2026-02-19 16:26:13 +01:00
83 changed files with 6497 additions and 864 deletions

View file

@ -14,4 +14,5 @@ __pycache__
htmlcov htmlcov
frontend/node_modules frontend/node_modules
frontend/dist frontend/dist
frontend/.vite
planning planning

View file

@ -21,6 +21,21 @@ MIGRATE_ON_START=1
SESSION_SECRET_KEY=replace-with-a-long-random-secret-at-least-32-characters SESSION_SECRET_KEY=replace-with-a-long-random-secret-at-least-32-characters
SESSION_COOKIE_SECURE=1 SESSION_COOKIE_SECURE=1
SECURITY_HEADERS_ENABLED=1
SECURITY_X_CONTENT_TYPE_OPTIONS=nosniff
SECURITY_X_FRAME_OPTIONS=DENY
SECURITY_REFERRER_POLICY=strict-origin-when-cross-origin
SECURITY_PERMISSIONS_POLICY=accelerometer=(), autoplay=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()
SECURITY_CROSS_ORIGIN_OPENER_POLICY=same-origin
SECURITY_CROSS_ORIGIN_RESOURCE_POLICY=same-origin
SECURITY_CSP_ENABLED=1
SECURITY_CSP_POLICY=default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data: https:; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self'; object-src 'none'
SECURITY_CSP_DOCS_POLICY=default-src 'self'; img-src 'self' data: https:; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self' https:; object-src 'none'; frame-ancestors 'none'
SECURITY_CSP_REPORT_ONLY=0
SECURITY_STRICT_TRANSPORT_SECURITY_ENABLED=0
SECURITY_STRICT_TRANSPORT_SECURITY_MAX_AGE=31536000
SECURITY_STRICT_TRANSPORT_SECURITY_INCLUDE_SUBDOMAINS=1
SECURITY_STRICT_TRANSPORT_SECURITY_PRELOAD=0
LOGIN_RATE_LIMIT_ATTEMPTS=5 LOGIN_RATE_LIMIT_ATTEMPTS=5
LOGIN_RATE_LIMIT_WINDOW_SECONDS=60 LOGIN_RATE_LIMIT_WINDOW_SECONDS=60
LOG_LEVEL=INFO LOG_LEVEL=INFO
@ -35,6 +50,9 @@ INGESTION_NETWORK_ERROR_RETRIES=1
INGESTION_RETRY_BACKOFF_SECONDS=1.0 INGESTION_RETRY_BACKOFF_SECONDS=1.0
INGESTION_MAX_PAGES_PER_SCHOLAR=30 INGESTION_MAX_PAGES_PER_SCHOLAR=30
INGESTION_PAGE_SIZE=100 INGESTION_PAGE_SIZE=100
INGESTION_ALERT_BLOCKED_FAILURE_THRESHOLD=1
INGESTION_ALERT_NETWORK_FAILURE_THRESHOLD=2
INGESTION_ALERT_RETRY_SCHEDULED_THRESHOLD=3
INGESTION_CONTINUATION_QUEUE_ENABLED=1 INGESTION_CONTINUATION_QUEUE_ENABLED=1
INGESTION_CONTINUATION_BASE_DELAY_SECONDS=120 INGESTION_CONTINUATION_BASE_DELAY_SECONDS=120
INGESTION_CONTINUATION_MAX_DELAY_SECONDS=3600 INGESTION_CONTINUATION_MAX_DELAY_SECONDS=3600
@ -50,6 +68,8 @@ SCHOLAR_NAME_SEARCH_MIN_INTERVAL_SECONDS=8.0
SCHOLAR_NAME_SEARCH_INTERVAL_JITTER_SECONDS=2.0 SCHOLAR_NAME_SEARCH_INTERVAL_JITTER_SECONDS=2.0
SCHOLAR_NAME_SEARCH_COOLDOWN_BLOCK_THRESHOLD=1 SCHOLAR_NAME_SEARCH_COOLDOWN_BLOCK_THRESHOLD=1
SCHOLAR_NAME_SEARCH_COOLDOWN_SECONDS=1800 SCHOLAR_NAME_SEARCH_COOLDOWN_SECONDS=1800
SCHOLAR_NAME_SEARCH_ALERT_RETRY_COUNT_THRESHOLD=2
SCHOLAR_NAME_SEARCH_ALERT_COOLDOWN_REJECTIONS_THRESHOLD=3
BOOTSTRAP_ADMIN_ON_START=0 BOOTSTRAP_ADMIN_ON_START=0
BOOTSTRAP_ADMIN_EMAIL= BOOTSTRAP_ADMIN_EMAIL=

View file

@ -7,8 +7,20 @@ on:
- main - main
jobs: jobs:
repo-hygiene:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Enforce no generated artifacts
run: ./scripts/check_no_generated_artifacts.sh
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs:
- repo-hygiene
services: services:
postgres: postgres:
image: postgres:15 image: postgres:15
@ -54,6 +66,8 @@ jobs:
frontend-quality: frontend-quality:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs:
- repo-hygiene
steps: steps:
- name: Checkout - name: Checkout
@ -76,6 +90,10 @@ jobs:
working-directory: frontend working-directory: frontend
run: npm install run: npm install
- name: Frontend theme token policy
working-directory: frontend
run: npm run check:theme-tokens
- name: Frontend typecheck - name: Frontend typecheck
working-directory: frontend working-directory: frontend
run: npm run typecheck run: npm run typecheck
@ -91,6 +109,7 @@ jobs:
docker-publish: docker-publish:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: needs:
- repo-hygiene
- test - test
- frontend-quality - frontend-quality
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'

54
.github/workflows/scheduled-probes.yml vendored Normal file
View file

@ -0,0 +1,54 @@
name: Scheduled Probes
on:
workflow_dispatch:
schedule:
- cron: "20 6 * * *"
jobs:
fixture-probes:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_DB: scholar
POSTGRES_USER: scholar
POSTGRES_PASSWORD: scholar
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U scholar -d scholar"
--health-interval 5s
--health-timeout 5s
--health-retries 20
env:
DATABASE_URL: postgresql+asyncpg://scholar:scholar@localhost:5432/scholar
SCHOLAR_IMAGE_UPLOAD_DIR: /tmp/scholarr_uploads/scholar_images
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Setup uv
uses: astral-sh/setup-uv@v4
- name: Install dependencies
run: uv sync --extra dev
- name: Fixture-backed parser unit probes
run: >-
uv run pytest
tests/unit/test_scholar_parser.py
tests/unit/test_scholar_search_safety.py
- name: Fixture-backed integration probe
run: >-
uv run pytest -m integration
tests/integration/test_fixture_probe_runs.py

2
.gitignore vendored
View file

@ -10,6 +10,8 @@ htmlcov/
.uv-cache/ .uv-cache/
.env .env
*.log *.log
.DS_Store
planning/ planning/
frontend/node_modules/ frontend/node_modules/
frontend/dist/ frontend/dist/
frontend/.vite/

24
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,24 @@
# Contributing
## Scope
This project favors small, reviewable pull requests that keep runtime behavior clear and operationally safe.
## Essential-File Policy
Commit only source-of-truth files required to build, run, test, or document the app.
Do not commit generated or local-only artifacts, including:
- `__pycache__/`, `*.pyc`, `.pytest_cache/`, `.mypy_cache/`, `.ruff_cache/`
- frontend build/install outputs like `frontend/dist/`, `frontend/node_modules/`, `frontend/.vite/`
- coverage outputs (`.coverage`, `htmlcov/`)
- packaging/build leftovers (`*.egg-info/`, `build/`, `dist/`)
- local probe/scratch material (`planning/`)
CI enforces this with `scripts/check_no_generated_artifacts.sh`.
## Merge Checklist
- [ ] Changes are minimal, purposeful, and remove obsolete/dead code in touched areas.
- [ ] Backend tests pass (`uv run pytest tests/unit` and integration scope as needed).
- [ ] Frontend checks pass (`npm run typecheck`, `npm run test:run`, `npm run build`).
- [ ] API/behavior docs are updated when env vars, endpoints, or payloads change.
- [ ] `README.md`, `.env.example`, and deployment notes stay aligned.
- [ ] `scripts/check_no_generated_artifacts.sh` passes locally.

View file

@ -69,12 +69,15 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml down
- `.env.example`: full env variable template. - `.env.example`: full env variable template.
- `Dockerfile`: multi-stage build (frontend + backend runtime). - `Dockerfile`: multi-stage build (frontend + backend runtime).
- `README.md`: deployment and operations reference. - `README.md`: deployment and operations reference.
- `CONTRIBUTING.md`: contribution policy and merge checklist.
- `scripts/check_no_generated_artifacts.sh`: tracked-artifact guard used by CI.
## Environment Variables (Complete Reference) ## Environment Variables (Complete Reference)
Notes: Notes:
- Boolean envs accept: `1/0`, `true/false`, `yes/no`, `on/off`. - Boolean envs accept: `1/0`, `true/false`, `yes/no`, `on/off`.
- Values shown are defaults from `.env.example`. - Values shown are deployment defaults from `.env.example` and `docker-compose.yml`.
- Some internal app fallbacks may differ for local test/dev safety when env vars are omitted.
- `deploy` means used in regular deployment. - `deploy` means used in regular deployment.
- `dev` means used in local dev workflow. - `dev` means used in local dev workflow.
@ -119,6 +122,26 @@ Notes:
| `LOGIN_RATE_LIMIT_ATTEMPTS` | `5` | integer >= 1 | deploy, dev | Login attempts allowed per window. | | `LOGIN_RATE_LIMIT_ATTEMPTS` | `5` | integer >= 1 | deploy, dev | Login attempts allowed per window. |
| `LOGIN_RATE_LIMIT_WINDOW_SECONDS` | `60` | integer >= 1 | deploy, dev | Login rate limit window in seconds. | | `LOGIN_RATE_LIMIT_WINDOW_SECONDS` | `60` | integer >= 1 | deploy, dev | Login rate limit window in seconds. |
### HTTP Security Headers and CSP
| Variable | Default | Options | Scope | Description |
| --- | --- | --- | --- | --- |
| `SECURITY_HEADERS_ENABLED` | `1` | boolean | deploy, dev | Global switch for response security headers middleware. |
| `SECURITY_X_CONTENT_TYPE_OPTIONS` | `nosniff` | header value | deploy, dev | `X-Content-Type-Options` response header value. |
| `SECURITY_X_FRAME_OPTIONS` | `DENY` | header value | deploy, dev | `X-Frame-Options` response header value. |
| `SECURITY_REFERRER_POLICY` | `strict-origin-when-cross-origin` | header value | deploy, dev | `Referrer-Policy` response header value. |
| `SECURITY_PERMISSIONS_POLICY` | `accelerometer=(), autoplay=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()` | permissions policy string | deploy, dev | `Permissions-Policy` response header value. |
| `SECURITY_CROSS_ORIGIN_OPENER_POLICY` | `same-origin` | header value | deploy, dev | `Cross-Origin-Opener-Policy` response header value. |
| `SECURITY_CROSS_ORIGIN_RESOURCE_POLICY` | `same-origin` | header value | deploy, dev | `Cross-Origin-Resource-Policy` response header value. |
| `SECURITY_CSP_ENABLED` | `1` | boolean | deploy, dev | Enable Content Security Policy headers. |
| `SECURITY_CSP_POLICY` | strict SPA/API default | CSP policy string | deploy, dev | CSP applied to app/API routes (excluding docs paths). |
| `SECURITY_CSP_DOCS_POLICY` | relaxed docs default | CSP policy string | deploy, dev | CSP override for `/docs` and `/redoc` to keep Swagger/ReDoc usable. |
| `SECURITY_CSP_REPORT_ONLY` | `0` | boolean | deploy, dev | Emit CSP in report-only mode instead of enforcement mode. |
| `SECURITY_STRICT_TRANSPORT_SECURITY_ENABLED` | `0` | boolean | deploy, dev | Enable `Strict-Transport-Security` header. |
| `SECURITY_STRICT_TRANSPORT_SECURITY_MAX_AGE` | `31536000` | integer >= 0 | deploy, dev | `max-age` for HSTS header. |
| `SECURITY_STRICT_TRANSPORT_SECURITY_INCLUDE_SUBDOMAINS` | `1` | boolean | deploy, dev | Add `includeSubDomains` directive to HSTS header. |
| `SECURITY_STRICT_TRANSPORT_SECURITY_PRELOAD` | `0` | boolean | deploy, dev | Add `preload` directive to HSTS header. |
### Logging ### Logging
| Variable | Default | Options | Scope | Description | | Variable | Default | Options | Scope | Description |
@ -141,6 +164,9 @@ Notes:
| `INGESTION_RETRY_BACKOFF_SECONDS` | `1.0` | float >= 0 | deploy, dev | Backoff delay for retry attempts. | | `INGESTION_RETRY_BACKOFF_SECONDS` | `1.0` | float >= 0 | deploy, dev | Backoff delay for retry attempts. |
| `INGESTION_MAX_PAGES_PER_SCHOLAR` | `30` | integer >= 1 | deploy, dev | Upper bound of pages fetched per scholar run. | | `INGESTION_MAX_PAGES_PER_SCHOLAR` | `30` | integer >= 1 | deploy, dev | Upper bound of pages fetched per scholar run. |
| `INGESTION_PAGE_SIZE` | `100` | integer >= 1 | deploy, dev | Requested Scholar page size. | | `INGESTION_PAGE_SIZE` | `100` | integer >= 1 | deploy, dev | Requested Scholar page size. |
| `INGESTION_ALERT_BLOCKED_FAILURE_THRESHOLD` | `1` | integer >= 1 | deploy, dev | Trigger blocked/captcha scrape alert flag when this many blocked failures occur in a run. |
| `INGESTION_ALERT_NETWORK_FAILURE_THRESHOLD` | `2` | integer >= 1 | deploy, dev | Trigger network scrape alert flag when this many network failures occur in a run. |
| `INGESTION_ALERT_RETRY_SCHEDULED_THRESHOLD` | `3` | integer >= 1 | deploy, dev | Trigger retry alert flag when scheduled retry count reaches this threshold in a run. |
| `INGESTION_CONTINUATION_QUEUE_ENABLED` | `1` | boolean | deploy, dev | Enable continuation queue for long runs. | | `INGESTION_CONTINUATION_QUEUE_ENABLED` | `1` | boolean | deploy, dev | Enable continuation queue for long runs. |
| `INGESTION_CONTINUATION_BASE_DELAY_SECONDS` | `120` | integer >= 0 | deploy, dev | Initial delay before retrying continuation queue items. | | `INGESTION_CONTINUATION_BASE_DELAY_SECONDS` | `120` | integer >= 0 | deploy, dev | Initial delay before retrying continuation queue items. |
| `INGESTION_CONTINUATION_MAX_DELAY_SECONDS` | `3600` | integer >= 0 | deploy, dev | Maximum continuation retry delay. | | `INGESTION_CONTINUATION_MAX_DELAY_SECONDS` | `3600` | integer >= 0 | deploy, dev | Maximum continuation retry delay. |
@ -150,7 +176,7 @@ Notes:
| Variable | Default | Options | Scope | Description | | Variable | Default | Options | Scope | Description |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| `SCHOLAR_IMAGE_UPLOAD_DIR` | `/var/lib/scholarr/uploads` | writable absolute path | deploy, dev | Storage path for uploaded scholar images. | | `SCHOLAR_IMAGE_UPLOAD_DIR` | `/var/lib/scholarr/uploads` | writable absolute path | deploy, dev | Storage path for uploaded scholar images in compose deployments. App fallback without env is `/tmp/scholarr_uploads/scholar_images`. |
| `SCHOLAR_IMAGE_UPLOAD_MAX_BYTES` | `2000000` | integer >= 1 | deploy, dev | Max uploaded image size in bytes. | | `SCHOLAR_IMAGE_UPLOAD_MAX_BYTES` | `2000000` | integer >= 1 | deploy, dev | Max uploaded image size in bytes. |
| `SCHOLAR_NAME_SEARCH_ENABLED` | `1` | boolean | deploy, dev | Enable name-search helper endpoint. | | `SCHOLAR_NAME_SEARCH_ENABLED` | `1` | boolean | deploy, dev | Enable name-search helper endpoint. |
| `SCHOLAR_NAME_SEARCH_CACHE_TTL_SECONDS` | `21600` | integer >= 1 | deploy, dev | Cache TTL for successful name-search responses. | | `SCHOLAR_NAME_SEARCH_CACHE_TTL_SECONDS` | `21600` | integer >= 1 | deploy, dev | Cache TTL for successful name-search responses. |
@ -160,6 +186,8 @@ Notes:
| `SCHOLAR_NAME_SEARCH_INTERVAL_JITTER_SECONDS` | `2.0` | float >= 0 | deploy, dev | Added jitter to reduce request burst patterns. | | `SCHOLAR_NAME_SEARCH_INTERVAL_JITTER_SECONDS` | `2.0` | float >= 0 | deploy, dev | Added jitter to reduce request burst patterns. |
| `SCHOLAR_NAME_SEARCH_COOLDOWN_BLOCK_THRESHOLD` | `1` | integer >= 1 | deploy, dev | Consecutive blocked responses before cooldown starts. | | `SCHOLAR_NAME_SEARCH_COOLDOWN_BLOCK_THRESHOLD` | `1` | integer >= 1 | deploy, dev | Consecutive blocked responses before cooldown starts. |
| `SCHOLAR_NAME_SEARCH_COOLDOWN_SECONDS` | `1800` | integer >= 1 | deploy, dev | Cooldown duration after repeated blocked responses. | | `SCHOLAR_NAME_SEARCH_COOLDOWN_SECONDS` | `1800` | integer >= 1 | deploy, dev | Cooldown duration after repeated blocked responses. |
| `SCHOLAR_NAME_SEARCH_ALERT_RETRY_COUNT_THRESHOLD` | `2` | integer >= 1 | deploy, dev | Emit retry-threshold observability warning when name-search retry count reaches this value. |
| `SCHOLAR_NAME_SEARCH_ALERT_COOLDOWN_REJECTIONS_THRESHOLD` | `3` | integer >= 1 | deploy, dev | Emit cooldown-threshold observability alert after this many requests are rejected during active cooldown. |
### Startup Bootstrap and DB Wait ### Startup Bootstrap and DB Wait
@ -197,6 +225,14 @@ Contract drift:
python3 scripts/check_frontend_api_contract.py python3 scripts/check_frontend_api_contract.py
``` ```
Repository hygiene:
```bash
./scripts/check_no_generated_artifacts.sh
```
Scheduled fixture probes run in GitHub Actions via `.github/workflows/scheduled-probes.yml`.
## API Contract ## API Contract
- Base path: `/api/v1` - Base path: `/api/v1`

View file

@ -1 +0,0 @@

View file

@ -0,0 +1,51 @@
"""Add per-user nav visibility settings.
Revision ID: 20260219_0008
Revises: 20260217_0007
Create Date: 2026-02-19 14:58:00.000000
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "20260219_0008"
down_revision: str | Sequence[str] | None = "20260217_0007"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
TABLE_NAME = "user_settings"
DEFAULT_NAV_VISIBLE_PAGES_SQL = (
"'[\"dashboard\",\"scholars\",\"publications\",\"settings\",\"style-guide\",\"runs\",\"users\"]'::jsonb"
)
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
columns = {column["name"] for column in inspector.get_columns(TABLE_NAME)}
if "nav_visible_pages" not in columns:
op.add_column(
TABLE_NAME,
sa.Column(
"nav_visible_pages",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
server_default=sa.text(DEFAULT_NAV_VISIBLE_PAGES_SQL),
),
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
columns = {column["name"] for column in inspector.get_columns(TABLE_NAME)}
if "nav_visible_pages" in columns:
op.drop_column(TABLE_NAME, "nav_visible_pages")

View file

@ -382,6 +382,9 @@ async def run_manual(
auto_queue_continuations=settings.ingestion_continuation_queue_enabled, auto_queue_continuations=settings.ingestion_continuation_queue_enabled,
queue_delay_seconds=settings.ingestion_continuation_base_delay_seconds, queue_delay_seconds=settings.ingestion_continuation_base_delay_seconds,
idempotency_key=idempotency_key, idempotency_key=idempotency_key,
alert_blocked_failure_threshold=settings.ingestion_alert_blocked_failure_threshold,
alert_network_failure_threshold=settings.ingestion_alert_network_failure_threshold,
alert_retry_scheduled_threshold=settings.ingestion_alert_retry_scheduled_threshold,
) )
except ingestion_service.RunAlreadyInProgressError as exc: except ingestion_service.RunAlreadyInProgressError as exc:
await db_session.rollback() await db_session.rollback()

View file

@ -167,6 +167,10 @@ async def search_scholars(
interval_jitter_seconds=settings.scholar_name_search_interval_jitter_seconds, interval_jitter_seconds=settings.scholar_name_search_interval_jitter_seconds,
cooldown_block_threshold=settings.scholar_name_search_cooldown_block_threshold, cooldown_block_threshold=settings.scholar_name_search_cooldown_block_threshold,
cooldown_seconds=settings.scholar_name_search_cooldown_seconds, cooldown_seconds=settings.scholar_name_search_cooldown_seconds,
retry_alert_threshold=settings.scholar_name_search_alert_retry_count_threshold,
cooldown_rejection_alert_threshold=(
settings.scholar_name_search_alert_cooldown_rejections_threshold
),
) )
except scholar_service.ScholarServiceError as exc: except scholar_service.ScholarServiceError as exc:
raise ApiException( raise ApiException(

View file

@ -23,6 +23,7 @@ def _serialize_settings(settings) -> dict[str, object]:
"auto_run_enabled": bool(settings.auto_run_enabled), "auto_run_enabled": bool(settings.auto_run_enabled),
"run_interval_minutes": int(settings.run_interval_minutes), "run_interval_minutes": int(settings.run_interval_minutes),
"request_delay_seconds": int(settings.request_delay_seconds), "request_delay_seconds": int(settings.request_delay_seconds),
"nav_visible_pages": list(settings.nav_visible_pages or []),
} }
@ -55,6 +56,11 @@ async def update_settings(
db_session: AsyncSession = Depends(get_db_session), db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_api_current_user), current_user: User = Depends(get_api_current_user),
): ):
settings = await user_settings_service.get_or_create_settings(
db_session,
user_id=current_user.id,
)
try: try:
parsed_interval = user_settings_service.parse_run_interval_minutes( parsed_interval = user_settings_service.parse_run_interval_minutes(
str(payload.run_interval_minutes) str(payload.run_interval_minutes)
@ -62,6 +68,11 @@ async def update_settings(
parsed_delay = user_settings_service.parse_request_delay_seconds( parsed_delay = user_settings_service.parse_request_delay_seconds(
str(payload.request_delay_seconds) str(payload.request_delay_seconds)
) )
parsed_nav_visible_pages = user_settings_service.parse_nav_visible_pages(
payload.nav_visible_pages
if payload.nav_visible_pages is not None
else list(settings.nav_visible_pages or user_settings_service.DEFAULT_NAV_VISIBLE_PAGES)
)
except user_settings_service.UserSettingsServiceError as exc: except user_settings_service.UserSettingsServiceError as exc:
raise ApiException( raise ApiException(
status_code=400, status_code=400,
@ -69,16 +80,13 @@ async def update_settings(
message=str(exc), message=str(exc),
) from exc ) from exc
settings = await user_settings_service.get_or_create_settings(
db_session,
user_id=current_user.id,
)
updated = await user_settings_service.update_settings( updated = await user_settings_service.update_settings(
db_session, db_session,
settings=settings, settings=settings,
auto_run_enabled=bool(payload.auto_run_enabled), auto_run_enabled=bool(payload.auto_run_enabled),
run_interval_minutes=parsed_interval, run_interval_minutes=parsed_interval,
request_delay_seconds=parsed_delay, request_delay_seconds=parsed_delay,
nav_visible_pages=parsed_nav_visible_pages,
) )
logger.info( logger.info(
"api.settings.updated", "api.settings.updated",
@ -88,6 +96,7 @@ async def update_settings(
"auto_run_enabled": updated.auto_run_enabled, "auto_run_enabled": updated.auto_run_enabled,
"run_interval_minutes": updated.run_interval_minutes, "run_interval_minutes": updated.run_interval_minutes,
"request_delay_seconds": updated.request_delay_seconds, "request_delay_seconds": updated.request_delay_seconds,
"nav_visible_pages": updated.nav_visible_pages,
}, },
) )
return success_payload( return success_payload(

View file

@ -219,6 +219,10 @@ class RunSummaryData(BaseModel):
partial_count: int partial_count: int
failed_state_counts: dict[str, int] = Field(default_factory=dict) failed_state_counts: dict[str, int] = Field(default_factory=dict)
failed_reason_counts: dict[str, int] = Field(default_factory=dict) failed_reason_counts: dict[str, int] = Field(default_factory=dict)
scrape_failure_counts: dict[str, int] = Field(default_factory=dict)
retry_counts: dict[str, int] = Field(default_factory=dict)
alert_thresholds: dict[str, int] = Field(default_factory=dict)
alert_flags: dict[str, bool] = Field(default_factory=dict)
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@ -429,6 +433,7 @@ class SettingsData(BaseModel):
auto_run_enabled: bool auto_run_enabled: bool
run_interval_minutes: int run_interval_minutes: int
request_delay_seconds: int request_delay_seconds: int
nav_visible_pages: list[str]
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@ -444,6 +449,7 @@ class SettingsUpdateRequest(BaseModel):
auto_run_enabled: bool auto_run_enabled: bool
run_interval_minutes: int run_interval_minutes: int
request_delay_seconds: int request_delay_seconds: int
nav_visible_pages: list[str] | None = None
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")

View file

@ -98,6 +98,13 @@ class UserSetting(Base):
request_delay_seconds: Mapped[int] = mapped_column( request_delay_seconds: Mapped[int] = mapped_column(
Integer, nullable=False, server_default=text("10") Integer, nullable=False, server_default=text("10")
) )
nav_visible_pages: Mapped[list[str]] = mapped_column(
JSONB,
nullable=False,
server_default=text(
'\'["dashboard","scholars","publications","settings","style-guide","runs","users"]\'::jsonb'
),
)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now() DateTime(timezone=True), nullable=False, server_default=func.now()
) )

View file

@ -11,6 +11,32 @@ from starlette.responses import Response
from app.logging_context import set_request_id from app.logging_context import set_request_id
REQUEST_ID_HEADER = "X-Request-ID" REQUEST_ID_HEADER = "X-Request-ID"
DEFAULT_PERMISSIONS_POLICY = (
"accelerometer=(), autoplay=(), camera=(), display-capture=(), "
"geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()"
)
DEFAULT_CSP_POLICY = (
"default-src 'self'; "
"base-uri 'self'; "
"form-action 'self'; "
"frame-ancestors 'none'; "
"img-src 'self' data: https:; "
"script-src 'self'; "
"style-src 'self' 'unsafe-inline'; "
"font-src 'self' data:; "
"connect-src 'self'; "
"object-src 'none'"
)
DEFAULT_CSP_DOCS_POLICY = (
"default-src 'self'; "
"img-src 'self' data: https:; "
"script-src 'self' 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline'; "
"font-src 'self' data:; "
"connect-src 'self' https:; "
"object-src 'none'; "
"frame-ancestors 'none'"
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -80,6 +106,100 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
return any(path.startswith(prefix) for prefix in self._skip_paths) return any(path.startswith(prefix) for prefix in self._skip_paths)
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
def __init__(
self,
app,
*,
enabled: bool = True,
x_content_type_options: str = "nosniff",
x_frame_options: str = "DENY",
referrer_policy: str = "strict-origin-when-cross-origin",
permissions_policy: str = DEFAULT_PERMISSIONS_POLICY,
cross_origin_opener_policy: str = "same-origin",
cross_origin_resource_policy: str = "same-origin",
content_security_policy_enabled: bool = True,
content_security_policy: str = DEFAULT_CSP_POLICY,
content_security_policy_docs: str = DEFAULT_CSP_DOCS_POLICY,
content_security_policy_report_only: bool = False,
strict_transport_security_enabled: bool = False,
strict_transport_security_max_age: int = 31_536_000,
strict_transport_security_include_subdomains: bool = True,
strict_transport_security_preload: bool = False,
) -> None:
super().__init__(app)
self._enabled = enabled
self._x_content_type_options = x_content_type_options.strip()
self._x_frame_options = x_frame_options.strip()
self._referrer_policy = referrer_policy.strip()
self._permissions_policy = permissions_policy.strip()
self._cross_origin_opener_policy = cross_origin_opener_policy.strip()
self._cross_origin_resource_policy = cross_origin_resource_policy.strip()
self._csp_enabled = content_security_policy_enabled
self._csp_policy = content_security_policy.strip()
self._csp_docs_policy = content_security_policy_docs.strip()
self._csp_report_only = content_security_policy_report_only
self._hsts_enabled = strict_transport_security_enabled
self._hsts_max_age = max(0, strict_transport_security_max_age)
self._hsts_include_subdomains = strict_transport_security_include_subdomains
self._hsts_preload = strict_transport_security_preload
async def dispatch(self, request: Request, call_next) -> Response:
response = await call_next(request)
if not self._enabled:
return response
if self._x_content_type_options:
response.headers.setdefault("X-Content-Type-Options", self._x_content_type_options)
if self._x_frame_options:
response.headers.setdefault("X-Frame-Options", self._x_frame_options)
if self._referrer_policy:
response.headers.setdefault("Referrer-Policy", self._referrer_policy)
if self._permissions_policy:
response.headers.setdefault("Permissions-Policy", self._permissions_policy)
if self._cross_origin_opener_policy:
response.headers.setdefault(
"Cross-Origin-Opener-Policy",
self._cross_origin_opener_policy,
)
if self._cross_origin_resource_policy:
response.headers.setdefault(
"Cross-Origin-Resource-Policy",
self._cross_origin_resource_policy,
)
csp_policy = self._csp_policy_for_path(request.url.path)
if self._csp_enabled and csp_policy:
csp_header = (
"Content-Security-Policy-Report-Only"
if self._csp_report_only
else "Content-Security-Policy"
)
response.headers.setdefault(csp_header, csp_policy)
hsts = self._strict_transport_security_value()
if hsts:
response.headers.setdefault("Strict-Transport-Security", hsts)
return response
def _csp_policy_for_path(self, path: str) -> str:
if path.startswith("/docs") or path.startswith("/redoc"):
return self._csp_docs_policy or self._csp_policy
return self._csp_policy
def _strict_transport_security_value(self) -> str:
if not self._hsts_enabled:
return ""
directives = [f"max-age={self._hsts_max_age}"]
if self._hsts_include_subdomains:
directives.append("includeSubDomains")
if self._hsts_preload:
directives.append("preload")
return "; ".join(directives)
def parse_skip_paths(raw_value: str) -> tuple[str, ...]: def parse_skip_paths(raw_value: str) -> tuple[str, ...]:
parts = [part.strip() for part in raw_value.split(",")] parts = [part.strip() for part in raw_value.split(",")]
return tuple(part for part in parts if part) return tuple(part for part in parts if part)

View file

@ -13,7 +13,11 @@ from app.api.router import router as api_router
from app.api.runtime_deps import get_ingestion_service, get_scholar_source from app.api.runtime_deps import get_ingestion_service, get_scholar_source
from app.db.session import check_database from app.db.session import check_database
from app.db.session import close_engine from app.db.session import close_engine
from app.http.middleware import RequestLoggingMiddleware, parse_skip_paths from app.http.middleware import (
RequestLoggingMiddleware,
SecurityHeadersMiddleware,
parse_skip_paths,
)
from app.logging_config import configure_logging, parse_redact_fields from app.logging_config import configure_logging, parse_redact_fields
from app.security.csrf import CSRFMiddleware from app.security.csrf import CSRFMiddleware
from app.services.scheduler import SchedulerService from app.services.scheduler import SchedulerService
@ -63,6 +67,26 @@ app.add_middleware(
log_requests=settings.log_requests, log_requests=settings.log_requests,
skip_paths=parse_skip_paths(settings.log_request_skip_paths), skip_paths=parse_skip_paths(settings.log_request_skip_paths),
) )
app.add_middleware(
SecurityHeadersMiddleware,
enabled=settings.security_headers_enabled,
x_content_type_options=settings.security_x_content_type_options,
x_frame_options=settings.security_x_frame_options,
referrer_policy=settings.security_referrer_policy,
permissions_policy=settings.security_permissions_policy,
cross_origin_opener_policy=settings.security_cross_origin_opener_policy,
cross_origin_resource_policy=settings.security_cross_origin_resource_policy,
content_security_policy_enabled=settings.security_csp_enabled,
content_security_policy=settings.security_csp_policy,
content_security_policy_docs=settings.security_csp_docs_policy,
content_security_policy_report_only=settings.security_csp_report_only,
strict_transport_security_enabled=settings.security_strict_transport_security_enabled,
strict_transport_security_max_age=settings.security_strict_transport_security_max_age,
strict_transport_security_include_subdomains=(
settings.security_strict_transport_security_include_subdomains
),
strict_transport_security_preload=settings.security_strict_transport_security_preload,
)
app.include_router(api_router) app.include_router(api_router)

View file

@ -40,6 +40,11 @@ FAILED_STATES = {
ParseState.NETWORK_ERROR.value, ParseState.NETWORK_ERROR.value,
"ingestion_error", "ingestion_error",
} }
FAILURE_BUCKET_BLOCKED = "blocked_or_captcha"
FAILURE_BUCKET_NETWORK = "network_error"
FAILURE_BUCKET_LAYOUT = "layout_changed"
FAILURE_BUCKET_INGESTION = "ingestion_error"
FAILURE_BUCKET_OTHER = "other_failure"
RUN_LOCK_NAMESPACE = 8217 RUN_LOCK_NAMESPACE = 8217
RESUMABLE_PARTIAL_REASONS = { RESUMABLE_PARTIAL_REASONS = {
"max_pages_reached", "max_pages_reached",
@ -83,6 +88,28 @@ class RunAlreadyInProgressError(RuntimeError):
"""Raised when a run lock for a user is already held by another process.""" """Raised when a run lock for a user is already held by another process."""
def _int_or_default(value: Any, default: int = 0) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def _classify_failure_bucket(*, state: str, state_reason: str) -> str:
reason = state_reason.strip().lower()
normalized_state = state.strip().lower()
if normalized_state == ParseState.BLOCKED_OR_CAPTCHA.value or reason.startswith("blocked_"):
return FAILURE_BUCKET_BLOCKED
if normalized_state == ParseState.NETWORK_ERROR.value or reason.startswith("network_"):
return FAILURE_BUCKET_NETWORK
if normalized_state == ParseState.LAYOUT_CHANGED.value:
return FAILURE_BUCKET_LAYOUT
if normalized_state == "ingestion_error":
return FAILURE_BUCKET_INGESTION
return FAILURE_BUCKET_OTHER
class ScholarIngestionService: class ScholarIngestionService:
def __init__(self, *, source: ScholarSource) -> None: def __init__(self, *, source: ScholarSource) -> None:
self._source = source self._source = source
@ -103,6 +130,9 @@ class ScholarIngestionService:
auto_queue_continuations: bool = True, auto_queue_continuations: bool = True,
queue_delay_seconds: int = 60, queue_delay_seconds: int = 60,
idempotency_key: str | None = None, idempotency_key: str | None = None,
alert_blocked_failure_threshold: int = 1,
alert_network_failure_threshold: int = 2,
alert_retry_scheduled_threshold: int = 3,
) -> RunExecutionSummary: ) -> RunExecutionSummary:
lock_acquired = await self._try_acquire_user_lock( lock_acquired = await self._try_acquire_user_lock(
db_session, db_session,
@ -161,6 +191,9 @@ class ScholarIngestionService:
"max_pages_per_scholar": max_pages_per_scholar, "max_pages_per_scholar": max_pages_per_scholar,
"page_size": page_size, "page_size": page_size,
"idempotency_key": idempotency_key, "idempotency_key": idempotency_key,
"alert_blocked_failure_threshold": alert_blocked_failure_threshold,
"alert_network_failure_threshold": alert_network_failure_threshold,
"alert_retry_scheduled_threshold": alert_retry_scheduled_threshold,
}, },
) )
@ -416,17 +449,87 @@ class ScholarIngestionService:
failed_state_counts: dict[str, int] = {} failed_state_counts: dict[str, int] = {}
failed_reason_counts: dict[str, int] = {} failed_reason_counts: dict[str, int] = {}
scrape_failure_counts: dict[str, int] = {}
retries_scheduled_count = 0
scholars_with_retries_count = 0
retry_exhausted_count = 0
for entry in scholar_results: for entry in scholar_results:
if str(entry.get("outcome", "")) != "failed": attempt_count = max(0, _int_or_default(entry.get("attempt_count"), 0))
retries_for_entry = max(0, attempt_count - 1)
if retries_for_entry > 0:
retries_scheduled_count += retries_for_entry
scholars_with_retries_count += 1
outcome = str(entry.get("outcome", ""))
if outcome != "failed":
continue continue
state = str(entry.get("state", ""))
state = str(entry.get("state", "")).strip()
if state not in FAILED_STATES: if state not in FAILED_STATES:
continue continue
failed_state_counts[state] = failed_state_counts.get(state, 0) + 1 failed_state_counts[state] = failed_state_counts.get(state, 0) + 1
reason = str(entry.get("state_reason", "")).strip() reason = str(entry.get("state_reason", "")).strip()
if reason: if reason:
failed_reason_counts[reason] = failed_reason_counts.get(reason, 0) + 1 failed_reason_counts[reason] = failed_reason_counts.get(reason, 0) + 1
failure_bucket = _classify_failure_bucket(state=state, state_reason=reason)
scrape_failure_counts[failure_bucket] = (
scrape_failure_counts.get(failure_bucket, 0) + 1
)
if (
state == ParseState.NETWORK_ERROR.value
and retries_for_entry > 0
):
retry_exhausted_count += 1
blocked_failure_count = int(scrape_failure_counts.get(FAILURE_BUCKET_BLOCKED, 0))
network_failure_count = int(scrape_failure_counts.get(FAILURE_BUCKET_NETWORK, 0))
bounded_blocked_threshold = max(1, int(alert_blocked_failure_threshold))
bounded_network_threshold = max(1, int(alert_network_failure_threshold))
bounded_retry_threshold = max(1, int(alert_retry_scheduled_threshold))
alert_flags = {
"blocked_failure_threshold_exceeded": blocked_failure_count >= bounded_blocked_threshold,
"network_failure_threshold_exceeded": network_failure_count >= bounded_network_threshold,
"retry_scheduled_threshold_exceeded": retries_scheduled_count >= bounded_retry_threshold,
}
if alert_flags["blocked_failure_threshold_exceeded"]:
logger.warning(
"ingestion.alert_blocked_failure_threshold_exceeded",
extra={
"event": "ingestion.alert_blocked_failure_threshold_exceeded",
"user_id": user_id,
"crawl_run_id": run.id,
"blocked_failure_count": blocked_failure_count,
"threshold": bounded_blocked_threshold,
},
)
if alert_flags["network_failure_threshold_exceeded"]:
logger.warning(
"ingestion.alert_network_failure_threshold_exceeded",
extra={
"event": "ingestion.alert_network_failure_threshold_exceeded",
"user_id": user_id,
"crawl_run_id": run.id,
"network_failure_count": network_failure_count,
"threshold": bounded_network_threshold,
},
)
if alert_flags["retry_scheduled_threshold_exceeded"]:
logger.warning(
"ingestion.alert_retry_scheduled_threshold_exceeded",
extra={
"event": "ingestion.alert_retry_scheduled_threshold_exceeded",
"user_id": user_id,
"crawl_run_id": run.id,
"retries_scheduled_count": retries_scheduled_count,
"threshold": bounded_retry_threshold,
},
)
run.end_dt = datetime.now(timezone.utc) run.end_dt = datetime.now(timezone.utc)
run.status = self._resolve_run_status( run.status = self._resolve_run_status(
scholar_count=len(scholars), scholar_count=len(scholars),
@ -442,6 +545,18 @@ class ScholarIngestionService:
"partial_count": partial_count, "partial_count": partial_count,
"failed_state_counts": failed_state_counts, "failed_state_counts": failed_state_counts,
"failed_reason_counts": failed_reason_counts, "failed_reason_counts": failed_reason_counts,
"scrape_failure_counts": scrape_failure_counts,
"retry_counts": {
"retries_scheduled_count": retries_scheduled_count,
"scholars_with_retries_count": scholars_with_retries_count,
"retry_exhausted_count": retry_exhausted_count,
},
"alert_thresholds": {
"blocked_failure_threshold": bounded_blocked_threshold,
"network_failure_threshold": bounded_network_threshold,
"retry_scheduled_threshold": bounded_retry_threshold,
},
"alert_flags": alert_flags,
}, },
"meta": { "meta": {
"idempotency_key": idempotency_key, "idempotency_key": idempotency_key,
@ -463,6 +578,10 @@ class ScholarIngestionService:
"failed_count": failed_count, "failed_count": failed_count,
"partial_count": partial_count, "partial_count": partial_count,
"new_publication_count": run.new_pub_count, "new_publication_count": run.new_pub_count,
"blocked_failure_count": blocked_failure_count,
"network_failure_count": network_failure_count,
"retries_scheduled_count": retries_scheduled_count,
"alert_flags": alert_flags,
}, },
) )

View file

@ -67,26 +67,65 @@ def _summary_dict(error_log: object) -> dict[str, Any]:
return summary return summary
def _summary_int_dict(summary: dict[str, Any], key: str) -> dict[str, int]:
value = summary.get(key)
if not isinstance(value, dict):
return {}
return {
str(item_key): _safe_int(item_value, 0)
for item_key, item_value in value.items()
if isinstance(item_key, str)
}
def _summary_bool_dict(summary: dict[str, Any], key: str) -> dict[str, bool]:
value = summary.get(key)
if not isinstance(value, dict):
return {}
return {
str(item_key): bool(item_value)
for item_key, item_value in value.items()
if isinstance(item_key, str)
}
def extract_run_summary(error_log: object) -> dict[str, Any]: def extract_run_summary(error_log: object) -> dict[str, Any]:
summary = _summary_dict(error_log) summary = _summary_dict(error_log)
return { return {
"succeeded_count": _safe_int(summary.get("succeeded_count", 0)), "succeeded_count": _safe_int(summary.get("succeeded_count", 0)),
"failed_count": _safe_int(summary.get("failed_count", 0)), "failed_count": _safe_int(summary.get("failed_count", 0)),
"partial_count": _safe_int(summary.get("partial_count", 0)), "partial_count": _safe_int(summary.get("partial_count", 0)),
"failed_state_counts": { "failed_state_counts": _summary_int_dict(summary, "failed_state_counts"),
str(key): _safe_int(value, 0) "failed_reason_counts": _summary_int_dict(summary, "failed_reason_counts"),
for key, value in summary.get("failed_state_counts", {}).items() "scrape_failure_counts": _summary_int_dict(summary, "scrape_failure_counts"),
if isinstance(key, str) "retry_counts": {
} "retries_scheduled_count": _safe_int(
if isinstance(summary.get("failed_state_counts"), dict) (
else {}, summary.get("retry_counts", {}).get("retries_scheduled_count")
"failed_reason_counts": { if isinstance(summary.get("retry_counts"), dict)
str(key): _safe_int(value, 0) else 0
for key, value in summary.get("failed_reason_counts", {}).items() ),
if isinstance(key, str) 0,
} ),
if isinstance(summary.get("failed_reason_counts"), dict) "scholars_with_retries_count": _safe_int(
else {}, (
summary.get("retry_counts", {}).get("scholars_with_retries_count")
if isinstance(summary.get("retry_counts"), dict)
else 0
),
0,
),
"retry_exhausted_count": _safe_int(
(
summary.get("retry_counts", {}).get("retry_exhausted_count")
if isinstance(summary.get("retry_counts"), dict)
else 0
),
0,
),
},
"alert_thresholds": _summary_int_dict(summary, "alert_thresholds"),
"alert_flags": _summary_bool_dict(summary, "alert_flags"),
} }

View file

@ -20,6 +20,7 @@ from app.db.session import get_session_factory
from app.services import continuation_queue as queue_service from app.services import continuation_queue as queue_service
from app.services.ingestion import RunAlreadyInProgressError, ScholarIngestionService from app.services.ingestion import RunAlreadyInProgressError, ScholarIngestionService
from app.services.scholar_source import LiveScholarSource from app.services.scholar_source import LiveScholarSource
from app.settings import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -195,6 +196,9 @@ class SchedulerService:
page_size=self._page_size, page_size=self._page_size,
auto_queue_continuations=self._continuation_queue_enabled, auto_queue_continuations=self._continuation_queue_enabled,
queue_delay_seconds=self._continuation_base_delay_seconds, queue_delay_seconds=self._continuation_base_delay_seconds,
alert_blocked_failure_threshold=settings.ingestion_alert_blocked_failure_threshold,
alert_network_failure_threshold=settings.ingestion_alert_network_failure_threshold,
alert_retry_scheduled_threshold=settings.ingestion_alert_retry_scheduled_threshold,
) )
except RunAlreadyInProgressError: except RunAlreadyInProgressError:
await session.rollback() await session.rollback()
@ -326,6 +330,9 @@ class SchedulerService:
}, },
auto_queue_continuations=self._continuation_queue_enabled, auto_queue_continuations=self._continuation_queue_enabled,
queue_delay_seconds=self._continuation_base_delay_seconds, queue_delay_seconds=self._continuation_base_delay_seconds,
alert_blocked_failure_threshold=settings.ingestion_alert_blocked_failure_threshold,
alert_network_failure_threshold=settings.ingestion_alert_network_failure_threshold,
alert_retry_scheduled_threshold=settings.ingestion_alert_retry_scheduled_threshold,
) )
except RunAlreadyInProgressError: except RunAlreadyInProgressError:
await session.rollback() await session.rollback()

View file

@ -38,6 +38,8 @@ DEFAULT_AUTHOR_SEARCH_COOLDOWN_BLOCK_THRESHOLD = 1
DEFAULT_AUTHOR_SEARCH_COOLDOWN_SECONDS = 1800 DEFAULT_AUTHOR_SEARCH_COOLDOWN_SECONDS = 1800
DEFAULT_AUTHOR_SEARCH_MIN_INTERVAL_SECONDS = 3.0 DEFAULT_AUTHOR_SEARCH_MIN_INTERVAL_SECONDS = 3.0
DEFAULT_AUTHOR_SEARCH_INTERVAL_JITTER_SECONDS = 1.0 DEFAULT_AUTHOR_SEARCH_INTERVAL_JITTER_SECONDS = 1.0
DEFAULT_AUTHOR_SEARCH_RETRY_ALERT_THRESHOLD = 2
DEFAULT_AUTHOR_SEARCH_COOLDOWN_REJECTION_ALERT_THRESHOLD = 3
ALLOWED_IMAGE_UPLOAD_CONTENT_TYPES = { ALLOWED_IMAGE_UPLOAD_CONTENT_TYPES = {
"image/jpeg": ".jpg", "image/jpeg": ".jpg",
"image/png": ".png", "image/png": ".png",
@ -101,6 +103,8 @@ _AUTHOR_SEARCH_CACHE: OrderedDict[str, _AuthorSearchCacheEntry] = OrderedDict()
_AUTHOR_SEARCH_LAST_LIVE_REQUEST_MONOTONIC = 0.0 _AUTHOR_SEARCH_LAST_LIVE_REQUEST_MONOTONIC = 0.0
_AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC: datetime | None = None _AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC: datetime | None = None
_AUTHOR_SEARCH_CONSECUTIVE_BLOCKED_COUNT = 0 _AUTHOR_SEARCH_CONSECUTIVE_BLOCKED_COUNT = 0
_AUTHOR_SEARCH_COOLDOWN_REJECTION_COUNT = 0
_AUTHOR_SEARCH_COOLDOWN_ALERT_EMITTED = False
class ScholarServiceError(ValueError): class ScholarServiceError(ValueError):
@ -281,10 +285,14 @@ def _reset_author_search_runtime_state_for_tests() -> None:
global _AUTHOR_SEARCH_LAST_LIVE_REQUEST_MONOTONIC global _AUTHOR_SEARCH_LAST_LIVE_REQUEST_MONOTONIC
global _AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC global _AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC
global _AUTHOR_SEARCH_CONSECUTIVE_BLOCKED_COUNT global _AUTHOR_SEARCH_CONSECUTIVE_BLOCKED_COUNT
global _AUTHOR_SEARCH_COOLDOWN_REJECTION_COUNT
global _AUTHOR_SEARCH_COOLDOWN_ALERT_EMITTED
_AUTHOR_SEARCH_CACHE.clear() _AUTHOR_SEARCH_CACHE.clear()
_AUTHOR_SEARCH_LAST_LIVE_REQUEST_MONOTONIC = 0.0 _AUTHOR_SEARCH_LAST_LIVE_REQUEST_MONOTONIC = 0.0
_AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC = None _AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC = None
_AUTHOR_SEARCH_CONSECUTIVE_BLOCKED_COUNT = 0 _AUTHOR_SEARCH_CONSECUTIVE_BLOCKED_COUNT = 0
_AUTHOR_SEARCH_COOLDOWN_REJECTION_COUNT = 0
_AUTHOR_SEARCH_COOLDOWN_ALERT_EMITTED = False
async def list_scholars_for_user( async def list_scholars_for_user(
@ -379,10 +387,14 @@ async def search_author_candidates(
interval_jitter_seconds: float = DEFAULT_AUTHOR_SEARCH_INTERVAL_JITTER_SECONDS, interval_jitter_seconds: float = DEFAULT_AUTHOR_SEARCH_INTERVAL_JITTER_SECONDS,
cooldown_block_threshold: int = DEFAULT_AUTHOR_SEARCH_COOLDOWN_BLOCK_THRESHOLD, cooldown_block_threshold: int = DEFAULT_AUTHOR_SEARCH_COOLDOWN_BLOCK_THRESHOLD,
cooldown_seconds: int = DEFAULT_AUTHOR_SEARCH_COOLDOWN_SECONDS, cooldown_seconds: int = DEFAULT_AUTHOR_SEARCH_COOLDOWN_SECONDS,
retry_alert_threshold: int = DEFAULT_AUTHOR_SEARCH_RETRY_ALERT_THRESHOLD,
cooldown_rejection_alert_threshold: int = DEFAULT_AUTHOR_SEARCH_COOLDOWN_REJECTION_ALERT_THRESHOLD,
) -> ParsedAuthorSearchPage: ) -> ParsedAuthorSearchPage:
global _AUTHOR_SEARCH_LAST_LIVE_REQUEST_MONOTONIC global _AUTHOR_SEARCH_LAST_LIVE_REQUEST_MONOTONIC
global _AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC global _AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC
global _AUTHOR_SEARCH_CONSECUTIVE_BLOCKED_COUNT global _AUTHOR_SEARCH_CONSECUTIVE_BLOCKED_COUNT
global _AUTHOR_SEARCH_COOLDOWN_REJECTION_COUNT
global _AUTHOR_SEARCH_COOLDOWN_ALERT_EMITTED
normalized_query = query.strip() normalized_query = query.strip()
if len(normalized_query) < 2: if len(normalized_query) < 2:
@ -408,8 +420,47 @@ async def search_author_candidates(
now_utc = datetime.now(timezone.utc) now_utc = datetime.now(timezone.utc)
now_monotonic = time.monotonic() now_monotonic = time.monotonic()
if (
_AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC is not None
and now_utc >= _AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC
):
logger.info(
"scholar_search.cooldown_expired",
extra={
"event": "scholar_search.cooldown_expired",
"cooldown_until_utc": _AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC.isoformat(),
},
)
_AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC = None
_AUTHOR_SEARCH_COOLDOWN_REJECTION_COUNT = 0
_AUTHOR_SEARCH_COOLDOWN_ALERT_EMITTED = False
cooldown_remaining_seconds = _author_search_cooldown_remaining_seconds(now_utc) cooldown_remaining_seconds = _author_search_cooldown_remaining_seconds(now_utc)
if cooldown_remaining_seconds > 0: if cooldown_remaining_seconds > 0:
_AUTHOR_SEARCH_COOLDOWN_REJECTION_COUNT += 1
bounded_cooldown_rejection_alert_threshold = max(
1,
int(cooldown_rejection_alert_threshold),
)
if (
_AUTHOR_SEARCH_COOLDOWN_REJECTION_COUNT
>= bounded_cooldown_rejection_alert_threshold
and not _AUTHOR_SEARCH_COOLDOWN_ALERT_EMITTED
):
logger.error(
"scholar_search.cooldown_rejection_threshold_exceeded",
extra={
"event": "scholar_search.cooldown_rejection_threshold_exceeded",
"query": normalized_query,
"cooldown_rejection_count": _AUTHOR_SEARCH_COOLDOWN_REJECTION_COUNT,
"threshold": bounded_cooldown_rejection_alert_threshold,
"cooldown_until_utc": _AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC.isoformat()
if _AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC
else None,
},
)
_AUTHOR_SEARCH_COOLDOWN_ALERT_EMITTED = True
logger.warning( logger.warning(
"scholar_search.cooldown_active", "scholar_search.cooldown_active",
extra={ extra={
@ -421,12 +472,15 @@ async def search_author_candidates(
else None, else None,
}, },
) )
return _policy_blocked_author_search_result( warning_codes = [
reason=SEARCH_COOLDOWN_REASON,
warning_codes=[
"author_search_cooldown_active", "author_search_cooldown_active",
f"author_search_cooldown_remaining_{cooldown_remaining_seconds}s", f"author_search_cooldown_remaining_{cooldown_remaining_seconds}s",
], ]
if _AUTHOR_SEARCH_COOLDOWN_ALERT_EMITTED:
warning_codes.append("author_search_cooldown_alert_threshold_exceeded")
return _policy_blocked_author_search_result(
reason=SEARCH_COOLDOWN_REASON,
warning_codes=warning_codes,
limit=bounded_limit, limit=bounded_limit,
) )
@ -472,6 +526,7 @@ async def search_author_candidates(
max_attempts = max(1, int(network_error_retries) + 1) max_attempts = max(1, int(network_error_retries) + 1)
parsed: ParsedAuthorSearchPage | None = None parsed: ParsedAuthorSearchPage | None = None
retry_warnings: list[str] = [] retry_warnings: list[str] = []
retry_scheduled_count = 0
for attempt_index in range(max_attempts): for attempt_index in range(max_attempts):
fetch_result = await source.fetch_author_search_html(normalized_query, start=0) fetch_result = await source.fetch_author_search_html(normalized_query, start=0)
@ -480,6 +535,7 @@ async def search_author_candidates(
break break
retry_warnings.append("network_retry_scheduled_for_author_search") retry_warnings.append("network_retry_scheduled_for_author_search")
retry_scheduled_count += 1
sleep_seconds = max(float(retry_backoff_seconds), 0.0) * (2**attempt_index) sleep_seconds = max(float(retry_backoff_seconds), 0.0) * (2**attempt_index)
if sleep_seconds > 0: if sleep_seconds > 0:
await asyncio.sleep(sleep_seconds) await asyncio.sleep(sleep_seconds)
@ -494,6 +550,27 @@ async def search_author_candidates(
warnings=_merge_warnings(parsed.warnings, retry_warnings), warnings=_merge_warnings(parsed.warnings, retry_warnings),
) )
bounded_retry_alert_threshold = max(1, int(retry_alert_threshold))
if retry_scheduled_count >= bounded_retry_alert_threshold:
logger.warning(
"scholar_search.retry_threshold_exceeded",
extra={
"event": "scholar_search.retry_threshold_exceeded",
"query": normalized_query,
"retry_scheduled_count": retry_scheduled_count,
"threshold": bounded_retry_alert_threshold,
"final_state": merged_parsed.state.value,
"final_state_reason": merged_parsed.state_reason,
},
)
merged_parsed = replace(
merged_parsed,
warnings=_merge_warnings(
merged_parsed.warnings,
[f"author_search_retry_threshold_exceeded_{retry_scheduled_count}"],
),
)
if _is_author_search_block_state(merged_parsed): if _is_author_search_block_state(merged_parsed):
_AUTHOR_SEARCH_CONSECUTIVE_BLOCKED_COUNT += 1 _AUTHOR_SEARCH_CONSECUTIVE_BLOCKED_COUNT += 1
logger.warning( logger.warning(
@ -510,6 +587,8 @@ async def search_author_candidates(
seconds=max(60, int(cooldown_seconds)) seconds=max(60, int(cooldown_seconds))
) )
_AUTHOR_SEARCH_CONSECUTIVE_BLOCKED_COUNT = 0 _AUTHOR_SEARCH_CONSECUTIVE_BLOCKED_COUNT = 0
_AUTHOR_SEARCH_COOLDOWN_REJECTION_COUNT = 0
_AUTHOR_SEARCH_COOLDOWN_ALERT_EMITTED = False
merged_parsed = replace( merged_parsed = replace(
merged_parsed, merged_parsed,
warnings=_merge_warnings( warnings=_merge_warnings(

View file

@ -10,13 +10,38 @@ class UserSettingsServiceError(ValueError):
"""Raised for expected settings-validation failures.""" """Raised for expected settings-validation failures."""
NAV_PAGE_DASHBOARD = "dashboard"
NAV_PAGE_SCHOLARS = "scholars"
NAV_PAGE_PUBLICATIONS = "publications"
NAV_PAGE_SETTINGS = "settings"
NAV_PAGE_STYLE_GUIDE = "style-guide"
NAV_PAGE_RUNS = "runs"
NAV_PAGE_USERS = "users"
ALLOWED_NAV_PAGES = (
NAV_PAGE_DASHBOARD,
NAV_PAGE_SCHOLARS,
NAV_PAGE_PUBLICATIONS,
NAV_PAGE_SETTINGS,
NAV_PAGE_STYLE_GUIDE,
NAV_PAGE_RUNS,
NAV_PAGE_USERS,
)
REQUIRED_NAV_PAGES = (
NAV_PAGE_DASHBOARD,
NAV_PAGE_SCHOLARS,
NAV_PAGE_SETTINGS,
)
DEFAULT_NAV_VISIBLE_PAGES = list(ALLOWED_NAV_PAGES)
def parse_run_interval_minutes(value: str) -> int: def parse_run_interval_minutes(value: str) -> int:
try: try:
parsed = int(value) parsed = int(value)
except ValueError as exc: except ValueError as exc:
raise UserSettingsServiceError("Run interval must be a whole number.") from exc raise UserSettingsServiceError("Check interval must be a whole number.") from exc
if parsed < 15: if parsed < 15:
raise UserSettingsServiceError("Run interval must be at least 15 minutes.") raise UserSettingsServiceError("Check interval must be at least 15 minutes.")
return parsed return parsed
@ -25,11 +50,41 @@ def parse_request_delay_seconds(value: str) -> int:
parsed = int(value) parsed = int(value)
except ValueError as exc: except ValueError as exc:
raise UserSettingsServiceError("Request delay must be a whole number.") from exc raise UserSettingsServiceError("Request delay must be a whole number.") from exc
if parsed < 1: if parsed < 2:
raise UserSettingsServiceError("Request delay must be at least 1 second.") raise UserSettingsServiceError("Request delay must be at least 2 seconds.")
return parsed return parsed
def parse_nav_visible_pages(value: object) -> list[str]:
if not isinstance(value, list):
raise UserSettingsServiceError("Navigation visibility must be a list of page ids.")
deduped: list[str] = []
seen: set[str] = set()
for raw_page in value:
if not isinstance(raw_page, str):
raise UserSettingsServiceError("Navigation visibility entries must be strings.")
page_id = raw_page.strip()
if page_id not in ALLOWED_NAV_PAGES:
raise UserSettingsServiceError(f"Unsupported navigation page id: {page_id}")
if page_id in seen:
continue
seen.add(page_id)
deduped.append(page_id)
missing_required = [page for page in REQUIRED_NAV_PAGES if page not in seen]
if missing_required:
raise UserSettingsServiceError(
"Dashboard, Scholars, and Settings must remain visible."
)
return deduped
async def get_or_create_settings( async def get_or_create_settings(
db_session: AsyncSession, db_session: AsyncSession,
*, *,
@ -56,11 +111,12 @@ async def update_settings(
auto_run_enabled: bool, auto_run_enabled: bool,
run_interval_minutes: int, run_interval_minutes: int,
request_delay_seconds: int, request_delay_seconds: int,
nav_visible_pages: list[str],
) -> UserSetting: ) -> UserSetting:
settings.auto_run_enabled = auto_run_enabled settings.auto_run_enabled = auto_run_enabled
settings.run_interval_minutes = run_interval_minutes settings.run_interval_minutes = run_interval_minutes
settings.request_delay_seconds = request_delay_seconds settings.request_delay_seconds = request_delay_seconds
settings.nav_visible_pages = nav_visible_pages
await db_session.commit() await db_session.commit()
await db_session.refresh(settings) await db_session.refresh(settings)
return settings return settings

View file

@ -1,6 +1,33 @@
from dataclasses import dataclass from dataclasses import dataclass
import os import os
DEFAULT_SECURITY_PERMISSIONS_POLICY = (
"accelerometer=(), autoplay=(), camera=(), display-capture=(), "
"geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()"
)
DEFAULT_SECURITY_CSP_POLICY = (
"default-src 'self'; "
"base-uri 'self'; "
"form-action 'self'; "
"frame-ancestors 'none'; "
"img-src 'self' data: https:; "
"script-src 'self'; "
"style-src 'self' 'unsafe-inline'; "
"font-src 'self' data:; "
"connect-src 'self'; "
"object-src 'none'"
)
DEFAULT_SECURITY_CSP_DOCS_POLICY = (
"default-src 'self'; "
"img-src 'self' data: https:; "
"script-src 'self' 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline'; "
"font-src 'self' data:; "
"connect-src 'self' https:; "
"object-src 'none'; "
"frame-ancestors 'none'"
)
def _env_bool(name: str, default: bool) -> bool: def _env_bool(name: str, default: bool) -> bool:
value = os.getenv(name) value = os.getenv(name)
@ -39,6 +66,42 @@ class Settings:
) )
session_secret_key: str = os.getenv("SESSION_SECRET_KEY", "dev-insecure-session-key") session_secret_key: str = os.getenv("SESSION_SECRET_KEY", "dev-insecure-session-key")
session_cookie_secure: bool = _env_bool("SESSION_COOKIE_SECURE", False) session_cookie_secure: bool = _env_bool("SESSION_COOKIE_SECURE", False)
security_headers_enabled: bool = _env_bool("SECURITY_HEADERS_ENABLED", True)
security_x_content_type_options: str = _env_str("SECURITY_X_CONTENT_TYPE_OPTIONS", "nosniff")
security_x_frame_options: str = _env_str("SECURITY_X_FRAME_OPTIONS", "DENY")
security_referrer_policy: str = _env_str("SECURITY_REFERRER_POLICY", "strict-origin-when-cross-origin")
security_permissions_policy: str = _env_str(
"SECURITY_PERMISSIONS_POLICY",
DEFAULT_SECURITY_PERMISSIONS_POLICY,
)
security_cross_origin_opener_policy: str = _env_str(
"SECURITY_CROSS_ORIGIN_OPENER_POLICY",
"same-origin",
)
security_cross_origin_resource_policy: str = _env_str(
"SECURITY_CROSS_ORIGIN_RESOURCE_POLICY",
"same-origin",
)
security_csp_enabled: bool = _env_bool("SECURITY_CSP_ENABLED", True)
security_csp_policy: str = _env_str("SECURITY_CSP_POLICY", DEFAULT_SECURITY_CSP_POLICY)
security_csp_docs_policy: str = _env_str("SECURITY_CSP_DOCS_POLICY", DEFAULT_SECURITY_CSP_DOCS_POLICY)
security_csp_report_only: bool = _env_bool("SECURITY_CSP_REPORT_ONLY", False)
security_strict_transport_security_enabled: bool = _env_bool(
"SECURITY_STRICT_TRANSPORT_SECURITY_ENABLED",
False,
)
security_strict_transport_security_max_age: int = _env_int(
"SECURITY_STRICT_TRANSPORT_SECURITY_MAX_AGE",
31_536_000,
)
security_strict_transport_security_include_subdomains: bool = _env_bool(
"SECURITY_STRICT_TRANSPORT_SECURITY_INCLUDE_SUBDOMAINS",
True,
)
security_strict_transport_security_preload: bool = _env_bool(
"SECURITY_STRICT_TRANSPORT_SECURITY_PRELOAD",
False,
)
login_rate_limit_attempts: int = _env_int("LOGIN_RATE_LIMIT_ATTEMPTS", 5) login_rate_limit_attempts: int = _env_int("LOGIN_RATE_LIMIT_ATTEMPTS", 5)
login_rate_limit_window_seconds: int = _env_int( login_rate_limit_window_seconds: int = _env_int(
"LOGIN_RATE_LIMIT_WINDOW_SECONDS", "LOGIN_RATE_LIMIT_WINDOW_SECONDS",
@ -62,6 +125,18 @@ class Settings:
30, 30,
) )
ingestion_page_size: int = _env_int("INGESTION_PAGE_SIZE", 100) ingestion_page_size: int = _env_int("INGESTION_PAGE_SIZE", 100)
ingestion_alert_blocked_failure_threshold: int = _env_int(
"INGESTION_ALERT_BLOCKED_FAILURE_THRESHOLD",
1,
)
ingestion_alert_network_failure_threshold: int = _env_int(
"INGESTION_ALERT_NETWORK_FAILURE_THRESHOLD",
2,
)
ingestion_alert_retry_scheduled_threshold: int = _env_int(
"INGESTION_ALERT_RETRY_SCHEDULED_THRESHOLD",
3,
)
ingestion_continuation_queue_enabled: bool = _env_bool( ingestion_continuation_queue_enabled: bool = _env_bool(
"INGESTION_CONTINUATION_QUEUE_ENABLED", "INGESTION_CONTINUATION_QUEUE_ENABLED",
True, True,
@ -104,11 +179,11 @@ class Settings:
) )
scholar_name_search_min_interval_seconds: float = _env_float( scholar_name_search_min_interval_seconds: float = _env_float(
"SCHOLAR_NAME_SEARCH_MIN_INTERVAL_SECONDS", "SCHOLAR_NAME_SEARCH_MIN_INTERVAL_SECONDS",
3.0, 8.0,
) )
scholar_name_search_interval_jitter_seconds: float = _env_float( scholar_name_search_interval_jitter_seconds: float = _env_float(
"SCHOLAR_NAME_SEARCH_INTERVAL_JITTER_SECONDS", "SCHOLAR_NAME_SEARCH_INTERVAL_JITTER_SECONDS",
1.0, 2.0,
) )
scholar_name_search_cooldown_block_threshold: int = _env_int( scholar_name_search_cooldown_block_threshold: int = _env_int(
"SCHOLAR_NAME_SEARCH_COOLDOWN_BLOCK_THRESHOLD", "SCHOLAR_NAME_SEARCH_COOLDOWN_BLOCK_THRESHOLD",
@ -118,6 +193,14 @@ class Settings:
"SCHOLAR_NAME_SEARCH_COOLDOWN_SECONDS", "SCHOLAR_NAME_SEARCH_COOLDOWN_SECONDS",
1800, 1800,
) )
scholar_name_search_alert_retry_count_threshold: int = _env_int(
"SCHOLAR_NAME_SEARCH_ALERT_RETRY_COUNT_THRESHOLD",
2,
)
scholar_name_search_alert_cooldown_rejections_threshold: int = _env_int(
"SCHOLAR_NAME_SEARCH_ALERT_COOLDOWN_REJECTIONS_THRESHOLD",
3,
)
settings = Settings() settings = Settings()

View file

@ -44,6 +44,9 @@ services:
INGESTION_RETRY_BACKOFF_SECONDS: ${INGESTION_RETRY_BACKOFF_SECONDS:-1.0} INGESTION_RETRY_BACKOFF_SECONDS: ${INGESTION_RETRY_BACKOFF_SECONDS:-1.0}
INGESTION_MAX_PAGES_PER_SCHOLAR: ${INGESTION_MAX_PAGES_PER_SCHOLAR:-30} INGESTION_MAX_PAGES_PER_SCHOLAR: ${INGESTION_MAX_PAGES_PER_SCHOLAR:-30}
INGESTION_PAGE_SIZE: ${INGESTION_PAGE_SIZE:-100} INGESTION_PAGE_SIZE: ${INGESTION_PAGE_SIZE:-100}
INGESTION_ALERT_BLOCKED_FAILURE_THRESHOLD: ${INGESTION_ALERT_BLOCKED_FAILURE_THRESHOLD:-1}
INGESTION_ALERT_NETWORK_FAILURE_THRESHOLD: ${INGESTION_ALERT_NETWORK_FAILURE_THRESHOLD:-2}
INGESTION_ALERT_RETRY_SCHEDULED_THRESHOLD: ${INGESTION_ALERT_RETRY_SCHEDULED_THRESHOLD:-3}
INGESTION_CONTINUATION_QUEUE_ENABLED: ${INGESTION_CONTINUATION_QUEUE_ENABLED:-1} INGESTION_CONTINUATION_QUEUE_ENABLED: ${INGESTION_CONTINUATION_QUEUE_ENABLED:-1}
INGESTION_CONTINUATION_BASE_DELAY_SECONDS: ${INGESTION_CONTINUATION_BASE_DELAY_SECONDS:-120} INGESTION_CONTINUATION_BASE_DELAY_SECONDS: ${INGESTION_CONTINUATION_BASE_DELAY_SECONDS:-120}
INGESTION_CONTINUATION_MAX_DELAY_SECONDS: ${INGESTION_CONTINUATION_MAX_DELAY_SECONDS:-3600} INGESTION_CONTINUATION_MAX_DELAY_SECONDS: ${INGESTION_CONTINUATION_MAX_DELAY_SECONDS:-3600}
@ -59,6 +62,8 @@ services:
SCHOLAR_NAME_SEARCH_INTERVAL_JITTER_SECONDS: ${SCHOLAR_NAME_SEARCH_INTERVAL_JITTER_SECONDS:-2.0} SCHOLAR_NAME_SEARCH_INTERVAL_JITTER_SECONDS: ${SCHOLAR_NAME_SEARCH_INTERVAL_JITTER_SECONDS:-2.0}
SCHOLAR_NAME_SEARCH_COOLDOWN_BLOCK_THRESHOLD: ${SCHOLAR_NAME_SEARCH_COOLDOWN_BLOCK_THRESHOLD:-1} SCHOLAR_NAME_SEARCH_COOLDOWN_BLOCK_THRESHOLD: ${SCHOLAR_NAME_SEARCH_COOLDOWN_BLOCK_THRESHOLD:-1}
SCHOLAR_NAME_SEARCH_COOLDOWN_SECONDS: ${SCHOLAR_NAME_SEARCH_COOLDOWN_SECONDS:-1800} SCHOLAR_NAME_SEARCH_COOLDOWN_SECONDS: ${SCHOLAR_NAME_SEARCH_COOLDOWN_SECONDS:-1800}
SCHOLAR_NAME_SEARCH_ALERT_RETRY_COUNT_THRESHOLD: ${SCHOLAR_NAME_SEARCH_ALERT_RETRY_COUNT_THRESHOLD:-2}
SCHOLAR_NAME_SEARCH_ALERT_COOLDOWN_REJECTIONS_THRESHOLD: ${SCHOLAR_NAME_SEARCH_ALERT_COOLDOWN_REJECTIONS_THRESHOLD:-3}
BOOTSTRAP_ADMIN_ON_START: ${BOOTSTRAP_ADMIN_ON_START:-0} BOOTSTRAP_ADMIN_ON_START: ${BOOTSTRAP_ADMIN_ON_START:-0}
BOOTSTRAP_ADMIN_EMAIL: ${BOOTSTRAP_ADMIN_EMAIL:-} BOOTSTRAP_ADMIN_EMAIL: ${BOOTSTRAP_ADMIN_EMAIL:-}
BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-} BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-}

View file

@ -8,6 +8,7 @@
"build": "vue-tsc --noEmit && vite build", "build": "vue-tsc --noEmit && vite build",
"preview": "vite preview --host 0.0.0.0 --port 4173", "preview": "vite preview --host 0.0.0.0 --port 4173",
"typecheck": "vue-tsc --noEmit", "typecheck": "vue-tsc --noEmit",
"check:theme-tokens": "node ./scripts/check_theme_tokens.mjs",
"test": "vitest", "test": "vitest",
"test:run": "vitest run" "test:run": "vitest run"
}, },

View file

@ -0,0 +1,84 @@
import fs from "node:fs";
import path from "node:path";
const projectRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
const sourceRoot = path.join(projectRoot, "src");
const allowedExtensions = new Set([".vue", ".css"]);
const checks = [
{
name: "non-token palette utility",
regex: /\b(?:bg|text|border|ring|from|to|via)-(?:zinc|gray|slate|neutral|stone|white|black)(?:[-/][A-Za-z0-9_\-[\]./%]+)?\b/g,
},
{
name: "dark mode utility variant",
regex: /\bdark:[^\s"'`<>}]*/g,
},
{
name: "inline hex color",
regex: /#[0-9a-fA-F]{3,8}\b/g,
},
];
function listFilesRecursively(dirPath) {
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const nextPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
files.push(...listFilesRecursively(nextPath));
continue;
}
if (allowedExtensions.has(path.extname(nextPath))) {
files.push(nextPath);
}
}
return files;
}
function relativePath(filePath) {
return path.relative(projectRoot, filePath).replaceAll(path.sep, "/");
}
function run() {
const violations = [];
const files = listFilesRecursively(sourceRoot);
for (const filePath of files) {
const content = fs.readFileSync(filePath, "utf8");
const lines = content.split(/\r?\n/);
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
for (const check of checks) {
check.regex.lastIndex = 0;
let match = check.regex.exec(line);
while (match) {
violations.push({
filePath: relativePath(filePath),
line: index + 1,
check: check.name,
value: match[0],
});
match = check.regex.exec(line);
}
}
}
}
if (violations.length > 0) {
console.error("Theme token policy violations found:\n");
for (const violation of violations) {
console.error(`${violation.filePath}:${violation.line} [${violation.check}] ${violation.value}`);
}
process.exit(1);
}
console.log(`Theme token policy passed for ${files.length} files.`);
}
run();

View file

@ -12,23 +12,27 @@ const showChrome = computed(() => auth.isAuthenticated);
</script> </script>
<template> <template>
<div class="min-h-screen overflow-x-clip"> <div class="h-[100dvh] max-h-[100dvh] w-full max-w-full overflow-hidden">
<a href="#app-main" class="skip-link">Skip to main content</a> <a href="#app-main" class="skip-link">Skip to main content</a>
<RequestErrorPanel /> <RequestErrorPanel />
<template v-if="showChrome"> <template v-if="showChrome">
<AppHeader /> <AppHeader />
<div <div
class="grid min-h-[calc(100dvh-4.5rem)] grid-cols-1 lg:h-[calc(100dvh-4.5rem)] lg:min-h-[calc(100dvh-4.5rem)] lg:grid-cols-[17rem_minmax(0,1fr)]" class="grid h-[calc(100dvh-4.5rem)] min-h-0 grid-cols-1 grid-rows-[auto_minmax(0,1fr)] lg:grid-cols-[17rem_minmax(0,1fr)] lg:grid-rows-1"
> >
<AppNav class="lg:min-h-0 lg:overflow-y-auto" /> <AppNav class="min-h-0 overflow-x-hidden lg:overflow-y-auto" />
<main id="app-main" class="min-w-0 px-4 py-6 sm:px-6 lg:min-h-0 lg:overflow-y-auto lg:px-8"> <main id="app-main" class="min-h-0 min-w-0 overflow-y-auto overflow-x-hidden px-4 py-6 sm:px-6 lg:px-8">
<RouterView /> <RouterView />
</main> </main>
</div> </div>
</template> </template>
<main id="app-main" v-else class="min-h-screen overflow-x-clip"> <main
id="app-main"
v-else
class="h-[100dvh] max-h-[100dvh] min-h-0 min-w-0 overflow-y-auto overflow-x-hidden"
>
<RouterView /> <RouterView />
</main> </main>
</div> </div>

View file

@ -1,6 +1,7 @@
import { setCsrfTokenProvider } from "@/lib/api/client"; import { setCsrfTokenProvider } from "@/lib/api/client";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { useThemeStore } from "@/stores/theme"; import { useThemeStore } from "@/stores/theme";
import { useUserSettingsStore } from "@/stores/user_settings";
export async function bootstrapAppProviders(): Promise<void> { export async function bootstrapAppProviders(): Promise<void> {
const theme = useThemeStore(); const theme = useThemeStore();
@ -9,4 +10,11 @@ export async function bootstrapAppProviders(): Promise<void> {
const auth = useAuthStore(); const auth = useAuthStore();
setCsrfTokenProvider(() => auth.csrfToken); setCsrfTokenProvider(() => auth.csrfToken);
await auth.bootstrapSession(); await auth.bootstrapSession();
const userSettings = useUserSettingsStore();
if (auth.isAuthenticated) {
await userSettings.bootstrap();
} else {
userSettings.reset();
}
} }

View file

@ -2,7 +2,9 @@
import { computed } from "vue"; import { computed } from "vue";
import AppButton from "@/components/ui/AppButton.vue"; import AppButton from "@/components/ui/AppButton.vue";
import AppSelect from "@/components/ui/AppSelect.vue";
import { useThemeStore } from "@/stores/theme"; import { useThemeStore } from "@/stores/theme";
import type { ThemePresetId } from "@/theme/presets";
const theme = useThemeStore(); const theme = useThemeStore();
@ -10,6 +12,12 @@ const isDarkTheme = computed(() => theme.active === "dark");
const toggleThemeLabel = computed(() => const toggleThemeLabel = computed(() =>
isDarkTheme.value ? "Switch to light theme" : "Switch to dark theme", isDarkTheme.value ? "Switch to light theme" : "Switch to dark theme",
); );
const selectedPreset = computed<ThemePresetId>({
get: () => theme.preset,
set: (value) => theme.setPreset(value),
});
const presetOptions = computed(() => theme.availablePresets);
const themePresetLabel = computed(() => theme.presetLabel);
function onToggleTheme(): void { function onToggleTheme(): void {
theme.setPreference(isDarkTheme.value ? "light" : "dark"); theme.setPreference(isDarkTheme.value ? "light" : "dark");
@ -17,18 +25,32 @@ function onToggleTheme(): void {
</script> </script>
<template> <template>
<header class="sticky top-0 z-30 border-b border-zinc-200/70 bg-white/90 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/90"> <header class="sticky top-0 z-30 border-b border-stroke-subtle bg-surface-nav/90 backdrop-blur">
<div class="flex min-h-[4.5rem] w-full items-center justify-between gap-4 px-4 sm:px-6 lg:px-8"> <div class="flex min-h-[4.5rem] w-full items-center justify-between gap-4 px-4 sm:px-6 lg:px-8">
<div class="flex min-w-0 items-center gap-2"> <div class="flex min-w-0 items-center gap-2">
<RouterLink <RouterLink
to="/dashboard" to="/dashboard"
class="rounded-sm font-display text-lg tracking-tight text-zinc-900 transition hover:text-zinc-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:text-zinc-100 dark:hover:text-zinc-300 dark:focus-visible:ring-brand-400 dark:focus-visible:ring-offset-zinc-950" class="rounded-sm font-display text-lg tracking-tight text-ink-primary transition hover:text-ink-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset"
> >
scholarr scholarr
</RouterLink> </RouterLink>
</div> </div>
<div class="flex items-center justify-end"> <div class="flex items-center justify-end gap-2">
<div class="w-36 sm:w-44">
<label for="theme-preset-select" class="sr-only">Theme preset</label>
<AppSelect
id="theme-preset-select"
v-model="selectedPreset"
:disabled="presetOptions.length <= 1"
:title="`Theme preset: ${themePresetLabel}`"
class="py-1.5"
>
<option v-for="preset in presetOptions" :key="preset.id" :value="preset.id">
{{ preset.label }}
</option>
</AppSelect>
</div>
<AppButton <AppButton
variant="ghost" variant="ghost"
class="h-10 w-10 rounded-full p-0" class="h-10 w-10 rounded-full p-0"

View file

@ -4,24 +4,29 @@ import { useRouter } from "vue-router";
import AppButton from "@/components/ui/AppButton.vue"; import AppButton from "@/components/ui/AppButton.vue";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { useUserSettingsStore } from "@/stores/user_settings";
const auth = useAuthStore(); const auth = useAuthStore();
const userSettings = useUserSettingsStore();
const router = useRouter(); const router = useRouter();
const links = computed(() => { const links = computed(() => {
const base = [ const base = [
{ to: "/dashboard", label: "Dashboard" }, { id: "dashboard", to: "/dashboard", label: "Dashboard", adminOnly: false },
{ to: "/scholars", label: "Scholars" }, { id: "scholars", to: "/scholars", label: "Scholars", adminOnly: false },
{ to: "/publications", label: "Publications" }, { id: "publications", to: "/publications", label: "Publications", adminOnly: false },
{ to: "/settings", label: "Settings" }, { id: "settings", to: "/settings", label: "Settings", adminOnly: false },
{ id: "style-guide", to: "/admin/style-guide", label: "Style Guide", adminOnly: true },
{ id: "runs", to: "/admin/runs", label: "Runs", adminOnly: true },
{ id: "users", to: "/admin/users", label: "Users", adminOnly: true },
]; ];
if (auth.isAdmin) { return base.filter((item) => {
base.push({ to: "/admin/runs", label: "Runs" }); if (item.adminOnly && !auth.isAdmin) {
base.push({ to: "/admin/users", label: "Users" }); return false;
} }
return userSettings.isPageVisible(item.id);
return base; });
}); });
async function onLogout(): Promise<void> { async function onLogout(): Promise<void> {
@ -32,7 +37,7 @@ async function onLogout(): Promise<void> {
<template> <template>
<aside <aside
class="min-w-0 border-b border-zinc-200 bg-white/70 px-4 py-4 dark:border-zinc-800 dark:bg-zinc-900/70 lg:h-full lg:min-h-0 lg:overflow-y-auto lg:border-b-0 lg:border-r lg:px-5 lg:py-6" class="min-h-0 min-w-0 overflow-x-hidden border-b border-stroke-subtle bg-surface-nav/70 px-4 py-4 lg:h-full lg:border-b-0 lg:border-r lg:px-5 lg:py-6"
> >
<div class="flex h-full min-h-0 flex-col gap-4"> <div class="flex h-full min-h-0 flex-col gap-4">
<nav class="grid grid-cols-2 content-start gap-2 sm:grid-cols-3 lg:grid-cols-1" aria-label="Primary"> <nav class="grid grid-cols-2 content-start gap-2 sm:grid-cols-3 lg:grid-cols-1" aria-label="Primary">
@ -40,15 +45,15 @@ async function onLogout(): Promise<void> {
v-for="link in links" v-for="link in links"
:key="link.to" :key="link.to"
:to="link.to" :to="link.to"
class="inline-flex min-h-10 min-w-0 items-center rounded-lg border border-transparent px-3 py-2 text-sm font-medium text-zinc-600 transition hover:border-zinc-300 hover:bg-zinc-50 hover:text-zinc-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800 dark:hover:text-zinc-100 dark:focus-visible:ring-brand-400 dark:focus-visible:ring-offset-zinc-950" class="inline-flex min-h-10 min-w-0 items-center rounded-lg border border-transparent px-3 py-2 text-sm font-medium text-ink-secondary transition hover:border-stroke-interactive hover:bg-surface-card-muted hover:text-ink-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset"
active-class="border-zinc-900 bg-zinc-900 text-white hover:border-zinc-900 hover:bg-zinc-900 hover:text-white dark:border-brand-400 dark:bg-brand-400 dark:text-zinc-950 dark:hover:border-brand-300 dark:hover:bg-brand-300" active-class="border-stroke-interactive bg-surface-nav-active font-semibold text-ink-primary shadow-sm hover:border-stroke-interactive hover:bg-surface-nav-active hover:text-ink-primary"
> >
{{ link.label }} {{ link.label }}
</RouterLink> </RouterLink>
</nav> </nav>
<div class="mt-auto grid gap-2 border-t border-zinc-200 pt-3 dark:border-zinc-800"> <div class="mt-auto grid gap-2 border-t border-stroke-subtle pt-3">
<p class="truncate text-xs text-zinc-500 dark:text-zinc-400">{{ auth.user?.email }}</p> <p class="truncate text-xs text-ink-muted">{{ auth.user?.email }}</p>
<AppButton variant="ghost" class="w-full justify-start" @click="onLogout">Logout</AppButton> <AppButton variant="ghost" class="w-full justify-start" @click="onLogout">Logout</AppButton>
</div> </div>
</div> </div>

View file

@ -1,16 +1,31 @@
<script setup lang="ts"> <script setup lang="ts">
defineProps<{ const props = withDefaults(
defineProps<{
title: string; title: string;
subtitle?: string; subtitle?: string;
}>(); fill?: boolean;
}>(),
{
fill: false,
},
);
</script> </script>
<template> <template>
<section class="page-stack min-w-0"> <section
:class="
props.fill
? 'min-w-0 flex min-h-0 flex-col gap-6 lg:h-full'
: 'page-stack min-w-0'
"
>
<header class="space-y-2"> <header class="space-y-2">
<h1 class="page-title">{{ title }}</h1> <h1 class="page-title">{{ props.title }}</h1>
<p v-if="subtitle" class="page-subtitle">{{ subtitle }}</p> <p v-if="props.subtitle" class="page-subtitle">{{ props.subtitle }}</p>
</header> </header>
<div v-if="props.fill" class="min-h-0 flex-1">
<slot /> <slot />
</div>
<slot v-else />
</section> </section>
</template> </template>

View file

@ -0,0 +1,34 @@
<script setup lang="ts">
import AppEmptyState from "@/components/ui/AppEmptyState.vue";
import AppSkeleton from "@/components/ui/AppSkeleton.vue";
const props = withDefaults(
defineProps<{
loading: boolean;
loadingLines?: number;
empty?: boolean;
emptyTitle?: string;
emptyBody?: string;
showEmpty?: boolean;
}>(),
{
loadingLines: 6,
empty: false,
emptyTitle: "No data available",
emptyBody: "No records matched this request.",
showEmpty: true,
},
);
</script>
<template>
<AppSkeleton v-if="props.loading" :lines="props.loadingLines" />
<AppEmptyState
v-else-if="props.showEmpty && props.empty"
:title="props.emptyTitle"
:body="props.emptyBody"
>
<slot name="empty" />
</AppEmptyState>
<slot v-else />
</template>

View file

@ -8,14 +8,19 @@ const props = defineProps<{
<template> <template>
<span <span
class="inline-flex flex-wrap items-center gap-2 rounded-full border border-zinc-300 bg-zinc-100 px-3 py-1 text-xs text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300" class="inline-flex flex-wrap items-center gap-2 rounded-xl border border-stroke-default bg-surface-card-muted/90 px-3 py-1.5 text-xs text-ink-secondary"
> >
<span class="font-medium">Queue</span> <span class="font-semibold tracking-wide text-ink-primary">Queue</span>
<span class="rounded-full bg-zinc-200 px-2 py-0.5 dark:bg-zinc-700">{{ props.queued }} queued</span> <span class="inline-flex items-center gap-1 rounded-full bg-surface-card px-2 py-0.5">
<span class="rounded-full bg-amber-100 px-2 py-0.5 text-amber-800 dark:bg-amber-950/50 dark:text-amber-300"> <span class="h-1.5 w-1.5 rounded-full bg-ink-muted" />
{{ props.queued }} queued
</span>
<span class="inline-flex items-center gap-1 rounded-full bg-state-warning-bg px-2 py-0.5 text-state-warning-text">
<span class="h-1.5 w-1.5 rounded-full bg-warning-500" />
{{ props.retrying }} retrying {{ props.retrying }} retrying
</span> </span>
<span class="rounded-full bg-rose-100 px-2 py-0.5 text-rose-700 dark:bg-rose-950/50 dark:text-rose-300"> <span class="inline-flex items-center gap-1 rounded-full bg-state-danger-bg px-2 py-0.5 text-state-danger-text">
<span class="h-1.5 w-1.5 rounded-full bg-danger-500" />
{{ props.dropped }} dropped {{ props.dropped }} dropped
</span> </span>
</span> </span>

View file

@ -0,0 +1,44 @@
<script setup lang="ts">
import AppAlert from "@/components/ui/AppAlert.vue";
const props = withDefaults(
defineProps<{
successMessage?: string | null;
successTitle?: string;
errorMessage?: string | null;
errorRequestId?: string | null;
errorTitle?: string;
}>(),
{
successMessage: null,
successTitle: "Operation complete",
errorMessage: null,
errorRequestId: null,
errorTitle: "Request failed",
},
);
const emit = defineEmits<{
dismissSuccess: [];
}>();
</script>
<template>
<div class="space-y-3">
<AppAlert
v-if="props.successMessage"
tone="success"
dismissible
@dismiss="emit('dismissSuccess')"
>
<template #title>{{ props.successTitle }}</template>
<p>{{ props.successMessage }}</p>
</AppAlert>
<AppAlert v-if="props.errorMessage" tone="danger">
<template #title>{{ props.errorTitle }}</template>
<p>{{ props.errorMessage }}</p>
<p class="text-secondary">Request ID: {{ props.errorRequestId || "n/a" }}</p>
</AppAlert>
</div>
</template>

View file

@ -9,18 +9,18 @@ const label = computed(() => props.status.replaceAll("_", " "));
const toneClass = computed(() => { const toneClass = computed(() => {
if (props.status === "success") { if (props.status === "success") {
return "border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/50 dark:text-emerald-300"; return "border-state-success-border bg-state-success-bg text-state-success-text";
} }
if (props.status === "partial_failure") { if (props.status === "partial_failure") {
return "border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950/50 dark:text-amber-300"; return "border-state-warning-border bg-state-warning-bg text-state-warning-text";
} }
if (props.status === "failed") { if (props.status === "failed") {
return "border-rose-300 bg-rose-50 text-rose-700 dark:border-rose-800 dark:bg-rose-950/50 dark:text-rose-300"; return "border-state-danger-border bg-state-danger-bg text-state-danger-text";
} }
if (props.status === "running") { if (props.status === "running") {
return "border-brand-300 bg-brand-50 text-brand-700 dark:border-brand-800 dark:bg-brand-950/50 dark:text-brand-300"; return "border-state-info-border bg-state-info-bg text-state-info-text";
} }
return "border-zinc-300 bg-zinc-100 text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300"; return "border-stroke-default bg-surface-card-muted text-ink-secondary";
}); });
</script> </script>

View file

@ -18,15 +18,15 @@ const emit = defineEmits<{ dismiss: [] }>();
const toneClass = computed(() => { const toneClass = computed(() => {
if (props.tone === "danger") { if (props.tone === "danger") {
return "border-rose-300 bg-rose-50 text-rose-900 dark:border-rose-800 dark:bg-rose-950/45 dark:text-rose-200"; return "border-state-danger-border bg-state-danger-bg text-state-danger-text";
} }
if (props.tone === "warning") { if (props.tone === "warning") {
return "border-amber-300 bg-amber-50 text-amber-900 dark:border-amber-800 dark:bg-amber-950/45 dark:text-amber-200"; return "border-state-warning-border bg-state-warning-bg text-state-warning-text";
} }
if (props.tone === "success") { if (props.tone === "success") {
return "border-emerald-300 bg-emerald-50 text-emerald-900 dark:border-emerald-800 dark:bg-emerald-950/45 dark:text-emerald-200"; return "border-state-success-border bg-state-success-bg text-state-success-text";
} }
return "border-brand-300 bg-brand-50 text-brand-900 dark:border-brand-800 dark:bg-brand-950/45 dark:text-brand-200"; return "border-state-info-border bg-state-info-bg text-state-info-text";
}); });
const alertRole = computed(() => (props.tone === "danger" || props.tone === "warning" ? "alert" : "status")); const alertRole = computed(() => (props.tone === "danger" || props.tone === "warning" ? "alert" : "status"));

View file

@ -7,22 +7,22 @@ const props = defineProps<{
const toneClass = computed(() => { const toneClass = computed(() => {
if (props.tone === "success") { if (props.tone === "success") {
return "border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/50 dark:text-emerald-300"; return "border-state-success-border bg-state-success-bg text-state-success-text";
} }
if (props.tone === "warning") { if (props.tone === "warning") {
return "border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950/50 dark:text-amber-300"; return "border-state-warning-border bg-state-warning-bg text-state-warning-text";
} }
if (props.tone === "danger") { if (props.tone === "danger") {
return "border-rose-300 bg-rose-50 text-rose-700 dark:border-rose-800 dark:bg-rose-950/50 dark:text-rose-300"; return "border-state-danger-border bg-state-danger-bg text-state-danger-text";
} }
if (props.tone === "info") { if (props.tone === "info") {
return "border-brand-300 bg-brand-50 text-brand-700 dark:border-brand-800 dark:bg-brand-950/50 dark:text-brand-300"; return "border-state-info-border bg-state-info-bg text-state-info-text";
} }
return "border-zinc-300 bg-zinc-100 text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300"; return "border-stroke-default bg-surface-card-muted text-ink-secondary";
}); });
</script> </script>

View file

@ -16,24 +16,24 @@ const props = withDefaults(
const variantClass = computed(() => { const variantClass = computed(() => {
if (props.variant === "secondary") { if (props.variant === "secondary") {
return "border-zinc-300 bg-zinc-100 text-zinc-900 hover:bg-zinc-200 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700"; return "border-action-secondary-border bg-action-secondary-bg text-action-secondary-text shadow-sm hover:border-action-secondary-hover-border hover:bg-action-secondary-hover-bg hover:text-action-secondary-hover-text";
} }
if (props.variant === "ghost") { if (props.variant === "ghost") {
return "border-zinc-300 bg-transparent text-zinc-700 hover:bg-zinc-100 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800"; return "border-action-ghost-border bg-action-ghost-bg text-action-ghost-text hover:border-action-ghost-hover-border hover:bg-action-ghost-hover-bg hover:text-action-ghost-hover-text";
} }
if (props.variant === "danger") { if (props.variant === "danger") {
return "border-rose-300 bg-rose-100 text-rose-800 hover:bg-rose-200 dark:border-rose-800 dark:bg-rose-950/60 dark:text-rose-200 dark:hover:bg-rose-900/70"; return "border-action-danger-border bg-action-danger-bg text-action-danger-text shadow-sm hover:border-action-danger-hover-border hover:bg-action-danger-hover-bg hover:text-action-danger-hover-text";
} }
return "border-brand-700 bg-brand-700 text-white hover:bg-brand-600 dark:border-brand-400 dark:bg-brand-400 dark:text-zinc-950 dark:hover:bg-brand-300"; return "border-action-primary-border bg-action-primary-bg text-action-primary-text shadow-sm hover:border-action-primary-hover-border hover:bg-action-primary-hover-bg hover:text-action-primary-hover-text";
}); });
</script> </script>
<template> <template>
<button <button
class="inline-flex min-h-10 items-center justify-center rounded-lg border px-3 py-2 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 disabled:cursor-not-allowed disabled:opacity-60 dark:focus-visible:ring-brand-400 dark:focus-visible:ring-offset-zinc-950" class="inline-flex min-h-10 items-center justify-center rounded-lg border px-3 py-2 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset disabled:cursor-not-allowed disabled:opacity-60"
:class="variantClass" :class="variantClass"
:type="props.type" :type="props.type"
:disabled="props.disabled" :disabled="props.disabled"

View file

@ -1,5 +1,5 @@
<template> <template>
<article class="min-w-0 rounded-2xl border border-zinc-200 bg-white p-5 shadow-panel dark:border-zinc-800 dark:bg-zinc-900"> <article class="min-w-0 rounded-2xl border border-stroke-default bg-surface-card p-5 shadow-panel">
<slot /> <slot />
</article> </article>
</template> </template>

View file

@ -9,11 +9,11 @@ defineProps<{
</script> </script>
<template> <template>
<label class="inline-flex min-h-10 items-center gap-2 text-sm text-zinc-800 dark:text-zinc-200" :for="id"> <label class="inline-flex min-h-10 items-center gap-2 text-sm text-ink-primary" :for="id">
<input <input
:id="id" :id="id"
v-model="model" v-model="model"
class="h-4 w-4 rounded border-zinc-400 text-brand-600 focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:border-zinc-600 dark:bg-zinc-900 dark:focus-visible:ring-brand-400 dark:focus-visible:ring-offset-zinc-950" class="h-4 w-4 rounded border-stroke-interactive bg-surface-input text-brand-600 focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset"
type="checkbox" type="checkbox"
:disabled="disabled" :disabled="disabled"
/> />

View file

@ -6,9 +6,9 @@ defineProps<{
</script> </script>
<template> <template>
<section class="rounded-xl border border-dashed border-zinc-300 bg-zinc-50 p-5 dark:border-zinc-700 dark:bg-zinc-900/60"> <section class="rounded-xl border border-dashed border-stroke-interactive bg-surface-card-muted p-5">
<h3 class="font-display text-lg font-semibold text-zinc-900 dark:text-zinc-100">{{ title }}</h3> <h3 class="font-display text-lg font-semibold text-ink-primary">{{ title }}</h3>
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{{ body }}</p> <p class="mt-1 text-sm text-ink-secondary">{{ body }}</p>
<div v-if="$slots.default" class="mt-4"> <div v-if="$slots.default" class="mt-4">
<slot /> <slot />
</div> </div>

View file

@ -0,0 +1,209 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, useSlots, watch } from "vue";
type TooltipSide = "right" | "left" | "top" | "bottom";
const props = withDefaults(
defineProps<{
text: string;
side?: TooltipSide | "auto";
}>(),
{
side: "auto",
},
);
const slots = useSlots();
const GAP = 8;
const EDGE_PADDING = 8;
const triggerRef = ref<HTMLElement | null>(null);
const tooltipRef = ref<HTMLElement | null>(null);
const isOpen = ref(false);
const resolvedSide = ref<TooltipSide>("top");
const tooltipStyle = ref<Record<string, string>>({
left: "0px",
top: "0px",
});
const sideClass = computed(() => {
if (resolvedSide.value === "left") {
return "origin-right";
}
if (resolvedSide.value === "right") {
return "origin-left";
}
if (resolvedSide.value === "bottom") {
return "origin-top";
}
return "origin-bottom";
});
const hasTriggerSlot = computed(() => Boolean(slots.trigger));
function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(Math.max(value, minimum), maximum);
}
function spaceBySide(rect: DOMRect): Record<TooltipSide, number> {
return {
top: rect.top,
right: window.innerWidth - rect.right,
bottom: window.innerHeight - rect.bottom,
left: rect.left,
};
}
function preferredSides(): TooltipSide[] {
const allSides: TooltipSide[] = ["top", "right", "bottom", "left"];
if (!props.side || props.side === "auto") {
return allSides;
}
return [props.side, ...allSides.filter((item) => item !== props.side)];
}
function chooseSide(rect: DOMRect, tooltipRect: DOMRect): TooltipSide {
const spaces = spaceBySide(rect);
const order = preferredSides();
for (const side of order) {
if (side === "top" || side === "bottom") {
if (spaces[side] >= tooltipRect.height + GAP) {
return side;
}
continue;
}
if (spaces[side] >= tooltipRect.width + GAP) {
return side;
}
}
return order.sort((left, right) => spaces[right] - spaces[left])[0] ?? "top";
}
function updatePosition(): void {
if (!isOpen.value || !triggerRef.value || !tooltipRef.value) {
return;
}
const trigger = triggerRef.value.getBoundingClientRect();
const tooltip = tooltipRef.value.getBoundingClientRect();
const side = chooseSide(trigger, tooltip);
resolvedSide.value = side;
let left = 0;
let top = 0;
if (side === "top") {
left = trigger.left + trigger.width / 2 - tooltip.width / 2;
top = trigger.top - tooltip.height - GAP;
} else if (side === "bottom") {
left = trigger.left + trigger.width / 2 - tooltip.width / 2;
top = trigger.bottom + GAP;
} else if (side === "left") {
left = trigger.left - tooltip.width - GAP;
top = trigger.top + trigger.height / 2 - tooltip.height / 2;
} else {
left = trigger.right + GAP;
top = trigger.top + trigger.height / 2 - tooltip.height / 2;
}
left = clamp(left, EDGE_PADDING, window.innerWidth - tooltip.width - EDGE_PADDING);
top = clamp(top, EDGE_PADDING, window.innerHeight - tooltip.height - EDGE_PADDING);
tooltipStyle.value = {
left: `${Math.round(left)}px`,
top: `${Math.round(top)}px`,
};
}
function closeTooltip(): void {
isOpen.value = false;
}
function openTooltip(): void {
isOpen.value = true;
void nextTick(() => {
updatePosition();
});
}
function onKeydown(event: KeyboardEvent): void {
if (event.key === "Escape") {
closeTooltip();
}
}
function onViewportChange(): void {
if (!isOpen.value) {
return;
}
updatePosition();
}
watch(isOpen, (open) => {
if (open) {
window.addEventListener("resize", onViewportChange);
window.addEventListener("scroll", onViewportChange, true);
window.addEventListener("keydown", onKeydown);
return;
}
window.removeEventListener("resize", onViewportChange);
window.removeEventListener("scroll", onViewportChange, true);
window.removeEventListener("keydown", onKeydown);
});
watch(
() => props.text,
() => {
if (isOpen.value) {
void nextTick(() => {
updatePosition();
});
}
},
);
onBeforeUnmount(() => {
window.removeEventListener("resize", onViewportChange);
window.removeEventListener("scroll", onViewportChange, true);
window.removeEventListener("keydown", onKeydown);
});
</script>
<template>
<span class="relative inline-flex items-center align-middle">
<component
:is="hasTriggerSlot ? 'span' : 'button'"
ref="triggerRef"
:type="hasTriggerSlot ? undefined : 'button'"
:tabindex="hasTriggerSlot ? 0 : undefined"
class="inline-flex items-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset"
:class="
hasTriggerSlot
? 'cursor-help rounded-sm'
: 'h-5 w-5 justify-center rounded-full border border-stroke-interactive/80 bg-surface-card-muted/80 text-[11px] font-semibold text-ink-secondary transition hover:bg-action-secondary-hover-bg'
"
:aria-label="text"
@mouseenter="openTooltip"
@mouseleave="closeTooltip"
@focus="openTooltip"
@blur="closeTooltip"
>
<slot v-if="hasTriggerSlot" name="trigger" />
<template v-else>?</template>
</component>
<Teleport to="body">
<span
v-if="isOpen"
ref="tooltipRef"
role="tooltip"
:style="tooltipStyle"
:class="sideClass"
class="pointer-events-none fixed z-[90] w-64 max-w-[calc(100vw-2rem)] rounded-lg border border-stroke-default bg-surface-card/95 px-2.5 py-2 text-xs leading-relaxed text-ink-secondary shadow-lg"
>
{{ text }}
</span>
</Teleport>
</span>
</template>

View file

@ -12,7 +12,7 @@ defineProps<{
<template> <template>
<input <input
v-model="model" v-model="model"
class="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 outline-none ring-brand-300 transition placeholder:text-zinc-400 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:ring-brand-400 dark:focus-visible:ring-offset-zinc-950 dark:placeholder:text-zinc-500" class="w-full rounded-lg border border-stroke-interactive bg-surface-input px-3 py-2 text-sm text-ink-primary outline-none transition placeholder:text-ink-muted focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset disabled:cursor-not-allowed disabled:opacity-60"
:id="id" :id="id"
:type="type || 'text'" :type="type || 'text'"
:placeholder="placeholder" :placeholder="placeholder"

View file

@ -8,9 +8,9 @@ const emit = defineEmits<{ close: [] }>();
</script> </script>
<template> <template>
<div v-if="props.open" class="fixed inset-0 z-50 grid place-items-center bg-zinc-950/60 p-4" @click.self="emit('close')"> <div v-if="props.open" class="fixed inset-0 z-50 grid place-items-center bg-surface-overlay/60 p-4" @click.self="emit('close')">
<div class="w-full max-w-lg rounded-2xl border border-zinc-200 bg-white p-6 shadow-panel dark:border-zinc-800 dark:bg-zinc-900"> <div class="w-full max-w-lg rounded-2xl border border-stroke-default bg-surface-card p-6 shadow-panel">
<h3 class="mb-4 font-display text-xl font-semibold text-zinc-900 dark:text-zinc-100">{{ props.title }}</h3> <h3 class="mb-4 font-display text-xl font-semibold text-ink-primary">{{ props.title }}</h3>
<slot /> <slot />
</div> </div>
</div> </div>

View file

@ -10,7 +10,7 @@ defineProps<{
<template> <template>
<select <select
v-model="model" v-model="model"
class="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 outline-none ring-brand-300 transition focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:ring-brand-400 dark:focus-visible:ring-offset-zinc-950" class="w-full rounded-lg border border-stroke-interactive bg-surface-input px-3 py-2 text-sm text-ink-primary outline-none transition focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset disabled:cursor-not-allowed disabled:opacity-60"
:id="id" :id="id"
:disabled="disabled" :disabled="disabled"
> >

View file

@ -9,7 +9,7 @@ defineProps<{
<span <span
v-for="line in lines || 3" v-for="line in lines || 3"
:key="line" :key="line"
class="block h-3 animate-pulse rounded-full bg-zinc-200 dark:bg-zinc-800" class="block h-3 animate-pulse rounded-full bg-surface-card-muted"
/> />
</div> </div>
</template> </template>

View file

@ -6,10 +6,10 @@ defineProps<{
<template> <template>
<div <div
class="max-w-full overflow-x-auto rounded-xl border border-zinc-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:border-zinc-800 dark:focus-visible:ring-brand-400 dark:focus-visible:ring-offset-zinc-950" class="max-w-full overflow-x-auto rounded-xl border border-stroke-default focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset"
tabindex="0" tabindex="0"
> >
<table class="min-w-full border-collapse bg-white text-sm dark:bg-zinc-900" :aria-label="label || 'Data table'"> <table class="min-w-full border-collapse bg-surface-table text-sm" :aria-label="label || 'Data table'">
<slot /> <slot />
</table> </table>
</div> </div>
@ -18,14 +18,14 @@ defineProps<{
<style scoped> <style scoped>
:deep(th), :deep(th),
:deep(td) { :deep(td) {
@apply border-b border-zinc-200 px-3 py-2 text-left align-top dark:border-zinc-800; @apply border-b border-stroke-default px-3 py-2 text-left align-top;
} }
:deep(th) { :deep(th) {
@apply sticky top-0 z-10 bg-zinc-100 font-semibold text-zinc-800 dark:bg-zinc-900 dark:text-zinc-200; @apply sticky top-0 z-10 bg-surface-table-header font-semibold text-ink-primary;
} }
:deep(td) { :deep(td) {
@apply text-zinc-700 dark:text-zinc-300; @apply text-ink-secondary;
} }
</style> </style>

View file

@ -1 +1,6 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
declare module "@/theme/presets/*.js" {
const value: Record<string, unknown>;
export default value;
}

View file

@ -18,6 +18,14 @@ export interface RunSummary {
partial_count: number; partial_count: number;
failed_state_counts: Record<string, number>; failed_state_counts: Record<string, number>;
failed_reason_counts: Record<string, number>; failed_reason_counts: Record<string, number>;
scrape_failure_counts: Record<string, number>;
retry_counts: {
retries_scheduled_count: number;
scholars_with_retries_count: number;
retry_exhausted_count: number;
};
alert_thresholds: Record<string, number>;
alert_flags: Record<string, boolean>;
} }
export interface RunScholarResult { export interface RunScholarResult {

View file

@ -0,0 +1,67 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
const props = withDefaults(
defineProps<{
label?: string | null;
scholarId: string;
imageUrl?: string | null;
size?: "sm" | "md";
}>(),
{
label: null,
imageUrl: null,
size: "md",
},
);
const imageFailed = ref(false);
watch(
() => props.imageUrl,
() => {
imageFailed.value = false;
},
);
const initials = computed(() => {
const source = (props.label || "").trim();
if (!source) {
return props.scholarId.slice(0, 2).toUpperCase();
}
const tokens = source.split(/\s+/).filter(Boolean);
if (tokens.length === 1) {
return tokens[0].slice(0, 2).toUpperCase();
}
return `${tokens[0].charAt(0)}${tokens[1].charAt(0)}`.toUpperCase();
});
const containerClass = computed(() =>
props.size === "sm" ? "h-10 w-10 text-[11px]" : "h-12 w-12 text-xs",
);
const canRenderImage = computed(() => Boolean(props.imageUrl) && !imageFailed.value);
function onImageError(): void {
imageFailed.value = true;
}
</script>
<template>
<div
class="flex shrink-0 items-center justify-center overflow-hidden rounded-full border border-stroke-default bg-surface-card-muted font-semibold text-ink-secondary"
:class="containerClass"
>
<img
v-if="canRenderImage"
:src="props.imageUrl || ''"
:alt="`${props.label || props.scholarId} profile image`"
class="h-full w-full object-cover"
loading="lazy"
referrerpolicy="no-referrer"
@error="onImageError"
/>
<span v-else>{{ initials }}</span>
</div>
</template>

View file

@ -4,6 +4,7 @@ export interface UserSettings {
auto_run_enabled: boolean; auto_run_enabled: boolean;
run_interval_minutes: number; run_interval_minutes: number;
request_delay_seconds: number; request_delay_seconds: number;
nav_visible_pages: string[];
} }
export interface ChangePasswordPayload { export interface ChangePasswordPayload {

View file

@ -1,18 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import AppPage from "@/components/layout/AppPage.vue"; import AppPage from "@/components/layout/AppPage.vue";
import AppAlert from "@/components/ui/AppAlert.vue"; import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue";
import AppButton from "@/components/ui/AppButton.vue"; import AppButton from "@/components/ui/AppButton.vue";
import AppCard from "@/components/ui/AppCard.vue"; import AppCard from "@/components/ui/AppCard.vue";
import AppCheckbox from "@/components/ui/AppCheckbox.vue"; import AppCheckbox from "@/components/ui/AppCheckbox.vue";
import AppEmptyState from "@/components/ui/AppEmptyState.vue"; import AppHelpHint from "@/components/ui/AppHelpHint.vue";
import AppInput from "@/components/ui/AppInput.vue"; import AppInput from "@/components/ui/AppInput.vue";
import AppSkeleton from "@/components/ui/AppSkeleton.vue"; import AppModal from "@/components/ui/AppModal.vue";
import AppTable from "@/components/ui/AppTable.vue"; import AppTable from "@/components/ui/AppTable.vue";
import { import {
createAdminUser, createAdminUser,
listAdminUsers, listAdminUsers,
resetAdminUserPassword,
setAdminUserActive, setAdminUserActive,
type AdminUser, type AdminUser,
} from "@/features/admin_users"; } from "@/features/admin_users";
@ -21,16 +23,21 @@ import { ApiRequestError } from "@/lib/api/errors";
const loading = ref(true); const loading = ref(true);
const creating = ref(false); const creating = ref(false);
const togglingUserId = ref<number | null>(null); const togglingUserId = ref<number | null>(null);
const resettingPassword = ref(false);
const activeUserId = ref<number | null>(null);
const users = ref<AdminUser[]>([]); const users = ref<AdminUser[]>([]);
const email = ref(""); const email = ref("");
const password = ref(""); const password = ref("");
const createIsAdmin = ref(false); const createIsAdmin = ref(false);
const resetPassword = ref("");
const errorMessage = ref<string | null>(null); const errorMessage = ref<string | null>(null);
const errorRequestId = ref<string | null>(null); const errorRequestId = ref<string | null>(null);
const successMessage = ref<string | null>(null); const successMessage = ref<string | null>(null);
const activeUser = computed(() => users.value.find((item) => item.id === activeUserId.value) ?? null);
function formatDate(value: string): string { function formatDate(value: string): string {
const asDate = new Date(value); const asDate = new Date(value);
if (Number.isNaN(asDate.getTime())) { if (Number.isNaN(asDate.getTime())) {
@ -39,6 +46,26 @@ function formatDate(value: string): string {
return asDate.toLocaleString(); return asDate.toLocaleString();
} }
function userRoleLabel(user: AdminUser): string {
return user.is_admin ? "Admin" : "User";
}
function statusDotClass(user: AdminUser): string {
return user.is_active
? "bg-success-500 ring-success-200"
: "bg-ink-muted/70 ring-stroke-default";
}
function openUserModal(user: AdminUser): void {
activeUserId.value = user.id;
resetPassword.value = "";
}
function closeUserModal(): void {
activeUserId.value = null;
resetPassword.value = "";
}
async function loadUsers(): Promise<void> { async function loadUsers(): Promise<void> {
loading.value = true; loading.value = true;
errorMessage.value = null; errorMessage.value = null;
@ -46,6 +73,9 @@ async function loadUsers(): Promise<void> {
try { try {
users.value = await listAdminUsers(); users.value = await listAdminUsers();
if (activeUserId.value !== null && !users.value.some((item) => item.id === activeUserId.value)) {
closeUserModal();
}
} catch (error) { } catch (error) {
users.value = []; users.value = [];
if (error instanceof ApiRequestError) { if (error instanceof ApiRequestError) {
@ -115,6 +145,40 @@ async function onToggleUser(user: AdminUser): Promise<void> {
} }
} }
async function onResetPassword(): Promise<void> {
const user = activeUser.value;
if (!user) {
return;
}
resettingPassword.value = true;
successMessage.value = null;
errorMessage.value = null;
errorRequestId.value = null;
try {
const candidate = resetPassword.value.trim();
if (candidate.length < 12) {
throw new Error("New password must be at least 12 characters.");
}
const result = await resetAdminUserPassword(user.id, candidate);
successMessage.value = result.message || `Password reset for ${user.email}.`;
resetPassword.value = "";
} catch (error) {
if (error instanceof ApiRequestError) {
errorMessage.value = error.message;
errorRequestId.value = error.requestId;
} else if (error instanceof Error) {
errorMessage.value = error.message;
} else {
errorMessage.value = "Unable to reset password.";
}
} finally {
resettingPassword.value = false;
}
}
onMounted(() => { onMounted(() => {
void loadUsers(); void loadUsers();
}); });
@ -122,27 +186,27 @@ onMounted(() => {
<template> <template>
<AppPage title="Admin Users" subtitle="Create and manage user access for this instance."> <AppPage title="Admin Users" subtitle="Create and manage user access for this instance.">
<AppAlert v-if="successMessage" tone="success" dismissible @dismiss="successMessage = null"> <RequestStateAlerts
<template #title>Operation complete</template> :success-message="successMessage"
<p>{{ successMessage }}</p> :error-message="errorMessage"
</AppAlert> :error-request-id="errorRequestId"
error-title="User management request failed"
<AppAlert v-if="errorMessage" tone="danger"> @dismiss-success="successMessage = null"
<template #title>User management request failed</template> />
<p>{{ errorMessage }}</p>
<p class="text-secondary">Request ID: {{ errorRequestId || "n/a" }}</p>
</AppAlert>
<section class="grid gap-4 lg:grid-cols-2"> <section class="grid gap-4 lg:grid-cols-2">
<AppCard class="space-y-4"> <AppCard class="space-y-4">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Create User</h2> <div class="flex items-center gap-1">
<h2 class="text-lg font-semibold text-ink-primary">Create User</h2>
<AppHelpHint text="Create local accounts for this Scholarr instance and optionally grant admin rights." />
</div>
<form class="grid gap-3" @submit.prevent="onCreateUser"> <form class="grid gap-3" @submit.prevent="onCreateUser">
<label class="grid gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300"> <label class="grid gap-2 text-sm font-medium text-ink-secondary">
<span>Email</span> <span>Email</span>
<AppInput id="admin-user-email" v-model="email" type="email" autocomplete="off" /> <AppInput id="admin-user-email" v-model="email" type="email" autocomplete="off" />
</label> </label>
<label class="grid gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300"> <label class="grid gap-2 text-sm font-medium text-ink-secondary">
<span>Password</span> <span>Password</span>
<AppInput id="admin-user-password" v-model="password" type="password" autocomplete="new-password" /> <AppInput id="admin-user-password" v-model="password" type="password" autocomplete="new-password" />
</label> </label>
@ -156,44 +220,97 @@ onMounted(() => {
</AppCard> </AppCard>
<AppCard class="space-y-4"> <AppCard class="space-y-4">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Users</h2> <div class="flex items-center gap-1">
<AppSkeleton v-if="loading" :lines="5" /> <h2 class="text-lg font-semibold text-ink-primary">Users</h2>
<AppHelpHint text="Review account roles and active status. Inactive users cannot sign in." />
<AppEmptyState </div>
v-else-if="users.length === 0" <AsyncStateGate
title="No users available" :loading="loading"
body="Create an account to begin assigning access." :loading-lines="5"
/> :empty="users.length === 0"
:show-empty="!errorMessage"
<AppTable v-else label="Admin users table"> empty-title="No users available"
empty-body="Create an account to begin assigning access."
>
<AppTable label="Admin users table">
<thead> <thead>
<tr> <tr>
<th scope="col">Email</th> <th scope="col">Email</th>
<th scope="col">Role</th> <th scope="col">Role</th>
<th scope="col">Active</th>
<th scope="col">Updated</th>
<th scope="col">Action</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="user in users" :key="user.id"> <tr v-for="user in users" :key="user.id">
<td>{{ user.email }}</td>
<td>{{ user.is_admin ? "Admin" : "User" }}</td>
<td>{{ user.is_active ? "Yes" : "No" }}</td>
<td>{{ formatDate(user.updated_at) }}</td>
<td> <td>
<AppButton <button
variant="secondary" type="button"
:disabled="togglingUserId === user.id" class="group inline-flex items-center gap-2 rounded-md px-1 py-0.5 text-left text-ink-primary transition hover:bg-surface-card-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset"
@click="onToggleUser(user)" @click="openUserModal(user)"
> >
{{ user.is_active ? "Deactivate" : "Activate" }} <span
</AppButton> :class="statusDotClass(user)"
class="h-2.5 w-2.5 rounded-full ring-2"
:aria-label="user.is_active ? 'Active user' : 'Inactive user'"
/>
<span class="underline-offset-2 group-hover:underline">{{ user.email }}</span>
</button>
</td> </td>
<td>{{ userRoleLabel(user) }}</td>
</tr> </tr>
</tbody> </tbody>
</AppTable> </AppTable>
<p class="text-xs text-secondary">Status dot: green is active, gray is inactive. Click an email to manage.</p>
</AsyncStateGate>
</AppCard> </AppCard>
</section> </section>
<AppModal :open="activeUser !== null" title="User settings" @close="closeUserModal">
<div v-if="activeUser" class="grid gap-4">
<div class="space-y-1">
<div class="flex items-center gap-2">
<span
:class="statusDotClass(activeUser)"
class="h-2.5 w-2.5 rounded-full ring-2"
:aria-label="activeUser.is_active ? 'Active user' : 'Inactive user'"
/>
<p class="truncate text-sm font-semibold text-ink-primary">{{ activeUser.email }}</p>
</div>
<p class="text-sm text-secondary">Role: {{ userRoleLabel(activeUser) }}</p>
<p class="text-xs text-secondary">Last updated: {{ formatDate(activeUser.updated_at) }}</p>
</div>
<div class="grid gap-2 border-t border-stroke-default pt-3">
<label class="grid gap-2 text-sm font-medium text-ink-secondary" for="admin-user-reset-password">
<span>Reset password</span>
<AppInput
id="admin-user-reset-password"
v-model="resetPassword"
type="password"
autocomplete="new-password"
placeholder="Minimum 12 characters"
:disabled="resettingPassword"
/>
</label>
<div class="flex flex-wrap justify-end gap-2">
<AppButton variant="secondary" :disabled="resettingPassword" @click="onResetPassword">
{{ resettingPassword ? "Resetting..." : "Reset password" }}
</AppButton>
</div>
</div>
<div class="flex flex-wrap items-center justify-between gap-2 border-t border-stroke-default pt-3">
<AppButton
variant="secondary"
:disabled="togglingUserId === activeUser.id"
@click="onToggleUser(activeUser)"
>
{{ activeUser.is_active ? "Deactivate user" : "Activate user" }}
</AppButton>
<AppButton variant="ghost" :disabled="togglingUserId === activeUser.id || resettingPassword" @click="closeUserModal">
Close
</AppButton>
</div>
</div>
</AppModal>
</AppPage> </AppPage>
</template> </template>

View file

@ -2,20 +2,24 @@
import { onMounted, ref } from "vue"; import { onMounted, ref } from "vue";
import { fetchDashboardSnapshot, type DashboardSnapshot } from "@/features/dashboard"; import { fetchDashboardSnapshot, type DashboardSnapshot } from "@/features/dashboard";
import { triggerManualRun } from "@/features/runs";
import { ApiRequestError } from "@/lib/api/errors"; import { ApiRequestError } from "@/lib/api/errors";
import AppPage from "@/components/layout/AppPage.vue"; import AppPage from "@/components/layout/AppPage.vue";
import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
import QueueHealthBadge from "@/components/patterns/QueueHealthBadge.vue"; import QueueHealthBadge from "@/components/patterns/QueueHealthBadge.vue";
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue";
import RunStatusBadge from "@/components/patterns/RunStatusBadge.vue"; import RunStatusBadge from "@/components/patterns/RunStatusBadge.vue";
import AppAlert from "@/components/ui/AppAlert.vue";
import AppButton from "@/components/ui/AppButton.vue"; import AppButton from "@/components/ui/AppButton.vue";
import AppCard from "@/components/ui/AppCard.vue"; import AppCard from "@/components/ui/AppCard.vue";
import AppEmptyState from "@/components/ui/AppEmptyState.vue"; import AppEmptyState from "@/components/ui/AppEmptyState.vue";
import AppSkeleton from "@/components/ui/AppSkeleton.vue"; import AppHelpHint from "@/components/ui/AppHelpHint.vue";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
const loading = ref(true); const loading = ref(true);
const pendingRun = ref(false);
const errorMessage = ref<string | null>(null); const errorMessage = ref<string | null>(null);
const errorRequestId = ref<string | null>(null); const errorRequestId = ref<string | null>(null);
const successMessage = ref<string | null>(null);
const snapshot = ref<DashboardSnapshot | null>(null); const snapshot = ref<DashboardSnapshot | null>(null);
const auth = useAuthStore(); const auth = useAuthStore();
@ -50,138 +54,223 @@ async function loadSnapshot(): Promise<void> {
} }
} }
async function onTriggerRun(): Promise<void> {
pendingRun.value = true;
errorMessage.value = null;
errorRequestId.value = null;
successMessage.value = null;
try {
const result = await triggerManualRun();
successMessage.value = `Update check #${result.run_id} started successfully.`;
await loadSnapshot();
} catch (error) {
if (error instanceof ApiRequestError) {
errorMessage.value = error.message;
errorRequestId.value = error.requestId;
} else {
errorMessage.value = "Unable to start an update check.";
}
} finally {
pendingRun.value = false;
}
}
onMounted(() => { onMounted(() => {
void loadSnapshot(); void loadSnapshot();
}); });
</script> </script>
<template> <template>
<AppPage title="Dashboard" subtitle="What changed since the latest run."> <AppPage
<div class="flex flex-wrap items-center justify-between gap-3"> title="Dashboard"
<AppButton variant="secondary" @click="loadSnapshot" :disabled="loading"> subtitle="Track recent publication updates and monitor background profile checks."
{{ loading ? "Refreshing..." : "Refresh" }} fill
</AppButton> >
<div class="flex min-h-0 flex-1 flex-col gap-4">
<RequestStateAlerts
:success-message="successMessage"
:error-message="errorMessage"
:error-request-id="errorRequestId"
error-title="Dashboard request failed"
@dismiss-success="successMessage = null"
/>
<div class="min-h-0 flex-1">
<AsyncStateGate :loading="loading" :loading-lines="6" :show-empty="false">
<template v-if="snapshot">
<section class="grid min-h-0 gap-4 xl:h-full xl:grid-cols-2">
<AppCard class="flex min-h-0 flex-col gap-4 xl:h-full xl:overflow-hidden">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="space-y-1">
<div class="flex items-center gap-1">
<h2 class="text-lg font-semibold text-ink-primary">Recent Publications</h2>
<AppHelpHint
text="Recent publications are paper records discovered from tracked scholar profile checks."
/>
</div>
<p class="text-sm text-secondary">Newest papers found while checking your tracked scholar profiles.</p>
</div>
<RouterLink to="/publications" class="link-inline text-sm">Open full publications view</RouterLink>
</div>
<AppEmptyState
v-if="snapshot.recentPublications.length === 0"
title="No new publications"
body="When a completed update check discovers changes, they will appear here."
/>
<ul v-else class="grid min-h-0 flex-1 gap-3 overflow-y-auto pr-1">
<li
v-for="item in snapshot.recentPublications.slice(0, 20)"
:key="item.publication_id"
class="grid gap-1 rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-2"
>
<a
v-if="item.pub_url"
:href="item.pub_url"
target="_blank"
rel="noreferrer"
class="link-inline text-sm font-semibold text-ink-primary"
>
{{ item.title }}
</a>
<span v-else class="text-sm font-semibold text-ink-primary">{{ item.title }}</span>
<div class="flex flex-wrap items-center gap-3 text-xs text-secondary">
<RouterLink
:to="{ name: 'publications', query: { scholar: String(item.scholar_profile_id) } }"
class="link-inline"
>
{{ item.scholar_label }}
</RouterLink>
<span>First seen: {{ formatDate(item.first_seen_at) }}</span>
</div>
</li>
</ul>
</AppCard>
<div class="grid min-h-0 gap-4 xl:h-full xl:grid-rows-[auto_minmax(0,1fr)]">
<AppCard class="flex min-h-0 flex-col gap-4">
<div class="flex items-center gap-1">
<h2 class="text-lg font-semibold text-ink-primary">Overview</h2>
<AppHelpHint text="Shared summary metrics for publication coverage and processing pressure." />
</div>
<div class="grid gap-3 sm:grid-cols-2">
<article class="rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-2">
<p class="text-xs uppercase tracking-wide text-ink-muted">Tracked publications</p>
<p class="mt-1 text-xl font-semibold text-ink-primary">{{ snapshot.totalCount }}</p>
</article>
<article class="rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-2">
<p class="text-xs uppercase tracking-wide text-ink-muted">New from latest check</p>
<p class="mt-1 text-xl font-semibold text-ink-primary">{{ snapshot.newCount }}</p>
</article>
<article class="rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-2">
<p class="text-xs uppercase tracking-wide text-ink-muted">Scholars processed</p>
<p class="mt-1 text-xl font-semibold text-ink-primary">{{ snapshot.latestRun?.scholar_count ?? 0 }}</p>
</article>
<article class="rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-2">
<p class="text-xs uppercase tracking-wide text-ink-muted">Queue pressure</p>
<p class="mt-1 text-xl font-semibold text-ink-primary">
{{ snapshot.queue.queued + snapshot.queue.retrying + snapshot.queue.dropped }}
</p>
</article>
</div>
<QueueHealthBadge <QueueHealthBadge
v-if="snapshot"
:queued="snapshot.queue.queued" :queued="snapshot.queue.queued"
:retrying="snapshot.queue.retrying" :retrying="snapshot.queue.retrying"
:dropped="snapshot.queue.dropped" :dropped="snapshot.queue.dropped"
/> />
</div>
<AppAlert v-if="errorMessage" tone="danger">
<template #title>Dashboard request failed</template>
<p>{{ errorMessage }}</p>
<p class="text-secondary">Request ID: {{ errorRequestId || "n/a" }}</p>
</AppAlert>
<AppSkeleton v-if="loading" :lines="6" />
<template v-else-if="snapshot">
<section class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_22rem]">
<AppCard class="space-y-4">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Snapshot Summary</h2>
<p class="text-sm text-secondary">Latest view mode: {{ snapshot.mode }}</p>
<ul class="grid gap-2 text-sm text-zinc-700 dark:text-zinc-300">
<li class="rounded-xl border border-zinc-200 bg-zinc-50 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-900/60">
Latest run introduced
<span class="font-semibold text-zinc-900 dark:text-zinc-100">{{ snapshot.newCount }}</span>
publications.
</li>
<li class="rounded-xl border border-zinc-200 bg-zinc-50 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-900/60">
Total tracked publications:
<span class="font-semibold text-zinc-900 dark:text-zinc-100">{{ snapshot.totalCount }}</span>
</li>
</ul>
</AppCard> </AppCard>
<AppCard class="space-y-4"> <AppCard class="flex min-h-0 flex-col gap-4 xl:overflow-hidden">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Latest Run</h2>
<div v-if="snapshot.latestRun" class="space-y-2 text-sm">
<div class="flex flex-wrap items-center justify-between gap-2"> <div class="flex flex-wrap items-center justify-between gap-2">
<RunStatusBadge :status="snapshot.latestRun.status" /> <div class="flex items-center gap-1">
<RouterLink <h2 class="text-lg font-semibold text-ink-primary">Activity Monitor</h2>
<AppHelpHint
text="Combines latest profile-check details with recent check history so you can spot issues quickly."
/>
</div>
<div class="flex items-center gap-2">
<AppButton
v-if="auth.isAdmin" v-if="auth.isAdmin"
:to="`/admin/runs/${snapshot.latestRun.id}`" :disabled="pendingRun"
class="link-inline" @click="onTriggerRun"
> >
Run #{{ snapshot.latestRun.id }} {{ pendingRun ? "Starting..." : "Start check" }}
</RouterLink> </AppButton>
<span v-else class="text-secondary">Run #{{ snapshot.latestRun.id }}</span>
</div>
<p class="text-secondary">Started: {{ formatDate(snapshot.latestRun.start_dt) }}</p>
<p class="text-muted">
{{ snapshot.latestRun.scholar_count }} scholars, {{ snapshot.latestRun.new_publication_count }} new
publications
</p>
</div>
<AppEmptyState
v-else
title="No runs recorded"
body="Trigger a manual run from the Runs screen to begin tracking."
/>
</AppCard>
</section>
<section class="grid gap-4 xl:grid-cols-2">
<AppCard class="space-y-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Recent Publications</h2>
<RouterLink
to="/publications"
class="link-inline text-sm"
>
Open publications
</RouterLink>
</div>
<AppEmptyState
v-if="snapshot.recentPublications.length === 0"
title="No new publications"
body="When a completed run discovers updates, they will appear here."
/>
<ul v-else class="grid gap-3">
<li
v-for="item in snapshot.recentPublications.slice(0, 6)"
:key="item.publication_id"
class="grid gap-1 rounded-xl border border-zinc-200 bg-zinc-50 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-900/60"
>
<strong class="text-zinc-900 dark:text-zinc-100">{{ item.title }}</strong>
<span class="text-secondary">{{ item.scholar_label }}</span>
</li>
</ul>
</AppCard>
<AppCard class="space-y-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Recent Runs</h2>
<RouterLink <RouterLink
v-if="auth.isAdmin" v-if="auth.isAdmin"
to="/admin/runs" to="/admin/runs"
class="link-inline text-sm" class="link-inline text-sm"
> >
Open runs Open check history
</RouterLink> </RouterLink>
</div> </div>
</div>
<div
v-if="snapshot.latestRun"
class="rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-2"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex items-center gap-2">
<RunStatusBadge :status="snapshot.latestRun.status" />
<span class="text-sm font-semibold text-ink-primary">Latest check #{{ snapshot.latestRun.id }}</span>
</div>
<RouterLink
v-if="auth.isAdmin"
:to="`/admin/runs/${snapshot.latestRun.id}`"
class="link-inline text-xs"
>
View diagnostics
</RouterLink>
</div>
<p class="mt-2 text-sm text-secondary">
Started {{ formatDate(snapshot.latestRun.start_dt) }}. Processed
{{ snapshot.latestRun.scholar_count }} scholars and discovered
{{ snapshot.latestRun.new_publication_count }} new publications.
</p>
</div>
<AppEmptyState
v-else
title="No checks recorded"
body="Start an update check to begin monitoring activity."
/>
<div class="grid min-h-0 flex-1 gap-2 xl:overflow-hidden">
<p class="text-xs font-semibold uppercase tracking-wide text-muted">Recent checks</p>
<AppEmptyState <AppEmptyState
v-if="snapshot.recentRuns.length === 0" v-if="snapshot.recentRuns.length === 0"
title="No runs yet" title="No checks yet"
body="Trigger a manual run from the Runs screen to begin tracking." body="Check history will appear here."
/> />
<ul v-else class="grid gap-3"> <ul v-else class="grid gap-2 overflow-y-auto pr-1">
<li <li
v-for="run in snapshot.recentRuns" v-for="run in snapshot.recentRuns"
:key="run.id" :key="run.id"
class="grid gap-1 rounded-xl border border-zinc-200 bg-zinc-50 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-900/60" class="grid gap-1 rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-2"
> >
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<RunStatusBadge :status="run.status" /> <RunStatusBadge :status="run.status" />
<span class="text-secondary">Run #{{ run.id }}</span> <span class="text-sm text-secondary">Check #{{ run.id }}</span>
</div> </div>
<span class="text-secondary">{{ formatDate(run.start_dt) }}</span> <RouterLink v-if="auth.isAdmin" :to="`/admin/runs/${run.id}`" class="link-inline text-xs">
Details
</RouterLink>
</div>
<span class="text-xs text-secondary">
{{ formatDate(run.start_dt) }} {{ run.new_publication_count }} new
</span>
</li> </li>
</ul> </ul>
</div>
</AppCard> </AppCard>
</div>
</section> </section>
</template> </template>
</AsyncStateGate>
</div>
</div>
</AppPage> </AppPage>
</template> </template>

View file

@ -54,43 +54,43 @@ async function onSubmit(): Promise<void> {
</script> </script>
<template> <template>
<div class="relative min-h-screen overflow-hidden bg-zinc-100 dark:bg-zinc-950"> <div class="relative h-[100dvh] max-h-[100dvh] overflow-y-auto overflow-x-hidden bg-surface-app">
<div class="pointer-events-none absolute inset-0"> <div class="pointer-events-none absolute inset-0">
<div class="absolute -top-20 left-[-8rem] h-72 w-72 rounded-full bg-brand-300/35 blur-3xl dark:bg-brand-900/35" /> <div class="absolute -top-20 left-[-8rem] h-72 w-72 rounded-full bg-brand-300/30 blur-3xl" />
<div class="absolute bottom-[-7rem] right-[-6rem] h-80 w-80 rounded-full bg-emerald-300/25 blur-3xl dark:bg-emerald-900/25" /> <div class="absolute bottom-[-7rem] right-[-6rem] h-80 w-80 rounded-full bg-success-300/25 blur-3xl" />
<div class="absolute left-1/3 top-1/3 h-52 w-52 rounded-full bg-amber-300/20 blur-3xl dark:bg-amber-900/20" /> <div class="absolute left-1/3 top-1/3 h-52 w-52 rounded-full bg-warning-300/20 blur-3xl" />
</div> </div>
<div <div
class="relative mx-auto grid min-h-screen w-full max-w-6xl items-center gap-8 px-4 py-10 sm:px-6 lg:grid-cols-[minmax(0,1fr)_26rem] lg:px-8" class="relative mx-auto grid min-h-full w-full max-w-6xl items-center gap-8 px-4 py-6 sm:px-6 lg:grid-cols-[minmax(0,1fr)_26rem] lg:px-8"
> >
<section class="hidden space-y-5 lg:block"> <section class="hidden space-y-5 lg:block">
<p class="inline-flex items-center rounded-full border border-zinc-300 bg-white/70 px-3 py-1 text-xs font-medium uppercase tracking-[0.12em] text-zinc-600 dark:border-zinc-700 dark:bg-zinc-900/70 dark:text-zinc-300"> <p class="inline-flex items-center rounded-full border border-stroke-strong bg-surface-card/70 px-3 py-1 text-xs font-medium uppercase tracking-[0.12em] text-ink-muted">
Scholarr Control Center Scholarr Control Center
</p> </p>
<h1 class="max-w-xl font-display text-4xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-100"> <h1 class="max-w-xl font-display text-4xl font-semibold tracking-tight text-ink-primary">
Scholar tracking with reliable operational controls. Scholar tracking with reliable operational controls.
</h1> </h1>
<p class="max-w-xl text-base leading-relaxed text-zinc-600 dark:text-zinc-400"> <p class="max-w-xl text-base leading-relaxed text-ink-muted">
Use your account to review ingestion runs, publication changes, and continuation queue diagnostics from a Use your account to review ingestion runs, publication changes, and continuation queue diagnostics from a
single workspace. single workspace.
</p> </p>
<ul class="grid max-w-xl gap-2 text-sm text-zinc-600 dark:text-zinc-400"> <ul class="grid max-w-xl gap-2 text-sm text-ink-muted">
<li class="rounded-lg border border-zinc-200 bg-white/60 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-900/60"> <li class="rounded-lg border border-stroke-default bg-surface-card/60 px-3 py-2">
Session-based authentication with CSRF enforcement. Session-based authentication with CSRF enforcement.
</li> </li>
<li class="rounded-lg border border-zinc-200 bg-white/60 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-900/60"> <li class="rounded-lg border border-stroke-default bg-surface-card/60 px-3 py-2">
Run and queue diagnostics are available for support workflows. Run and queue diagnostics are available for support workflows.
</li> </li>
<li class="rounded-lg border border-zinc-200 bg-white/60 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-900/60"> <li class="rounded-lg border border-stroke-default bg-surface-card/60 px-3 py-2">
Theme-aware interface with light and dark mode support. Theme-aware interface with light and dark mode support.
</li> </li>
</ul> </ul>
</section> </section>
<AppCard class="w-full max-w-md justify-self-end space-y-6 border-zinc-200/80 bg-white/90 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/85"> <AppCard class="w-full max-w-md justify-self-end space-y-6 border-stroke-default/80 bg-surface-card/90 backdrop-blur">
<div class="space-y-2"> <div class="space-y-2">
<h1 class="font-display text-3xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-100">Sign In</h1> <h1 class="font-display text-3xl font-semibold tracking-tight text-ink-primary">Sign In</h1>
<p class="text-sm text-secondary"> <p class="text-sm text-secondary">
Sign in to access scholar tracking, publication updates, and run diagnostics. Sign in to access scholar tracking, publication updates, and run diagnostics.
</p> </p>
@ -104,12 +104,12 @@ async function onSubmit(): Promise<void> {
</AppAlert> </AppAlert>
<form class="grid gap-4" @submit.prevent="onSubmit"> <form class="grid gap-4" @submit.prevent="onSubmit">
<label class="grid gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300"> <label class="grid gap-2 text-sm font-medium text-ink-secondary">
<span>Email</span> <span>Email</span>
<AppInput id="login-email" v-model="email" type="email" autocomplete="email" /> <AppInput id="login-email" v-model="email" type="email" autocomplete="email" />
</label> </label>
<label class="grid gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300"> <label class="grid gap-2 text-sm font-medium text-ink-secondary">
<span>Password</span> <span>Password</span>
<AppInput <AppInput
id="login-password" id="login-password"

View file

@ -3,14 +3,14 @@ import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import AppPage from "@/components/layout/AppPage.vue"; import AppPage from "@/components/layout/AppPage.vue";
import AppAlert from "@/components/ui/AppAlert.vue"; import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue";
import AppBadge from "@/components/ui/AppBadge.vue"; import AppBadge from "@/components/ui/AppBadge.vue";
import AppButton from "@/components/ui/AppButton.vue"; import AppButton from "@/components/ui/AppButton.vue";
import AppCard from "@/components/ui/AppCard.vue"; import AppCard from "@/components/ui/AppCard.vue";
import AppEmptyState from "@/components/ui/AppEmptyState.vue"; import AppHelpHint from "@/components/ui/AppHelpHint.vue";
import AppInput from "@/components/ui/AppInput.vue"; import AppInput from "@/components/ui/AppInput.vue";
import AppSelect from "@/components/ui/AppSelect.vue"; import AppSelect from "@/components/ui/AppSelect.vue";
import AppSkeleton from "@/components/ui/AppSkeleton.vue";
import AppTable from "@/components/ui/AppTable.vue"; import AppTable from "@/components/ui/AppTable.vue";
import { import {
listPublications, listPublications,
@ -23,12 +23,16 @@ import {
import { listScholars, type ScholarProfile } from "@/features/scholars"; import { listScholars, type ScholarProfile } from "@/features/scholars";
import { ApiRequestError } from "@/lib/api/errors"; import { ApiRequestError } from "@/lib/api/errors";
type PublicationSortKey = "title" | "scholar" | "year" | "citations" | "status" | "first_seen";
const loading = ref(true); const loading = ref(true);
const publishingAll = ref(false); const publishingAll = ref(false);
const publishingSelected = ref(false); const publishingSelected = ref(false);
const mode = ref<PublicationMode>("new"); const mode = ref<PublicationMode>("new");
const selectedScholarFilter = ref(""); const selectedScholarFilter = ref("");
const searchQuery = ref(""); const searchQuery = ref("");
const sortKey = ref<PublicationSortKey>("first_seen");
const sortDirection = ref<"asc" | "desc">("desc");
const scholars = ref<ScholarProfile[]>([]); const scholars = ref<ScholarProfile[]>([]);
const listState = ref<PublicationsResult | null>(null); const listState = ref<PublicationsResult | null>(null);
@ -39,6 +43,7 @@ const errorRequestId = ref<string | null>(null);
const successMessage = ref<string | null>(null); const successMessage = ref<string | null>(null);
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const textCollator = new Intl.Collator(undefined, { sensitivity: "base", numeric: true });
function normalizeScholarFilterQuery(value: unknown): string { function normalizeScholarFilterQuery(value: unknown): string {
if (Array.isArray(value)) { if (Array.isArray(value)) {
@ -90,6 +95,19 @@ function publicationKey(item: PublicationItem): string {
return `${item.scholar_profile_id}:${item.publication_id}`; return `${item.scholar_profile_id}:${item.publication_id}`;
} }
function scholarLabel(item: ScholarProfile): string {
return item.display_name || item.scholar_id;
}
const selectedScholarName = computed(() => {
const selectedId = Number(selectedScholarFilter.value);
if (!Number.isInteger(selectedId) || selectedId <= 0) {
return "all scholars";
}
const profile = scholars.value.find((item) => item.id === selectedId);
return profile ? scholarLabel(profile) : "the selected scholar";
});
const filteredPublications = computed(() => { const filteredPublications = computed(() => {
const base = listState.value?.publications ?? []; const base = listState.value?.publications ?? [];
const normalized = searchQuery.value.trim().toLowerCase(); const normalized = searchQuery.value.trim().toLowerCase();
@ -106,9 +124,56 @@ const filteredPublications = computed(() => {
}); });
}); });
function publicationSortValue(item: PublicationItem, key: PublicationSortKey): number | string {
if (key === "title") {
return item.title;
}
if (key === "scholar") {
return item.scholar_label;
}
if (key === "year") {
return item.year ?? -1;
}
if (key === "citations") {
return item.citation_count;
}
if (key === "status") {
if (item.is_read) {
return 2;
}
if (item.is_new_in_latest_run) {
return 0;
}
return 1;
}
const timestamp = Date.parse(item.first_seen_at);
return Number.isNaN(timestamp) ? 0 : timestamp;
}
const sortedPublications = computed(() => {
const sorted = [...filteredPublications.value];
sorted.sort((a, b) => {
const left = publicationSortValue(a, sortKey.value);
const right = publicationSortValue(b, sortKey.value);
let comparison: number;
if (typeof left === "string" && typeof right === "string") {
comparison = textCollator.compare(left, right);
} else {
comparison = Number(left) - Number(right);
}
if (comparison === 0) {
comparison = textCollator.compare(a.title, b.title);
}
return sortDirection.value === "asc" ? comparison : comparison * -1;
});
return sorted;
});
const visibleUnreadKeys = computed(() => { const visibleUnreadKeys = computed(() => {
const keys = new Set<string>(); const keys = new Set<string>();
for (const item of filteredPublications.value) { for (const item of sortedPublications.value) {
if (!item.is_read) { if (!item.is_read) {
keys.add(publicationKey(item)); keys.add(publicationKey(item));
} }
@ -117,6 +182,25 @@ const visibleUnreadKeys = computed(() => {
}); });
const selectedCount = computed(() => selectedPublicationKeys.value.size); const selectedCount = computed(() => selectedPublicationKeys.value.size);
const visibleCount = computed(() => sortedPublications.value.length);
const visibleUnreadCount = computed(() => visibleUnreadKeys.value.size);
const actionBusy = computed(() => loading.value || publishingAll.value || publishingSelected.value);
const showingEmptyList = computed(() => Boolean(listState.value) && sortedPublications.value.length === 0);
const modeLabel = computed(() => (mode.value === "new" ? "Needs review" : "All records"));
const emptyTitle = computed(() =>
searchQuery.value.trim().length > 0 ? "No publications match this search" : "No publications found",
);
const emptyBody = computed(() => {
if (searchQuery.value.trim().length > 0) {
return "Try another title, scholar, venue, or year.";
}
if (mode.value === "new") {
return `No publications currently need review for ${selectedScholarName.value}.`;
}
return `No publication records for ${selectedScholarName.value}.`;
});
const allVisibleUnreadSelected = computed(() => { const allVisibleUnreadSelected = computed(() => {
if (visibleUnreadKeys.value.size === 0) { if (visibleUnreadKeys.value.size === 0) {
@ -130,7 +214,7 @@ const allVisibleUnreadSelected = computed(() => {
return true; return true;
}); });
watch(filteredPublications, (items) => { watch(sortedPublications, (items) => {
const validKeys = new Set(items.filter((item) => !item.is_read).map((item) => publicationKey(item))); const validKeys = new Set(items.filter((item) => !item.is_read).map((item) => publicationKey(item)));
const next = new Set<string>(); const next = new Set<string>();
for (const key of selectedPublicationKeys.value) { for (const key of selectedPublicationKeys.value) {
@ -143,6 +227,22 @@ watch(filteredPublications, (items) => {
} }
}); });
function toggleSort(nextKey: PublicationSortKey): void {
if (sortKey.value === nextKey) {
sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
return;
}
sortKey.value = nextKey;
sortDirection.value = nextKey === "first_seen" ? "desc" : "asc";
}
function sortMarker(key: PublicationSortKey): string {
if (sortKey.value !== key) {
return "↕";
}
return sortDirection.value === "asc" ? "↑" : "↓";
}
async function loadScholarFilters(): Promise<void> { async function loadScholarFilters(): Promise<void> {
try { try {
scholars.value = await listScholars(); scholars.value = await listScholars();
@ -240,14 +340,14 @@ async function onMarkSelectedRead(): Promise<void> {
try { try {
const response = await markSelectedRead(selections); const response = await markSelectedRead(selections);
successMessage.value = `${response.updated_count} publication${response.updated_count === 1 ? "" : "s"} marked as read.`; successMessage.value = `${response.updated_count} publication${response.updated_count === 1 ? "" : "s"} marked as reviewed.`;
await loadPublications(); await loadPublications();
} catch (error) { } catch (error) {
if (error instanceof ApiRequestError) { if (error instanceof ApiRequestError) {
errorMessage.value = error.message; errorMessage.value = error.message;
errorRequestId.value = error.requestId; errorRequestId.value = error.requestId;
} else { } else {
errorMessage.value = "Unable to mark selected publications as read."; errorMessage.value = "Unable to mark selected publications as reviewed.";
} }
} finally { } finally {
publishingSelected.value = false; publishingSelected.value = false;
@ -262,20 +362,24 @@ async function onMarkAllRead(): Promise<void> {
try { try {
const response = await markAllRead(); const response = await markAllRead();
successMessage.value = response.message; successMessage.value = `${response.updated_count} publication${response.updated_count === 1 ? "" : "s"} marked as reviewed.`;
await loadPublications(); await loadPublications();
} catch (error) { } catch (error) {
if (error instanceof ApiRequestError) { if (error instanceof ApiRequestError) {
errorMessage.value = error.message; errorMessage.value = error.message;
errorRequestId.value = error.requestId; errorRequestId.value = error.requestId;
} else { } else {
errorMessage.value = "Unable to mark publications as read."; errorMessage.value = "Unable to mark publications as reviewed.";
} }
} finally { } finally {
publishingAll.value = false; publishingAll.value = false;
} }
} }
function resetSearchQuery(): void {
searchQuery.value = "";
}
onMounted(() => { onMounted(() => {
syncScholarFilterFromRoute(); syncScholarFilterFromRoute();
void Promise.all([loadScholarFilters(), loadPublications()]); void Promise.all([loadScholarFilters(), loadPublications()]);
@ -294,17 +398,48 @@ watch(
</script> </script>
<template> <template>
<AppPage title="Publications" subtitle="Filter, search, and triage publication status quickly."> <AppPage
<div class="grid gap-3 xl:grid-cols-[minmax(0,1fr)_auto] xl:items-end"> title="Publications"
<div class="grid gap-3 md:grid-cols-[auto_auto_minmax(0,1fr)] md:items-end"> subtitle="Filter and review discovered publications, then mark what you have handled so the next check stays focused."
<div class="grid gap-1 text-xs text-secondary"> >
<span>Scope</span> <AppCard class="space-y-4">
<div class="flex min-h-10 flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center justify-between gap-2">
<AppButton :variant="mode === 'new' ? 'primary' : 'secondary'" @click="setMode('new')"> <div class="space-y-1">
New <div class="flex items-center gap-1">
<h2 class="text-lg font-semibold text-ink-primary">What you can do here</h2>
<AppHelpHint
text="Publications are discovered records from tracked scholar profiles. Review mode helps focus only on newly detected items."
/>
</div>
<p class="text-sm text-secondary">
Select a scholar or time scope, search within results, and mark items reviewed when you are done.
</p>
</div>
<AppButton variant="ghost" :disabled="loading" @click="loadPublications">
{{ loading ? "Refreshing..." : "Refresh" }}
</AppButton> </AppButton>
<AppButton :variant="mode === 'all' ? 'primary' : 'secondary'" @click="setMode('all')"> </div>
All
<div class="grid gap-3 xl:grid-cols-[minmax(0,15rem)_minmax(0,18rem)_minmax(0,1fr)] xl:items-end">
<div class="grid gap-1 text-xs text-secondary">
<span>View mode</span>
<div class="flex min-h-10 flex-wrap items-center gap-2">
<AppButton
:variant="mode === 'new' ? 'primary' : 'secondary'"
class="min-w-16 justify-center"
:disabled="actionBusy"
@click="setMode('new')"
>
Needs review
</AppButton>
<AppButton
:variant="mode === 'all' ? 'primary' : 'secondary'"
class="min-w-16 justify-center"
:disabled="actionBusy"
@click="setMode('all')"
>
All records
</AppButton> </AppButton>
</div> </div>
</div> </div>
@ -314,100 +449,140 @@ watch(
<AppSelect <AppSelect
id="publications-scholar-filter" id="publications-scholar-filter"
v-model="selectedScholarFilter" v-model="selectedScholarFilter"
:disabled="loading || publishingAll || publishingSelected" :disabled="actionBusy"
@change="onScholarFilterChanged" @change="onScholarFilterChanged"
> >
<option value="">All scholars</option> <option value="">All scholars</option>
<option v-for="scholar in scholars" :key="scholar.id" :value="String(scholar.id)"> <option v-for="scholar in scholars" :key="scholar.id" :value="String(scholar.id)">
{{ scholar.display_name || scholar.scholar_id }} {{ scholarLabel(scholar) }}
</option> </option>
</AppSelect> </AppSelect>
</label> </label>
<label class="grid gap-1 text-xs text-secondary" for="publications-search-input"> <label class="grid gap-1 text-xs text-secondary" for="publications-search-input">
<span>Search</span> <span>Search within current scope</span>
<div class="flex min-w-0 items-center gap-2">
<AppInput <AppInput
id="publications-search-input" id="publications-search-input"
v-model="searchQuery" v-model="searchQuery"
placeholder="Search title, scholar, venue, year" placeholder="Search title, scholar, venue, year"
:disabled="loading" :disabled="loading"
/> />
<AppButton
v-if="searchQuery.trim().length > 0"
variant="secondary"
class="shrink-0"
:disabled="loading"
@click="resetSearchQuery"
>
Clear
</AppButton>
</div>
</label> </label>
</div> </div>
<div class="flex flex-wrap items-center justify-between gap-2 border-t border-stroke-default pt-3">
<div class="flex flex-wrap items-center gap-2">
<AppBadge tone="info">Mode: {{ modeLabel }}</AppBadge>
<AppBadge tone="neutral">Visible: {{ visibleCount }}</AppBadge>
<AppBadge tone="warning">Needs review: {{ visibleUnreadCount }}</AppBadge>
<AppBadge tone="success">Selected: {{ selectedCount }}</AppBadge>
</div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<AppButton <AppButton
variant="secondary" variant="secondary"
:disabled="selectedCount === 0 || loading || publishingSelected || publishingAll" :disabled="selectedCount === 0 || actionBusy"
@click="onMarkSelectedRead" @click="onMarkSelectedRead"
> >
{{ publishingSelected ? "Marking..." : `Mark selected read (${selectedCount})` }} {{ publishingSelected ? "Updating..." : `Mark selected reviewed (${selectedCount})` }}
</AppButton> </AppButton>
<AppButton variant="secondary" :disabled="publishingAll || loading || publishingSelected" @click="onMarkAllRead"> <AppButton variant="secondary" :disabled="actionBusy || visibleUnreadCount === 0" @click="onMarkAllRead">
{{ publishingAll ? "Marking..." : "Mark all unread as read" }} {{ publishingAll ? "Updating..." : "Mark all as reviewed" }}
</AppButton>
<AppButton variant="ghost" :disabled="loading" @click="loadPublications">
{{ loading ? "Refreshing..." : "Refresh" }}
</AppButton> </AppButton>
</div> </div>
</div> </div>
</AppCard>
<AppAlert v-if="successMessage" tone="success" dismissible @dismiss="successMessage = null"> <RequestStateAlerts
<template #title>Update complete</template> :success-message="successMessage"
<p>{{ successMessage }}</p> success-title="Publication update complete"
</AppAlert> :error-message="errorMessage"
:error-request-id="errorRequestId"
<AppAlert v-if="errorMessage" tone="danger"> error-title="Publication request failed"
<template #title>Publication request failed</template> @dismiss-success="successMessage = null"
<p>{{ errorMessage }}</p>
<p class="text-secondary">Request ID: {{ errorRequestId || "n/a" }}</p>
</AppAlert>
<AppSkeleton v-if="loading" :lines="8" />
<template v-else-if="listState">
<AppCard class="space-y-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Publication list</h2>
<div class="flex flex-wrap items-center gap-2">
<AppBadge tone="info">Mode: {{ listState.mode }}</AppBadge>
<AppBadge tone="neutral">Visible: {{ filteredPublications.length }}</AppBadge>
</div>
</div>
<AppEmptyState
v-if="filteredPublications.length === 0"
title="No publications found"
body="Try changing mode, scholar filter, or search terms."
/> />
<AppTable v-else label="Publication list table"> <AppCard class="space-y-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex items-center gap-1">
<h2 class="text-lg font-semibold text-ink-primary">Publication List</h2>
<AppHelpHint
text="Use sorting, search, and bulk actions here to move discovered records from needs-review into reviewed history."
/>
</div>
<span class="text-xs text-secondary">Currently showing {{ selectedScholarName }}</span>
</div>
<AsyncStateGate
:loading="loading"
:loading-lines="8"
:empty="showingEmptyList"
:empty-title="emptyTitle"
:empty-body="emptyBody"
:show-empty="!errorMessage"
>
<AppTable v-if="listState" label="Publication list table">
<thead> <thead>
<tr> <tr>
<th scope="col" class="w-12"> <th scope="col" class="w-12">
<input <input
type="checkbox" type="checkbox"
class="h-4 w-4 rounded border-zinc-400 text-brand-600 focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:border-zinc-600 dark:bg-zinc-900 dark:focus-visible:ring-brand-400 dark:focus-visible:ring-offset-zinc-950" class="h-4 w-4 rounded border-stroke-interactive bg-surface-input text-brand-600 focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset"
:checked="allVisibleUnreadSelected" :checked="allVisibleUnreadSelected"
:disabled="visibleUnreadKeys.size === 0" :disabled="visibleUnreadKeys.size === 0"
aria-label="Select all visible unread publications" aria-label="Select all visible publications that need review"
@change="onToggleAllVisible" @change="onToggleAllVisible"
/> />
</th> </th>
<th scope="col">Title</th> <th scope="col">
<th scope="col">Scholar</th> <button type="button" class="table-sort" @click="toggleSort('title')">
<th scope="col">Year</th> Title <span aria-hidden="true">{{ sortMarker("title") }}</span>
<th scope="col">Citations</th> </button>
<th scope="col">Status</th> </th>
<th scope="col">First seen</th> <th scope="col">
<button type="button" class="table-sort" @click="toggleSort('scholar')">
Scholar <span aria-hidden="true">{{ sortMarker("scholar") }}</span>
</button>
</th>
<th scope="col">
<button type="button" class="table-sort" @click="toggleSort('year')">
Year <span aria-hidden="true">{{ sortMarker("year") }}</span>
</button>
</th>
<th scope="col">
<button type="button" class="table-sort" @click="toggleSort('citations')">
Citations <span aria-hidden="true">{{ sortMarker("citations") }}</span>
</button>
</th>
<th scope="col">
<button type="button" class="table-sort" @click="toggleSort('status')">
Review status <span aria-hidden="true">{{ sortMarker("status") }}</span>
</button>
</th>
<th scope="col">
<button type="button" class="table-sort" @click="toggleSort('first_seen')">
First seen <span aria-hidden="true">{{ sortMarker("first_seen") }}</span>
</button>
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="item in filteredPublications" :key="publicationKey(item)"> <tr v-for="item in sortedPublications" :key="publicationKey(item)">
<td> <td>
<input <input
type="checkbox" type="checkbox"
class="h-4 w-4 rounded border-zinc-400 text-brand-600 focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:border-zinc-600 dark:bg-zinc-900 dark:focus-visible:ring-brand-400 dark:focus-visible:ring-offset-zinc-950" class="h-4 w-4 rounded border-stroke-interactive bg-surface-input text-brand-600 focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset"
:checked="selectedPublicationKeys.has(publicationKey(item))" :checked="selectedPublicationKeys.has(publicationKey(item))"
:disabled="item.is_read" :disabled="item.is_read"
:aria-label="`Select publication ${item.title}`" :aria-label="`Select publication ${item.title}`"
@ -432,10 +607,10 @@ watch(
<td> <td>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<AppBadge :tone="item.is_new_in_latest_run ? 'info' : 'neutral'"> <AppBadge :tone="item.is_new_in_latest_run ? 'info' : 'neutral'">
{{ item.is_new_in_latest_run ? "New" : "Existing" }} {{ item.is_new_in_latest_run ? "New this check" : "Previously seen" }}
</AppBadge> </AppBadge>
<AppBadge :tone="item.is_read ? 'success' : 'warning'"> <AppBadge :tone="item.is_read ? 'success' : 'warning'">
{{ item.is_read ? "Read" : "Unread" }} {{ item.is_read ? "Reviewed" : "Needs review" }}
</AppBadge> </AppBadge>
</div> </div>
</td> </td>
@ -443,7 +618,13 @@ watch(
</tr> </tr>
</tbody> </tbody>
</AppTable> </AppTable>
</AsyncStateGate>
</AppCard> </AppCard>
</template>
</AppPage> </AppPage>
</template> </template>
<style scoped>
.table-sort {
@apply inline-flex items-center gap-1 text-left font-semibold text-ink-primary transition hover:text-ink-link focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset;
}
</style>

View file

@ -3,12 +3,13 @@ import { computed, onMounted, ref } from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import AppPage from "@/components/layout/AppPage.vue"; import AppPage from "@/components/layout/AppPage.vue";
import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue";
import RunStatusBadge from "@/components/patterns/RunStatusBadge.vue"; import RunStatusBadge from "@/components/patterns/RunStatusBadge.vue";
import AppAlert from "@/components/ui/AppAlert.vue";
import AppButton from "@/components/ui/AppButton.vue"; import AppButton from "@/components/ui/AppButton.vue";
import AppCard from "@/components/ui/AppCard.vue"; import AppCard from "@/components/ui/AppCard.vue";
import AppEmptyState from "@/components/ui/AppEmptyState.vue"; import AppEmptyState from "@/components/ui/AppEmptyState.vue";
import AppSkeleton from "@/components/ui/AppSkeleton.vue"; import AppHelpHint from "@/components/ui/AppHelpHint.vue";
import AppTable from "@/components/ui/AppTable.vue"; import AppTable from "@/components/ui/AppTable.vue";
import { getRunDetail, type RunDetail } from "@/features/runs"; import { getRunDetail, type RunDetail } from "@/features/runs";
import { ApiRequestError } from "@/lib/api/errors"; import { ApiRequestError } from "@/lib/api/errors";
@ -21,6 +22,35 @@ const errorMessage = ref<string | null>(null);
const errorRequestId = ref<string | null>(null); const errorRequestId = ref<string | null>(null);
const runId = computed(() => Number(route.params.id)); const runId = computed(() => Number(route.params.id));
const defaultRetryCounts = {
retries_scheduled_count: 0,
scholars_with_retries_count: 0,
retry_exhausted_count: 0,
};
function sortedCountEntries(record: Record<string, number> | null | undefined): Array<[string, number]> {
if (!record) {
return [];
}
return Object.entries(record)
.filter(([, value]) => Number.isFinite(value) && value > 0)
.sort((a, b) => (b[1] - a[1]) || a[0].localeCompare(b[0]));
}
const failureBucketEntries = computed(() =>
detail.value ? sortedCountEntries(detail.value.summary.scrape_failure_counts) : [],
);
const alertFlagEntries = computed(() => {
if (!detail.value) {
return [];
}
return Object.entries(detail.value.summary.alert_flags ?? {})
.filter(([, enabled]) => Boolean(enabled))
.sort((a, b) => a[0].localeCompare(b[0]));
});
const retryCounts = computed(() => detail.value?.summary.retry_counts ?? defaultRetryCounts);
function formatDate(value: string | null): string { function formatDate(value: string | null): string {
if (!value) { if (!value) {
@ -67,39 +97,89 @@ onMounted(() => {
<template> <template>
<AppPage title="Run Detail" subtitle="Per-scholar diagnostics for the selected run."> <AppPage title="Run Detail" subtitle="Per-scholar diagnostics for the selected run.">
<div class="flex justify-end"> <AppCard class="space-y-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="space-y-1">
<div class="flex items-center gap-1">
<h2 class="text-lg font-semibold text-ink-primary">Diagnostics controls</h2>
<AppHelpHint
text="Run diagnostics explain per-scholar outcomes, retry behavior, and scrape failure classifications."
/>
</div>
<p class="text-sm text-secondary">Refresh this run detail view after queue/run updates.</p>
</div>
<AppButton variant="secondary" @click="loadDetail" :disabled="loading"> <AppButton variant="secondary" @click="loadDetail" :disabled="loading">
{{ loading ? "Refreshing..." : "Refresh" }} {{ loading ? "Refreshing..." : "Refresh" }}
</AppButton> </AppButton>
</div> </div>
</AppCard>
<AppAlert v-if="errorMessage" tone="danger"> <RequestStateAlerts
<template #title>Run detail request failed</template> :error-message="errorMessage"
<p>{{ errorMessage }}</p> :error-request-id="errorRequestId"
<p class="text-secondary">Request ID: {{ errorRequestId || "n/a" }}</p> error-title="Run detail request failed"
</AppAlert> />
<AppSkeleton v-if="loading" :lines="8" /> <AsyncStateGate :loading="loading" :loading-lines="8" :show-empty="false">
<template v-if="detail">
<template v-else-if="detail">
<AppCard class="space-y-4"> <AppCard class="space-y-4">
<div class="flex flex-wrap items-start justify-between gap-3"> <div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1"> <div class="space-y-1">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Run #{{ detail.run.id }}</h2> <div class="flex items-center gap-1">
<h2 class="text-lg font-semibold text-ink-primary">Run #{{ detail.run.id }}</h2>
<AppHelpHint text="This panel summarizes one full checking cycle and its completion status." />
</div>
<p class="text-sm text-secondary">Started: {{ formatDate(detail.run.start_dt) }}</p> <p class="text-sm text-secondary">Started: {{ formatDate(detail.run.start_dt) }}</p>
<p class="text-sm text-secondary">Ended: {{ formatDate(detail.run.end_dt) }}</p> <p class="text-sm text-secondary">Ended: {{ formatDate(detail.run.end_dt) }}</p>
</div> </div>
<RunStatusBadge :status="detail.run.status" /> <RunStatusBadge :status="detail.run.status" />
</div> </div>
<p class="text-sm text-zinc-700 dark:text-zinc-300"> <p class="text-sm text-ink-secondary">
Outcome summary: {{ detail.summary.succeeded_count }} succeeded, {{ detail.summary.partial_count }} partial, Outcome summary: {{ detail.summary.succeeded_count }} succeeded, {{ detail.summary.partial_count }} partial,
{{ detail.summary.failed_count }} failed. {{ detail.summary.failed_count }} failed.
</p> </p>
<div class="grid gap-3 md:grid-cols-3">
<div class="rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-2 text-sm">
<p class="font-medium text-ink-primary">Retries scheduled</p>
<p>{{ retryCounts.retries_scheduled_count }}</p>
</div>
<div class="rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-2 text-sm">
<p class="font-medium text-ink-primary">Scholars with retries</p>
<p>{{ retryCounts.scholars_with_retries_count }}</p>
</div>
<div class="rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-2 text-sm">
<p class="font-medium text-ink-primary">Retry exhausted</p>
<p>{{ retryCounts.retry_exhausted_count }}</p>
</div>
</div>
<div class="grid gap-3 md:grid-cols-2">
<div class="rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-2 text-sm">
<p class="font-medium text-ink-primary">Scrape failure buckets</p>
<p v-if="failureBucketEntries.length === 0" class="text-secondary">n/a</p>
<ul v-else class="space-y-1">
<li v-for="[name, count] in failureBucketEntries" :key="name">
<code>{{ name }}</code>: {{ count }}
</li>
</ul>
</div>
<div class="rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-2 text-sm">
<p class="font-medium text-ink-primary">Active alerts</p>
<p v-if="alertFlagEntries.length === 0" class="text-secondary">none</p>
<ul v-else class="space-y-1">
<li v-for="[name] in alertFlagEntries" :key="name">
<code>{{ name }}</code>
</li>
</ul>
</div>
</div>
</AppCard> </AppCard>
<AppCard class="space-y-4"> <AppCard class="space-y-4">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Scholar Results</h2> <div class="flex items-center gap-1">
<h2 class="text-lg font-semibold text-ink-primary">Scholar Results</h2>
<AppHelpHint text="One row per scholar profile processed in this run, including final state and warnings." />
</div>
<AppEmptyState <AppEmptyState
v-if="detail.scholar_results.length === 0" v-if="detail.scholar_results.length === 0"
title="No scholar diagnostics" title="No scholar diagnostics"
@ -130,5 +210,6 @@ onMounted(() => {
</AppTable> </AppTable>
</AppCard> </AppCard>
</template> </template>
</AsyncStateGate>
</AppPage> </AppPage>
</template> </template>

View file

@ -2,13 +2,14 @@
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import AppPage from "@/components/layout/AppPage.vue"; import AppPage from "@/components/layout/AppPage.vue";
import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
import QueueHealthBadge from "@/components/patterns/QueueHealthBadge.vue"; import QueueHealthBadge from "@/components/patterns/QueueHealthBadge.vue";
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue";
import RunStatusBadge from "@/components/patterns/RunStatusBadge.vue"; import RunStatusBadge from "@/components/patterns/RunStatusBadge.vue";
import AppAlert from "@/components/ui/AppAlert.vue";
import AppButton from "@/components/ui/AppButton.vue"; import AppButton from "@/components/ui/AppButton.vue";
import AppCard from "@/components/ui/AppCard.vue"; import AppCard from "@/components/ui/AppCard.vue";
import AppEmptyState from "@/components/ui/AppEmptyState.vue"; import AppEmptyState from "@/components/ui/AppEmptyState.vue";
import AppSkeleton from "@/components/ui/AppSkeleton.vue"; import AppHelpHint from "@/components/ui/AppHelpHint.vue";
import AppTable from "@/components/ui/AppTable.vue"; import AppTable from "@/components/ui/AppTable.vue";
import { import {
clearQueueItem, clearQueueItem,
@ -136,7 +137,23 @@ onMounted(() => {
<template> <template>
<AppPage title="Runs" subtitle="Manual run controls and continuation queue diagnostics."> <AppPage title="Runs" subtitle="Manual run controls and continuation queue diagnostics.">
<AppCard class="space-y-4">
<div class="flex flex-wrap items-center justify-between gap-3"> <div class="flex flex-wrap items-center justify-between gap-3">
<div class="space-y-1">
<div class="flex items-center gap-1">
<h2 class="text-lg font-semibold text-ink-primary">Run controls</h2>
<AppHelpHint
text="A run is one full publication-check cycle across eligible tracked scholar profiles."
/>
</div>
<p class="text-sm text-secondary">Trigger runs and monitor continuation-queue pressure.</p>
</div>
<QueueHealthBadge
:queued="queueCounts.queued"
:retrying="queueCounts.retrying"
:dropped="queueCounts.dropped"
/>
</div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<AppButton :disabled="pendingRun" @click="onTriggerManualRun"> <AppButton :disabled="pendingRun" @click="onTriggerManualRun">
{{ pendingRun ? "Triggering..." : "Run now" }} {{ pendingRun ? "Triggering..." : "Run now" }}
@ -145,30 +162,22 @@ onMounted(() => {
{{ loading ? "Refreshing..." : "Refresh" }} {{ loading ? "Refreshing..." : "Refresh" }}
</AppButton> </AppButton>
</div> </div>
</AppCard>
<QueueHealthBadge <RequestStateAlerts
:queued="queueCounts.queued" :success-message="successMessage"
:retrying="queueCounts.retrying" :error-message="errorMessage"
:dropped="queueCounts.dropped" :error-request-id="errorRequestId"
error-title="Run diagnostics request failed"
@dismiss-success="successMessage = null"
/> />
</div>
<AppAlert v-if="successMessage" tone="success" dismissible @dismiss="successMessage = null"> <AsyncStateGate :loading="loading" :loading-lines="8" :show-empty="false">
<template #title>Operation complete</template>
<p>{{ successMessage }}</p>
</AppAlert>
<AppAlert v-if="errorMessage" tone="danger">
<template #title>Run diagnostics request failed</template>
<p>{{ errorMessage }}</p>
<p class="text-secondary">Request ID: {{ errorRequestId || "n/a" }}</p>
</AppAlert>
<AppSkeleton v-if="loading" :lines="8" />
<template v-else>
<AppCard class="space-y-4"> <AppCard class="space-y-4">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Recent Runs</h2> <div class="flex items-center gap-1">
<h2 class="text-lg font-semibold text-ink-primary">Recent Runs</h2>
<AppHelpHint text="Recent run history with volume and failure indicators per cycle." />
</div>
<AppEmptyState <AppEmptyState
v-if="runs.length === 0" v-if="runs.length === 0"
title="No runs found" title="No runs found"
@ -207,7 +216,12 @@ onMounted(() => {
</AppCard> </AppCard>
<AppCard class="space-y-4"> <AppCard class="space-y-4">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Continuation Queue</h2> <div class="flex items-center gap-1">
<h2 class="text-lg font-semibold text-ink-primary">Continuation Queue</h2>
<AppHelpHint
text="Retry queue for interrupted profile checks. Use actions to retry, drop, or clear items."
/>
</div>
<AppEmptyState <AppEmptyState
v-if="queueItems.length === 0" v-if="queueItems.length === 0"
title="Queue is empty" title="Queue is empty"
@ -261,6 +275,6 @@ onMounted(() => {
</tbody> </tbody>
</AppTable> </AppTable>
</AppCard> </AppCard>
</template> </AsyncStateGate>
</AppPage> </AppPage>
</template> </template>

View file

@ -2,15 +2,15 @@
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import AppPage from "@/components/layout/AppPage.vue"; import AppPage from "@/components/layout/AppPage.vue";
import RunStatusBadge from "@/components/patterns/RunStatusBadge.vue"; import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue";
import AppAlert from "@/components/ui/AppAlert.vue"; import AppAlert from "@/components/ui/AppAlert.vue";
import AppBadge from "@/components/ui/AppBadge.vue"; import AppBadge from "@/components/ui/AppBadge.vue";
import AppButton from "@/components/ui/AppButton.vue"; import AppButton from "@/components/ui/AppButton.vue";
import AppCard from "@/components/ui/AppCard.vue"; import AppCard from "@/components/ui/AppCard.vue";
import AppEmptyState from "@/components/ui/AppEmptyState.vue"; import AppHelpHint from "@/components/ui/AppHelpHint.vue";
import AppInput from "@/components/ui/AppInput.vue"; import AppInput from "@/components/ui/AppInput.vue";
import AppModal from "@/components/ui/AppModal.vue"; import AppModal from "@/components/ui/AppModal.vue";
import AppSkeleton from "@/components/ui/AppSkeleton.vue";
import AppTable from "@/components/ui/AppTable.vue"; import AppTable from "@/components/ui/AppTable.vue";
import { import {
clearScholarImage, clearScholarImage,
@ -25,6 +25,7 @@ import {
type ScholarSearchCandidate, type ScholarSearchCandidate,
type ScholarSearchResult, type ScholarSearchResult,
} from "@/features/scholars"; } from "@/features/scholars";
import ScholarAvatar from "@/features/scholars/components/ScholarAvatar.vue";
import { ApiRequestError } from "@/lib/api/errors"; import { ApiRequestError } from "@/lib/api/errors";
const loading = ref(true); const loading = ref(true);
@ -38,7 +39,6 @@ const activeScholarSettingsId = ref<number | null>(null);
const scholars = ref<ScholarProfile[]>([]); const scholars = ref<ScholarProfile[]>([]);
const imageUrlDraftByScholarId = ref<Record<number, string>>({}); const imageUrlDraftByScholarId = ref<Record<number, string>>({});
const failedImageByKey = ref<Record<string, boolean>>({});
const scholarBatchInput = ref(""); const scholarBatchInput = ref("");
const searchQuery = ref(""); const searchQuery = ref("");
@ -52,34 +52,40 @@ const successMessage = ref<string | null>(null);
const SCHOLAR_ID_PATTERN = /^[a-zA-Z0-9_-]{12}$/; const SCHOLAR_ID_PATTERN = /^[a-zA-Z0-9_-]{12}$/;
const URL_USER_PARAM_PATTERN = /(?:\?|&)user=([a-zA-Z0-9_-]{12})(?:&|#|$)/i; const URL_USER_PARAM_PATTERN = /(?:\?|&)user=([a-zA-Z0-9_-]{12})(?:&|#|$)/i;
const nameSearchWip = true;
const trackedScholarIds = computed(() => new Set(scholars.value.map((item) => item.scholar_id))); const trackedScholarIds = computed(() => new Set(scholars.value.map((item) => item.scholar_id)));
const activeScholarSettings = computed( const activeScholarSettings = computed(
() => scholars.value.find((item) => item.id === activeScholarSettingsId.value) ?? null, () => scholars.value.find((item) => item.id === activeScholarSettingsId.value) ?? null,
); );
const parsedBatchCount = computed(() => parseScholarIds(scholarBatchInput.value).length);
const searchHasRun = computed(() => searchResult.value !== null);
const searchIsDegraded = computed(() => { const searchIsDegraded = computed(() => {
if (!searchResult.value) { if (!searchResult.value) {
return false; return false;
} }
return searchResult.value.state === "blocked_or_captcha" || searchResult.value.state === "network_error"; return searchResult.value.state === "blocked_or_captcha" || searchResult.value.state === "network_error";
}); });
const searchStateTone = computed(() => {
function formatDate(value: string | null, compact = false): string { const result = searchResult.value;
if (!value) { if (!result) {
return "n/a"; return "neutral" as const;
} }
const asDate = new Date(value); if (result.state === "ok") {
if (Number.isNaN(asDate.getTime())) { return "success" as const;
return value;
} }
if (compact) { if (result.state === "blocked_or_captcha" || result.state === "network_error") {
return asDate.toLocaleString(undefined, { return "warning" as const;
dateStyle: "short",
timeStyle: "short",
});
} }
return asDate.toLocaleString(); return "neutral" as const;
} });
const emptySearchCandidates = computed(() => {
const result = searchResult.value;
if (!result) {
return false;
}
return result.candidates.length === 0;
});
function scholarProfileUrl(scholarId: string): string { function scholarProfileUrl(scholarId: string): string {
return `https://scholar.google.com/citations?hl=en&user=${encodeURIComponent(scholarId)}`; return `https://scholar.google.com/citations?hl=en&user=${encodeURIComponent(scholarId)}`;
@ -138,58 +144,6 @@ function parseScholarIds(raw: string): string[] {
return ordered; return ordered;
} }
function formatImageSource(value: ScholarProfile["profile_image_source"]): string {
if (value === "upload") {
return "Uploaded";
}
if (value === "override") {
return "Custom URL";
}
if (value === "scraped") {
return "Scraped";
}
return "Fallback";
}
function sourceTone(value: ScholarProfile["profile_image_source"]): "neutral" | "info" | "success" {
if (value === "upload") {
return "success";
}
if (value === "override" || value === "scraped") {
return "info";
}
return "neutral";
}
function makeInitials(label: string | null | undefined, scholarId: string): string {
const source = (label || "").trim();
if (source.length === 0) {
return scholarId.slice(0, 2).toUpperCase();
}
const tokens = source.split(/\s+/).filter(Boolean);
if (tokens.length === 1) {
return tokens[0].slice(0, 2).toUpperCase();
}
return `${tokens[0].charAt(0)}${tokens[1].charAt(0)}`.toUpperCase();
}
function imageKey(prefix: string, id: string | number, imageUrl: string | null): string {
return `${prefix}:${String(id)}:${imageUrl || "none"}`;
}
function canRenderImage(prefix: string, id: string | number, imageUrl: string | null): boolean {
if (!imageUrl) {
return false;
}
return !failedImageByKey.value[imageKey(prefix, id, imageUrl)];
}
function markImageFailed(prefix: string, id: string | number, imageUrl: string | null): void {
failedImageByKey.value[imageKey(prefix, id, imageUrl)] = true;
}
function scholarLabel(profile: ScholarProfile): string { function scholarLabel(profile: ScholarProfile): string {
return profile.display_name || profile.scholar_id; return profile.display_name || profile.scholar_id;
} }
@ -311,6 +265,11 @@ async function onAddScholars(): Promise<void> {
} }
async function onSearchByName(): Promise<void> { async function onSearchByName(): Promise<void> {
if (nameSearchWip) {
searchErrorMessage.value = "Search by name is temporarily disabled while reliability hardening is in progress.";
searchErrorRequestId.value = null;
return;
}
searchingByName.value = true; searchingByName.value = true;
searchErrorMessage.value = null; searchErrorMessage.value = null;
searchErrorRequestId.value = null; searchErrorRequestId.value = null;
@ -502,171 +461,151 @@ onMounted(() => {
</script> </script>
<template> <template>
<AppPage title="Scholars" subtitle="Track scholars and manage profile behavior with less noise."> <AppPage
<div class="flex justify-end"> title="Scholars"
<AppButton variant="secondary" @click="loadScholars" :disabled="loading || saving || searchingByName"> subtitle="Add and maintain the Google Scholar profiles you want Scholarr to monitor."
{{ loading ? "Refreshing..." : "Refresh" }} >
</AppButton> <RequestStateAlerts
</div> :success-message="successMessage"
success-title="Scholar update complete"
:error-message="errorMessage"
:error-request-id="errorRequestId"
error-title="Scholar request failed"
@dismiss-success="successMessage = null"
/>
<AppAlert v-if="successMessage" tone="success" dismissible @dismiss="successMessage = null"> <section class="grid gap-4 xl:grid-cols-2">
<template #title>Scholar update complete</template>
<p>{{ successMessage }}</p>
</AppAlert>
<AppAlert v-if="errorMessage" tone="danger">
<template #title>Scholar request failed</template>
<p>{{ errorMessage }}</p>
<p class="text-secondary">Request ID: {{ errorRequestId || "n/a" }}</p>
</AppAlert>
<section class="grid gap-4 xl:grid-cols-[minmax(0,34rem)_minmax(0,1fr)]">
<div class="grid content-start gap-4"> <div class="grid content-start gap-4">
<AppCard class="space-y-4"> <AppCard class="space-y-4">
<div class="space-y-1"> <div class="space-y-1">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Add Scholars</h2> <div class="flex items-center gap-1">
<p class="text-sm text-secondary"> <h2 class="text-lg font-semibold text-ink-primary">Add Scholar Profiles</h2>
Paste one or many Scholar IDs or profile URLs. Duplicate and already-tracked IDs are ignored per response. <AppHelpHint
</p> text="A scholar profile is a Google Scholar author page that Scholarr will monitor for publication changes."
/>
</div>
<p class="text-sm text-secondary">Paste one or more Scholar IDs or profile URLs and add them in one action.</p>
</div> </div>
<form class="grid gap-3" @submit.prevent="onAddScholars"> <form class="grid gap-3" @submit.prevent="onAddScholars">
<label class="grid gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300"> <label class="grid gap-2 text-sm font-medium text-ink-secondary" for="scholar-batch-input">
<span>Scholar IDs or profile URLs</span> <span>Scholar IDs or profile URLs</span>
<textarea <textarea
id="scholar-batch-input" id="scholar-batch-input"
v-model="scholarBatchInput" v-model="scholarBatchInput"
rows="5" rows="5"
placeholder="A-UbBTPM15wL\nhttps://scholar.google.com/citations?hl=en&user=A-UbBTPM15wL" placeholder="A-UbBTPM15wL\nhttps://scholar.google.com/citations?hl=en&user=A-UbBTPM15wL"
class="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 outline-none ring-brand-300 transition placeholder:text-zinc-400 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:ring-brand-400 dark:focus-visible:ring-offset-zinc-950 dark:placeholder:text-zinc-500" class="w-full rounded-lg border border-stroke-interactive bg-surface-input px-3 py-2 text-sm text-ink-primary outline-none ring-focus-ring transition placeholder:text-ink-muted focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset disabled:cursor-not-allowed disabled:opacity-60"
/> />
</label> </label>
<div class="flex flex-wrap items-center justify-between gap-2"> <div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-xs text-secondary">
Parsed IDs: <strong class="text-ink-primary">{{ parsedBatchCount }}</strong>
</p>
<AppButton type="submit" :disabled="saving || loading"> <AppButton type="submit" :disabled="saving || loading">
{{ saving ? "Adding..." : "Add scholars" }} {{ saving ? "Adding..." : "Add scholars" }}
</AppButton> </AppButton>
<span class="text-xs text-secondary">Accepted pattern: <code>[a-zA-Z0-9_-]{12}</code></span>
</div> </div>
</form> </form>
</AppCard> </AppCard>
<AppCard class="space-y-4"> <AppCard class="relative space-y-4 select-none">
<div class="flex flex-wrap items-start justify-between gap-2">
<div class="space-y-1"> <div class="space-y-1">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Search by Name</h2> <div class="flex items-center gap-1">
<p class="text-sm text-secondary"> <h2 class="text-lg font-semibold text-ink-primary">Search by Name</h2>
Best-effort helper. For reliable adds, use Scholar URL/ID above. <AppHelpHint text="This workflow is currently paused while anti-block reliability changes are finalized." />
</p> </div>
<p class="text-sm text-secondary">This helper is paused while reliability hardening is completed.</p>
</div>
<AppHelpHint text="Name search is not currently supported in production. It is on the roadmap and will return after reliability hardening.">
<template #trigger>
<AppBadge tone="warning">WIP</AppBadge>
</template>
</AppHelpHint>
</div> </div>
<form class="flex flex-wrap items-center gap-2" @submit.prevent="onSearchByName"> <form class="pointer-events-none flex flex-wrap items-center gap-2 opacity-60">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<AppInput <AppInput
id="scholar-search-name" id="scholar-search-name"
v-model="searchQuery" v-model="searchQuery"
placeholder="e.g. Geoffrey Hinton" placeholder="e.g. Geoffrey Hinton"
:disabled="searchingByName" :disabled="nameSearchWip"
/> />
</div> </div>
<AppButton type="submit" :disabled="searchingByName || searchQuery.trim().length < 2"> <AppButton type="button" :disabled="nameSearchWip">
{{ searchingByName ? "Searching..." : "Search" }} Search
</AppButton> </AppButton>
</form> </form>
<p class="text-xs text-secondary">
Direct Scholar ID/URL adds above remain the dependable path while this feature is in progress.
</p>
<AppAlert v-if="searchErrorMessage" tone="danger"> <RequestStateAlerts
<template #title>Search failed</template> :error-message="searchErrorMessage"
<p>{{ searchErrorMessage }}</p> :error-request-id="searchErrorRequestId"
<p class="text-secondary">Request ID: {{ searchErrorRequestId || "n/a" }}</p> error-title="Search request failed"
</AppAlert> />
<AppSkeleton v-else-if="searchingByName" :lines="4" /> <AsyncStateGate :loading="searchingByName" :loading-lines="4" :show-empty="false">
<template v-if="searchResult">
<template v-else-if="searchResult">
<div class="flex flex-wrap items-center justify-between gap-2"> <div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-sm text-secondary"> <p class="text-sm text-secondary">
{{ searchResult.candidates.length }} candidate{{ searchResult.candidates.length === 1 ? "" : "s" }} for {{ searchResult.candidates.length }} candidate{{ searchResult.candidates.length === 1 ? "" : "s" }}
<strong class="text-zinc-900 dark:text-zinc-100">{{ searchResult.query }}</strong> for <strong class="text-ink-primary">{{ searchResult.query }}</strong>
</p> </p>
<AppBadge <AppBadge :tone="searchStateTone">{{ searchResult.state }}</AppBadge>
:tone="
searchResult.state === 'ok'
? 'success'
: searchResult.state === 'blocked_or_captcha' || searchResult.state === 'network_error'
? 'warning'
: 'neutral'
"
>
{{ searchResult.state }}
</AppBadge>
</div> </div>
<p
v-if="searchResult.state !== 'ok' || searchResult.warnings.length > 0" <p v-if="searchResult.state !== 'ok' || searchResult.warnings.length > 0" class="text-xs text-secondary">
class="text-xs text-secondary"
>
<span>State reason: <code>{{ searchResult.state_reason }}</code></span> <span>State reason: <code>{{ searchResult.state_reason }}</code></span>
<span v-if="searchResult.action_hint">. {{ searchResult.action_hint }}</span> <span v-if="searchResult.action_hint">. {{ searchResult.action_hint }}</span>
<span v-if="searchResult.warnings.length > 0"> <span v-if="searchResult.warnings.length > 0">. Warnings: {{ searchResult.warnings.join(", ") }}</span>
. Warnings: {{ searchResult.warnings.join(", ") }}
</span>
</p> </p>
<AppAlert v-if="searchIsDegraded" tone="warning"> <AppAlert v-if="searchIsDegraded" tone="warning">
<template #title>Name search is degraded</template> <template #title>Name search is degraded</template>
<p> <p>
To avoid blocks, this feature is throttled and may temporarily pause itself. Use Scholar URL/ID adds This endpoint throttles aggressively to avoid blocks. Use Scholar URL/ID adds for dependable tracking.
for dependable tracking.
</p> </p>
</AppAlert> </AppAlert>
<AppEmptyState <AsyncStateGate
v-if="searchResult.candidates.length === 0" :loading="false"
title="No scholar matches returned" :show-empty="true"
body="Try again later or paste Scholar profile URLs/IDs to continue safely." :empty="emptySearchCandidates"
/> empty-title="No scholar matches returned"
empty-body="Try another query later or add directly by Scholar URL/ID."
<ul v-else class="grid gap-3"> >
<ul class="grid gap-3">
<li <li
v-for="candidate in searchResult.candidates" v-for="candidate in searchResult.candidates"
:key="candidate.scholar_id" :key="candidate.scholar_id"
class="rounded-xl border border-zinc-200 bg-zinc-50/70 p-3 dark:border-zinc-800 dark:bg-zinc-900/50" class="rounded-xl border border-stroke-default bg-surface-card-muted/70 p-3"
> >
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<div <ScholarAvatar
class="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-full border border-zinc-200 bg-zinc-100 text-xs font-semibold text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200" size="sm"
> :label="candidate.display_name"
<img :scholar-id="candidate.scholar_id"
v-if="canRenderImage('candidate', candidate.scholar_id, candidate.profile_image_url)" :image-url="candidate.profile_image_url"
:src="candidate.profile_image_url || ''"
:alt="`${candidate.display_name} profile image`"
class="h-full w-full object-cover"
loading="lazy"
referrerpolicy="no-referrer"
@error="markImageFailed('candidate', candidate.scholar_id, candidate.profile_image_url)"
/> />
<span v-else>{{ makeInitials(candidate.display_name, candidate.scholar_id) }}</span>
</div>
<div class="min-w-0 flex-1 space-y-1"> <div class="min-w-0 flex-1 space-y-1">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<strong class="text-sm text-zinc-900 dark:text-zinc-100">{{ candidate.display_name }}</strong> <strong class="text-sm text-ink-primary">{{ candidate.display_name }}</strong>
<code class="text-xs text-secondary">{{ candidate.scholar_id }}</code> <code class="text-xs text-secondary">{{ candidate.scholar_id }}</code>
<AppBadge v-if="trackedScholarIds.has(candidate.scholar_id)" tone="success">Tracked</AppBadge> <AppBadge v-if="trackedScholarIds.has(candidate.scholar_id)" tone="success">Tracked</AppBadge>
</div> </div>
<p class="truncate text-xs text-secondary"> <p class="truncate text-xs text-secondary">{{ candidate.affiliation || "No affiliation provided" }}</p>
{{ candidate.affiliation || "No affiliation provided" }}
</p>
<div class="flex flex-wrap items-center gap-2 text-xs text-secondary"> <div class="flex flex-wrap items-center gap-2 text-xs text-secondary">
<span v-if="candidate.email_domain">Email: {{ candidate.email_domain }}</span> <span v-if="candidate.email_domain">Email: {{ candidate.email_domain }}</span>
<span v-if="candidate.cited_by_count !== null">Cited by: {{ candidate.cited_by_count }}</span> <span v-if="candidate.cited_by_count !== null">Cited by: {{ candidate.cited_by_count }}</span>
</div> </div>
<div class="flex flex-wrap items-center gap-1">
<AppBadge v-for="interest in candidate.interests.slice(0, 3)" :key="interest" tone="neutral">
{{ interest }}
</AppBadge>
</div>
</div> </div>
<div class="grid shrink-0 gap-2"> <div class="grid shrink-0 gap-2">
@ -683,62 +622,71 @@ onMounted(() => {
</div> </div>
</li> </li>
</ul> </ul>
</AsyncStateGate>
</template> </template>
<p v-else-if="!searchErrorMessage && !searchHasRun" class="text-sm text-secondary">
<p v-else class="text-sm text-secondary">Run a name search to see matching scholar candidates.</p> Run a name search to get candidates with one-click add actions.
</p>
</AsyncStateGate>
</AppCard> </AppCard>
</div> </div>
<AppCard class="space-y-4"> <AppCard class="space-y-4">
<div class="flex flex-wrap items-center justify-between gap-2"> <div class="space-y-3">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Tracked Scholars</h2> <div class="space-y-1">
<span class="text-sm text-secondary">{{ scholars.length }} total</span> <div class="flex items-center gap-1">
<h2 class="text-lg font-semibold text-ink-primary">Tracked Scholars</h2>
<AppHelpHint
text="Tracked scholars are active profile sources. Open Manage to control status, image overrides, and removal."
/>
</div>
<p class="text-sm text-secondary">Review tracked profiles and open per-scholar settings when needed.</p>
</div>
<div
class="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-stroke-default bg-surface-card-muted/70 px-3 py-2"
>
<p class="text-xs font-medium uppercase tracking-wide text-secondary">Tracking status</p>
<div class="flex flex-wrap items-center gap-2">
<span
class="inline-flex min-h-10 items-center rounded-lg border border-state-info-border bg-state-info-bg px-3 text-sm font-semibold text-state-info-text"
>
{{ scholars.length }} tracked
</span>
<AppButton variant="secondary" :disabled="loading || saving" @click="loadScholars">
{{ loading ? "Refreshing..." : "Refresh" }}
</AppButton>
</div>
</div>
</div> </div>
<AppSkeleton v-if="loading" :lines="6" /> <AsyncStateGate
:loading="loading"
<AppEmptyState :loading-lines="6"
v-else-if="scholars.length === 0" :empty="scholars.length === 0"
title="No scholars tracked" :show-empty="!errorMessage"
body="Add a Scholar ID directly or search by name to start ingestion tracking." empty-title="No scholars tracked"
/> empty-body="Add a Scholar ID or URL to start ingestion tracking."
>
<div v-else class="space-y-3"> <div class="space-y-3">
<ul class="grid gap-3 lg:hidden"> <ul class="grid gap-3 lg:hidden">
<li <li
v-for="item in scholars" v-for="item in scholars"
:key="item.id" :key="item.id"
class="rounded-xl border border-zinc-200 bg-zinc-50/70 p-3 dark:border-zinc-800 dark:bg-zinc-900/50" class="rounded-xl border border-stroke-default bg-surface-card-muted/70 p-3"
> >
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<div <ScholarAvatar :label="item.display_name" :scholar-id="item.scholar_id" :image-url="item.profile_image_url" />
class="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-full border border-zinc-200 bg-zinc-100 text-xs font-semibold text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200"
>
<img
v-if="canRenderImage('scholar', item.id, item.profile_image_url)"
:src="item.profile_image_url || ''"
:alt="`${scholarLabel(item)} profile image`"
class="h-full w-full object-cover"
loading="lazy"
referrerpolicy="no-referrer"
@error="markImageFailed('scholar', item.id, item.profile_image_url)"
/>
<span v-else>{{ makeInitials(item.display_name, item.scholar_id) }}</span>
</div>
<div class="min-w-0 flex-1 space-y-1"> <div class="min-w-0 flex-1 space-y-1">
<p class="truncate text-sm font-semibold text-zinc-900 dark:text-zinc-100">{{ scholarLabel(item) }}</p> <p class="truncate text-sm font-semibold text-ink-primary">{{ scholarLabel(item) }}</p>
<p class="text-xs text-secondary"><code>{{ item.scholar_id }}</code></p> <div class="flex flex-wrap items-center gap-3">
<div class="flex flex-wrap items-center gap-2">
<AppBadge :tone="item.is_enabled ? 'success' : 'warning'">
{{ item.is_enabled ? "Enabled" : "Disabled" }}
</AppBadge>
<RunStatusBadge :status="item.last_run_status || 'unknown'" />
<span class="text-xs text-secondary">{{ formatDate(item.last_run_dt, true) }}</span>
</div>
<RouterLink :to="scholarPublicationsRoute(item)" class="link-inline text-xs"> <RouterLink :to="scholarPublicationsRoute(item)" class="link-inline text-xs">
View publications Publications
</RouterLink> </RouterLink>
<a :href="scholarProfileUrl(item.scholar_id)" target="_blank" rel="noreferrer" class="link-inline text-xs">
Open profile
</a>
</div>
</div> </div>
<AppButton variant="secondary" :disabled="saving" @click="openScholarSettings(item)">Manage</AppButton> <AppButton variant="secondary" :disabled="saving" @click="openScholarSettings(item)">Manage</AppButton>
@ -746,95 +694,54 @@ onMounted(() => {
</li> </li>
</ul> </ul>
<AppTable class="hidden lg:block" label="Scholars table"> <AppTable class="hidden lg:block" label="Tracked scholars table">
<thead> <thead>
<tr> <tr>
<th scope="col">Scholar</th> <th scope="col">Scholar</th>
<th scope="col">Scholar ID</th> <th scope="col" class="w-[11rem]">Manage</th>
<th scope="col">Enabled</th>
<th scope="col" class="w-[15rem]">Last run</th>
<th scope="col">Manage</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="item in scholars" :key="item.id"> <tr v-for="item in scholars" :key="item.id">
<td> <td>
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<div <ScholarAvatar :label="item.display_name" :scholar-id="item.scholar_id" :image-url="item.profile_image_url" />
class="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-full border border-zinc-200 bg-zinc-100 text-xs font-semibold text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200"
>
<img
v-if="canRenderImage('scholar', item.id, item.profile_image_url)"
:src="item.profile_image_url || ''"
:alt="`${scholarLabel(item)} profile image`"
class="h-full w-full object-cover"
loading="lazy"
referrerpolicy="no-referrer"
@error="markImageFailed('scholar', item.id, item.profile_image_url)"
/>
<span v-else>{{ makeInitials(item.display_name, item.scholar_id) }}</span>
</div>
<div class="grid min-w-0 gap-1"> <div class="grid min-w-0 gap-1">
<strong class="truncate text-zinc-900 dark:text-zinc-100">{{ scholarLabel(item) }}</strong> <strong class="truncate text-ink-primary">{{ scholarLabel(item) }}</strong>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-3">
<RouterLink :to="scholarPublicationsRoute(item)" class="link-inline text-xs"> <RouterLink :to="scholarPublicationsRoute(item)" class="link-inline text-xs">
Publications Publications
</RouterLink> </RouterLink>
<a :href="scholarProfileUrl(item.scholar_id)" target="_blank" rel="noreferrer" class="link-inline text-xs"> <a :href="scholarProfileUrl(item.scholar_id)" target="_blank" rel="noreferrer" class="link-inline text-xs">
Open profile Open profile
</a> </a>
<AppBadge :tone="sourceTone(item.profile_image_source)">
{{ formatImageSource(item.profile_image_source) }}
</AppBadge>
</div> </div>
</div> </div>
</div> </div>
</td> </td>
<td><code class="text-xs">{{ item.scholar_id }}</code></td>
<td> <td>
<AppBadge :tone="item.is_enabled ? 'success' : 'warning'"> <AppButton variant="secondary" :disabled="saving" @click="openScholarSettings(item)">Manage</AppButton>
{{ item.is_enabled ? "Enabled" : "Disabled" }}
</AppBadge>
</td>
<td>
<div class="grid gap-1 text-xs">
<RunStatusBadge :status="item.last_run_status || 'unknown'" />
<span class="whitespace-nowrap text-secondary">{{ formatDate(item.last_run_dt, true) }}</span>
</div>
</td>
<td>
<AppButton variant="secondary" :disabled="saving" @click="openScholarSettings(item)">
Manage
</AppButton>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</AppTable> </AppTable>
</div> </div>
</AsyncStateGate>
</AppCard> </AppCard>
</section> </section>
<AppModal :open="activeScholarSettings !== null" title="Scholar settings" @close="closeScholarSettings"> <AppModal :open="activeScholarSettings !== null" title="Scholar settings" @close="closeScholarSettings">
<div v-if="activeScholarSettings" class="grid gap-4"> <div v-if="activeScholarSettings" class="grid gap-4">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<div <ScholarAvatar
class="flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-full border border-zinc-200 bg-zinc-100 text-xs font-semibold text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200" :label="activeScholarSettings.display_name"
> :scholar-id="activeScholarSettings.scholar_id"
<img :image-url="activeScholarSettings.profile_image_url"
v-if="canRenderImage('scholar', activeScholarSettings.id, activeScholarSettings.profile_image_url)"
:src="activeScholarSettings.profile_image_url || ''"
:alt="`${scholarLabel(activeScholarSettings)} profile image`"
class="h-full w-full object-cover"
loading="lazy"
referrerpolicy="no-referrer"
@error="markImageFailed('scholar', activeScholarSettings.id, activeScholarSettings.profile_image_url)"
/> />
<span v-else>{{ makeInitials(activeScholarSettings.display_name, activeScholarSettings.scholar_id) }}</span>
</div>
<div class="min-w-0 space-y-1"> <div class="min-w-0 space-y-1">
<p class="truncate text-sm font-semibold text-zinc-900 dark:text-zinc-100"> <p class="truncate text-sm font-semibold text-ink-primary">
{{ scholarLabel(activeScholarSettings) }} {{ scholarLabel(activeScholarSettings) }}
</p> </p>
<p class="text-xs text-secondary"> <p class="text-xs text-secondary">
@ -857,7 +764,7 @@ onMounted(() => {
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
<label class="text-sm font-medium text-zinc-700 dark:text-zinc-300" :for="`scholar-image-url-${activeScholarSettings.id}`"> <label class="text-sm font-medium text-ink-secondary" :for="`scholar-image-url-${activeScholarSettings.id}`">
Profile image URL override Profile image URL override
</label> </label>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
@ -880,7 +787,7 @@ onMounted(() => {
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label <label
:for="`scholar-image-upload-${activeScholarSettings.id}`" :for="`scholar-image-upload-${activeScholarSettings.id}`"
class="inline-flex min-h-10 cursor-pointer items-center justify-center rounded-lg border border-zinc-300 bg-zinc-100 px-3 py-2 text-sm font-semibold text-zinc-900 transition hover:bg-zinc-200 focus-within:outline-none focus-within:ring-2 focus-within:ring-brand-500 focus-within:ring-offset-2 focus-within:ring-offset-zinc-100 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700 dark:focus-within:ring-brand-400 dark:focus-within:ring-offset-zinc-950" class="inline-flex min-h-10 cursor-pointer items-center justify-center rounded-lg border border-stroke-strong bg-action-secondary-bg px-3 py-2 text-sm font-semibold text-action-secondary-text transition hover:bg-action-secondary-hover-bg focus-within:outline-none focus-within:ring-2 focus-within:ring-focus-ring focus-within:ring-offset-2 focus-within:ring-offset-focus-offset"
:class="{ 'pointer-events-none opacity-60': isImageBusy(activeScholarSettings.id) }" :class="{ 'pointer-events-none opacity-60': isImageBusy(activeScholarSettings.id) }"
> >
{{ imageUploadingScholarId === activeScholarSettings.id ? "Uploading..." : "Upload image" }} {{ imageUploadingScholarId === activeScholarSettings.id ? "Uploading..." : "Upload image" }}
@ -899,7 +806,7 @@ onMounted(() => {
</div> </div>
</div> </div>
<div class="flex flex-wrap items-center justify-between gap-2 border-t border-zinc-200 pt-3 dark:border-zinc-800"> <div class="flex flex-wrap items-center justify-between gap-2 border-t border-stroke-default pt-3">
<AppButton <AppButton
variant="secondary" variant="secondary"
:disabled="isImageBusy(activeScholarSettings.id) || saving" :disabled="isImageBusy(activeScholarSettings.id) || saving"

View file

@ -1,21 +1,83 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import AppPage from "@/components/layout/AppPage.vue"; import AppPage from "@/components/layout/AppPage.vue";
import AppAlert from "@/components/ui/AppAlert.vue"; import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue";
import AppButton from "@/components/ui/AppButton.vue"; import AppButton from "@/components/ui/AppButton.vue";
import AppCard from "@/components/ui/AppCard.vue"; import AppCard from "@/components/ui/AppCard.vue";
import AppCheckbox from "@/components/ui/AppCheckbox.vue"; import AppCheckbox from "@/components/ui/AppCheckbox.vue";
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
import AppInput from "@/components/ui/AppInput.vue"; import AppInput from "@/components/ui/AppInput.vue";
import AppModal from "@/components/ui/AppModal.vue"; import AppModal from "@/components/ui/AppModal.vue";
import AppSkeleton from "@/components/ui/AppSkeleton.vue";
import { import {
changePassword, changePassword,
fetchSettings, fetchSettings,
updateSettings,
type UserSettings, type UserSettings,
updateSettings,
} from "@/features/settings"; } from "@/features/settings";
import { ApiRequestError } from "@/lib/api/errors"; import { ApiRequestError } from "@/lib/api/errors";
import { useAuthStore } from "@/stores/auth";
import { normalizeUserNavVisiblePages, useUserSettingsStore } from "@/stores/user_settings";
interface NavPageOption {
id: string;
label: string;
description: string;
required: boolean;
adminOnly?: boolean;
}
const NAV_PAGE_OPTIONS: NavPageOption[] = [
{
id: "dashboard",
label: "Dashboard",
description: "Overview and latest publication updates.",
required: true,
},
{
id: "scholars",
label: "Scholars",
description: "Tracked scholar profiles and profile management.",
required: true,
},
{
id: "publications",
label: "Publications",
description: "Review and search discovered publication records.",
required: false,
},
{
id: "settings",
label: "Settings",
description: "Configuration and account controls.",
required: true,
},
{
id: "style-guide",
label: "Style Guide",
description: "Admin-only visual reference for theme and component tokens.",
required: false,
adminOnly: true,
},
{
id: "runs",
label: "Runs",
description: "Admin-only diagnostics and queue operations.",
required: false,
adminOnly: true,
},
{
id: "users",
label: "Users",
description: "Admin-only user management.",
required: false,
adminOnly: true,
},
];
const auth = useAuthStore();
const userSettings = useUserSettingsStore();
const loading = ref(true); const loading = ref(true);
const saving = ref(false); const saving = ref(false);
@ -24,6 +86,7 @@ const updatingPassword = ref(false);
const autoRunEnabled = ref(false); const autoRunEnabled = ref(false);
const runIntervalMinutes = ref("60"); const runIntervalMinutes = ref("60");
const requestDelaySeconds = ref("2"); const requestDelaySeconds = ref("2");
const navVisiblePages = ref<string[]>([]);
const currentPassword = ref(""); const currentPassword = ref("");
const newPassword = ref(""); const newPassword = ref("");
@ -34,21 +97,58 @@ const errorRequestId = ref<string | null>(null);
const successMessage = ref<string | null>(null); const successMessage = ref<string | null>(null);
const showIngestionModal = ref(false); const showIngestionModal = ref(false);
const showPasswordModal = ref(false); const showPasswordModal = ref(false);
const showNavigationModal = ref(false);
const MIN_CHECK_INTERVAL_MINUTES = 15;
const MIN_REQUEST_DELAY_SECONDS = 2;
const visibleNavOptions = computed(() =>
NAV_PAGE_OPTIONS.filter((option) => !option.adminOnly || auth.isAdmin),
);
const visibleNavLabels = computed(() =>
visibleNavOptions.value
.filter((option) => navVisiblePages.value.includes(option.id))
.map((option) => option.label),
);
function hydrateSettings(settings: UserSettings): void { function hydrateSettings(settings: UserSettings): void {
autoRunEnabled.value = settings.auto_run_enabled; autoRunEnabled.value = settings.auto_run_enabled;
runIntervalMinutes.value = String(settings.run_interval_minutes); runIntervalMinutes.value = String(settings.run_interval_minutes);
requestDelaySeconds.value = String(settings.request_delay_seconds); requestDelaySeconds.value = String(settings.request_delay_seconds);
navVisiblePages.value = normalizeUserNavVisiblePages(settings.nav_visible_pages);
userSettings.applySettings(settings);
} }
function parsePositiveInteger(value: string, label: string): number { function parseBoundedInteger(value: string, label: string, minimum: number): number {
const parsed = Number(value); const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) { if (!Number.isInteger(parsed)) {
throw new Error(`${label} must be a positive integer.`); throw new Error(`${label} must be a whole number.`);
}
if (parsed < minimum) {
throw new Error(`${label} must be at least ${minimum}.`);
} }
return parsed; return parsed;
} }
function isNavPageVisible(pageId: string): boolean {
return navVisiblePages.value.includes(pageId);
}
function onToggleNavPage(page: NavPageOption, event: Event): void {
const input = event.target as HTMLInputElement;
if (page.required && !input.checked) {
return;
}
if (input.checked) {
navVisiblePages.value = normalizeUserNavVisiblePages([...navVisiblePages.value, page.id]);
return;
}
navVisiblePages.value = normalizeUserNavVisiblePages(
navVisiblePages.value.filter((pageId) => pageId !== page.id),
);
}
async function loadSettings(): Promise<void> { async function loadSettings(): Promise<void> {
loading.value = true; loading.value = true;
errorMessage.value = null; errorMessage.value = null;
@ -78,14 +178,24 @@ async function onSaveSettings(): Promise<void> {
try { try {
const payload: UserSettings = { const payload: UserSettings = {
auto_run_enabled: autoRunEnabled.value, auto_run_enabled: autoRunEnabled.value,
run_interval_minutes: parsePositiveInteger(runIntervalMinutes.value, "Run interval"), run_interval_minutes: parseBoundedInteger(
request_delay_seconds: parsePositiveInteger(requestDelaySeconds.value, "Request delay"), runIntervalMinutes.value,
"Check interval (minutes)",
MIN_CHECK_INTERVAL_MINUTES,
),
request_delay_seconds: parseBoundedInteger(
requestDelaySeconds.value,
"Delay between requests (seconds)",
MIN_REQUEST_DELAY_SECONDS,
),
nav_visible_pages: normalizeUserNavVisiblePages(navVisiblePages.value),
}; };
const saved = await updateSettings(payload); const saved = await updateSettings(payload);
hydrateSettings(saved); hydrateSettings(saved);
successMessage.value = "Settings updated."; successMessage.value = "Settings updated.";
showIngestionModal.value = false; showIngestionModal.value = false;
showNavigationModal.value = false;
} catch (error) { } catch (error) {
if (error instanceof ApiRequestError) { if (error instanceof ApiRequestError) {
errorMessage.value = error.message; errorMessage.value = error.message;
@ -142,65 +252,107 @@ onMounted(() => {
</script> </script>
<template> <template>
<AppPage title="Settings" subtitle="Configure ingestion and account controls."> <AppPage
<AppAlert v-if="successMessage" tone="success" dismissible @dismiss="successMessage = null"> title="Settings"
<template #title>Saved</template> subtitle="Control how often Scholarr checks profiles and how cautiously it sends requests."
<p>{{ successMessage }}</p> >
</AppAlert> <RequestStateAlerts
:success-message="successMessage"
success-title="Saved"
:error-message="errorMessage"
:error-request-id="errorRequestId"
error-title="Settings request failed"
@dismiss-success="successMessage = null"
/>
<AppAlert v-if="errorMessage" tone="danger"> <AsyncStateGate :loading="loading" :loading-lines="7" :show-empty="false">
<template #title>Settings request failed</template> <section class="grid gap-4 xl:grid-cols-3">
<p>{{ errorMessage }}</p> <AppCard class="flex h-full flex-col gap-4">
<p class="text-secondary">Request ID: {{ errorRequestId || "n/a" }}</p> <div class="flex items-center gap-1">
</AppAlert> <h2 class="text-lg font-semibold text-ink-primary">Automatic Checking</h2>
<AppHelpHint text="Controls when Scholarr runs automatic profile checks and how cautiously it scrapes." />
<AppSkeleton v-if="loading" :lines="7" /> </div>
<p class="text-sm text-secondary">
<template v-else> Configure the background checker that looks for new publications on your tracked profiles.
<section class="grid gap-4 lg:grid-cols-2"> </p>
<AppCard class="space-y-4">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Scholar ingestion defaults</h2>
<dl class="grid gap-2 text-sm text-secondary"> <dl class="grid gap-2 text-sm text-secondary">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<dt>Scheduler</dt> <dt>Automatic checks</dt>
<dd class="font-semibold text-zinc-900 dark:text-zinc-100"> <dd class="font-semibold text-ink-primary">
{{ autoRunEnabled ? "Enabled" : "Disabled" }} {{ autoRunEnabled ? "Enabled" : "Disabled" }}
</dd> </dd>
</div> </div>
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<dt>Run interval</dt> <dt>Check interval</dt>
<dd class="font-semibold text-zinc-900 dark:text-zinc-100">{{ runIntervalMinutes }} min</dd> <dd class="font-semibold text-ink-primary">Every {{ runIntervalMinutes }} min</dd>
</div> </div>
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<dt>Request delay</dt> <dt>Delay between requests</dt>
<dd class="font-semibold text-zinc-900 dark:text-zinc-100">{{ requestDelaySeconds }} sec</dd> <dd class="font-semibold text-ink-primary">{{ requestDelaySeconds }} sec</dd>
</div> </div>
</dl> </dl>
<AppButton variant="secondary" @click="showIngestionModal = true"> <AppButton variant="secondary" class="mt-auto self-start" @click="showIngestionModal = true">
Manage ingestion settings Edit checking rules
</AppButton> </AppButton>
</AppCard> </AppCard>
<AppCard class="space-y-4"> <AppCard class="flex h-full flex-col gap-4">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Account security</h2> <div class="flex items-center gap-1">
<p class="text-sm text-secondary">Password changes are handled in a dedicated overlay to reduce clutter.</p> <h2 class="text-lg font-semibold text-ink-primary">Account Access</h2>
<AppButton variant="secondary" @click="showPasswordModal = true">Manage password</AppButton> <AppHelpHint text="Manage credentials for your current signed-in account on this instance." />
</div>
<p class="text-sm text-secondary">
Change your sign-in password from a focused view. This does not affect other users.
</p>
<AppButton variant="secondary" class="mt-auto self-start" @click="showPasswordModal = true">
Change password
</AppButton>
</AppCard>
<AppCard class="flex h-full flex-col gap-4">
<div class="flex items-center gap-1">
<h2 class="text-lg font-semibold text-ink-primary">Navigation</h2>
<AppHelpHint text="Choose which pages appear in the left sidebar. Dashboard, Scholars, and Settings are always visible." />
</div>
<p class="text-sm text-secondary">
Visible now: {{ visibleNavLabels.length > 0 ? visibleNavLabels.join(", ") : "none" }}.
</p>
<AppButton variant="secondary" class="mt-auto self-start" @click="showNavigationModal = true">
Customize sidebar pages
</AppButton>
</AppCard> </AppCard>
</section> </section>
</template> </AsyncStateGate>
<AppModal :open="showIngestionModal" title="Ingestion settings" @close="showIngestionModal = false"> <AppModal
:open="showIngestionModal"
title="Automatic Checking Settings"
@close="showIngestionModal = false"
>
<form class="grid gap-3" @submit.prevent="onSaveSettings"> <form class="grid gap-3" @submit.prevent="onSaveSettings">
<AppCheckbox id="auto-run-enabled" v-model="autoRunEnabled" label="Enable scheduler auto-run" /> <AppCheckbox id="auto-run-enabled" v-model="autoRunEnabled" label="Enable automatic background checks" />
<label class="grid gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300"> <label class="grid gap-2 text-sm font-medium text-ink-secondary">
<span>Run interval (minutes)</span> <span class="inline-flex items-center gap-1">
<AppInput id="run-interval" v-model="runIntervalMinutes" type="number" min="1" /> Check interval (minutes)
<AppHelpHint text="How often Scholarr starts a background update check." />
</span>
<AppInput id="run-interval" v-model="runIntervalMinutes" type="number" :min="MIN_CHECK_INTERVAL_MINUTES" />
<span class="text-xs text-secondary">Minimum: {{ MIN_CHECK_INTERVAL_MINUTES }} minutes.</span>
</label> </label>
<label class="grid gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300"> <label class="grid gap-2 text-sm font-medium text-ink-secondary">
<span>Request delay (seconds)</span> <span class="inline-flex items-center gap-1">
<AppInput id="request-delay" v-model="requestDelaySeconds" type="number" min="1" /> Delay between requests (seconds)
<AppHelpHint text="Pause between profile requests during a check. Higher values are slower but safer." />
</span>
<AppInput
id="request-delay"
v-model="requestDelaySeconds"
type="number"
:min="MIN_REQUEST_DELAY_SECONDS"
/>
<span class="text-xs text-secondary">Minimum: {{ MIN_REQUEST_DELAY_SECONDS }} seconds.</span>
</label> </label>
<div class="mt-2 flex flex-wrap justify-end gap-2"> <div class="mt-2 flex flex-wrap justify-end gap-2">
@ -219,19 +371,19 @@ onMounted(() => {
</form> </form>
</AppModal> </AppModal>
<AppModal :open="showPasswordModal" title="Change password" @close="showPasswordModal = false"> <AppModal :open="showPasswordModal" title="Change Sign-in Password" @close="showPasswordModal = false">
<form class="grid gap-3" @submit.prevent="onChangePassword"> <form class="grid gap-3" @submit.prevent="onChangePassword">
<label class="grid gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300"> <label class="grid gap-2 text-sm font-medium text-ink-secondary">
<span>Current password</span> <span>Current password</span>
<AppInput v-model="currentPassword" type="password" autocomplete="current-password" /> <AppInput v-model="currentPassword" type="password" autocomplete="current-password" />
</label> </label>
<label class="grid gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300"> <label class="grid gap-2 text-sm font-medium text-ink-secondary">
<span>New password</span> <span>New password</span>
<AppInput v-model="newPassword" type="password" autocomplete="new-password" /> <AppInput v-model="newPassword" type="password" autocomplete="new-password" />
</label> </label>
<label class="grid gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300"> <label class="grid gap-2 text-sm font-medium text-ink-secondary">
<span>Confirm new password</span> <span>Confirm new password</span>
<AppInput v-model="confirmPassword" type="password" autocomplete="new-password" /> <AppInput v-model="confirmPassword" type="password" autocomplete="new-password" />
</label> </label>
@ -251,5 +403,52 @@ onMounted(() => {
</div> </div>
</form> </form>
</AppModal> </AppModal>
<AppModal :open="showNavigationModal" title="Sidebar Page Visibility" @close="showNavigationModal = false">
<form class="grid gap-3" @submit.prevent="onSaveSettings">
<p class="text-sm text-secondary">
Turn optional pages on or off in the sidebar for your account.
</p>
<ul class="grid gap-2">
<li
v-for="option in visibleNavOptions"
:key="option.id"
class="rounded-lg border border-stroke-default bg-surface-card-muted/70 px-3 py-2"
>
<label class="flex items-start gap-3">
<input
type="checkbox"
class="mt-0.5 h-4 w-4 rounded border-stroke-interactive bg-surface-input text-brand-600 focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset"
:checked="isNavPageVisible(option.id)"
:disabled="saving || option.required"
@change="onToggleNavPage(option, $event)"
/>
<span class="grid gap-0.5">
<span class="text-sm font-medium text-ink-primary">
{{ option.label }}
<span v-if="option.required" class="ml-1 text-xs text-ink-muted">(required)</span>
</span>
<span class="text-xs text-secondary">{{ option.description }}</span>
</span>
</label>
</li>
</ul>
<div class="mt-2 flex flex-wrap justify-end gap-2">
<AppButton
variant="ghost"
type="button"
:disabled="saving"
@click="showNavigationModal = false"
>
Cancel
</AppButton>
<AppButton type="submit" :disabled="saving">
{{ saving ? "Saving..." : "Save sidebar settings" }}
</AppButton>
</div>
</form>
</AppModal>
</AppPage> </AppPage>
</template> </template>

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"; import { computed, ref } from "vue";
import AppPage from "@/components/layout/AppPage.vue"; import AppPage from "@/components/layout/AppPage.vue";
import QueueHealthBadge from "@/components/patterns/QueueHealthBadge.vue"; import QueueHealthBadge from "@/components/patterns/QueueHealthBadge.vue";
@ -12,17 +12,107 @@ import AppCheckbox from "@/components/ui/AppCheckbox.vue";
import AppInput from "@/components/ui/AppInput.vue"; import AppInput from "@/components/ui/AppInput.vue";
import AppSelect from "@/components/ui/AppSelect.vue"; import AppSelect from "@/components/ui/AppSelect.vue";
import AppTable from "@/components/ui/AppTable.vue"; import AppTable from "@/components/ui/AppTable.vue";
import { useThemeStore } from "@/stores/theme";
const sampleName = ref("Ada Lovelace"); const sampleName = ref("Ada Lovelace");
const sampleEmail = ref("ada@example.com"); const sampleEmail = ref("ada@example.com");
const sampleRole = ref("operator"); const sampleRole = ref("operator");
const sampleEnabled = ref(true); const sampleEnabled = ref(true);
const theme = useThemeStore();
const isDarkTheme = computed(() => theme.active === "dark");
const toggleThemeLabel = computed(() =>
isDarkTheme.value ? "Switch to light mode" : "Switch to dark mode",
);
const selectedPreset = computed({
get: () => theme.preset,
set: (value: string) => theme.setPreset(value),
});
const surfaceSamples = [
{ label: "App surface", value: "--theme-surface-app" },
{ label: "Nav surface", value: "--theme-surface-nav" },
{ label: "Active nav", value: "--theme-surface-nav-active" },
{ label: "Card surface", value: "--theme-surface-card" },
{ label: "Table header", value: "--theme-surface-table-header" },
{ label: "Input surface", value: "--theme-surface-input" },
];
const textSamples = [
{ label: "Primary text", value: "--theme-text-primary" },
{ label: "Secondary text", value: "--theme-text-secondary" },
{ label: "Muted text", value: "--theme-text-muted" },
{ label: "Inverse text", value: "--theme-text-inverse" },
{ label: "Link text", value: "--theme-text-link" },
];
const actionSamples = [
{ label: "Primary action", value: "--theme-action-primary-bg" },
{ label: "Secondary action", value: "--theme-action-secondary-bg" },
{ label: "Ghost action", value: "--theme-action-ghost-bg" },
{ label: "Danger action", value: "--theme-action-danger-bg" },
];
const borderSamples = [
{ label: "Default border", value: "--theme-border-default" },
{ label: "Strong border", value: "--theme-border-strong" },
{ label: "Subtle border", value: "--theme-border-subtle" },
{ label: "Interactive border", value: "--theme-border-interactive" },
];
const focusSamples = [
{ label: "Focus ring", value: "--theme-focus-ring" },
{ label: "Focus offset", value: "--theme-focus-ring-offset" },
];
const stateSamples = [
{ label: "Info state", value: "--theme-state-info-bg", text: "--theme-state-info-text", border: "--theme-state-info-border" },
{
label: "Success state",
value: "--theme-state-success-bg",
text: "--theme-state-success-text",
border: "--theme-state-success-border",
},
{
label: "Warning state",
value: "--theme-state-warning-bg",
text: "--theme-state-warning-text",
border: "--theme-state-warning-border",
},
{ label: "Danger state", value: "--theme-state-danger-bg", text: "--theme-state-danger-text", border: "--theme-state-danger-border" },
];
const scaleFamilies = ["brand", "info", "success", "warning", "danger"] as const;
const scaleSteps = ["50", "100", "200", "300", "400", "500", "600", "700", "800", "900", "950"] as const;
const sampleRows = [ const sampleRows = [
{ id: 101, title: "Entity Resolution Improvements", status: "running", owner: "Scheduler" }, { id: 101, title: "Entity Resolution Improvements", status: "running", owner: "Scheduler" },
{ id: 102, title: "Retry Queue Drain", status: "partial_failure", owner: "Ops" }, { id: 102, title: "Retry Queue Drain", status: "partial_failure", owner: "Ops" },
{ id: 103, title: "Weekly Publication Sync", status: "success", owner: "Ingestion" }, { id: 103, title: "Weekly Publication Sync", status: "success", owner: "Ingestion" },
]; ];
function cssVarStyle(tokenName: string): Record<string, string> {
return { backgroundColor: `rgb(var(${tokenName}) / 1)` };
}
function cssVarBorderStyle(tokenName: string): Record<string, string> {
return { borderColor: `rgb(var(${tokenName}) / 1)` };
}
function textSampleStyle(tokenName: string): Record<string, string> {
if (tokenName === "--theme-text-inverse") {
return { backgroundColor: "rgb(var(--theme-surface-overlay) / 1)" };
}
return {};
}
function colorScaleStyle(family: (typeof scaleFamilies)[number], step: (typeof scaleSteps)[number]): Record<string, string> {
return { backgroundColor: `rgb(var(--color-${family}-${step}) / 1)` };
}
function onToggleTheme(): void {
theme.setPreference(isDarkTheme.value ? "light" : "dark");
}
</script> </script>
<template> <template>
@ -30,9 +120,193 @@ const sampleRows = [
title="Style Guide" title="Style Guide"
subtitle="Reference page for Tailwind component patterns used across the application." subtitle="Reference page for Tailwind component patterns used across the application."
> >
<AppCard class="space-y-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="space-y-1">
<h2 class="text-lg font-semibold text-ink-primary">Theme Lab</h2>
<p class="text-sm text-secondary">Preview preset + mode behavior and inspect semantic token groups.</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<AppSelect v-model="selectedPreset" class="w-40">
<option v-for="preset in theme.availablePresets" :key="preset.id" :value="preset.id">
{{ preset.label }}
</option>
</AppSelect>
<AppButton variant="secondary" @click="onToggleTheme">
{{ toggleThemeLabel }}
</AppButton>
</div>
</div>
<div class="grid gap-4 lg:grid-cols-3">
<div class="space-y-2">
<p class="text-xs font-semibold uppercase tracking-wide text-muted">Surfaces</p>
<ul class="grid gap-2">
<li
v-for="sample in surfaceSamples"
:key="sample.value"
class="flex items-center justify-between rounded-lg border border-stroke-default px-3 py-2 text-xs text-ink-secondary"
:style="cssVarStyle(sample.value)"
>
<span class="font-medium text-ink-primary">{{ sample.label }}</span>
<code>{{ sample.value }}</code>
</li>
</ul>
</div>
<div class="space-y-2">
<p class="text-xs font-semibold uppercase tracking-wide text-muted">Text</p>
<ul class="grid gap-2">
<li
v-for="sample in textSamples"
:key="sample.value"
class="flex items-center justify-between rounded-lg border border-stroke-default bg-surface-card px-3 py-2 text-xs"
:style="textSampleStyle(sample.value)"
>
<span class="font-medium" :style="{ color: `rgb(var(${sample.value}) / 1)` }">{{ sample.label }}</span>
<code class="text-ink-muted">{{ sample.value }}</code>
</li>
</ul>
</div>
<div class="space-y-2">
<p class="text-xs font-semibold uppercase tracking-wide text-muted">Actions</p>
<ul class="grid gap-2">
<li
v-for="sample in actionSamples"
:key="sample.value"
class="flex items-center justify-between rounded-lg border border-stroke-default px-3 py-2 text-xs text-ink-secondary"
:style="cssVarStyle(sample.value)"
>
<span class="font-medium text-ink-primary">{{ sample.label }}</span>
<code>{{ sample.value }}</code>
</li>
</ul>
</div>
</div>
<div class="grid gap-4 lg:grid-cols-3">
<div class="space-y-2">
<p class="text-xs font-semibold uppercase tracking-wide text-muted">Borders</p>
<ul class="grid gap-2">
<li
v-for="sample in borderSamples"
:key="sample.value"
class="flex items-center justify-between rounded-lg border-2 bg-surface-card px-3 py-2 text-xs text-ink-secondary"
:style="cssVarBorderStyle(sample.value)"
>
<span class="font-medium text-ink-primary">{{ sample.label }}</span>
<code>{{ sample.value }}</code>
</li>
</ul>
</div>
<div class="space-y-2">
<p class="text-xs font-semibold uppercase tracking-wide text-muted">Focus</p>
<ul class="grid gap-2">
<li
v-for="sample in focusSamples"
:key="sample.value"
class="flex items-center justify-between rounded-lg border border-stroke-default bg-surface-card px-3 py-2 text-xs text-ink-secondary"
>
<button
type="button"
class="rounded-md px-2 py-1 font-medium text-ink-primary focus-visible:ring-2 focus-visible:ring-offset-2"
:style="{ '--tw-ring-color': `rgb(var(${sample.value}) / 1)`, '--tw-ring-offset-color': 'rgb(var(--theme-focus-ring-offset) / 1)' }"
>
Focus me
</button>
<code>{{ sample.value }}</code>
</li>
</ul>
</div>
<div class="space-y-2">
<p class="text-xs font-semibold uppercase tracking-wide text-muted">States</p>
<ul class="grid gap-2">
<li
v-for="sample in stateSamples"
:key="sample.value"
class="flex items-center justify-between rounded-lg border px-3 py-2 text-xs"
:style="{
backgroundColor: `rgb(var(${sample.value}) / 1)`,
borderColor: `rgb(var(${sample.border}) / 1)`,
color: `rgb(var(${sample.text}) / 1)`,
}"
>
<span class="font-medium">{{ sample.label }}</span>
<code>{{ sample.value }}</code>
</li>
</ul>
</div>
</div>
</AppCard>
<AppCard class="space-y-4">
<h2 class="text-lg font-semibold text-ink-primary">Scale Ramps</h2>
<div class="grid gap-3">
<div v-for="family in scaleFamilies" :key="family" class="grid gap-1">
<p class="text-xs font-semibold uppercase tracking-wide text-muted">{{ family }}</p>
<div class="grid grid-cols-11 gap-1">
<span
v-for="step in scaleSteps"
:key="`${family}-${step}`"
class="h-8 rounded-md border border-stroke-default"
:style="colorScaleStyle(family, step)"
:title="`--color-${family}-${step}`"
/>
</div>
</div>
</div>
</AppCard>
<AppCard class="space-y-4">
<h2 class="text-lg font-semibold text-ink-primary">Shell Preview</h2>
<p class="text-sm text-secondary">Quick check for app background, nav, card, table, action, and status token coverage.</p>
<div class="grid gap-3 rounded-2xl border border-stroke-default bg-surface-app p-3 lg:grid-cols-[14rem_minmax(0,1fr)]">
<aside class="space-y-2 rounded-xl border border-stroke-default bg-surface-nav p-3">
<div class="rounded-lg bg-surface-nav-active px-2 py-1 text-sm font-semibold text-ink-inverse">Dashboard</div>
<div class="rounded-lg px-2 py-1 text-sm text-ink-secondary">Publications</div>
<div class="rounded-lg px-2 py-1 text-sm text-ink-secondary">Scholars</div>
</aside>
<section class="space-y-3 rounded-xl border border-stroke-default bg-surface-card p-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-sm font-semibold text-ink-primary">Latest Update Check</p>
<RunStatusBadge status="running" />
</div>
<div class="flex flex-wrap gap-2">
<AppButton>Primary action</AppButton>
<AppButton variant="secondary">Secondary</AppButton>
<AppBadge tone="warning">Needs review</AppBadge>
</div>
<AppTable label="Shell preview table">
<thead>
<tr>
<th scope="col">Scholar</th>
<th scope="col">State</th>
<th scope="col">Count</th>
</tr>
</thead>
<tbody>
<tr>
<td>Ada Lovelace</td>
<td>Checked</td>
<td>4</td>
</tr>
<tr>
<td>Geoffrey Hinton</td>
<td>Queued</td>
<td>2</td>
</tr>
</tbody>
</AppTable>
</section>
</div>
</AppCard>
<section class="grid gap-4 xl:grid-cols-2"> <section class="grid gap-4 xl:grid-cols-2">
<AppCard class="space-y-4"> <AppCard class="space-y-4">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Buttons</h2> <h2 class="text-lg font-semibold text-ink-primary">Buttons</h2>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<AppButton>Primary</AppButton> <AppButton>Primary</AppButton>
<AppButton variant="secondary">Secondary</AppButton> <AppButton variant="secondary">Secondary</AppButton>
@ -44,7 +318,7 @@ const sampleRows = [
</AppCard> </AppCard>
<AppCard class="space-y-4"> <AppCard class="space-y-4">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Status System</h2> <h2 class="text-lg font-semibold text-ink-primary">Status System</h2>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<AppBadge tone="neutral">Neutral</AppBadge> <AppBadge tone="neutral">Neutral</AppBadge>
<AppBadge tone="info">Info</AppBadge> <AppBadge tone="info">Info</AppBadge>
@ -64,19 +338,19 @@ const sampleRows = [
<section class="grid gap-4 xl:grid-cols-2"> <section class="grid gap-4 xl:grid-cols-2">
<AppCard class="space-y-4"> <AppCard class="space-y-4">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Forms</h2> <h2 class="text-lg font-semibold text-ink-primary">Forms</h2>
<form class="grid gap-3" @submit.prevent> <form class="grid gap-3" @submit.prevent>
<label class="grid gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300"> <label class="grid gap-2 text-sm font-medium text-ink-secondary">
<span>Name</span> <span>Name</span>
<AppInput v-model="sampleName" autocomplete="name" /> <AppInput v-model="sampleName" autocomplete="name" />
</label> </label>
<label class="grid gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300"> <label class="grid gap-2 text-sm font-medium text-ink-secondary">
<span>Email</span> <span>Email</span>
<AppInput v-model="sampleEmail" type="email" autocomplete="email" /> <AppInput v-model="sampleEmail" type="email" autocomplete="email" />
</label> </label>
<label class="grid gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300"> <label class="grid gap-2 text-sm font-medium text-ink-secondary">
<span>Role</span> <span>Role</span>
<AppSelect v-model="sampleRole"> <AppSelect v-model="sampleRole">
<option value="viewer">Viewer</option> <option value="viewer">Viewer</option>
@ -95,7 +369,7 @@ const sampleRows = [
</AppCard> </AppCard>
<AppCard class="space-y-4"> <AppCard class="space-y-4">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Alerts</h2> <h2 class="text-lg font-semibold text-ink-primary">Alerts</h2>
<AppAlert tone="info"> <AppAlert tone="info">
<template #title>Informational</template> <template #title>Informational</template>
<p>Use for contextual guidance and non-critical notices.</p> <p>Use for contextual guidance and non-critical notices.</p>
@ -112,7 +386,7 @@ const sampleRows = [
</section> </section>
<AppCard class="space-y-4"> <AppCard class="space-y-4">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Table Pattern</h2> <h2 class="text-lg font-semibold text-ink-primary">Table Pattern</h2>
<AppTable label="Style guide sample run table"> <AppTable label="Style guide sample run table">
<thead> <thead>
<tr> <tr>

View file

@ -4,6 +4,7 @@ import { ApiRequestError } from "@/lib/api/errors";
import { fetchCsrfBootstrap } from "@/lib/api/csrf"; import { fetchCsrfBootstrap } from "@/lib/api/csrf";
import { fetchMe, loginSession, logoutSession, type SessionUser } from "@/lib/auth/session"; import { fetchMe, loginSession, logoutSession, type SessionUser } from "@/lib/auth/session";
import { useUiStore } from "@/stores/ui"; import { useUiStore } from "@/stores/ui";
import { useUserSettingsStore } from "@/stores/user_settings";
export type AuthState = "unknown" | "authenticated" | "anonymous"; export type AuthState = "unknown" | "authenticated" | "anonymous";
@ -53,12 +54,16 @@ export const useAuthStore = defineStore("auth", {
this.state = "authenticated"; this.state = "authenticated";
this.user = response.data.user; this.user = response.data.user;
this.csrfToken = response.data.csrf_token; this.csrfToken = response.data.csrf_token;
const userSettings = useUserSettingsStore();
await userSettings.bootstrap();
}, },
async logout(): Promise<void> { async logout(): Promise<void> {
await logoutSession(); await logoutSession();
this.state = "anonymous"; this.state = "anonymous";
this.user = null; this.user = null;
this.csrfToken = null; this.csrfToken = null;
const userSettings = useUserSettingsStore();
userSettings.reset();
try { try {
const csrf = await fetchCsrfBootstrap(); const csrf = await fetchCsrfBootstrap();

View file

@ -1,45 +1,87 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import {
DEFAULT_THEME_PRESET,
THEME_PRESET_OPTIONS,
getThemePreset,
isThemePresetId,
themeCssVariables,
type ThemePresetId,
} from "@/theme/presets";
export type ThemePreference = "system" | "light" | "dark"; export type ThemePreference = "system" | "light" | "dark";
export type ThemeValue = "light" | "dark"; export type ThemeValue = "light" | "dark";
const STORAGE_KEY = "scholarr-theme-preference"; const STORAGE_KEY = "scholarr-theme-preference";
const PRESET_STORAGE_KEY = "scholarr-theme-preset";
function systemTheme(): ThemeValue { function systemTheme(): ThemeValue {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
} }
function applyTheme(theme: ThemeValue, preference: ThemePreference): void { function applyTheme(theme: ThemeValue, preference: ThemePreference, preset: ThemePresetId): void {
const root = document.documentElement; const root = document.documentElement;
root.classList.toggle("dark", theme === "dark"); root.classList.toggle("dark", theme === "dark");
root.setAttribute("data-theme-preference", preference); root.setAttribute("data-theme-preference", preference);
root.setAttribute("data-theme-preset", preset);
const variables = themeCssVariables(preset, theme);
Object.entries(variables).forEach(([name, value]) => {
root.style.setProperty(name, value);
});
} }
export const useThemeStore = defineStore("theme", { export const useThemeStore = defineStore("theme", {
state: () => ({ state: () => ({
preference: "system" as ThemePreference, preference: "system" as ThemePreference,
active: "light" as ThemeValue, active: "light" as ThemeValue,
preset: DEFAULT_THEME_PRESET as ThemePresetId,
}), }),
getters: {
availablePresets: () => THEME_PRESET_OPTIONS,
presetLabel: (state) => getThemePreset(state.preset).label,
},
actions: { actions: {
initialize(): void { initialize(): void {
let stored: string | null = null; let storedPreference: string | null = null;
let storedPreset: string | null = null;
try { try {
stored = localStorage.getItem(STORAGE_KEY); storedPreference = localStorage.getItem(STORAGE_KEY);
storedPreset = localStorage.getItem(PRESET_STORAGE_KEY);
} catch (_err) { } catch (_err) {
stored = null; storedPreference = null;
storedPreset = null;
} }
if (stored === "light" || stored === "dark" || stored === "system") { if (storedPreference === "light" || storedPreference === "dark" || storedPreference === "system") {
this.preference = stored; this.preference = storedPreference;
}
if (storedPreset && isThemePresetId(storedPreset)) {
this.preset = storedPreset;
} }
this.active = this.preference === "system" ? systemTheme() : this.preference; this.active = this.preference === "system" ? systemTheme() : this.preference;
applyTheme(this.active, this.preference); applyTheme(this.active, this.preference, this.preset);
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleSystemChange = (): void => {
if (this.preference !== "system") {
return;
}
this.active = systemTheme();
applyTheme(this.active, this.preference, this.preset);
};
if (typeof mediaQuery.addEventListener === "function") {
mediaQuery.addEventListener("change", handleSystemChange);
} else if (typeof mediaQuery.addListener === "function") {
mediaQuery.addListener(handleSystemChange);
}
}, },
setPreference(preference: ThemePreference): void { setPreference(preference: ThemePreference): void {
this.preference = preference; this.preference = preference;
this.active = preference === "system" ? systemTheme() : preference; this.active = preference === "system" ? systemTheme() : preference;
applyTheme(this.active, this.preference); applyTheme(this.active, this.preference, this.preset);
try { try {
localStorage.setItem(STORAGE_KEY, preference); localStorage.setItem(STORAGE_KEY, preference);
@ -47,5 +89,19 @@ export const useThemeStore = defineStore("theme", {
// Ignore storage failures in private contexts. // Ignore storage failures in private contexts.
} }
}, },
setPreset(preset: ThemePresetId): void {
if (!isThemePresetId(preset)) {
return;
}
this.preset = preset;
applyTheme(this.active, this.preference, this.preset);
try {
localStorage.setItem(PRESET_STORAGE_KEY, preset);
} catch (_err) {
// Ignore storage failures in private contexts.
}
},
}, },
}); });

View file

@ -0,0 +1,87 @@
import { defineStore } from "pinia";
import { fetchSettings, type UserSettings } from "@/features/settings";
export const REQUIRED_NAV_PAGES = ["dashboard", "scholars", "settings"] as const;
export const DEFAULT_NAV_VISIBLE_PAGES = [
"dashboard",
"scholars",
"publications",
"settings",
"style-guide",
"runs",
"users",
] as const;
const ALLOWED_NAV_PAGES = new Set<string>(DEFAULT_NAV_VISIBLE_PAGES);
const REQUIRED_NAV_PAGES_SET = new Set<string>(REQUIRED_NAV_PAGES);
function normalizeNavVisiblePages(value: unknown): string[] {
if (!Array.isArray(value)) {
return [...DEFAULT_NAV_VISIBLE_PAGES];
}
const deduped: string[] = [];
const seen = new Set<string>();
for (const candidate of value) {
if (typeof candidate !== "string") {
continue;
}
const pageId = candidate.trim();
if (!ALLOWED_NAV_PAGES.has(pageId) || seen.has(pageId)) {
continue;
}
seen.add(pageId);
deduped.push(pageId);
}
for (const requiredPage of REQUIRED_NAV_PAGES) {
if (!seen.has(requiredPage)) {
deduped.push(requiredPage);
seen.add(requiredPage);
}
}
return deduped;
}
export const useUserSettingsStore = defineStore("userSettings", {
state: () => ({
navVisiblePages: [...DEFAULT_NAV_VISIBLE_PAGES] as string[],
}),
getters: {
visiblePageSet: (state) => new Set(state.navVisiblePages),
},
actions: {
setNavVisiblePages(value: unknown): void {
this.navVisiblePages = normalizeNavVisiblePages(value);
},
applySettings(settings: UserSettings): void {
this.setNavVisiblePages(settings.nav_visible_pages);
},
reset(): void {
this.navVisiblePages = [...DEFAULT_NAV_VISIBLE_PAGES];
},
isPageVisible(pageId: string): boolean {
if (REQUIRED_NAV_PAGES_SET.has(pageId)) {
return true;
}
return this.visiblePageSet.has(pageId);
},
async bootstrap(): Promise<void> {
try {
const settings = await fetchSettings();
this.applySettings(settings);
} catch {
this.reset();
}
},
},
});
export function normalizeUserNavVisiblePages(value: unknown): string[] {
return normalizeNavVisiblePages(value);
}

View file

@ -1,18 +1,143 @@
@import url("https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Space+Grotesk:wght@500;600;700&display=swap");
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer base { @layer base {
:root {
--color-brand-50: 243 246 251;
--color-brand-100: 231 237 247;
--color-brand-200: 206 220 238;
--color-brand-300: 175 198 223;
--color-brand-400: 142 170 200;
--color-brand-500: 111 143 172;
--color-brand-600: 90 116 144;
--color-brand-700: 73 95 119;
--color-brand-800: 63 80 99;
--color-brand-900: 55 69 84;
--color-brand-950: 36 45 56;
--color-info-50: 240 249 255;
--color-info-100: 224 242 254;
--color-info-200: 186 230 253;
--color-info-300: 125 211 252;
--color-info-400: 56 189 248;
--color-info-500: 14 165 233;
--color-info-600: 2 132 199;
--color-info-700: 3 105 161;
--color-info-800: 7 89 133;
--color-info-900: 12 74 110;
--color-info-950: 8 47 73;
--color-success-50: 236 253 245;
--color-success-100: 209 250 229;
--color-success-200: 167 243 208;
--color-success-300: 110 231 183;
--color-success-400: 52 211 153;
--color-success-500: 16 185 129;
--color-success-600: 5 150 105;
--color-success-700: 4 120 87;
--color-success-800: 6 95 70;
--color-success-900: 6 78 59;
--color-success-950: 2 44 34;
--color-warning-50: 255 251 235;
--color-warning-100: 254 243 199;
--color-warning-200: 253 230 138;
--color-warning-300: 252 211 77;
--color-warning-400: 251 191 36;
--color-warning-500: 245 158 11;
--color-warning-600: 217 119 6;
--color-warning-700: 180 83 9;
--color-warning-800: 146 64 14;
--color-warning-900: 120 53 15;
--color-warning-950: 69 26 3;
--color-danger-50: 255 241 242;
--color-danger-100: 255 228 230;
--color-danger-200: 254 205 211;
--color-danger-300: 253 164 175;
--color-danger-400: 251 113 133;
--color-danger-500: 244 63 94;
--color-danger-600: 225 29 72;
--color-danger-700: 190 18 60;
--color-danger-800: 159 18 57;
--color-danger-900: 136 19 55;
--color-danger-950: 76 5 25;
--theme-surface-app: 243 246 251;
--theme-surface-nav: 255 255 255;
--theme-surface-nav-active: 73 95 119;
--theme-surface-card: 255 255 255;
--theme-surface-card-muted: 231 237 247;
--theme-surface-table: 255 255 255;
--theme-surface-table-header: 243 246 251;
--theme-surface-input: 255 255 255;
--theme-surface-overlay: 15 23 42;
--theme-text-primary: 31 41 55;
--theme-text-secondary: 71 85 105;
--theme-text-muted: 100 116 139;
--theme-text-inverse: 248 250 252;
--theme-text-link: 73 95 119;
--theme-border-default: 206 220 238;
--theme-border-strong: 175 198 223;
--theme-border-subtle: 231 237 247;
--theme-border-interactive: 142 170 200;
--theme-focus-ring: 111 143 172;
--theme-focus-ring-offset: 243 246 251;
--theme-action-primary-bg: 90 116 144;
--theme-action-primary-border: 90 116 144;
--theme-action-primary-text: 248 250 252;
--theme-action-primary-hover-bg: 73 95 119;
--theme-action-primary-hover-border: 73 95 119;
--theme-action-primary-hover-text: 255 255 255;
--theme-action-secondary-bg: 238 242 247;
--theme-action-secondary-border: 206 220 238;
--theme-action-secondary-text: 51 65 85;
--theme-action-secondary-hover-bg: 226 232 240;
--theme-action-secondary-hover-border: 175 198 223;
--theme-action-secondary-hover-text: 31 41 55;
--theme-action-ghost-bg: 243 246 251;
--theme-action-ghost-border: 206 220 238;
--theme-action-ghost-text: 73 95 119;
--theme-action-ghost-hover-bg: 231 237 247;
--theme-action-ghost-hover-border: 175 198 223;
--theme-action-ghost-hover-text: 55 69 84;
--theme-action-danger-bg: 255 228 230;
--theme-action-danger-border: 254 205 211;
--theme-action-danger-text: 190 18 60;
--theme-action-danger-hover-bg: 254 205 211;
--theme-action-danger-hover-border: 253 164 175;
--theme-action-danger-hover-text: 159 18 57;
--theme-state-info-bg: 240 249 255;
--theme-state-info-border: 186 230 253;
--theme-state-info-text: 7 89 133;
--theme-state-success-bg: 236 253 245;
--theme-state-success-border: 167 243 208;
--theme-state-success-text: 4 120 87;
--theme-state-warning-bg: 255 251 235;
--theme-state-warning-border: 253 230 138;
--theme-state-warning-text: 146 64 14;
--theme-state-danger-bg: 255 241 242;
--theme-state-danger-border: 254 205 211;
--theme-state-danger-text: 190 18 60;
}
html, html,
body, body,
#app { #app {
@apply min-h-full overflow-x-clip; @apply h-full w-full max-w-full overflow-x-clip;
} }
body { body {
@apply bg-zinc-100 text-zinc-900 antialiased transition-colors duration-300 dark:bg-zinc-950 dark:text-zinc-100; @apply overflow-hidden bg-surface-app text-ink-primary antialiased transition-colors duration-300;
} }
h1, h1,
@ -36,27 +161,27 @@
@layer components { @layer components {
.skip-link { .skip-link {
@apply sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-3 focus:z-50 focus:rounded-md focus:bg-zinc-900 focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:text-white focus:outline-none dark:focus:bg-brand-400 dark:focus:text-zinc-950; @apply sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-3 focus:z-50 focus:rounded-md focus:bg-action-primary-bg focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:text-action-primary-text focus:outline-none;
} }
.page-stack { .page-stack {
@apply space-y-6; @apply flex min-h-0 flex-col gap-6;
} }
.page-title { .page-title {
@apply font-display text-3xl font-semibold tracking-tight text-zinc-900 sm:text-4xl dark:text-zinc-100; @apply font-display text-3xl font-semibold tracking-tight text-ink-primary sm:text-4xl;
} }
.page-subtitle { .page-subtitle {
@apply text-sm leading-relaxed text-zinc-600 dark:text-zinc-400; @apply text-sm leading-relaxed text-ink-secondary;
} }
.text-secondary { .text-secondary {
@apply text-zinc-600 dark:text-zinc-400; @apply text-ink-secondary;
} }
.text-muted { .text-muted {
@apply text-zinc-500 dark:text-zinc-500; @apply text-ink-muted;
} }
.row { .row {
@ -64,6 +189,6 @@
} }
.link-inline { .link-inline {
@apply rounded-sm text-brand-700 underline-offset-4 transition hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:text-brand-300 dark:focus-visible:ring-brand-400 dark:focus-visible:ring-offset-zinc-950; @apply rounded-sm text-ink-link underline-offset-4 transition hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset;
} }
} }

View file

@ -0,0 +1,89 @@
# Theme Inventory (Phase 0)
This file captures the semantic color system baseline for the theme refactor.
## Token Domains
- `scale`:
- `brand` (`50..950`)
- `info` (`50..950`)
- `success` (`50..950`)
- `warning` (`50..950`)
- `danger` (`50..950`)
- `surface`:
- `app`
- `nav`
- `nav_active`
- `card`
- `card_muted`
- `table`
- `table_header`
- `input`
- `overlay`
- `text`:
- `primary`
- `secondary`
- `muted`
- `inverse`
- `link`
- `border`:
- `default`
- `strong`
- `subtle`
- `interactive`
- `focus`:
- `ring`
- `ring_offset`
- `action` variants (`primary`, `secondary`, `ghost`, `danger`):
- `bg`
- `border`
- `text`
- `hover_bg`
- `hover_border`
- `hover_text`
- `state` variants (`info`, `success`, `warning`, `danger`):
- `bg`
- `border`
- `text`
## Theme Sources
Theme presets are dynamically loaded from `frontend/src/theme/presets/*.{json,js}`.
Current preset files:
- `frontend/src/theme/presets/parchment.js`
- `frontend/src/theme/presets/lilac.js`
- `frontend/src/theme/presets/dune.js`
- `frontend/src/theme/presets/oatmeal.js`
- `frontend/src/theme/presets/scholarly.json`
- `frontend/src/theme/presets/graphite.json`
- `frontend/src/theme/presets/tide.json`
Each preset contains both `light` and `dark` mode token definitions.
## Adoption Status (Phase 0-3 complete baseline)
Tokenized foundation components:
- `AppButton`
- `AppCard`
- `AppCheckbox`
- `AppEmptyState`
- `AppHelpHint`
- `AppInput`
- `AppSelect`
- `AppTable`
- `AppModal`
- `AppHeader`
- `AppNav`
- `AppAlert`
- `AppBadge`
- `RunStatusBadge`
- `QueueHealthBadge`
Hardening in place:
- Frontend token policy check script: `frontend/scripts/check_theme_tokens.mjs`
- CI enforcement step in `frontend-quality` workflow
- Theme preset integrity tests in `frontend/src/theme/presets.test.ts`

View file

@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import {
COLOR_SCALE_STEPS,
THEME_ACTION_KEYS,
THEME_ACTION_VARIANTS,
THEME_BORDER_KEYS,
THEME_COLOR_TYPES,
THEME_FOCUS_KEYS,
THEME_PRESETS,
THEME_STATE_KEYS,
THEME_STATE_VARIANTS,
THEME_SURFACE_KEYS,
THEME_TEXT_KEYS,
THEME_MODES,
themeCssVariables,
} from "@/theme/presets";
const RGB_TRIPLET_PATTERN = /^\d{1,3} \d{1,3} \d{1,3}$/;
describe("theme presets", () => {
it("exposes unique preset identifiers", () => {
const ids = THEME_PRESETS.map((preset) => preset.id);
expect(ids.length).toBeGreaterThan(0);
expect(new Set(ids).size).toBe(ids.length);
});
it("builds complete CSS variable maps for each preset and mode", () => {
const expectedCount =
THEME_COLOR_TYPES.length * COLOR_SCALE_STEPS.length +
THEME_SURFACE_KEYS.length +
THEME_TEXT_KEYS.length +
THEME_BORDER_KEYS.length +
THEME_FOCUS_KEYS.length +
THEME_ACTION_VARIANTS.length * THEME_ACTION_KEYS.length +
THEME_STATE_VARIANTS.length * THEME_STATE_KEYS.length;
for (const preset of THEME_PRESETS) {
for (const mode of THEME_MODES) {
const cssVars = themeCssVariables(preset.id, mode);
expect(Object.keys(cssVars)).toHaveLength(expectedCount);
expect(cssVars["--theme-surface-app"]).toBeDefined();
expect(cssVars["--theme-text-primary"]).toBeDefined();
expect(cssVars["--theme-border-default"]).toBeDefined();
expect(cssVars["--theme-focus-ring"]).toBeDefined();
expect(cssVars["--theme-action-primary-bg"]).toBeDefined();
expect(cssVars["--theme-state-warning-text"]).toBeDefined();
}
}
});
it("converts all theme variables to RGB triplets", () => {
for (const preset of THEME_PRESETS) {
for (const mode of THEME_MODES) {
const cssVars = themeCssVariables(preset.id, mode);
for (const value of Object.values(cssVars)) {
expect(value).toMatch(RGB_TRIPLET_PATTERN);
const [red, green, blue] = value.split(" ").map(Number);
expect(red).toBeGreaterThanOrEqual(0);
expect(red).toBeLessThanOrEqual(255);
expect(green).toBeGreaterThanOrEqual(0);
expect(green).toBeLessThanOrEqual(255);
expect(blue).toBeGreaterThanOrEqual(0);
expect(blue).toBeLessThanOrEqual(255);
}
}
}
});
});

View file

@ -0,0 +1,274 @@
export const COLOR_SCALE_STEPS = ["50", "100", "200", "300", "400", "500", "600", "700", "800", "900", "950"] as const;
export const THEME_MODES = ["light", "dark"] as const;
export const THEME_COLOR_TYPES = ["brand", "info", "success", "warning", "danger"] as const;
export const THEME_SURFACE_KEYS = [
"app",
"nav",
"nav_active",
"card",
"card_muted",
"table",
"table_header",
"input",
"overlay",
] as const;
export const THEME_TEXT_KEYS = ["primary", "secondary", "muted", "inverse", "link"] as const;
export const THEME_BORDER_KEYS = ["default", "strong", "subtle", "interactive"] as const;
export const THEME_FOCUS_KEYS = ["ring", "ring_offset"] as const;
export const THEME_ACTION_VARIANTS = ["primary", "secondary", "ghost", "danger"] as const;
export const THEME_ACTION_KEYS = ["bg", "border", "text", "hover_bg", "hover_border", "hover_text"] as const;
export const THEME_STATE_VARIANTS = ["info", "success", "warning", "danger"] as const;
export const THEME_STATE_KEYS = ["bg", "border", "text"] as const;
export type ColorScaleStep = (typeof COLOR_SCALE_STEPS)[number];
export type ThemeMode = (typeof THEME_MODES)[number];
export type ThemeColorType = (typeof THEME_COLOR_TYPES)[number];
export type ThemeSurfaceKey = (typeof THEME_SURFACE_KEYS)[number];
export type ThemeTextKey = (typeof THEME_TEXT_KEYS)[number];
export type ThemeBorderKey = (typeof THEME_BORDER_KEYS)[number];
export type ThemeFocusKey = (typeof THEME_FOCUS_KEYS)[number];
export type ThemeActionVariant = (typeof THEME_ACTION_VARIANTS)[number];
export type ThemeActionKey = (typeof THEME_ACTION_KEYS)[number];
export type ThemeStateVariant = (typeof THEME_STATE_VARIANTS)[number];
export type ThemeStateKey = (typeof THEME_STATE_KEYS)[number];
export type ThemePresetId = string;
export type ThemeColorScale = Record<ColorScaleStep, string>;
export type ThemeScaleTokenMap = Record<ThemeColorType, ThemeColorScale>;
export type ThemeSurfaceTokens = Record<ThemeSurfaceKey, string>;
export type ThemeTextTokens = Record<ThemeTextKey, string>;
export type ThemeBorderTokens = Record<ThemeBorderKey, string>;
export type ThemeFocusTokens = Record<ThemeFocusKey, string>;
export type ThemeActionTokens = Record<ThemeActionKey, string>;
export type ThemeActionTokenMap = Record<ThemeActionVariant, ThemeActionTokens>;
export type ThemeStateTokens = Record<ThemeStateKey, string>;
export type ThemeStateTokenMap = Record<ThemeStateVariant, ThemeStateTokens>;
export interface ThemePresetOption {
id: ThemePresetId;
label: string;
description: string;
}
export interface ThemeModeDefinition {
scale: ThemeScaleTokenMap;
surface: ThemeSurfaceTokens;
text: ThemeTextTokens;
border: ThemeBorderTokens;
focus: ThemeFocusTokens;
action: ThemeActionTokenMap;
state: ThemeStateTokenMap;
}
export interface ThemePresetDefinition extends ThemePresetOption {
modes: Record<ThemeMode, ThemeModeDefinition>;
}
const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
const presetModules = import.meta.glob<{ default?: unknown }>(
"./presets/*.{json,js}",
{ eager: true },
);
function asObject(value: unknown, path: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`Invalid theme schema at ${path}: expected object.`);
}
return value as Record<string, unknown>;
}
function asString(value: unknown, path: string): string {
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`Invalid theme schema at ${path}: expected non-empty string.`);
}
return value;
}
function asHexColor(value: unknown, path: string): string {
const color = asString(value, path).trim();
if (!HEX_COLOR_PATTERN.test(color)) {
throw new Error(`Invalid theme color at ${path}: expected #RGB or #RRGGBB.`);
}
return color;
}
function readRecordByKeys<K extends readonly string[]>(
source: unknown,
keys: K,
path: string,
): Record<K[number], string> {
const record = asObject(source, path);
const result = {} as Record<K[number], string>;
for (const key of keys as readonly K[number][]) {
result[key] = asHexColor(record[key], `${path}.${key}`);
}
return result;
}
function readScaleTokens(source: unknown, path: string): ThemeScaleTokenMap {
const record = asObject(source, path);
return {
brand: readRecordByKeys(record.brand, COLOR_SCALE_STEPS, `${path}.brand`),
info: readRecordByKeys(record.info, COLOR_SCALE_STEPS, `${path}.info`),
success: readRecordByKeys(record.success, COLOR_SCALE_STEPS, `${path}.success`),
warning: readRecordByKeys(record.warning, COLOR_SCALE_STEPS, `${path}.warning`),
danger: readRecordByKeys(record.danger, COLOR_SCALE_STEPS, `${path}.danger`),
};
}
function readActionTokens(source: unknown, path: string): ThemeActionTokenMap {
const record = asObject(source, path);
return {
primary: readRecordByKeys(record.primary, THEME_ACTION_KEYS, `${path}.primary`),
secondary: readRecordByKeys(record.secondary, THEME_ACTION_KEYS, `${path}.secondary`),
ghost: readRecordByKeys(record.ghost, THEME_ACTION_KEYS, `${path}.ghost`),
danger: readRecordByKeys(record.danger, THEME_ACTION_KEYS, `${path}.danger`),
};
}
function readStateTokens(source: unknown, path: string): ThemeStateTokenMap {
const record = asObject(source, path);
return {
info: readRecordByKeys(record.info, THEME_STATE_KEYS, `${path}.info`),
success: readRecordByKeys(record.success, THEME_STATE_KEYS, `${path}.success`),
warning: readRecordByKeys(record.warning, THEME_STATE_KEYS, `${path}.warning`),
danger: readRecordByKeys(record.danger, THEME_STATE_KEYS, `${path}.danger`),
};
}
function readModeTokens(source: unknown, path: string): ThemeModeDefinition {
const record = asObject(source, path);
return {
scale: readScaleTokens(record.scale, `${path}.scale`),
surface: readRecordByKeys(record.surface, THEME_SURFACE_KEYS, `${path}.surface`),
text: readRecordByKeys(record.text, THEME_TEXT_KEYS, `${path}.text`),
border: readRecordByKeys(record.border, THEME_BORDER_KEYS, `${path}.border`),
focus: readRecordByKeys(record.focus, THEME_FOCUS_KEYS, `${path}.focus`),
action: readActionTokens(record.action, `${path}.action`),
state: readStateTokens(record.state, `${path}.state`),
};
}
function validateThemePreset(source: unknown, sourcePath: string): ThemePresetDefinition {
const record = asObject(source, sourcePath);
const id = asString(record.id, `${sourcePath}.id`);
return {
id,
label: asString(record.label, `${sourcePath}.label`),
description: asString(record.description, `${sourcePath}.description`),
modes: {
light: readModeTokens(asObject(record.modes, `${sourcePath}.modes`).light, `${sourcePath}.modes.light`),
dark: readModeTokens(asObject(record.modes, `${sourcePath}.modes`).dark, `${sourcePath}.modes.dark`),
},
};
}
const loadedPresetSources = Object.entries(presetModules)
.sort(([left], [right]) => left.localeCompare(right))
.map(([sourcePath, moduleValue]) => ({
sourcePath,
source:
moduleValue && typeof moduleValue === "object" && "default" in moduleValue
? moduleValue.default
: moduleValue,
}));
export const THEME_PRESETS: ThemePresetDefinition[] = loadedPresetSources.map(
({ source, sourcePath }) => validateThemePreset(source, sourcePath),
);
const presetById = new Map<ThemePresetId, ThemePresetDefinition>();
for (const preset of THEME_PRESETS) {
if (presetById.has(preset.id)) {
throw new Error(`Duplicate theme preset id detected: ${preset.id}`);
}
presetById.set(preset.id, preset);
}
export const DEFAULT_THEME_PRESET: ThemePresetId = presetById.has("parchment")
? "parchment"
: (THEME_PRESETS[0]?.id ?? "parchment");
export const THEME_PRESET_OPTIONS: ThemePresetOption[] = THEME_PRESETS.map((preset) => ({
id: preset.id,
label: preset.label,
description: preset.description,
}));
function normalizeHex(hex: string): string {
const normalized = hex.trim().replace(/^#/, "");
if (normalized.length === 3) {
return normalized
.split("")
.map((char) => `${char}${char}`)
.join("");
}
return normalized;
}
function hexToRgbTriplet(hex: string): string {
const normalized = normalizeHex(hex);
const red = parseInt(normalized.slice(0, 2), 16);
const green = parseInt(normalized.slice(2, 4), 16);
const blue = parseInt(normalized.slice(4, 6), 16);
return `${red} ${green} ${blue}`;
}
export function isThemePresetId(value: string): boolean {
return presetById.has(value);
}
export function getThemePreset(presetId: ThemePresetId): ThemePresetDefinition {
return presetById.get(presetId) ?? presetById.get(DEFAULT_THEME_PRESET) ?? THEME_PRESETS[0];
}
export function themeCssVariables(presetId: ThemePresetId, mode: ThemeMode): Record<string, string> {
const preset = getThemePreset(presetId);
const selected = preset.modes[mode];
const entries: Array<[string, string]> = [];
for (const role of THEME_COLOR_TYPES) {
for (const step of COLOR_SCALE_STEPS) {
entries.push([`--color-${role}-${step}`, hexToRgbTriplet(selected.scale[role][step])]);
}
}
for (const key of THEME_SURFACE_KEYS) {
entries.push([`--theme-surface-${key.replace(/_/g, "-")}`, hexToRgbTriplet(selected.surface[key])]);
}
for (const key of THEME_TEXT_KEYS) {
entries.push([`--theme-text-${key.replace(/_/g, "-")}`, hexToRgbTriplet(selected.text[key])]);
}
for (const key of THEME_BORDER_KEYS) {
entries.push([`--theme-border-${key.replace(/_/g, "-")}`, hexToRgbTriplet(selected.border[key])]);
}
for (const key of THEME_FOCUS_KEYS) {
entries.push([`--theme-focus-${key.replace(/_/g, "-")}`, hexToRgbTriplet(selected.focus[key])]);
}
for (const variant of THEME_ACTION_VARIANTS) {
for (const key of THEME_ACTION_KEYS) {
entries.push([
`--theme-action-${variant}-${key.replace(/_/g, "-")}`,
hexToRgbTriplet(selected.action[variant][key]),
]);
}
}
for (const variant of THEME_STATE_VARIANTS) {
for (const key of THEME_STATE_KEYS) {
entries.push([
`--theme-state-${variant}-${key.replace(/_/g, "-")}`,
hexToRgbTriplet(selected.state[variant][key]),
]);
}
}
return Object.fromEntries(entries);
}

View file

@ -0,0 +1,314 @@
export default {
"id": "dune",
"label": "Dune",
"description": "Sandy beige environments featuring soft mauve highlights.",
"modes": {
"light": {
"scale": {
"brand": {
"50": "#f7f5f6",
"100": "#ece7e9",
"200": "#d7cccd",
"300": "#baabb2",
"400": "#9e8893",
"500": "#856b77",
"600": "#6c5460",
"700": "#58424d",
"800": "#4a3841",
"900": "#3d3036",
"950": "#21181d"
},
"info": {
"50": "#f4f5f8",
"100": "#e5e8ef",
"200": "#cdd4e1",
"300": "#a9b6cb",
"400": "#8496b1",
"500": "#697a98",
"600": "#53617e",
"700": "#455067",
"800": "#3a4356",
"900": "#323948",
"950": "#21252f"
},
"success": {
"50": "#f3f7f4",
"100": "#e1ebe4",
"200": "#c4d8c9",
"300": "#9dbfa6",
"400": "#75a383",
"500": "#588567",
"600": "#446950",
"700": "#385441",
"800": "#2f4436",
"900": "#27382d",
"950": "#131f18"
},
"warning": {
"50": "#fdf7f3",
"100": "#faebe2",
"200": "#f3d2be",
"300": "#eab192",
"400": "#de8f68",
"500": "#cc784f",
"600": "#b05e3b",
"700": "#924a2f",
"800": "#783e29",
"900": "#633524",
"950": "#351a0f"
},
"danger": {
"50": "#fcf5f6",
"100": "#f8e6e9",
"200": "#efcbd1",
"300": "#e2a6b1",
"400": "#d07d8d",
"500": "#b85d6e",
"600": "#9b4556",
"700": "#823746",
"800": "#6c303c",
"900": "#5a2a33",
"950": "#31141a"
}
},
"surface": {
"app": "#efece5",
"nav": "#f7f5f0",
"nav_active": "#d7cccd",
"card": "#f7f5f0",
"card_muted": "#e6e3dd",
"table": "#f7f5f0",
"table_header": "#efece5",
"input": "#ffffff",
"overlay": "#3d3a3b"
},
"text": {
"primary": "#3d3a3b",
"secondary": "#757072",
"muted": "#9c9699",
"inverse": "#f7f5f0",
"link": "#6c5460"
},
"border": {
"default": "#e6e3dd",
"strong": "#d1cdc6",
"subtle": "#f2f0eb",
"interactive": "#baabb2"
},
"focus": {
"ring": "#9e8893",
"ring_offset": "#efece5"
},
"action": {
"primary": {
"bg": "#9e8893",
"border": "#9e8893",
"text": "#f7f5f0",
"hover_bg": "#856b77",
"hover_border": "#856b77",
"hover_text": "#ffffff"
},
"secondary": {
"bg": "#e6e3dd",
"border": "#d1cdc6",
"text": "#3d3a3b",
"hover_bg": "#d8d5ce",
"hover_border": "#bcbbb5",
"hover_text": "#1f1d1e"
},
"ghost": {
"bg": "#efece5",
"border": "#e6e3dd",
"text": "#6c5460",
"hover_bg": "#e6e3dd",
"hover_border": "#d1cdc6",
"hover_text": "#3d3a3b"
},
"danger": {
"bg": "#f8e6e9",
"border": "#efcbd1",
"text": "#823746",
"hover_bg": "#efcbd1",
"hover_border": "#e2a6b1",
"hover_text": "#9b4556"
}
},
"state": {
"info": {
"bg": "#e5e8ef",
"border": "#cdd4e1",
"text": "#455067"
},
"success": {
"bg": "#e1ebe4",
"border": "#c4d8c9",
"text": "#385441"
},
"warning": {
"bg": "#faebe2",
"border": "#f3d2be",
"text": "#924a2f"
},
"danger": {
"bg": "#f8e6e9",
"border": "#efcbd1",
"text": "#823746"
}
}
},
"dark": {
"scale": {
"brand": {
"50": "#f7f5f6",
"100": "#ece7e9",
"200": "#d7cccd",
"300": "#baabb2",
"400": "#9e8893",
"500": "#856b77",
"600": "#6c5460",
"700": "#58424d",
"800": "#4a3841",
"900": "#3d3036",
"950": "#21181d"
},
"info": {
"50": "#f4f5f8",
"100": "#e5e8ef",
"200": "#cdd4e1",
"300": "#a9b6cb",
"400": "#8496b1",
"500": "#697a98",
"600": "#53617e",
"700": "#455067",
"800": "#3a4356",
"900": "#323948",
"950": "#21252f"
},
"success": {
"50": "#f3f7f4",
"100": "#e1ebe4",
"200": "#c4d8c9",
"300": "#9dbfa6",
"400": "#75a383",
"500": "#588567",
"600": "#446950",
"700": "#385441",
"800": "#2f4436",
"900": "#27382d",
"950": "#131f18"
},
"warning": {
"50": "#fdf7f3",
"100": "#faebe2",
"200": "#f3d2be",
"300": "#eab192",
"400": "#de8f68",
"500": "#cc784f",
"600": "#b05e3b",
"700": "#924a2f",
"800": "#783e29",
"900": "#633524",
"950": "#351a0f"
},
"danger": {
"50": "#fcf5f6",
"100": "#f8e6e9",
"200": "#efcbd1",
"300": "#e2a6b1",
"400": "#d07d8d",
"500": "#b85d6e",
"600": "#9b4556",
"700": "#823746",
"800": "#6c303c",
"900": "#5a2a33",
"950": "#31141a"
}
},
"surface": {
"app": "#3d3a3b",
"nav": "#4a4748",
"nav_active": "#6c5460",
"card": "#4a4748",
"card_muted": "#575354",
"table": "#4a4748",
"table_header": "#575354",
"input": "#312e2f",
"overlay": "#1f1d1e"
},
"text": {
"primary": "#e6e3e4",
"secondary": "#b8b4b6",
"muted": "#918e8f",
"inverse": "#3d3a3b",
"link": "#baabb2"
},
"border": {
"default": "#575354",
"strong": "#696466",
"subtle": "#474345",
"interactive": "#9e8893"
},
"focus": {
"ring": "#9e8893",
"ring_offset": "#3d3a3b"
},
"action": {
"primary": {
"bg": "#9e8893",
"border": "#9e8893",
"text": "#1f1d1e",
"hover_bg": "#baabb2",
"hover_border": "#baabb2",
"hover_text": "#1f1d1e"
},
"secondary": {
"bg": "#575354",
"border": "#696466",
"text": "#e6e3e4",
"hover_bg": "#696466",
"hover_border": "#7a7577",
"hover_text": "#ffffff"
},
"ghost": {
"bg": "#3d3a3b",
"border": "#575354",
"text": "#b8b4b6",
"hover_bg": "#575354",
"hover_border": "#696466",
"hover_text": "#ffffff"
},
"danger": {
"bg": "#5a2a33",
"border": "#6c303c",
"text": "#efcbd1",
"hover_bg": "#6c303c",
"hover_border": "#823746",
"hover_text": "#f8e6e9"
}
},
"state": {
"info": {
"bg": "#323948",
"border": "#455067",
"text": "#cdd4e1"
},
"success": {
"bg": "#27382d",
"border": "#385441",
"text": "#c4d8c9"
},
"warning": {
"bg": "#633524",
"border": "#924a2f",
"text": "#f3d2be"
},
"danger": {
"bg": "#5a2a33",
"border": "#823746",
"text": "#efcbd1"
}
}
}
}
}
;

View file

@ -0,0 +1,313 @@
{
"id": "graphite",
"label": "Graphite",
"description": "Neutral slate base with crisp indigo accents.",
"modes": {
"light": {
"scale": {
"brand": {
"50": "#f8fafc",
"100": "#f1f5f9",
"200": "#e2e8f0",
"300": "#cbd5e1",
"400": "#94a3b8",
"500": "#64748b",
"600": "#475569",
"700": "#334155",
"800": "#1e293b",
"900": "#0f172a",
"950": "#020617"
},
"info": {
"50": "#eef2ff",
"100": "#e0e7ff",
"200": "#c7d2fe",
"300": "#a5b4fc",
"400": "#818cf8",
"500": "#6366f1",
"600": "#4f46e5",
"700": "#4338ca",
"800": "#3730a3",
"900": "#312e81",
"950": "#1e1b4b"
},
"success": {
"50": "#ecfdf5",
"100": "#d1fae5",
"200": "#a7f3d0",
"300": "#6ee7b7",
"400": "#34d399",
"500": "#10b981",
"600": "#059669",
"700": "#047857",
"800": "#065f46",
"900": "#064e3b",
"950": "#022c22"
},
"warning": {
"50": "#fffbeb",
"100": "#fef3c7",
"200": "#fde68a",
"300": "#fcd34d",
"400": "#fbbf24",
"500": "#f59e0b",
"600": "#d97706",
"700": "#b45309",
"800": "#92400e",
"900": "#78350f",
"950": "#451a03"
},
"danger": {
"50": "#fff1f2",
"100": "#ffe4e6",
"200": "#fecdd3",
"300": "#fda4af",
"400": "#fb7185",
"500": "#f43f5e",
"600": "#e11d48",
"700": "#be123c",
"800": "#9f1239",
"900": "#881337",
"950": "#4c0519"
}
},
"surface": {
"app": "#f5f7fb",
"nav": "#ffffff",
"nav_active": "#334155",
"card": "#ffffff",
"card_muted": "#f1f5f9",
"table": "#ffffff",
"table_header": "#f8fafc",
"input": "#ffffff",
"overlay": "#0f172a"
},
"text": {
"primary": "#111827",
"secondary": "#374151",
"muted": "#6b7280",
"inverse": "#f9fafb",
"link": "#334155"
},
"border": {
"default": "#dbe2ea",
"strong": "#cbd5e1",
"subtle": "#eef2f7",
"interactive": "#94a3b8"
},
"focus": {
"ring": "#6366f1",
"ring_offset": "#f5f7fb"
},
"action": {
"primary": {
"bg": "#334155",
"border": "#334155",
"text": "#f9fafb",
"hover_bg": "#1e293b",
"hover_border": "#1e293b",
"hover_text": "#ffffff"
},
"secondary": {
"bg": "#f3f4f6",
"border": "#d1d5db",
"text": "#1f2937",
"hover_bg": "#e5e7eb",
"hover_border": "#cbd5e1",
"hover_text": "#111827"
},
"ghost": {
"bg": "#f8fafc",
"border": "#cbd5e1",
"text": "#334155",
"hover_bg": "#f1f5f9",
"hover_border": "#94a3b8",
"hover_text": "#1f2937"
},
"danger": {
"bg": "#ffe4e6",
"border": "#fecdd3",
"text": "#be123c",
"hover_bg": "#fecdd3",
"hover_border": "#fda4af",
"hover_text": "#9f1239"
}
},
"state": {
"info": {
"bg": "#eef2ff",
"border": "#c7d2fe",
"text": "#3730a3"
},
"success": {
"bg": "#ecfdf5",
"border": "#a7f3d0",
"text": "#047857"
},
"warning": {
"bg": "#fffbeb",
"border": "#fde68a",
"text": "#92400e"
},
"danger": {
"bg": "#fff1f2",
"border": "#fecdd3",
"text": "#be123c"
}
}
},
"dark": {
"scale": {
"brand": {
"50": "#f8fafc",
"100": "#f1f5f9",
"200": "#e2e8f0",
"300": "#cbd5e1",
"400": "#94a3b8",
"500": "#64748b",
"600": "#475569",
"700": "#334155",
"800": "#1e293b",
"900": "#0f172a",
"950": "#020617"
},
"info": {
"50": "#eef2ff",
"100": "#e0e7ff",
"200": "#c7d2fe",
"300": "#a5b4fc",
"400": "#818cf8",
"500": "#6366f1",
"600": "#4f46e5",
"700": "#4338ca",
"800": "#3730a3",
"900": "#312e81",
"950": "#1e1b4b"
},
"success": {
"50": "#ecfdf5",
"100": "#d1fae5",
"200": "#a7f3d0",
"300": "#6ee7b7",
"400": "#34d399",
"500": "#10b981",
"600": "#059669",
"700": "#047857",
"800": "#065f46",
"900": "#064e3b",
"950": "#022c22"
},
"warning": {
"50": "#fffbeb",
"100": "#fef3c7",
"200": "#fde68a",
"300": "#fcd34d",
"400": "#fbbf24",
"500": "#f59e0b",
"600": "#d97706",
"700": "#b45309",
"800": "#92400e",
"900": "#78350f",
"950": "#451a03"
},
"danger": {
"50": "#fff1f2",
"100": "#ffe4e6",
"200": "#fecdd3",
"300": "#fda4af",
"400": "#fb7185",
"500": "#f43f5e",
"600": "#e11d48",
"700": "#be123c",
"800": "#9f1239",
"900": "#881337",
"950": "#4c0519"
}
},
"surface": {
"app": "#0b1120",
"nav": "#111827",
"nav_active": "#64748b",
"card": "#111827",
"card_muted": "#1f2937",
"table": "#111827",
"table_header": "#1f2937",
"input": "#0f172a",
"overlay": "#020617"
},
"text": {
"primary": "#e5e7eb",
"secondary": "#cbd5e1",
"muted": "#94a3b8",
"inverse": "#0b1120",
"link": "#a5b4fc"
},
"border": {
"default": "#334155",
"strong": "#475569",
"subtle": "#1f2937",
"interactive": "#64748b"
},
"focus": {
"ring": "#818cf8",
"ring_offset": "#0b1120"
},
"action": {
"primary": {
"bg": "#64748b",
"border": "#64748b",
"text": "#020617",
"hover_bg": "#94a3b8",
"hover_border": "#94a3b8",
"hover_text": "#020617"
},
"secondary": {
"bg": "#1f2937",
"border": "#475569",
"text": "#e5e7eb",
"hover_bg": "#334155",
"hover_border": "#64748b",
"hover_text": "#f8fafc"
},
"ghost": {
"bg": "#0f172a",
"border": "#334155",
"text": "#cbd5e1",
"hover_bg": "#1f2937",
"hover_border": "#475569",
"hover_text": "#f8fafc"
},
"danger": {
"bg": "#4c0519",
"border": "#881337",
"text": "#fecdd3",
"hover_bg": "#9f1239",
"hover_border": "#be123c",
"hover_text": "#fff1f2"
}
},
"state": {
"info": {
"bg": "#1e1b4b",
"border": "#4338ca",
"text": "#c7d2fe"
},
"success": {
"bg": "#022c22",
"border": "#047857",
"text": "#a7f3d0"
},
"warning": {
"bg": "#451a03",
"border": "#b45309",
"text": "#fde68a"
},
"danger": {
"bg": "#4c0519",
"border": "#be123c",
"text": "#fecdd3"
}
}
}
}
}

View file

@ -0,0 +1,313 @@
export default {
"id": "lilac",
"label": "Lilac",
"description": "Soft pastel lilac foundation with gentle purple interactions.",
"modes": {
"light": {
"scale": {
"brand": {
"50": "#faf8fb",
"100": "#f3ecf5",
"200": "#e5d5ea",
"300": "#d1b4db",
"400": "#b98dc8",
"500": "#9e63b0",
"600": "#844a95",
"700": "#6d397a",
"800": "#5a3164",
"900": "#4a2a51",
"950": "#2d1332"
},
"info": {
"50": "#f2f6fa",
"100": "#e1ebf4",
"200": "#c9dceb",
"300": "#a5c4df",
"400": "#7da8cc",
"500": "#5e8ab3",
"600": "#4a6e94",
"700": "#3c5978",
"800": "#334a63",
"900": "#2b3e51",
"950": "#1a2736"
},
"success": {
"50": "#f2f8f4",
"100": "#e0efe5",
"200": "#c3dfcb",
"300": "#9cc8a8",
"400": "#74ad84",
"500": "#558e66",
"600": "#41704e",
"700": "#355a40",
"800": "#2d4935",
"900": "#253d2d",
"950": "#132218"
},
"warning": {
"50": "#fdf8f4",
"100": "#faeee5",
"200": "#f3daca",
"300": "#e8bea5",
"400": "#d99f7c",
"500": "#c68058",
"600": "#ab623e",
"700": "#8d4e32",
"800": "#73412b",
"900": "#5e3624",
"950": "#331b10"
},
"danger": {
"50": "#fdf5f6",
"100": "#f9e7ea",
"200": "#f1d1d7",
"300": "#e4b0bc",
"400": "#d3889a",
"500": "#be6379",
"600": "#a1485d",
"700": "#85384b",
"800": "#6f3140",
"900": "#5d2b37",
"950": "#33141c"
}
},
"surface": {
"app": "#f7f5f9",
"nav": "#ffffff",
"nav_active": "#e5d5ea",
"card": "#ffffff",
"card_muted": "#f3ecf5",
"table": "#ffffff",
"table_header": "#f7f5f9",
"input": "#ffffff",
"overlay": "#2d1332"
},
"text": {
"primary": "#2d1332",
"secondary": "#5a3164",
"muted": "#844a95",
"inverse": "#ffffff",
"link": "#844a95"
},
"border": {
"default": "#e5d5ea",
"strong": "#d1b4db",
"subtle": "#f3ecf5",
"interactive": "#b98dc8"
},
"focus": {
"ring": "#9e63b0",
"ring_offset": "#f7f5f9"
},
"action": {
"primary": {
"bg": "#9e63b0",
"border": "#9e63b0",
"text": "#ffffff",
"hover_bg": "#844a95",
"hover_border": "#844a95",
"hover_text": "#ffffff"
},
"secondary": {
"bg": "#f3ecf5",
"border": "#d1b4db",
"text": "#2d1332",
"hover_bg": "#e5d5ea",
"hover_border": "#b98dc8",
"hover_text": "#1a0a1d"
},
"ghost": {
"bg": "#f7f5f9",
"border": "#e5d5ea",
"text": "#844a95",
"hover_bg": "#f3ecf5",
"hover_border": "#d1b4db",
"hover_text": "#2d1332"
},
"danger": {
"bg": "#f9e7ea",
"border": "#f1d1d7",
"text": "#85384b",
"hover_bg": "#f1d1d7",
"hover_border": "#e4b0bc",
"hover_text": "#a1485d"
}
},
"state": {
"info": {
"bg": "#e1ebf4",
"border": "#c9dceb",
"text": "#3c5978"
},
"success": {
"bg": "#e0efe5",
"border": "#c3dfcb",
"text": "#355a40"
},
"warning": {
"bg": "#faeee5",
"border": "#f3daca",
"text": "#8d4e32"
},
"danger": {
"bg": "#f9e7ea",
"border": "#f1d1d7",
"text": "#85384b"
}
}
},
"dark": {
"scale": {
"brand": {
"50": "#faf8fb",
"100": "#f3ecf5",
"200": "#e5d5ea",
"300": "#d1b4db",
"400": "#b98dc8",
"500": "#9e63b0",
"600": "#844a95",
"700": "#6d397a",
"800": "#5a3164",
"900": "#4a2a51",
"950": "#2d1332"
},
"info": {
"50": "#f2f6fa",
"100": "#e1ebf4",
"200": "#c9dceb",
"300": "#a5c4df",
"400": "#7da8cc",
"500": "#5e8ab3",
"600": "#4a6e94",
"700": "#3c5978",
"800": "#334a63",
"900": "#2b3e51",
"950": "#1a2736"
},
"success": {
"50": "#f2f8f4",
"100": "#e0efe5",
"200": "#c3dfcb",
"300": "#9cc8a8",
"400": "#74ad84",
"500": "#558e66",
"600": "#41704e",
"700": "#355a40",
"800": "#2d4935",
"900": "#253d2d",
"950": "#132218"
},
"warning": {
"50": "#fdf8f4",
"100": "#faeee5",
"200": "#f3daca",
"300": "#e8bea5",
"400": "#d99f7c",
"500": "#c68058",
"600": "#ab623e",
"700": "#8d4e32",
"800": "#73412b",
"900": "#5e3624",
"950": "#331b10"
},
"danger": {
"50": "#fdf5f6",
"100": "#f9e7ea",
"200": "#f1d1d7",
"300": "#e4b0bc",
"400": "#d3889a",
"500": "#be6379",
"600": "#a1485d",
"700": "#85384b",
"800": "#6f3140",
"900": "#5d2b37",
"950": "#33141c"
}
},
"surface": {
"app": "#27242a",
"nav": "#312d35",
"nav_active": "#6d397a",
"card": "#312d35",
"card_muted": "#3d3842",
"table": "#312d35",
"table_header": "#3d3842",
"input": "#1e1b21",
"overlay": "#141116"
},
"text": {
"primary": "#e8e4e9",
"secondary": "#b6aebd",
"muted": "#8e8496",
"inverse": "#27242a",
"link": "#d1b4db"
},
"border": {
"default": "#4a4450",
"strong": "#5f5766",
"subtle": "#3d3842",
"interactive": "#9e63b0"
},
"focus": {
"ring": "#b98dc8",
"ring_offset": "#27242a"
},
"action": {
"primary": {
"bg": "#9e63b0",
"border": "#9e63b0",
"text": "#141116",
"hover_bg": "#b98dc8",
"hover_border": "#b98dc8",
"hover_text": "#141116"
},
"secondary": {
"bg": "#4a4450",
"border": "#5f5766",
"text": "#e8e4e9",
"hover_bg": "#5f5766",
"hover_border": "#6d397a",
"hover_text": "#ffffff"
},
"ghost": {
"bg": "#27242a",
"border": "#4a4450",
"text": "#b6aebd",
"hover_bg": "#4a4450",
"hover_border": "#5f5766",
"hover_text": "#ffffff"
},
"danger": {
"bg": "#5d2b37",
"border": "#6f3140",
"text": "#f1d1d7",
"hover_bg": "#6f3140",
"hover_border": "#85384b",
"hover_text": "#f9e7ea"
}
},
"state": {
"info": {
"bg": "#2b3e51",
"border": "#3c5978",
"text": "#c9dceb"
},
"success": {
"bg": "#253d2d",
"border": "#355a40",
"text": "#c3dfcb"
},
"warning": {
"bg": "#5e3624",
"border": "#8d4e32",
"text": "#f3daca"
},
"danger": {
"bg": "#5d2b37",
"border": "#85384b",
"text": "#f1d1d7"
}
}
}
}
}

View file

@ -0,0 +1,314 @@
export default {
"id": "oatmeal",
"label": "Oatmeal",
"description": "Warm beige foundation with muted sage green interactions.",
"modes": {
"light": {
"scale": {
"brand": {
"50": "#f4f7f5",
"100": "#e5ece7",
"200": "#ccd9cf",
"300": "#a8bdae",
"400": "#8ba390",
"500": "#6c8471",
"600": "#546958",
"700": "#435446",
"800": "#364338",
"900": "#2d372f",
"950": "#161d18"
},
"info": {
"50": "#f0f5f6",
"100": "#d9e6e8",
"200": "#b6ced4",
"300": "#8aadb6",
"400": "#648d99",
"500": "#4a717e",
"600": "#3e5c68",
"700": "#344b55",
"800": "#2d3f47",
"900": "#27353d",
"950": "#151f25"
},
"success": {
"50": "#f2f7f3",
"100": "#e0efe3",
"200": "#c3dfc9",
"300": "#9bc4a6",
"400": "#75a383",
"500": "#568465",
"600": "#42694f",
"700": "#365340",
"800": "#2d4234",
"900": "#25372b",
"950": "#121e16"
},
"warning": {
"50": "#fcf9f2",
"100": "#f6efe0",
"200": "#ecdeb8",
"300": "#dfc68a",
"400": "#cfa85e",
"500": "#c79f67",
"600": "#ac7f42",
"700": "#8f6336",
"800": "#755030",
"900": "#60422a",
"950": "#352213"
},
"danger": {
"50": "#fdf4f4",
"100": "#fae5e5",
"200": "#f3cccc",
"300": "#e8a8a8",
"400": "#da7b7b",
"500": "#c47575",
"600": "#a64d4d",
"700": "#8b3f3f",
"800": "#753737",
"900": "#623232",
"950": "#361818"
}
},
"surface": {
"app": "#f5f2eb",
"nav": "#fcfaf5",
"nav_active": "#e5e1d8",
"card": "#fcfaf5",
"card_muted": "#ece9e1",
"table": "#fcfaf5",
"table_header": "#f5f2eb",
"input": "#ffffff",
"overlay": "#3d3b38"
},
"text": {
"primary": "#3d3b38",
"secondary": "#716d68",
"muted": "#96918a",
"inverse": "#fcfaf5",
"link": "#546958"
},
"border": {
"default": "#e5e1d8",
"strong": "#cecabf",
"subtle": "#f0ede6",
"interactive": "#a8bdae"
},
"focus": {
"ring": "#8ba390",
"ring_offset": "#f5f2eb"
},
"action": {
"primary": {
"bg": "#8ba390",
"border": "#8ba390",
"text": "#fcfaf5",
"hover_bg": "#6c8471",
"hover_border": "#6c8471",
"hover_text": "#ffffff"
},
"secondary": {
"bg": "#ece9e1",
"border": "#cecabf",
"text": "#3d3b38",
"hover_bg": "#e5e1d8",
"hover_border": "#b8b3a8",
"hover_text": "#1a1918"
},
"ghost": {
"bg": "#f5f2eb",
"border": "#e5e1d8",
"text": "#546958",
"hover_bg": "#ece9e1",
"hover_border": "#cecabf",
"hover_text": "#3d3b38"
},
"danger": {
"bg": "#fae5e5",
"border": "#f3cccc",
"text": "#8b3f3f",
"hover_bg": "#f3cccc",
"hover_border": "#e8a8a8",
"hover_text": "#a64d4d"
}
},
"state": {
"info": {
"bg": "#d9e6e8",
"border": "#b6ced4",
"text": "#344b55"
},
"success": {
"bg": "#e0efe3",
"border": "#c3dfc9",
"text": "#365340"
},
"warning": {
"bg": "#f6efe0",
"border": "#ecdeb8",
"text": "#8f6336"
},
"danger": {
"bg": "#fae5e5",
"border": "#f3cccc",
"text": "#8b3f3f"
}
}
},
"dark": {
"scale": {
"brand": {
"50": "#f4f7f5",
"100": "#e5ece7",
"200": "#ccd9cf",
"300": "#a8bdae",
"400": "#8ba390",
"500": "#6c8471",
"600": "#546958",
"700": "#435446",
"800": "#364338",
"900": "#2d372f",
"950": "#161d18"
},
"info": {
"50": "#f0f5f6",
"100": "#d9e6e8",
"200": "#b6ced4",
"300": "#8aadb6",
"400": "#648d99",
"500": "#4a717e",
"600": "#3e5c68",
"700": "#344b55",
"800": "#2d3f47",
"900": "#27353d",
"950": "#151f25"
},
"success": {
"50": "#f2f7f3",
"100": "#e0efe3",
"200": "#c3dfc9",
"300": "#9bc4a6",
"400": "#75a383",
"500": "#568465",
"600": "#42694f",
"700": "#365340",
"800": "#2d4234",
"900": "#25372b",
"950": "#121e16"
},
"warning": {
"50": "#fcf9f2",
"100": "#f6efe0",
"200": "#ecdeb8",
"300": "#dfc68a",
"400": "#cfa85e",
"500": "#c79f67",
"600": "#ac7f42",
"700": "#8f6336",
"800": "#755030",
"900": "#60422a",
"950": "#352213"
},
"danger": {
"50": "#fdf4f4",
"100": "#fae5e5",
"200": "#f3cccc",
"300": "#e8a8a8",
"400": "#da7b7b",
"500": "#c47575",
"600": "#a64d4d",
"700": "#8b3f3f",
"800": "#753737",
"900": "#623232",
"950": "#361818"
}
},
"surface": {
"app": "#3a3937",
"nav": "#454442",
"nav_active": "#546958",
"card": "#454442",
"card_muted": "#504e4c",
"table": "#454442",
"table_header": "#504e4c",
"input": "#2e2d2b",
"overlay": "#1a1918"
},
"text": {
"primary": "#e8e6e1",
"secondary": "#bebcb8",
"muted": "#969490",
"inverse": "#3a3937",
"link": "#a8bdae"
},
"border": {
"default": "#504e4c",
"strong": "#62605d",
"subtle": "#403f3d",
"interactive": "#8ba390"
},
"focus": {
"ring": "#8ba390",
"ring_offset": "#3a3937"
},
"action": {
"primary": {
"bg": "#8ba390",
"border": "#8ba390",
"text": "#1a1918",
"hover_bg": "#a8bdae",
"hover_border": "#a8bdae",
"hover_text": "#1a1918"
},
"secondary": {
"bg": "#504e4c",
"border": "#62605d",
"text": "#e8e6e1",
"hover_bg": "#62605d",
"hover_border": "#73716e",
"hover_text": "#ffffff"
},
"ghost": {
"bg": "#3a3937",
"border": "#504e4c",
"text": "#bebcb8",
"hover_bg": "#504e4c",
"hover_border": "#62605d",
"hover_text": "#ffffff"
},
"danger": {
"bg": "#623232",
"border": "#753737",
"text": "#f3cccc",
"hover_bg": "#753737",
"hover_border": "#8b3f3f",
"hover_text": "#fae5e5"
}
},
"state": {
"info": {
"bg": "#27353d",
"border": "#344b55",
"text": "#b6ced4"
},
"success": {
"bg": "#25372b",
"border": "#365340",
"text": "#c3dfc9"
},
"warning": {
"bg": "#60422a",
"border": "#8f6336",
"text": "#ecdeb8"
},
"danger": {
"bg": "#623232",
"border": "#8b3f3f",
"text": "#f3cccc"
}
}
}
}
}
;

View file

@ -0,0 +1,313 @@
export default {
"id": "parchment",
"label": "Parchment",
"description": "Dusty beige canvas paired with slate blue accents.",
"modes": {
"light": {
"scale": {
"brand": {
"50": "#f5f7f9",
"100": "#eaf0f4",
"200": "#d5e0e9",
"300": "#b3c9d7",
"400": "#8daabc",
"500": "#7d93a4",
"600": "#5c7487",
"700": "#495e6f",
"800": "#3f4d5b",
"900": "#36414e",
"950": "#232b35"
},
"info": {
"50": "#f2f6fa",
"100": "#e0ebf4",
"200": "#c8dceb",
"300": "#a1c4de",
"400": "#73a5cc",
"500": "#538ab8",
"600": "#406d96",
"700": "#34577a",
"800": "#2d4965",
"900": "#273e55",
"950": "#192838"
},
"success": {
"50": "#f1f8f5",
"100": "#def0e6",
"200": "#bee0ce",
"300": "#93c7b0",
"400": "#6a9982",
"500": "#4c8a6d",
"600": "#386b53",
"700": "#2e5543",
"800": "#274537",
"900": "#21392e",
"950": "#112019"
},
"warning": {
"50": "#fdf8f1",
"100": "#f9eedc",
"200": "#f3dbbc",
"300": "#ebc191",
"400": "#e0a163",
"500": "#d1a569",
"600": "#b37435",
"700": "#945c2d",
"800": "#784b29",
"900": "#613e24",
"950": "#36200f"
},
"danger": {
"50": "#fcf5f5",
"100": "#f9e8e8",
"200": "#f1d1d1",
"300": "#e5b1b1",
"400": "#d48888",
"500": "#c26b6b",
"600": "#a64f4f",
"700": "#8a4141",
"800": "#733838",
"900": "#603232",
"950": "#331818"
}
},
"surface": {
"app": "#f2eee9",
"nav": "#faf8f5",
"nav_active": "#d5e0e9",
"card": "#faf8f5",
"card_muted": "#eae5df",
"table": "#faf8f5",
"table_header": "#f2eee9",
"input": "#ffffff",
"overlay": "#333638"
},
"text": {
"primary": "#333638",
"secondary": "#6b737c",
"muted": "#929ba5",
"inverse": "#faf8f5",
"link": "#5c7487"
},
"border": {
"default": "#eae5df",
"strong": "#d4cec6",
"subtle": "#f6f3ef",
"interactive": "#b3c9d7"
},
"focus": {
"ring": "#7d93a4",
"ring_offset": "#f2eee9"
},
"action": {
"primary": {
"bg": "#7d93a4",
"border": "#7d93a4",
"text": "#faf8f5",
"hover_bg": "#5c7487",
"hover_border": "#5c7487",
"hover_text": "#ffffff"
},
"secondary": {
"bg": "#eae5df",
"border": "#d4cec6",
"text": "#333638",
"hover_bg": "#dcd6cd",
"hover_border": "#c2bba0",
"hover_text": "#1a1b1d"
},
"ghost": {
"bg": "#f2eee9",
"border": "#eae5df",
"text": "#5c7487",
"hover_bg": "#eae5df",
"hover_border": "#d4cec6",
"hover_text": "#333638"
},
"danger": {
"bg": "#f9e8e8",
"border": "#f1d1d1",
"text": "#8a4141",
"hover_bg": "#f1d1d1",
"hover_border": "#e5b1b1",
"hover_text": "#a64f4f"
}
},
"state": {
"info": {
"bg": "#e0ebf4",
"border": "#c8dceb",
"text": "#34577a"
},
"success": {
"bg": "#def0e6",
"border": "#bee0ce",
"text": "#2e5543"
},
"warning": {
"bg": "#f9eedc",
"border": "#f3dbbc",
"text": "#945c2d"
},
"danger": {
"bg": "#f9e8e8",
"border": "#f1d1d1",
"text": "#8a4141"
}
}
},
"dark": {
"scale": {
"brand": {
"50": "#f5f7f9",
"100": "#eaf0f4",
"200": "#d5e0e9",
"300": "#b3c9d7",
"400": "#8daabc",
"500": "#7d93a4",
"600": "#5c7487",
"700": "#495e6f",
"800": "#3f4d5b",
"900": "#36414e",
"950": "#232b35"
},
"info": {
"50": "#f2f6fa",
"100": "#e0ebf4",
"200": "#c8dceb",
"300": "#a1c4de",
"400": "#73a5cc",
"500": "#538ab8",
"600": "#406d96",
"700": "#34577a",
"800": "#2d4965",
"900": "#273e55",
"950": "#192838"
},
"success": {
"50": "#f1f8f5",
"100": "#def0e6",
"200": "#bee0ce",
"300": "#93c7b0",
"400": "#6a9982",
"500": "#4c8a6d",
"600": "#386b53",
"700": "#2e5543",
"800": "#274537",
"900": "#21392e",
"950": "#112019"
},
"warning": {
"50": "#fdf8f1",
"100": "#f9eedc",
"200": "#f3dbbc",
"300": "#ebc191",
"400": "#e0a163",
"500": "#d1a569",
"600": "#b37435",
"700": "#945c2d",
"800": "#784b29",
"900": "#613e24",
"950": "#36200f"
},
"danger": {
"50": "#fcf5f5",
"100": "#f9e8e8",
"200": "#f1d1d1",
"300": "#e5b1b1",
"400": "#d48888",
"500": "#c26b6b",
"600": "#a64f4f",
"700": "#8a4141",
"800": "#733838",
"900": "#603232",
"950": "#331818"
}
},
"surface": {
"app": "#35373a",
"nav": "#424549",
"nav_active": "#5c7487",
"card": "#424549",
"card_muted": "#4d5156",
"table": "#424549",
"table_header": "#4d5156",
"input": "#2a2c2e",
"overlay": "#1a1b1d"
},
"text": {
"primary": "#e3e5e8",
"secondary": "#aeb5bc",
"muted": "#889098",
"inverse": "#35373a",
"link": "#b3c9d7"
},
"border": {
"default": "#4d5156",
"strong": "#5d6268",
"subtle": "#3e4145",
"interactive": "#7d93a4"
},
"focus": {
"ring": "#7d93a4",
"ring_offset": "#35373a"
},
"action": {
"primary": {
"bg": "#7d93a4",
"border": "#7d93a4",
"text": "#1a1b1d",
"hover_bg": "#b3c9d7",
"hover_border": "#b3c9d7",
"hover_text": "#1a1b1d"
},
"secondary": {
"bg": "#4d5156",
"border": "#5d6268",
"text": "#e3e5e8",
"hover_bg": "#5d6268",
"hover_border": "#70767c",
"hover_text": "#ffffff"
},
"ghost": {
"bg": "#35373a",
"border": "#4d5156",
"text": "#aeb5bc",
"hover_bg": "#4d5156",
"hover_border": "#5d6268",
"hover_text": "#ffffff"
},
"danger": {
"bg": "#603232",
"border": "#733838",
"text": "#f1d1d1",
"hover_bg": "#733838",
"hover_border": "#8a4141",
"hover_text": "#f9e8e8"
}
},
"state": {
"info": {
"bg": "#273e55",
"border": "#34577a",
"text": "#c8dceb"
},
"success": {
"bg": "#21392e",
"border": "#2e5543",
"text": "#bee0ce"
},
"warning": {
"bg": "#613e24",
"border": "#945c2d",
"text": "#f3dbbc"
},
"danger": {
"bg": "#603232",
"border": "#8a4141",
"text": "#f1d1d1"
}
}
}
}
};

View file

@ -0,0 +1,313 @@
{
"id": "scholarly",
"label": "Scholarly",
"description": "Calm, academic blues with restrained contrast.",
"modes": {
"light": {
"scale": {
"brand": {
"50": "#f3f6fb",
"100": "#e7edf7",
"200": "#cedcee",
"300": "#afc6df",
"400": "#8eaac8",
"500": "#6f8fac",
"600": "#5a7490",
"700": "#495f77",
"800": "#3f5063",
"900": "#374554",
"950": "#242d38"
},
"info": {
"50": "#f0f9ff",
"100": "#e0f2fe",
"200": "#bae6fd",
"300": "#7dd3fc",
"400": "#38bdf8",
"500": "#0ea5e9",
"600": "#0284c7",
"700": "#0369a1",
"800": "#075985",
"900": "#0c4a6e",
"950": "#082f49"
},
"success": {
"50": "#ecfdf5",
"100": "#d1fae5",
"200": "#a7f3d0",
"300": "#6ee7b7",
"400": "#34d399",
"500": "#10b981",
"600": "#059669",
"700": "#047857",
"800": "#065f46",
"900": "#064e3b",
"950": "#022c22"
},
"warning": {
"50": "#fffbeb",
"100": "#fef3c7",
"200": "#fde68a",
"300": "#fcd34d",
"400": "#fbbf24",
"500": "#f59e0b",
"600": "#d97706",
"700": "#b45309",
"800": "#92400e",
"900": "#78350f",
"950": "#451a03"
},
"danger": {
"50": "#fff1f2",
"100": "#ffe4e6",
"200": "#fecdd3",
"300": "#fda4af",
"400": "#fb7185",
"500": "#f43f5e",
"600": "#e11d48",
"700": "#be123c",
"800": "#9f1239",
"900": "#881337",
"950": "#4c0519"
}
},
"surface": {
"app": "#f3f6fb",
"nav": "#ffffff",
"nav_active": "#495f77",
"card": "#ffffff",
"card_muted": "#e7edf7",
"table": "#ffffff",
"table_header": "#f3f6fb",
"input": "#ffffff",
"overlay": "#0f172a"
},
"text": {
"primary": "#1f2937",
"secondary": "#475569",
"muted": "#64748b",
"inverse": "#f8fafc",
"link": "#495f77"
},
"border": {
"default": "#cedcee",
"strong": "#afc6df",
"subtle": "#e7edf7",
"interactive": "#8eaac8"
},
"focus": {
"ring": "#6f8fac",
"ring_offset": "#f3f6fb"
},
"action": {
"primary": {
"bg": "#5a7490",
"border": "#5a7490",
"text": "#f8fafc",
"hover_bg": "#495f77",
"hover_border": "#495f77",
"hover_text": "#ffffff"
},
"secondary": {
"bg": "#eef2f7",
"border": "#cedcee",
"text": "#334155",
"hover_bg": "#e2e8f0",
"hover_border": "#afc6df",
"hover_text": "#1f2937"
},
"ghost": {
"bg": "#f3f6fb",
"border": "#cedcee",
"text": "#495f77",
"hover_bg": "#e7edf7",
"hover_border": "#afc6df",
"hover_text": "#374554"
},
"danger": {
"bg": "#ffe4e6",
"border": "#fecdd3",
"text": "#be123c",
"hover_bg": "#fecdd3",
"hover_border": "#fda4af",
"hover_text": "#9f1239"
}
},
"state": {
"info": {
"bg": "#f0f9ff",
"border": "#bae6fd",
"text": "#075985"
},
"success": {
"bg": "#ecfdf5",
"border": "#a7f3d0",
"text": "#047857"
},
"warning": {
"bg": "#fffbeb",
"border": "#fde68a",
"text": "#92400e"
},
"danger": {
"bg": "#fff1f2",
"border": "#fecdd3",
"text": "#be123c"
}
}
},
"dark": {
"scale": {
"brand": {
"50": "#f3f6fb",
"100": "#e7edf7",
"200": "#cedcee",
"300": "#afc6df",
"400": "#8eaac8",
"500": "#6f8fac",
"600": "#5a7490",
"700": "#495f77",
"800": "#3f5063",
"900": "#374554",
"950": "#242d38"
},
"info": {
"50": "#f0f9ff",
"100": "#e0f2fe",
"200": "#bae6fd",
"300": "#7dd3fc",
"400": "#38bdf8",
"500": "#0ea5e9",
"600": "#0284c7",
"700": "#0369a1",
"800": "#075985",
"900": "#0c4a6e",
"950": "#082f49"
},
"success": {
"50": "#ecfdf5",
"100": "#d1fae5",
"200": "#a7f3d0",
"300": "#6ee7b7",
"400": "#34d399",
"500": "#10b981",
"600": "#059669",
"700": "#047857",
"800": "#065f46",
"900": "#064e3b",
"950": "#022c22"
},
"warning": {
"50": "#fffbeb",
"100": "#fef3c7",
"200": "#fde68a",
"300": "#fcd34d",
"400": "#fbbf24",
"500": "#f59e0b",
"600": "#d97706",
"700": "#b45309",
"800": "#92400e",
"900": "#78350f",
"950": "#451a03"
},
"danger": {
"50": "#fff1f2",
"100": "#ffe4e6",
"200": "#fecdd3",
"300": "#fda4af",
"400": "#fb7185",
"500": "#f43f5e",
"600": "#e11d48",
"700": "#be123c",
"800": "#9f1239",
"900": "#881337",
"950": "#4c0519"
}
},
"surface": {
"app": "#0b1220",
"nav": "#111827",
"nav_active": "#8eaac8",
"card": "#111827",
"card_muted": "#1f2937",
"table": "#111827",
"table_header": "#1f2937",
"input": "#0f172a",
"overlay": "#020617"
},
"text": {
"primary": "#e5e7eb",
"secondary": "#cbd5e1",
"muted": "#94a3b8",
"inverse": "#0b1220",
"link": "#afc6df"
},
"border": {
"default": "#334155",
"strong": "#475569",
"subtle": "#1f2937",
"interactive": "#64748b"
},
"focus": {
"ring": "#8eaac8",
"ring_offset": "#0b1220"
},
"action": {
"primary": {
"bg": "#8eaac8",
"border": "#8eaac8",
"text": "#0b1220",
"hover_bg": "#afc6df",
"hover_border": "#afc6df",
"hover_text": "#020617"
},
"secondary": {
"bg": "#1f2937",
"border": "#475569",
"text": "#e5e7eb",
"hover_bg": "#334155",
"hover_border": "#64748b",
"hover_text": "#f8fafc"
},
"ghost": {
"bg": "#0f172a",
"border": "#334155",
"text": "#cbd5e1",
"hover_bg": "#1f2937",
"hover_border": "#475569",
"hover_text": "#f8fafc"
},
"danger": {
"bg": "#4c0519",
"border": "#881337",
"text": "#fecdd3",
"hover_bg": "#9f1239",
"hover_border": "#be123c",
"hover_text": "#fff1f2"
}
},
"state": {
"info": {
"bg": "#082f49",
"border": "#0369a1",
"text": "#bae6fd"
},
"success": {
"bg": "#022c22",
"border": "#047857",
"text": "#a7f3d0"
},
"warning": {
"bg": "#451a03",
"border": "#b45309",
"text": "#fde68a"
},
"danger": {
"bg": "#4c0519",
"border": "#be123c",
"text": "#fecdd3"
}
}
}
}
}

View file

@ -0,0 +1,313 @@
{
"id": "tide",
"label": "Tide",
"description": "Ocean-inspired palette with teal emphasis.",
"modes": {
"light": {
"scale": {
"brand": {
"50": "#f0fdfa",
"100": "#ccfbf1",
"200": "#99f6e4",
"300": "#5eead4",
"400": "#2dd4bf",
"500": "#14b8a6",
"600": "#0d9488",
"700": "#0f766e",
"800": "#115e59",
"900": "#134e4a",
"950": "#042f2e"
},
"info": {
"50": "#f0f9ff",
"100": "#e0f2fe",
"200": "#bae6fd",
"300": "#7dd3fc",
"400": "#38bdf8",
"500": "#0ea5e9",
"600": "#0284c7",
"700": "#0369a1",
"800": "#075985",
"900": "#0c4a6e",
"950": "#082f49"
},
"success": {
"50": "#ecfdf5",
"100": "#d1fae5",
"200": "#a7f3d0",
"300": "#6ee7b7",
"400": "#34d399",
"500": "#10b981",
"600": "#059669",
"700": "#047857",
"800": "#065f46",
"900": "#064e3b",
"950": "#022c22"
},
"warning": {
"50": "#fffbeb",
"100": "#fef3c7",
"200": "#fde68a",
"300": "#fcd34d",
"400": "#fbbf24",
"500": "#f59e0b",
"600": "#d97706",
"700": "#b45309",
"800": "#92400e",
"900": "#78350f",
"950": "#451a03"
},
"danger": {
"50": "#fff1f2",
"100": "#ffe4e6",
"200": "#fecdd3",
"300": "#fda4af",
"400": "#fb7185",
"500": "#f43f5e",
"600": "#e11d48",
"700": "#be123c",
"800": "#9f1239",
"900": "#881337",
"950": "#4c0519"
}
},
"surface": {
"app": "#f0fdfa",
"nav": "#ffffff",
"nav_active": "#0f766e",
"card": "#ffffff",
"card_muted": "#ccfbf1",
"table": "#ffffff",
"table_header": "#f0fdfa",
"input": "#ffffff",
"overlay": "#0f172a"
},
"text": {
"primary": "#134e4a",
"secondary": "#0f766e",
"muted": "#115e59",
"inverse": "#f8fafc",
"link": "#0d9488"
},
"border": {
"default": "#99f6e4",
"strong": "#5eead4",
"subtle": "#ccfbf1",
"interactive": "#2dd4bf"
},
"focus": {
"ring": "#14b8a6",
"ring_offset": "#f0fdfa"
},
"action": {
"primary": {
"bg": "#0d9488",
"border": "#0d9488",
"text": "#f8fafc",
"hover_bg": "#0f766e",
"hover_border": "#0f766e",
"hover_text": "#ffffff"
},
"secondary": {
"bg": "#ecfeff",
"border": "#99f6e4",
"text": "#115e59",
"hover_bg": "#ccfbf1",
"hover_border": "#5eead4",
"hover_text": "#134e4a"
},
"ghost": {
"bg": "#f0fdfa",
"border": "#99f6e4",
"text": "#0f766e",
"hover_bg": "#ccfbf1",
"hover_border": "#5eead4",
"hover_text": "#115e59"
},
"danger": {
"bg": "#ffe4e6",
"border": "#fecdd3",
"text": "#be123c",
"hover_bg": "#fecdd3",
"hover_border": "#fda4af",
"hover_text": "#9f1239"
}
},
"state": {
"info": {
"bg": "#f0f9ff",
"border": "#bae6fd",
"text": "#075985"
},
"success": {
"bg": "#ecfdf5",
"border": "#a7f3d0",
"text": "#047857"
},
"warning": {
"bg": "#fffbeb",
"border": "#fde68a",
"text": "#92400e"
},
"danger": {
"bg": "#fff1f2",
"border": "#fecdd3",
"text": "#be123c"
}
}
},
"dark": {
"scale": {
"brand": {
"50": "#f0fdfa",
"100": "#ccfbf1",
"200": "#99f6e4",
"300": "#5eead4",
"400": "#2dd4bf",
"500": "#14b8a6",
"600": "#0d9488",
"700": "#0f766e",
"800": "#115e59",
"900": "#134e4a",
"950": "#042f2e"
},
"info": {
"50": "#f0f9ff",
"100": "#e0f2fe",
"200": "#bae6fd",
"300": "#7dd3fc",
"400": "#38bdf8",
"500": "#0ea5e9",
"600": "#0284c7",
"700": "#0369a1",
"800": "#075985",
"900": "#0c4a6e",
"950": "#082f49"
},
"success": {
"50": "#ecfdf5",
"100": "#d1fae5",
"200": "#a7f3d0",
"300": "#6ee7b7",
"400": "#34d399",
"500": "#10b981",
"600": "#059669",
"700": "#047857",
"800": "#065f46",
"900": "#064e3b",
"950": "#022c22"
},
"warning": {
"50": "#fffbeb",
"100": "#fef3c7",
"200": "#fde68a",
"300": "#fcd34d",
"400": "#fbbf24",
"500": "#f59e0b",
"600": "#d97706",
"700": "#b45309",
"800": "#92400e",
"900": "#78350f",
"950": "#451a03"
},
"danger": {
"50": "#fff1f2",
"100": "#ffe4e6",
"200": "#fecdd3",
"300": "#fda4af",
"400": "#fb7185",
"500": "#f43f5e",
"600": "#e11d48",
"700": "#be123c",
"800": "#9f1239",
"900": "#881337",
"950": "#4c0519"
}
},
"surface": {
"app": "#041f1f",
"nav": "#042f2e",
"nav_active": "#5eead4",
"card": "#042f2e",
"card_muted": "#134e4a",
"table": "#042f2e",
"table_header": "#134e4a",
"input": "#063a38",
"overlay": "#020617"
},
"text": {
"primary": "#e6fffb",
"secondary": "#c9fff3",
"muted": "#99f6e4",
"inverse": "#041f1f",
"link": "#5eead4"
},
"border": {
"default": "#115e59",
"strong": "#0f766e",
"subtle": "#134e4a",
"interactive": "#2dd4bf"
},
"focus": {
"ring": "#2dd4bf",
"ring_offset": "#041f1f"
},
"action": {
"primary": {
"bg": "#2dd4bf",
"border": "#2dd4bf",
"text": "#042f2e",
"hover_bg": "#5eead4",
"hover_border": "#5eead4",
"hover_text": "#042f2e"
},
"secondary": {
"bg": "#0f766e",
"border": "#14b8a6",
"text": "#e6fffb",
"hover_bg": "#0d9488",
"hover_border": "#2dd4bf",
"hover_text": "#ffffff"
},
"ghost": {
"bg": "#042f2e",
"border": "#0f766e",
"text": "#c9fff3",
"hover_bg": "#115e59",
"hover_border": "#14b8a6",
"hover_text": "#ffffff"
},
"danger": {
"bg": "#4c0519",
"border": "#881337",
"text": "#fecdd3",
"hover_bg": "#9f1239",
"hover_border": "#be123c",
"hover_text": "#fff1f2"
}
},
"state": {
"info": {
"bg": "#082f49",
"border": "#0369a1",
"text": "#bae6fd"
},
"success": {
"bg": "#022c22",
"border": "#047857",
"text": "#a7f3d0"
},
"warning": {
"bg": "#451a03",
"border": "#b45309",
"text": "#fde68a"
},
"danger": {
"bg": "#4c0519",
"border": "#be123c",
"text": "#fecdd3"
}
}
}
}
}

View file

@ -1,3 +1,15 @@
const COLOR_SCALE_STEPS = ["50", "100", "200", "300", "400", "500", "600", "700", "800", "900", "950"];
function cssColorScale(token) {
return Object.fromEntries(
COLOR_SCALE_STEPS.map((step) => [step, `rgb(var(--color-${token}-${step}) / <alpha-value>)`]),
);
}
function cssToken(token) {
return `rgb(var(--theme-${token}) / <alpha-value>)`;
}
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
darkMode: "class", darkMode: "class",
@ -12,18 +24,94 @@ export default {
panel: "0 10px 30px -20px rgba(8, 15, 30, 0.45)", panel: "0 10px 30px -20px rgba(8, 15, 30, 0.45)",
}, },
colors: { colors: {
brand: { brand: cssColorScale("brand"),
50: "#ecfeff", info: cssColorScale("info"),
100: "#cffafe", success: cssColorScale("success"),
200: "#a5f3fc", warning: cssColorScale("warning"),
300: "#67e8f9", danger: cssColorScale("danger"),
400: "#22d3ee", surface: {
500: "#06b6d4", app: cssToken("surface-app"),
600: "#0891b2", nav: cssToken("surface-nav"),
700: "#0e7490", "nav-active": cssToken("surface-nav-active"),
800: "#155e75", card: cssToken("surface-card"),
900: "#164e63", "card-muted": cssToken("surface-card-muted"),
950: "#083344", table: cssToken("surface-table"),
"table-header": cssToken("surface-table-header"),
input: cssToken("surface-input"),
overlay: cssToken("surface-overlay"),
},
ink: {
primary: cssToken("text-primary"),
secondary: cssToken("text-secondary"),
muted: cssToken("text-muted"),
inverse: cssToken("text-inverse"),
link: cssToken("text-link"),
},
stroke: {
default: cssToken("border-default"),
strong: cssToken("border-strong"),
subtle: cssToken("border-subtle"),
interactive: cssToken("border-interactive"),
},
focus: {
ring: cssToken("focus-ring"),
offset: cssToken("focus-ring-offset"),
},
action: {
primary: {
bg: cssToken("action-primary-bg"),
border: cssToken("action-primary-border"),
text: cssToken("action-primary-text"),
"hover-bg": cssToken("action-primary-hover-bg"),
"hover-border": cssToken("action-primary-hover-border"),
"hover-text": cssToken("action-primary-hover-text"),
},
secondary: {
bg: cssToken("action-secondary-bg"),
border: cssToken("action-secondary-border"),
text: cssToken("action-secondary-text"),
"hover-bg": cssToken("action-secondary-hover-bg"),
"hover-border": cssToken("action-secondary-hover-border"),
"hover-text": cssToken("action-secondary-hover-text"),
},
ghost: {
bg: cssToken("action-ghost-bg"),
border: cssToken("action-ghost-border"),
text: cssToken("action-ghost-text"),
"hover-bg": cssToken("action-ghost-hover-bg"),
"hover-border": cssToken("action-ghost-hover-border"),
"hover-text": cssToken("action-ghost-hover-text"),
},
danger: {
bg: cssToken("action-danger-bg"),
border: cssToken("action-danger-border"),
text: cssToken("action-danger-text"),
"hover-bg": cssToken("action-danger-hover-bg"),
"hover-border": cssToken("action-danger-hover-border"),
"hover-text": cssToken("action-danger-hover-text"),
},
},
state: {
info: {
bg: cssToken("state-info-bg"),
border: cssToken("state-info-border"),
text: cssToken("state-info-text"),
},
success: {
bg: cssToken("state-success-bg"),
border: cssToken("state-success-border"),
text: cssToken("state-success-text"),
},
warning: {
bg: cssToken("state-warning-bg"),
border: cssToken("state-warning-border"),
text: cssToken("state-warning-text"),
},
danger: {
bg: cssToken("state-danger-bg"),
border: cssToken("state-danger-border"),
text: cssToken("state-danger-text"),
},
}, },
}, },
}, },

View file

@ -0,0 +1,42 @@
#!/usr/bin/env bash
set -euo pipefail
blocked_regexes=(
'(^|/)__pycache__/'
'\\.py[co]$'
'(^|/)\\.pytest_cache(/|$)'
'(^|/)\\.mypy_cache(/|$)'
'(^|/)\\.ruff_cache(/|$)'
'(^|/)\\.coverage$'
'(^|/)htmlcov(/|$)'
'(^|/)(dist|build)(/|$)'
'(^|/)frontend/(dist|node_modules|\\.vite)(/|$)'
'(^|/)[^/]+\\.egg-info(/|$)'
'(^|/)\\.DS_Store$'
'(^|/)planning(/|$)'
)
tracked_files=()
while IFS= read -r path; do
tracked_files+=("$path")
done < <(git ls-files)
offenders=()
for path in "${tracked_files[@]}"; do
for regex in "${blocked_regexes[@]}"; do
if [[ "$path" =~ $regex ]]; then
offenders+=("$path")
break
fi
done
done
if (( ${#offenders[@]} > 0 )); then
{
echo "Tracked generated artifacts detected (remove from git tracking):"
printf ' - %s\n' "${offenders[@]}"
} >&2
exit 1
fi
echo "Generated artifact guard passed (no tracked cache/build/probe files)."

View file

@ -593,6 +593,7 @@ async def test_api_settings_get_and_update(db_session: AsyncSession) -> None:
get_response = client.get("/api/v1/settings") get_response = client.get("/api/v1/settings")
assert get_response.status_code == 200 assert get_response.status_code == 200
assert "request_delay_seconds" in get_response.json()["data"] assert "request_delay_seconds" in get_response.json()["data"]
assert "nav_visible_pages" in get_response.json()["data"]
update_response = client.put( update_response = client.put(
"/api/v1/settings", "/api/v1/settings",
@ -600,6 +601,7 @@ async def test_api_settings_get_and_update(db_session: AsyncSession) -> None:
"auto_run_enabled": True, "auto_run_enabled": True,
"run_interval_minutes": 45, "run_interval_minutes": 45,
"request_delay_seconds": 6, "request_delay_seconds": 6,
"nav_visible_pages": ["dashboard", "scholars", "publications", "settings", "runs"],
}, },
headers=headers, headers=headers,
) )
@ -608,6 +610,13 @@ async def test_api_settings_get_and_update(db_session: AsyncSession) -> None:
assert updated["auto_run_enabled"] is True assert updated["auto_run_enabled"] is True
assert updated["run_interval_minutes"] == 45 assert updated["run_interval_minutes"] == 45
assert updated["request_delay_seconds"] == 6 assert updated["request_delay_seconds"] == 6
assert updated["nav_visible_pages"] == [
"dashboard",
"scholars",
"publications",
"settings",
"runs",
]
@pytest.mark.integration @pytest.mark.integration

View file

@ -0,0 +1,182 @@
from __future__ import annotations
from collections import defaultdict
from pathlib import Path
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import RunStatus, RunTriggerType
from app.services.ingestion import ScholarIngestionService
from app.services.scholar_source import FetchResult
from tests.integration.helpers import insert_user
REGRESSION_FIXTURE_DIR = Path("tests/fixtures/scholar/regression")
def _fixture(name: str) -> str:
return (REGRESSION_FIXTURE_DIR / name).read_text(encoding="utf-8")
def _profile_fetch(*, scholar_id: str, body: str) -> FetchResult:
return FetchResult(
requested_url=f"https://scholar.google.com/citations?hl=en&user={scholar_id}",
status_code=200,
final_url=f"https://scholar.google.com/citations?hl=en&user={scholar_id}",
body=body,
error=None,
)
def _blocked_fetch(*, scholar_id: str, body: str) -> FetchResult:
return FetchResult(
requested_url=f"https://scholar.google.com/citations?hl=en&user={scholar_id}",
status_code=200,
final_url=(
"https://accounts.google.com/v3/signin/identifier"
"?continue=https%3A%2F%2Fscholar.google.com%2Fcitations"
),
body=body,
error=None,
)
def _network_timeout_fetch(*, scholar_id: str) -> FetchResult:
return FetchResult(
requested_url=f"https://scholar.google.com/citations?hl=en&user={scholar_id}",
status_code=None,
final_url=None,
body="",
error="timed out",
)
class FixtureScholarSource:
def __init__(self, responses: dict[tuple[str, int], list[FetchResult]]) -> None:
self._responses = responses
self._calls: defaultdict[tuple[str, int], int] = defaultdict(int)
async def fetch_profile_page_html(
self,
scholar_id: str,
*,
cstart: int,
pagesize: int,
) -> FetchResult:
_ = pagesize
key = (scholar_id, int(cstart))
fetches = self._responses.get(key)
if not fetches:
return _network_timeout_fetch(scholar_id=scholar_id)
call_index = self._calls[key]
self._calls[key] += 1
return fetches[min(call_index, len(fetches) - 1)]
async def fetch_profile_html(self, scholar_id: str) -> FetchResult:
return await self.fetch_profile_page_html(scholar_id, cstart=0, pagesize=100)
@pytest.mark.integration
@pytest.mark.db
@pytest.mark.asyncio
async def test_fixture_probe_run_emits_failure_and_retry_summary_metrics(
db_session: AsyncSession,
) -> None:
user_id = await insert_user(
db_session,
email="fixture-probe@example.com",
password="fixture-probe-password",
)
scholar_ids = {
"ok": "abcDEF123456",
"blocked": "A1B2C3D4E5F6",
"retry": "RSTUVWX12345",
}
for label in ("ok", "blocked", "retry"):
await db_session.execute(
text(
"""
INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
VALUES (:user_id, :scholar_id, :display_name, true)
"""
),
{
"user_id": user_id,
"scholar_id": scholar_ids[label],
"display_name": f"fixture-{label}",
},
)
await db_session.commit()
source = FixtureScholarSource(
{
(scholar_ids["ok"], 0): [
_profile_fetch(
scholar_id=scholar_ids["ok"],
body=_fixture("profile_P1RwlvoAAAAJ.html"),
)
],
(scholar_ids["blocked"], 0): [
_blocked_fetch(
scholar_id=scholar_ids["blocked"],
body=_fixture("profile_AAAAAAAAAAAA.html"),
)
],
(scholar_ids["retry"], 0): [
_network_timeout_fetch(scholar_id=scholar_ids["retry"]),
_profile_fetch(
scholar_id=scholar_ids["retry"],
body=_fixture("profile_LZ5D_p4AAAAJ.html"),
),
],
}
)
ingestion = ScholarIngestionService(source=source)
summary = await ingestion.run_for_user(
db_session,
user_id=user_id,
trigger_type=RunTriggerType.MANUAL,
request_delay_seconds=0,
network_error_retries=1,
retry_backoff_seconds=0.0,
max_pages_per_scholar=1,
page_size=100,
auto_queue_continuations=False,
alert_blocked_failure_threshold=1,
alert_network_failure_threshold=1,
alert_retry_scheduled_threshold=1,
)
assert summary.status == RunStatus.PARTIAL_FAILURE
assert summary.scholar_count == 3
assert summary.succeeded_count == 2
assert summary.failed_count == 1
assert summary.partial_count == 0
run_result = await db_session.execute(
text("SELECT error_log FROM crawl_runs WHERE id = :run_id"),
{"run_id": summary.crawl_run_id},
)
error_log = run_result.scalar_one()
run_summary = error_log["summary"]
assert run_summary["failed_state_counts"]["blocked_or_captcha"] == 1
assert run_summary["failed_reason_counts"]["blocked_accounts_redirect"] == 1
assert run_summary["scrape_failure_counts"]["blocked_or_captcha"] == 1
assert run_summary["retry_counts"]["retries_scheduled_count"] == 1
assert run_summary["retry_counts"]["scholars_with_retries_count"] == 1
assert run_summary["retry_counts"]["retry_exhausted_count"] == 0
assert run_summary["alert_thresholds"]["blocked_failure_threshold"] == 1
assert run_summary["alert_thresholds"]["network_failure_threshold"] == 1
assert run_summary["alert_thresholds"]["retry_scheduled_threshold"] == 1
assert run_summary["alert_flags"]["blocked_failure_threshold_exceeded"] is True
assert run_summary["alert_flags"]["network_failure_threshold_exceeded"] is False
assert run_summary["alert_flags"]["retry_scheduled_threshold_exceeded"] is True

View file

@ -14,7 +14,7 @@ EXPECTED_TABLES = {
} }
EXPECTED_ENUMS = {"run_status", "run_trigger_type"} EXPECTED_ENUMS = {"run_status", "run_trigger_type"}
EXPECTED_REVISION = "20260217_0007" EXPECTED_REVISION = "20260219_0008"
@pytest.mark.integration @pytest.mark.integration
@ -182,3 +182,20 @@ async def test_scholar_profiles_has_initial_page_snapshot_columns(db_session: As
"last_initial_page_fingerprint_sha256", "last_initial_page_fingerprint_sha256",
"last_initial_page_checked_at", "last_initial_page_checked_at",
} }
@pytest.mark.integration
@pytest.mark.db
@pytest.mark.migrations
@pytest.mark.asyncio
async def test_user_settings_has_nav_visible_pages_column(db_session: AsyncSession) -> None:
result = await db_session.execute(
text(
"""
SELECT 1
FROM information_schema.columns
WHERE table_name = 'user_settings' AND column_name = 'nav_visible_pages'
"""
)
)
assert result.scalar_one() == 1

View file

@ -1,15 +1,24 @@
from alembic.config import Config
from alembic.script import ScriptDirectory
import pytest import pytest
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
def expected_head_revision() -> str:
script = ScriptDirectory.from_config(Config("alembic.ini"))
head = script.get_current_head()
assert head is not None
return head
@pytest.mark.integration @pytest.mark.integration
@pytest.mark.smoke @pytest.mark.smoke
@pytest.mark.db @pytest.mark.db
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_schema_head_revision_is_available(db_session: AsyncSession) -> None: async def test_schema_head_revision_is_available(db_session: AsyncSession) -> None:
result = await db_session.execute(text("SELECT version_num FROM alembic_version")) result = await db_session.execute(text("SELECT version_num FROM alembic_version"))
assert result.scalar_one() == "20260217_0007" assert result.scalar_one() == expected_head_revision()
@pytest.mark.integration @pytest.mark.integration

View file

@ -0,0 +1,58 @@
from __future__ import annotations
from app.services.runs import extract_run_summary
def test_extract_run_summary_includes_extended_metrics() -> None:
error_log = {
"summary": {
"succeeded_count": 3,
"failed_count": 1,
"partial_count": 2,
"failed_state_counts": {"network_error": 1},
"failed_reason_counts": {"network_timeout": 1},
"scrape_failure_counts": {"network_error": 1},
"retry_counts": {
"retries_scheduled_count": 4,
"scholars_with_retries_count": 2,
"retry_exhausted_count": 1,
},
"alert_thresholds": {
"blocked_failure_threshold": 1,
"network_failure_threshold": 2,
"retry_scheduled_threshold": 3,
},
"alert_flags": {
"blocked_failure_threshold_exceeded": False,
"network_failure_threshold_exceeded": True,
"retry_scheduled_threshold_exceeded": True,
},
}
}
summary = extract_run_summary(error_log)
assert summary["succeeded_count"] == 3
assert summary["failed_count"] == 1
assert summary["partial_count"] == 2
assert summary["failed_state_counts"] == {"network_error": 1}
assert summary["failed_reason_counts"] == {"network_timeout": 1}
assert summary["scrape_failure_counts"] == {"network_error": 1}
assert summary["retry_counts"]["retries_scheduled_count"] == 4
assert summary["retry_counts"]["scholars_with_retries_count"] == 2
assert summary["retry_counts"]["retry_exhausted_count"] == 1
assert summary["alert_thresholds"]["retry_scheduled_threshold"] == 3
assert summary["alert_flags"]["network_failure_threshold_exceeded"] is True
def test_extract_run_summary_defaults_extended_metrics() -> None:
summary = extract_run_summary({})
assert summary["scrape_failure_counts"] == {}
assert summary["retry_counts"] == {
"retries_scheduled_count": 0,
"scholars_with_retries_count": 0,
"retry_exhausted_count": 0,
}
assert summary["alert_thresholds"] == {}
assert summary["alert_flags"] == {}

View file

@ -63,6 +63,16 @@ def _blocked_author_search_fetch() -> FetchResult:
) )
def _network_timeout_fetch() -> FetchResult:
return FetchResult(
requested_url="https://scholar.google.com/citations?hl=en&view_op=search_authors&mauthors=ada",
status_code=None,
final_url=None,
body="",
error="timed out",
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_search_author_candidates_serves_cached_response_for_same_query() -> None: async def test_search_author_candidates_serves_cached_response_for_same_query() -> None:
source = StubScholarSource([_ok_author_search_fetch()]) source = StubScholarSource([_ok_author_search_fetch()])
@ -150,6 +160,79 @@ async def test_search_author_candidates_trips_cooldown_after_blocked_responses()
assert "author_search_cooldown_active" in second.warnings assert "author_search_cooldown_active" in second.warnings
@pytest.mark.asyncio
async def test_search_author_candidates_emits_cooldown_alert_warning_after_threshold() -> None:
source = StubScholarSource([_blocked_author_search_fetch()])
_ = await scholar_service.search_author_candidates(
source=source,
query="Blocked Query",
limit=10,
network_error_retries=0,
retry_backoff_seconds=0.0,
search_enabled=True,
cache_ttl_seconds=0,
blocked_cache_ttl_seconds=0,
cache_max_entries=64,
min_interval_seconds=0.0,
interval_jitter_seconds=0.0,
cooldown_block_threshold=1,
cooldown_seconds=300,
cooldown_rejection_alert_threshold=1,
)
second = await scholar_service.search_author_candidates(
source=source,
query="Blocked Query",
limit=10,
network_error_retries=0,
retry_backoff_seconds=0.0,
search_enabled=True,
cache_ttl_seconds=0,
blocked_cache_ttl_seconds=0,
cache_max_entries=64,
min_interval_seconds=0.0,
interval_jitter_seconds=0.0,
cooldown_block_threshold=1,
cooldown_seconds=300,
cooldown_rejection_alert_threshold=1,
)
assert second.state == ParseState.BLOCKED_OR_CAPTCHA
assert "author_search_cooldown_alert_threshold_exceeded" in second.warnings
@pytest.mark.asyncio
async def test_search_author_candidates_adds_retry_threshold_warning() -> None:
source = StubScholarSource(
[
_network_timeout_fetch(),
_network_timeout_fetch(),
_ok_author_search_fetch(),
]
)
result = await scholar_service.search_author_candidates(
source=source,
query="Ada Lovelace",
limit=10,
network_error_retries=2,
retry_backoff_seconds=0.0,
search_enabled=True,
cache_ttl_seconds=0,
blocked_cache_ttl_seconds=0,
cache_max_entries=64,
min_interval_seconds=0.0,
interval_jitter_seconds=0.0,
cooldown_block_threshold=3,
cooldown_seconds=300,
retry_alert_threshold=2,
)
assert result.state == ParseState.OK
assert "author_search_retry_threshold_exceeded_2" in result.warnings
assert source.calls == 3
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_search_author_candidates_can_be_disabled_by_configuration() -> None: async def test_search_author_candidates_can_be_disabled_by_configuration() -> None:
source = StubScholarSource([_ok_author_search_fetch()]) source = StubScholarSource([_ok_author_search_fetch()])

View file

@ -0,0 +1,36 @@
from unittest.mock import AsyncMock
from fastapi.testclient import TestClient
from app.main import app
def test_security_headers_are_set_for_api_responses(monkeypatch) -> None:
monkeypatch.setattr("app.main.check_database", AsyncMock(return_value=True))
client = TestClient(app)
response = client.get("/healthz")
assert response.status_code == 200
assert response.headers["x-content-type-options"] == "nosniff"
assert response.headers["x-frame-options"] == "DENY"
assert response.headers["referrer-policy"] == "strict-origin-when-cross-origin"
assert response.headers["cross-origin-opener-policy"] == "same-origin"
assert response.headers["cross-origin-resource-policy"] == "same-origin"
assert "permissions-policy" in response.headers
assert "content-security-policy" in response.headers
assert "script-src 'self'" in response.headers["content-security-policy"]
assert "frame-ancestors 'none'" in response.headers["content-security-policy"]
assert "strict-transport-security" not in response.headers
def test_docs_route_uses_docs_csp_policy(monkeypatch) -> None:
monkeypatch.setattr("app.main.check_database", AsyncMock(return_value=True))
client = TestClient(app)
response = client.get("/docs")
assert response.status_code == 200
csp = response.headers["content-security-policy"]
assert "script-src 'self' 'unsafe-inline'" in csp
assert "style-src 'self' 'unsafe-inline'" in csp

View file

@ -0,0 +1,70 @@
from __future__ import annotations
import pytest
from app.services.user_settings import (
DEFAULT_NAV_VISIBLE_PAGES,
UserSettingsServiceError,
parse_nav_visible_pages,
parse_request_delay_seconds,
parse_run_interval_minutes,
)
def test_parse_run_interval_minutes_accepts_valid_value() -> None:
assert parse_run_interval_minutes("30") == 30
def test_parse_run_interval_minutes_rejects_below_minimum() -> None:
with pytest.raises(
UserSettingsServiceError,
match="Check interval must be at least 15 minutes.",
):
parse_run_interval_minutes("14")
def test_parse_request_delay_seconds_accepts_valid_value() -> None:
assert parse_request_delay_seconds("2") == 2
def test_parse_request_delay_seconds_rejects_below_minimum() -> None:
with pytest.raises(
UserSettingsServiceError,
match="Request delay must be at least 2 seconds.",
):
parse_request_delay_seconds("1")
def test_parse_nav_visible_pages_accepts_valid_pages() -> None:
parsed = parse_nav_visible_pages(
[
"dashboard",
"scholars",
"publications",
"settings",
"runs",
]
)
assert parsed == [
"dashboard",
"scholars",
"publications",
"settings",
"runs",
]
def test_parse_nav_visible_pages_rejects_missing_required_pages() -> None:
with pytest.raises(
UserSettingsServiceError,
match="Dashboard, Scholars, and Settings must remain visible.",
):
parse_nav_visible_pages(["dashboard", "publications"])
def test_parse_nav_visible_pages_rejects_unknown_page() -> None:
with pytest.raises(
UserSettingsServiceError,
match="Unsupported navigation page id: reports",
):
parse_nav_visible_pages(DEFAULT_NAV_VISIBLE_PAGES + ["reports"])