diff --git a/.agents/skills/dev-local/SKILL.md b/.agents/skills/dev-local/SKILL.md index 919f39b1..7b869bca 100644 --- a/.agents/skills/dev-local/SKILL.md +++ b/.agents/skills/dev-local/SKILL.md @@ -11,8 +11,9 @@ settings, and gives you a sandboxed CLI that never touches `~/.treg/config.json` | Service | Command (managed by the script) | Port | |---|---|---| | treg server | `uv run python -m treg --reload` + `TREG_EMAIL_DEV_MODE=true`, own sqlite `treg-dev.db` | 18790 | +| Dashboard Vite | `npm run dev` in `frontend/`, hot updates loaded by the Python entry | 5173 | -No infra deps (sqlite). Prerequisites: `tmux`, `uv` (the script runs `uv sync` if `.venv` is missing). +No infra deps (sqlite). Prerequisites: Node 22.12+, npm, `tmux`, `uv` (the script runs `uv sync` if `.venv` is missing). ## Subcommands @@ -32,6 +33,8 @@ repo's CLI against localhost with `HOME=scripts/.dev-home`, so the real `~/.treg/config.json` (usually pointing at production) is never overwritten. Email OTP dev mode is on — codes appear in the login page / API response, no mail sender needed. +`TREG_DEV_DB=/absolute/path/to/dev.db` selects a separate database without resetting the default. + ## Troubleshooting - **Port 18790 in use, no session** → something else owns it: `lsof -i :18790`. diff --git a/.agents/skills/tools-registry-context/MAP.md b/.agents/skills/tools-registry-context/MAP.md index c9e282cb..83b985a6 100644 --- a/.agents/skills/tools-registry-context/MAP.md +++ b/.agents/skills/tools-registry-context/MAP.md @@ -23,18 +23,98 @@ Regenerate via `scripts/build-map.py`. | `dsh/index.js` | interface/skill.md | | `examples/proxy-demo/server.js` | architecture/local-proxy.md | | `external:meetings/2026-06-30-jason-tools-registry.md` | foundation/charter.md, reference/glossary.md | +| `frontend/index.html` | interface/dashboard.md, interface/seo.md | +| `frontend/package.json` | interface/dashboard.md | +| `frontend/src/App.vue` | interface/dashboard.md, interface/seo.md | +| `frontend/src/api.ts` | interface/dashboard.md | +| `frontend/src/components/DashboardNavigation.vue` | interface/dashboard.md | +| `frontend/src/components/PublicNavigation.vue` | interface/dashboard.md, interface/seo.md | +| `frontend/src/components/SignInDialog.vue` | interface/dashboard.md | +| `frontend/src/components/SignedOutPage.vue` | interface/dashboard.md, interface/landing-sandbox.md | +| `frontend/src/dialogs/AcceptInvitesDialog.vue` | interface/dashboard.md | +| `frontend/src/dialogs/AgentGuideDialog.vue` | interface/dashboard.md | +| `frontend/src/dialogs/CallDetailsDialog.vue` | interface/dashboard.md | +| `frontend/src/dialogs/ConnectTokenDialog.vue` | interface/dashboard.md | +| `frontend/src/dialogs/ConnectionMethodDialog.vue` | interface/dashboard.md | +| `frontend/src/dialogs/CopyToolDialog.vue` | interface/dashboard.md | +| `frontend/src/dialogs/EditToolDialog.vue` | interface/dashboard.md | +| `frontend/src/dialogs/ExtraCredentialDialog.vue` | interface/dashboard.md | +| `frontend/src/dialogs/ImportSkillDialog.vue` | interface/dashboard.md | +| `frontend/src/dialogs/RecipeDialog.vue` | interface/dashboard.md | +| `frontend/src/dialogs/RequestToolDialog.vue` | interface/dashboard.md | +| `frontend/src/dialogs/ResourcePickerDialog.vue` | interface/dashboard.md | +| `frontend/src/dialogs/RunToolDialog.vue` | interface/dashboard.md | +| `frontend/src/dialogs/ShareDialog.vue` | interface/dashboard.md | +| `frontend/src/dialogs/TopUpDialog.vue` | interface/dashboard.md | +| `frontend/src/dialogs/TryEndpointDialog.vue` | interface/dashboard.md | +| `frontend/src/dialogs/WelcomeDialog.vue` | interface/dashboard.md, interface/onboarding.md | +| `frontend/src/main.ts` | interface/dashboard.md | +| `frontend/src/pages/ActivityPage.vue` | interface/dashboard.md | +| `frontend/src/pages/AdminPage.vue` | interface/dashboard.md | +| `frontend/src/pages/CatalogPage.vue` | interface/dashboard.md | +| `frontend/src/pages/DetailPage.vue` | interface/dashboard.md | +| `frontend/src/pages/GettingStartedPage.vue` | interface/dashboard.md, interface/onboarding.md | +| `frontend/src/pages/HelpPage.vue` | interface/dashboard.md | +| `frontend/src/pages/PlatformPage.vue` | interface/dashboard.md | +| `frontend/src/pages/ProviderPage.vue` | architecture/instagram-oauth.md, interface/dashboard.md | +| `frontend/src/pages/ReferralsPage.vue` | interface/dashboard.md | +| `frontend/src/pages/SecretsPage.vue` | interface/dashboard.md | +| `frontend/src/pages/TeamPage.vue` | architecture/auth-secrets.md, interface/dashboard.md | +| `frontend/src/pages/ToolsPage.vue` | interface/dashboard.md | +| `frontend/src/state/activity.js` | interface/dashboard.md | +| `frontend/src/state/admin.js` | interface/dashboard.md | +| `frontend/src/state/agents.js` | interface/dashboard.md | +| `frontend/src/state/agentsComputed.js` | interface/dashboard.md | +| `frontend/src/state/analytics.js` | interface/dashboard.md | +| `frontend/src/state/billing.js` | interface/dashboard.md | +| `frontend/src/state/billingComputed.js` | interface/dashboard.md | +| `frontend/src/state/boot.js` | interface/dashboard.md, interface/landing-sandbox.md, interface/seo.md | +| `frontend/src/state/catalog.js` | interface/dashboard.md | +| `frontend/src/state/catalogComputed.js` | interface/dashboard.md | +| `frontend/src/state/connections.js` | architecture/instagram-oauth.md, interface/dashboard.md | +| `frontend/src/state/constants.js` | interface/dashboard.md | +| `frontend/src/state/context.ts` | interface/dashboard.md | +| `frontend/src/state/controller.js` | interface/dashboard.md | +| `frontend/src/state/data.js` | interface/dashboard.md | +| `frontend/src/state/details.js` | interface/dashboard.md | +| `frontend/src/state/detailsComputed.js` | interface/dashboard.md | +| `frontend/src/state/format.js` | interface/dashboard.md | +| `frontend/src/state/governance.js` | interface/dashboard.md | +| `frontend/src/state/help.js` | interface/dashboard.md | +| `frontend/src/state/keys.js` | architecture/auth-secrets.md, interface/dashboard.md | +| `frontend/src/state/lifecycle.js` | interface/dashboard.md | +| `frontend/src/state/navigation.js` | interface/dashboard.md | +| `frontend/src/state/onboarding.js` | interface/dashboard.md, interface/onboarding.md | +| `frontend/src/state/onboardingComputed.js` | interface/dashboard.md | +| `frontend/src/state/projects.js` | interface/dashboard.md | +| `frontend/src/state/referrals.js` | interface/dashboard.md | +| `frontend/src/state/secrets.js` | interface/dashboard.md | +| `frontend/src/state/session.js` | architecture/auth-secrets.md, interface/dashboard.md | +| `frontend/src/state/sessionComputed.js` | interface/dashboard.md | +| `frontend/src/state/sharing.js` | interface/dashboard.md | +| `frontend/src/state/skills.js` | interface/dashboard.md | +| `frontend/src/state/snippets.js` | interface/dashboard.md | +| `frontend/src/state/team.js` | interface/dashboard.md | +| `frontend/src/state/tools.js` | interface/dashboard.md | +| `frontend/src/state/tryTool.js` | interface/dashboard.md | +| `frontend/src/styles/base.css` | interface/dashboard.md | +| `frontend/vite.config.ts` | interface/dashboard.md | +| `hatch_build.py` | ops/deploy.md | | `package.json` | interface/skill.md | | `plugin/.codex-plugin/plugin.json` | interface/skill.md | | `plugins/minimax/.minimax-plugin/plugin.json` | interface/skill.md | | `plugins/treg/.cursor-plugin/plugin.json` | interface/skill.md | | `pyproject.toml` | architecture/import-boundaries.md, ops/deploy.md | | `scripts/backfill_call_archive_links.py` | architecture/archive.md | +| `scripts/build-dashboard.sh` | ops/deploy.md | +| `scripts/build-web.sh` | ops/deploy.md | | `scripts/build_plugin.py` | interface/skill.md | | `scripts/catalog_drift.py` | architecture/catalog.md | | `scripts/catalog_ingest.py` | architecture/catalog.md, architecture/instagram-oauth.md | | `scripts/catalog_validate.py` | architecture/catalog.md | | `scripts/dev-local.sh` | ops/deploy.md | | `scripts/dump_surface.py` | architecture/composition.md | +| `scripts/frontend-e2e-server.sh` | ops/deploy.md | | `scripts/import_arena_verification.py` | interface/enrich-arena.md | | `scripts/indexnow_submit.py` | interface/seo.md | | `scripts/minimax_plugin.py` | interface/skill.md | @@ -308,7 +388,6 @@ Regenerate via `scripts/build-map.py`. | `src/treg/web/feedback.md` | architecture/feedback.md | | `src/treg/web/grokbot.html` | interface/seo.md | | `src/treg/web/gtag.js` | architecture/ads-conversions.md | -| `src/treg/web/index.html` | architecture/auth-secrets.md, architecture/instagram-oauth.md, interface/dashboard.md, interface/landing-sandbox.md, interface/onboarding.md, interface/seo.md | | `src/treg/web/install.sh` | interface/landing-sandbox.md | | `src/treg/web/jev.html` | interface/seo.md | | `src/treg/web/landing.html` | interface/seo.md | @@ -423,13 +502,13 @@ Regenerate via `scripts/build-map.py`. |---|---| | `architecture/ads-conversions.md` | `adsconv.py`, `signup.py`, `adtrack.js`, `gtag.js` | | `architecture/archive.md` | `archive.py`, `hunter.yaml`, `results.py`, `0031_archive_result_admission.py`, `test_cache_result_admission.py`, `archive_bodies.py`, `config.py`, `object_store.py`, `0032_archive_body_storage.py`, `test_archive_r2.py`, `fake_object_store.py`, `smoke_archive_r2.py`, `0002_archive_tables.py`, `0003_callrecord_cached.py`, `0004_archivekey_request_shape.py`, `0011_callrecord_archive_link.py`, `service.py`, `settle.py`, `0039_archive_own_key_and_repeat_pricing.py`, `backfill_call_archive_links.py`, `api.py`, `bootstrap.py`, `admin.py`, `asynctasks.py` | -| `architecture/auth-secrets.md` | `injectors.py`, `ssrf.py`, `crypto.py`, `oauth.py`, `__init__.py`, `authorization.py`, `oauth_flow.py`, `refresh.py`, `oauth_exchange.py`, `oauth_refresh.py`, `oauth_providers.py`, `index.html`, `health.py`, `connect.py`, `connections.py`, `resources.py`, `__init__.py`, `bindings.py`, `bundles.py`, `api_keys.py`, `access.py`, `api_keys.py`, `test_api_keys.py`, `test_oauth_refresh.py`, `test_financialdatasets.py`, `test_key_providers.py`, `test_dashboard_markup.py`, `config.py` | +| `architecture/auth-secrets.md` | `injectors.py`, `ssrf.py`, `crypto.py`, `oauth.py`, `__init__.py`, `authorization.py`, `oauth_flow.py`, `refresh.py`, `oauth_exchange.py`, `oauth_refresh.py`, `oauth_providers.py`, `session.js`, `keys.js`, `TeamPage.vue`, `health.py`, `connect.py`, `connections.py`, `resources.py`, `__init__.py`, `bindings.py`, `bundles.py`, `api_keys.py`, `access.py`, `api_keys.py`, `test_api_keys.py`, `test_oauth_refresh.py`, `test_financialdatasets.py`, `test_key_providers.py`, `test_dashboard_markup.py`, `config.py` | | `architecture/catalog.md` | `tavily.yaml`, `exa.yaml`, `anyapi.extended.yaml`, `trestleiq.yaml`, `financialdatasets.yaml`, `test_financialdatasets.py`, `quickenrich.yaml`, `influencersclub.yaml`, `quickenrich.extended.yaml`, `trykitt.yaml`, `contracts.yaml`, `millionverifier.yaml`, `adapters.yaml`, `prospeo.yaml`, `test_route_cost_ceiling.py`, `tomba.yaml`, `__init__.py`, `contracts.py`, `paths.py`, `plan.py`, `synthetic.py`, `route.py`, `test_routing.py`, `catalog-drift.yml`, `catalog_drift.py`, `catalog_ingest.py`, `catalog_validate.py`, `aliases.yaml`, `fx.yaml`, `cloro.yaml`, `aviato.yaml`, `crustdata.yaml`, `google-search-console.yaml`, `google-search-console.extended.yaml`, `google-tag-manager.yaml`, `google-tag-manager.extended.yaml`, `instagram.yaml`, `instagram.extended.yaml`, `justoneapi.extended.yaml`, `minimax.yaml`, `apify.yaml`, `brightdata.yaml`, `companyenrich.yaml`, `oceanio.yaml`, `akta.extended.yaml`, `dataforseo.yaml`, `dataforseo.extended.yaml`, `test_dataforseo_constraints.py`, `scrapecreators.yaml`, `scrapecreators.extended.yaml`, `serpapi.yaml`, `serpapi.extended.yaml`, `diffbot.yaml`, `diffbot.extended.yaml`, `tikhub.extended.yaml`, `lusha.extended.yaml`, `openrouter.yaml`, `openrouter.extended.yaml`, `replicate.yaml`, `replicate.extended.yaml`, `reapi.yaml`, `piapi.yaml`, `__init__.py`, `store.py`, `hunter.yaml`, `mcp.py`, `settlement.py`, `stats.py`, `catalog_observations.py`, `catalog_stats.py`, `0038_endpoint_day_stats.py`, `catalog.py`, `test_aigc_pr_b.py`, `test_catalog_api.py`, `test_catalog_validate.py` | | `architecture/composition.md` | `bootstrap.py`, `bootstrap_handlers.py`, `bootstrap_http.py`, `call_surface.py`, `connect.py`, `mcp_oauth.py`, `session.py`, `admin.py`, `auth.py`, `billing.py`, `call.py`, `connections.py`, `onboard.py`, `orgs.py`, `resources.py`, `referrals.py`, `web.py`, `dump_surface.py`, `test_app_roles.py` | | `architecture/data-model.md` | `alembic.ini`, `env.py`, `0001_baseline_current_schema.py`, `0002_archive_tables.py`, `0003_callrecord_cached.py`, `0004_archivekey_request_shape.py`, `0005_capacity_policy_snapshot.py`, `0006_overflow_route.py`, `0007_overflow_spend.py`, `0008_org_platform_overflow_disabled.py`, `0009_callrecord_hit.py`, `0017_async_task_record.py`, `0018_async_resource_ownership.py`, `0019_async_poll_failures.py`, `0020_callrecord_created_at_indexes.py`, `0021_ledgerentry_org_created_at_index.py`, `0022_org_spent_today_counter.py`, `0023_callrecord_org_user_created_at_index.py`, `0024_membership_calls_today_counter.py`, `0027_enrich_arena.py`, `0028_arena_insights.py`, `0029_arena_verification_snapshot.py`, `0011_callrecord_archive_link.py`, `0015_idempotentcall_membership_cascade.py`, `0034_managed_api_keys.py`, `0035_default_key_generation.py`, `0036_activity_key_indexes.py`, `0038_endpoint_day_stats.py`, `maintenance.py`, `sitetrack.js`, `models.py`, `0031_archive_result_admission.py`, `0032_archive_body_storage.py`, `0039_archive_own_key_and_repeat_pricing.py`, `0033_signup_promo_eligibility.py`, `0041_searchlog.py`, `timeutil.py`, `db.py`, `referrals.py`, `audit.py`, `analytics.py`, `bootstrap_handlers.py`, `ratestore.py`, `auth.py`, `test_postgres_reset.py`, `test_alembic_expand_safety.py`, `test_api_keys.py` | | `architecture/feedback.md` | `feedback_contract.py`, `__init__.py`, `reports.py`, `reviews.py`, `hints.py`, `config.py`, `call.py`, `invite.py`, `kv.py`, `feedback.py`, `feedback.py`, `0025_feedback.py`, `0026_callreview.py`, `0030_feedback_handling.py`, `test_feedback_handling_schema.py`, `feedback.md`, `test_feedback.py`, `test_reviews.py`, `test_hints.py`, `test_kv.py` | | `architecture/import-boundaries.md` | `pyproject.toml`, `ci.yml`, `__init__.py`, `__init__.py`, `access.py`, `authorize.py`, `idempotency.py`, `overflow.py`, `route.py`, `__init__.py`, `intake.py`, `resolve.py`, `reserve.py`, `settle.py`, `evidence.py`, `service.py`, `types.py`, `client_identity.py`, `__init__.py`, `__init__.py`, `access.py`, `budgets.py`, `publicdemo.py`, `teams.py`, `usage.py`, `__init__.py`, `__init__.py`, `authorization.py`, `oauth_flow.py`, `refresh.py`, `__init__.py`, `__init__.py`, `__init__.py`, `__init__.py`, `__init__.py`, `injectors.py`, `relay.py`, `__init__.py`, `limiter.py`, `test_call_architecture.py`, `test_import_lightness.py` | -| `architecture/instagram-oauth.md` | `catalog_ingest.py`, `access.py`, `resolve.py`, `service.py`, `instagram.yaml`, `instagram.extended.yaml`, `cli.py`, `store.py`, `authorization.py`, `oauth_flow.py`, `oauth_exchange.py`, `mcp.py`, `call.py`, `index.html`, `0010_oauth_authorization_method.py`, `test_instagram_oauth_architecture.py` | +| `architecture/instagram-oauth.md` | `catalog_ingest.py`, `access.py`, `resolve.py`, `service.py`, `instagram.yaml`, `instagram.extended.yaml`, `cli.py`, `store.py`, `authorization.py`, `oauth_flow.py`, `oauth_exchange.py`, `mcp.py`, `call.py`, `connections.js`, `ProviderPage.vue`, `0010_oauth_authorization_method.py`, `test_instagram_oauth_architecture.py` | | `architecture/local-proxy.md` | `localproxy.py`, `server.js` | | `architecture/local-run.md` | `localrun.py`, `egress.py`, `fsjail.py` | | `architecture/mcp-oauth.md` | `auth.py`, `mcp.py`, `health.py`, `mcp_oauth.py`, `session.py`, `access.py`, `api_keys.py`, `api_keys.py`, `auth.py`, `claude-connector.html`, `connect-demo.html`, `CLAUDE-CONNECTOR-SUBMISSION.md`, `test_mcp.py`, `test_mcp_oauth.py`, `test_mcp_directory.py`, `test_marketplace_call.py` | @@ -444,14 +523,14 @@ Regenerate via `scripts/build-map.py`. | `interface/api.md` | `media.py`, `sitetrack.js`, `api.py`, `bootstrap_handlers.py`, `bootstrap_http.py`, `call_surface.py`, `caller_metadata.py`, `client_identity.py`, `auth.py`, `access.py`, `authorize.py`, `idempotency.py`, `intake.py`, `resolve.py`, `reserve.py`, `settle.py`, `evidence.py`, `service.py`, `types.py`, `relay.py`, `connect.py`, `onboard.py`, `referrals.py`, `signup.py`, `__init__.py`, `admin.py`, `auth.py`, `auth_helpers.py`, `billing.py`, `call.py`, `catalog.py`, `connections.py`, `onboard.py`, `orgs.py`, `api_keys.py`, `resources.py`, `referrals.py`, `signup_cookies.py`, `web.py`, `access.py`, `api_keys.py`, `teams.py`, `access.py`, `budgets.py`, `publicdemo.py`, `usage.py`, `mcp_oauth.py`, `session.py`, `timeutil.py`, `store.py`, `email.py`, `runner.py`, `ratestore.py` | | `interface/catalog-review-proposal.md` | `store.py`, `capabilities.yaml` | | `interface/cli.md` | `cli.py`, `test_released_cli_compat.py`, `test_cli_key_compatibility.py`, `auth_helpers.py`, `cli_analytics.py`, `convert.py`, `agents.py`, `api_keys.py`, `test_api_keys.py` | -| `interface/dashboard.md` | `sitetrack.js`, `index.html`, `agent-setup.js`, `dashboard.css`, `SOURCES.md`, `README.md`, `vue-3.5.41.global.prod.js`, `tutorial.js`, `tutorial.html`, `tour.js`, `index.html`, `api.py`, `web.py`, `session.py`, `api_keys.py`, `test_api_keys.py` | +| `interface/dashboard.md` | `sitetrack.js`, `index.html`, `package.json`, `vite.config.ts`, `App.vue`, `api.ts`, `DashboardNavigation.vue`, `PublicNavigation.vue`, `SignInDialog.vue`, `SignedOutPage.vue`, `AcceptInvitesDialog.vue`, `AgentGuideDialog.vue`, `CallDetailsDialog.vue`, `ConnectTokenDialog.vue`, `ConnectionMethodDialog.vue`, `CopyToolDialog.vue`, `EditToolDialog.vue`, `ExtraCredentialDialog.vue`, `ImportSkillDialog.vue`, `RecipeDialog.vue`, `RequestToolDialog.vue`, `ResourcePickerDialog.vue`, `RunToolDialog.vue`, `ShareDialog.vue`, `TopUpDialog.vue`, `TryEndpointDialog.vue`, `WelcomeDialog.vue`, `main.ts`, `ActivityPage.vue`, `AdminPage.vue`, `CatalogPage.vue`, `DetailPage.vue`, `GettingStartedPage.vue`, `HelpPage.vue`, `PlatformPage.vue`, `ProviderPage.vue`, `ReferralsPage.vue`, `SecretsPage.vue`, `TeamPage.vue`, `ToolsPage.vue`, `activity.js`, `admin.js`, `agents.js`, `agentsComputed.js`, `analytics.js`, `billing.js`, `billingComputed.js`, `boot.js`, `catalog.js`, `catalogComputed.js`, `connections.js`, `constants.js`, `context.ts`, `controller.js`, `data.js`, `details.js`, `detailsComputed.js`, `format.js`, `governance.js`, `help.js`, `keys.js`, `lifecycle.js`, `navigation.js`, `onboarding.js`, `onboardingComputed.js`, `projects.js`, `referrals.js`, `secrets.js`, `session.js`, `sessionComputed.js`, `sharing.js`, `skills.js`, `snippets.js`, `team.js`, `tools.js`, `tryTool.js`, `base.css`, `agent-setup.js`, `dashboard.css`, `SOURCES.md`, `README.md`, `vue-3.5.41.global.prod.js`, `tutorial.js`, `tutorial.html`, `tour.js`, `index.html`, `api.py`, `web.py`, `session.py`, `api_keys.py`, `test_api_keys.py` | | `interface/enrich-arena.md` | `arena.py`, `arena.py`, `arena.py`, `models.py`, `0027_enrich_arena.py`, `teams.py`, `auth.py`, `bootstrap.py`, `enrich-arena.html`, `arena.js`, `bench.js`, `arena-bench.test.cjs`, `arena-template.test.cjs`, `arena.css`, `agent-setup.js`, `arena_verification_insights.py`, `0029_arena_verification_snapshot.py`, `import_arena_verification.py`, `test_arena_verification_insights.py`, `arena_insights.py`, `arena_insights.py`, `0028_arena_insights.py`, `test_arena_insights.py`, `apollo.svg`, `branddev.svg`, `companyenrich.svg`, `findymail.svg`, `hunter.svg`, `icypeas.svg`, `leadmagic.svg`, `leadsforge.svg`, `lusha.svg`, `pdl.svg`, `predictleads.svg`, `thecompaniesapi.svg`, `tomba.svg`, `sitetrack.js`, `test_enrich_arena.py`, `enrich-arena.test.cjs` | | `interface/env-import.md` | `providers.py`, `skills.py` | -| `interface/landing-sandbox.md` | `sandbox.py`, `sandbox_identity.py`, `pubfeed.py`, `sandbox.py`, `__init__.py`, `sandbox.py`, `api.py`, `onboard.py`, `web.py`, `index.html`, `install.sh` | -| `interface/onboarding.md` | `auth.py`, `__init__.py`, `demo.py`, `cli.py`, `auth.py`, `onboard.py`, `index.html` | -| `interface/seo.md` | `api.py`, `web.py`, `agent_pages.py`, `robots.txt`, `catalog.css`, `usecase.css`, `index.html`, `landing.html`, `terms.html`, `usecase-seo.html`, `usecase-company.html`, `usecase-enrichment.html`, `usecase-social.html`, `usecase-ads.html`, `people-search.html`, `grokbot.html`, `fable-gtm.html`, `ugc.html`, `jev.html`, `jev_xboost.py`, `xboost-seed.json`, `triage.json`, `signals.json`, `astra.html`, `page.css`, `page.js`, `llms.txt`, `indexnow_submit.py`, `support.html`, `og-card.html` | +| `interface/landing-sandbox.md` | `sandbox.py`, `sandbox_identity.py`, `pubfeed.py`, `sandbox.py`, `__init__.py`, `sandbox.py`, `api.py`, `onboard.py`, `web.py`, `boot.js`, `SignedOutPage.vue`, `install.sh` | +| `interface/onboarding.md` | `auth.py`, `__init__.py`, `demo.py`, `cli.py`, `auth.py`, `onboard.py`, `onboarding.js`, `WelcomeDialog.vue`, `GettingStartedPage.vue` | +| `interface/seo.md` | `api.py`, `web.py`, `agent_pages.py`, `robots.txt`, `catalog.css`, `usecase.css`, `index.html`, `App.vue`, `PublicNavigation.vue`, `boot.js`, `landing.html`, `terms.html`, `usecase-seo.html`, `usecase-company.html`, `usecase-enrichment.html`, `usecase-social.html`, `usecase-ads.html`, `people-search.html`, `grokbot.html`, `fable-gtm.html`, `ugc.html`, `jev.html`, `jev_xboost.py`, `xboost-seed.json`, `triage.json`, `signals.json`, `astra.html`, `page.css`, `page.js`, `llms.txt`, `indexnow_submit.py`, `support.html`, `og-card.html` | | `interface/shell.md` | `shell.py`, `cli.py` | | `interface/skill.md` | `skill.md`, `SKILL.md`, `web.py`, `mcp_install.py`, `build_plugin.py`, `plugin.json`, `marketplace.json`, `plugin.json`, `plugin.json`, `package.json`, `cordis.patch.yml`, `index.js`, `plugin.json`, `minimax_plugin.py` | | `ops/capacity.md` | `__init__.py`, `collectors.py`, `policy.py`, `sweep.py`, `view.py`, `routes.py`, `signatures.py`, `verify.py`, `marks.py`, `test_capacity_protect.py`, `limiter.py`, `overflow_spend.py`, `routes_view.py`, `overflow.py`, `0007_overflow_spend.py`, `test_capacity_overflow.py`, `test_capacity_overflow_spend.py`, `0008_org_platform_overflow_disabled.py`, `test_capacity_smoothing.py`, `overflow_seed.json`, `__init__.py`, `orthogonal.py`, `monid.py`, `catalogs.py`, `0006_overflow_route.py`, `test_capacity_overflow_routes.py`, `test_influencersclub_overflow.py`, `worker.py`, `provider_balances.py`, `0005_capacity_policy_snapshot.py`, `test_capacity_know.py`, `test_capacity_collectors.py`, `test_financialdatasets.py` | -| `ops/deploy.md` | `pyproject.toml`, `__main__.py`, `maintenance.py`, `env.py`, `0034_managed_api_keys.py`, `0035_default_key_generation.py`, `0036_activity_key_indexes.py`, `worker.py`, `selfhost.sh`, `config.py`, `db.py`, `email.py`, `audit.py`, `dev-local.sh`, `render.example.yaml` | +| `ops/deploy.md` | `pyproject.toml`, `hatch_build.py`, `build-dashboard.sh`, `build-web.sh`, `frontend-e2e-server.sh`, `__main__.py`, `maintenance.py`, `env.py`, `0034_managed_api_keys.py`, `0035_default_key_generation.py`, `0036_activity_key_indexes.py`, `worker.py`, `selfhost.sh`, `config.py`, `db.py`, `email.py`, `audit.py`, `dev-local.sh`, `render.example.yaml` | | `reference/glossary.md` | `2026-06-30-jason-tools-registry.md` | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e435f17..a2f08ac0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,15 @@ jobs: if: steps.docs.outputs.only != 'true' with: python-version: "3.13" + - uses: actions/setup-node@v4 + if: steps.docs.outputs.only != 'true' + with: + node-version: '22' + cache: npm + cache-dependency-path: frontend/package-lock.json + - name: Build dashboard + if: steps.docs.outputs.only != 'true' + run: bash scripts/build-dashboard.sh - name: Install dependencies if: steps.docs.outputs.only != 'true' run: uv sync --locked @@ -85,6 +94,13 @@ jobs: - uses: astral-sh/setup-uv@v7 with: python-version: "3.13" + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: frontend/package-lock.json + - name: Build dashboard + run: bash scripts/build-dashboard.sh - name: Install dependencies run: uv sync --locked - name: Run Postgres tests serially @@ -160,3 +176,38 @@ jobs: # HEAD is the PR merge commit (both parents) or the pushed main commit. Scan all # reachable history, including deleted secrets, without unrelated fetched branch tips. run: gitleaks detect --source . --config .gitleaks.toml --log-opts="HEAD" --verbose --redact + + frontend: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v7 + with: + python-version: "3.13" + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: frontend/package-lock.json + - run: uv sync --locked + - run: bash scripts/build-dashboard.sh + - run: npm --prefix frontend test + - name: Install browser + working-directory: frontend + run: npx playwright install --with-deps chromium + - run: npm --prefix frontend run test:e2e + - name: Check distributable assets + run: | + uv build + python - <<'PYCODE' + from pathlib import Path + from zipfile import ZipFile + import re + with ZipFile(next(Path('dist').glob('*.whl'))) as wheel: + html = wheel.read('treg/web/dashboard/index.html').decode() + assets = re.findall(r'/app/ui/(assets/[^"\s]+)', html) + assert assets, 'Dashboard entry must reference compiled assets' + for asset in assets: + assert wheel.read('treg/web/dashboard/' + asset) + PYCODE diff --git a/.gitignore b/.gitignore index 58687cde..66045df1 100644 --- a/.gitignore +++ b/.gitignore @@ -88,3 +88,9 @@ tests/screenshots/ # Arena production statistics belong in the database, never a bundled snapshot. /src/treg/web/enrich-arena/insights.json + +# Frontend dependencies, build output and browser test artifacts. +node_modules/ +/src/treg/web/dashboard/ +/frontend/test-results/ +/frontend/playwright-report/ diff --git a/AGENTS.md b/AGENTS.md index fff28064..ac5f03cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -152,8 +152,11 @@ xdist is pulled via `--with`, not the lockfile — same as CI. The Postgres CI j `[server]` extra, the certificate authority is `[proxy]`. Never import a heavy dependency at the top of a CLI-path module; the "Lightweight CLI modules" import-linter contract lists them and fails the build. -- **The dashboard** (`src/treg/web/index.html`) is a single-file Vue app with no build step, so a - broken view name fails silently. Verify in a browser. +- **The dashboard** lives in `frontend/` (Vue components, TypeScript entry/transport, Vite). + Build with `bash scripts/build-dashboard.sh`; generated assets in `src/treg/web/dashboard/` + ship with Python. Run `npm --prefix frontend test` and `npm --prefix frontend run test:e2e`. + Existing Options API use cases live in `frontend/src/state/`; preserve their session and + navigation behavior when narrowing component state. Never put dashboard logic back into HTML. - **Schema.** Alembic owns it (`src/treg/alembic/versions/`); every schema change is a revision. Startup only verifies the revision and refuses to boot when behind; migrations run only via `python -m treg upgrade`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0929a34a..5907c481 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,6 +7,7 @@ Thanks for your interest! ```bash git clone https://github.com/superdesigndev/treg cd treg +bash scripts/build-dashboard.sh # Node 22.12+ and npm; compile the browser app uv sync # install deps (uv >= 0.12, pinned in pyproject - https://docs.astral.sh/uv/) uv run --with pytest-xdist pytest -n auto -q # daily local default (same shape as CI) ``` @@ -16,6 +17,18 @@ sqlite). The `TREG_*` knobs for persistence / a real deployment are documented i **Configuration** section (and `docs/context/ops/deploy.md`). A one-command local stack is in `scripts/dev-local.sh` (`up` / `logs` / `cli` / `reset`). +It starts Python on :18790 and Vite on :5173; open `http://localhost:18790/app`. +`TREG_DEV_DB=/absolute/path/to/dev.db` selects a separate local database without resetting another. + +The Dashboard source is in `frontend/src/`: `.vue` pages, components and dialogs, feature use cases +in `state/`, and a typed JSON client in `api.ts`. `frontend/index.html` is only the document entry. +Run `npm --prefix frontend test` for transport tests and `npm --prefix frontend run test:e2e` for +browser flows against a disposable SQLite server. Install Chromium first with +`cd frontend && npx playwright install chromium`. + +Before `uv build`, run `bash scripts/build-dashboard.sh`. The wheel and sdist include the resulting +assets; installing a published package needs no Node runtime. Editable Python installs do not +require a frontend build, so CLI and background-worker development remains independent. ## Project layout diff --git a/README.md b/README.md index ccefdacf..9f00b3dd 100644 --- a/README.md +++ b/README.md @@ -292,6 +292,7 @@ scripts/dev-local.sh reset # wipe the dev DB + CLI sandbox for a fresh sta Or run the server directly, without tmux: ```bash +bash scripts/build-dashboard.sh # Node 22.12+ and npm; build the Dashboard uv sync # create the venv from uv.lock (pulls the server deps for dev) uv run python -m treg upgrade # prepare schema + run idempotent release tasks without serving uv run python -m treg # serve on 0.0.0.0:18790 (add --reload for dev) diff --git a/deploy/render.example.yaml b/deploy/render.example.yaml index d4dc768f..9361c8b8 100644 --- a/deploy/render.example.yaml +++ b/deploy/render.example.yaml @@ -22,13 +22,15 @@ services: # tested. A plain `pip install ".[server]"` resolves afresh on every build and picks up whatever # a dependency published since; that is how a breaking upstream release reaches production # without any commit. `--locked` fails the build when the lock is stale instead of resolving. - buildCommand: uv sync --locked --no-dev --extra server --active + buildCommand: bash scripts/build-web.sh preDeployCommand: python -m treg upgrade startCommand: python -m treg healthCheckPath: /meta envVars: # Render adds uv to the Python runtime when uv.lock is present; its default is older than the # `required-version` in pyproject.toml, so name one it accepts. + - key: NODE_VERSION + value: "22.22.0" - key: UV_VERSION value: "0.12.3" - key: TREG_DATABASE_URL diff --git a/docs/context/README.md b/docs/context/README.md index 4c270513..2181c1ea 100644 --- a/docs/context/README.md +++ b/docs/context/README.md @@ -42,7 +42,7 @@ covers (frontmatter `sources:`). Regenerate this index with | [The API — the only brain (FastAPI)](interface/api.md) | shipped | media.py, sitetrack.js, api.py, bootstrap_handlers.py, … | | [Catalog browse review — categories, platform placement, and domain sections](interface/catalog-review-proposal.md) | reference | store.py, capabilities.yaml | | [The CLI (treg) + skill scaffolding](interface/cli.md) | shipped | cli.py, test_released_cli_compat.py, test_cli_key_compatibility.py, auth_helpers.py, … | -| [The web dashboard (served from FastAPI)](interface/dashboard.md) | shipped | sitetrack.js, index.html, agent-setup.js, dashboard.css, … | +| [The web dashboard (served from FastAPI)](interface/dashboard.md) | shipped | sitetrack.js, index.html, package.json, vite.config.ts, … | | [Enrich Arena — paid comparisons, one-click feedback, and visible waterfalls](interface/enrich-arena.md) | shipped | arena.py, arena.py, arena.py, models.py, … | | [Import — scan a .env AND/OR a skills dir, auto-register as tools + bundles](interface/env-import.md) | in-progress | providers.py, skills.py | | [Landing sandbox backend - front-end entry removed](interface/landing-sandbox.md) | shipped | sandbox.py, sandbox_identity.py, pubfeed.py, sandbox.py, … | @@ -56,7 +56,7 @@ covers (frontmatter `sources:`). Regenerate this index with | Fragment | Status | Covers | |---|---|---| | [Provider capacity — knowing what treg's own vendor accounts have left](ops/capacity.md) | shipped | __init__.py, collectors.py, policy.py, sweep.py, … | -| [Running & deploying the server](ops/deploy.md) | shipped | pyproject.toml, __main__.py, maintenance.py, env.py, … | +| [Running & deploying the server](ops/deploy.md) | shipped | pyproject.toml, hatch_build.py, build-dashboard.sh, build-web.sh, … | ## Reference diff --git a/docs/context/architecture/auth-secrets.md b/docs/context/architecture/auth-secrets.md index 0ab777ae..02817d69 100644 --- a/docs/context/architecture/auth-secrets.md +++ b/docs/context/architecture/auth-secrets.md @@ -13,7 +13,9 @@ sources: - src/treg/infra/oauth_exchange.py - src/treg/infra/oauth_refresh.py - src/treg/oauth_providers.py - - src/treg/web/index.html + - frontend/src/state/session.js + - frontend/src/state/keys.js + - frontend/src/pages/TeamPage.vue - src/treg/health.py - src/treg/application/connect.py - src/treg/routers/connections.py diff --git a/docs/context/architecture/composition.md b/docs/context/architecture/composition.md index 28deef4c..a1cc3fda 100644 --- a/docs/context/architecture/composition.md +++ b/docs/context/architecture/composition.md @@ -34,7 +34,8 @@ The standalone [Enrich Arena](../interface/enrich-arena.md) pages (`/enrich-aren `/enrich-arena/leaderboard`) and `/arena/*` routes are control-role surfaces. Paid interactive runs use the ordinary call application internally. Shutdown drains their in-process owners before closing the shared upstream client. -The shared `/agent-setup.js` browser asset also belongs to the control role. +The shared `/agent-setup.js` browser asset and the compiled Dashboard assets at +`/app/ui/assets/{name}` also belong to the control role. `bootstrap.create_app(role)` is the FastAPI composition root. `api.py` hosts the ordered route table, attaches concern routers at compatibility-sensitive registration points, and calls the factory once at diff --git a/docs/context/architecture/instagram-oauth.md b/docs/context/architecture/instagram-oauth.md index 695fd21d..ddfcba4f 100644 --- a/docs/context/architecture/instagram-oauth.md +++ b/docs/context/architecture/instagram-oauth.md @@ -15,7 +15,8 @@ sources: - src/treg/infra/oauth_exchange.py - src/treg/mcp.py - src/treg/routers/call.py - - src/treg/web/index.html + - frontend/src/state/connections.js + - frontend/src/pages/ProviderPage.vue - src/treg/alembic/versions/0010_oauth_authorization_method.py - tests/test_instagram_oauth_architecture.py related: diff --git a/docs/context/interface/dashboard.md b/docs/context/interface/dashboard.md index 5c892b57..f61abc3a 100644 --- a/docs/context/interface/dashboard.md +++ b/docs/context/interface/dashboard.md @@ -3,7 +3,82 @@ title: The web dashboard (served from FastAPI) status: shipped sources: - src/treg/web/sitetrack.js - - src/treg/web/index.html + - frontend/index.html + - frontend/package.json + - frontend/vite.config.ts + - frontend/src/App.vue + - frontend/src/api.ts + - frontend/src/components/DashboardNavigation.vue + - frontend/src/components/PublicNavigation.vue + - frontend/src/components/SignInDialog.vue + - frontend/src/components/SignedOutPage.vue + - frontend/src/dialogs/AcceptInvitesDialog.vue + - frontend/src/dialogs/AgentGuideDialog.vue + - frontend/src/dialogs/CallDetailsDialog.vue + - frontend/src/dialogs/ConnectTokenDialog.vue + - frontend/src/dialogs/ConnectionMethodDialog.vue + - frontend/src/dialogs/CopyToolDialog.vue + - frontend/src/dialogs/EditToolDialog.vue + - frontend/src/dialogs/ExtraCredentialDialog.vue + - frontend/src/dialogs/ImportSkillDialog.vue + - frontend/src/dialogs/RecipeDialog.vue + - frontend/src/dialogs/RequestToolDialog.vue + - frontend/src/dialogs/ResourcePickerDialog.vue + - frontend/src/dialogs/RunToolDialog.vue + - frontend/src/dialogs/ShareDialog.vue + - frontend/src/dialogs/TopUpDialog.vue + - frontend/src/dialogs/TryEndpointDialog.vue + - frontend/src/dialogs/WelcomeDialog.vue + - frontend/src/main.ts + - frontend/src/pages/ActivityPage.vue + - frontend/src/pages/AdminPage.vue + - frontend/src/pages/CatalogPage.vue + - frontend/src/pages/DetailPage.vue + - frontend/src/pages/GettingStartedPage.vue + - frontend/src/pages/HelpPage.vue + - frontend/src/pages/PlatformPage.vue + - frontend/src/pages/ProviderPage.vue + - frontend/src/pages/ReferralsPage.vue + - frontend/src/pages/SecretsPage.vue + - frontend/src/pages/TeamPage.vue + - frontend/src/pages/ToolsPage.vue + - frontend/src/state/activity.js + - frontend/src/state/admin.js + - frontend/src/state/agents.js + - frontend/src/state/agentsComputed.js + - frontend/src/state/analytics.js + - frontend/src/state/billing.js + - frontend/src/state/billingComputed.js + - frontend/src/state/boot.js + - frontend/src/state/catalog.js + - frontend/src/state/catalogComputed.js + - frontend/src/state/connections.js + - frontend/src/state/constants.js + - frontend/src/state/context.ts + - frontend/src/state/controller.js + - frontend/src/state/data.js + - frontend/src/state/details.js + - frontend/src/state/detailsComputed.js + - frontend/src/state/format.js + - frontend/src/state/governance.js + - frontend/src/state/help.js + - frontend/src/state/keys.js + - frontend/src/state/lifecycle.js + - frontend/src/state/navigation.js + - frontend/src/state/onboarding.js + - frontend/src/state/onboardingComputed.js + - frontend/src/state/projects.js + - frontend/src/state/referrals.js + - frontend/src/state/secrets.js + - frontend/src/state/session.js + - frontend/src/state/sessionComputed.js + - frontend/src/state/sharing.js + - frontend/src/state/skills.js + - frontend/src/state/snippets.js + - frontend/src/state/team.js + - frontend/src/state/tools.js + - frontend/src/state/tryTool.js + - frontend/src/styles/base.css - src/treg/web/agent-setup.js - src/treg/web/media/redesign/dashboard.css - src/treg/web/media/redesign/SOURCES.md @@ -108,34 +183,36 @@ generated tool name in the second, then health/capabilities and actions. Method- discovery is unchanged. A direct identity miss shows `setup required`; it does not show the connection as working and no direct tool exists. -A single-file Vue 3 dashboard in `src/treg/web/index.html`, served **same-origin** by the API -(`GET /app` → `FileResponse`, `dashboard()` in `routers.web`, via `_WEB_DIR`). Same origin = no CORS and it -ships with the server (Render/Fly). The authenticated redesign follows the root `design.md`; older Ledger references in -`docs/style-board.html` / `docs/DASHBOARD-PLAN.md` describe the previous visual system. +The Vue 3 Dashboard is authored in `frontend/` and compiled with Vite. `frontend/index.html` +contains only the document entry; templates live in `.vue` pages, dialogs and shared navigation. +`state/` separates the existing Options API use cases by feature. `context.ts` keeps their +per-application state available to extracted components during this incremental migration; it is +not a singleton, and this boundary is not yet a fully typed domain store. The TypeScript entry, +JSON transport and development configuration are checked with `vue-tsc` before every build. +History navigation retains existing hashes, catalog URLs and shared links in `state/navigation.js`, +`state/catalog.js`, `state/details.js` and `state/boot.js`. -### Vue is vendored, not fetched from a CDN -There is no bundler, so Vue arrives as a plain ` + + + + +

Loading treg…

If this page does not load, reload to get the latest version.

+ + + + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 00000000..3724f578 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1497 @@ +{ + "name": "treg-dashboard", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "treg-dashboard", + "dependencies": { + "vue": "3.5.41" + }, + "devDependencies": { + "@playwright/test": "1.63.0", + "@types/node": "26.6.2", + "@vitejs/plugin-vue": "6.0.9", + "typescript": "5.9.3", + "vite": "8.3.0", + "vitest": "5.0.1", + "vue-tsc": "3.3.11" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz", + "integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz", + "integrity": "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.9.tgz", + "integrity": "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.9.tgz", + "integrity": "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.9.tgz", + "integrity": "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz", + "integrity": "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.9.tgz", + "integrity": "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.9.tgz", + "integrity": "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.9.tgz", + "integrity": "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.9.tgz", + "integrity": "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.9.tgz", + "integrity": "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.9.tgz", + "integrity": "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.9.tgz", + "integrity": "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.9.tgz", + "integrity": "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.9.tgz", + "integrity": "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.9.tgz", + "integrity": "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz", + "integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.9", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.9.tgz", + "integrity": "sha512-rD/MORlhaZMlXWW0rEn4FB4wMinWC0z/D7Ye160S5+Rs1mC8ZDAdcDp4SjKfUhG5t8q1ktcPVw4xuTkPRlzrSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz", + "integrity": "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.1", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz", + "integrity": "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.1.tgz", + "integrity": "sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.41.tgz", + "integrity": "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.41", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.41.tgz", + "integrity": "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.41.tgz", + "integrity": "sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.41", + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-ssr": "3.5.41", + "@vue/shared": "3.5.41", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.41.tgz", + "integrity": "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/language-core": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.3.11.tgz", + "integrity": "sha512-QJmpliwAVpC/OxubIByPAhNzsQPRc8/gxlN2qnVzVfIMjMDz/9RnXRFoetjz5yEgXVXyp4LqhXq3V53PjmNzFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.2.1", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.4" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.41.tgz", + "integrity": "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.41.tgz", + "integrity": "sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.41.tgz", + "integrity": "sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.41", + "@vue/runtime-core": "3.5.41", + "@vue/shared": "3.5.41", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.41.tgz", + "integrity": "sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz", + "integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==", + "license": "MIT" + }, + "node_modules/alien-signals": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz", + "integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz", + "integrity": "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.150.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.9", + "@rolldown/binding-android-arm64": "1.2.9", + "@rolldown/binding-darwin-arm64": "1.2.9", + "@rolldown/binding-darwin-x64": "1.2.9", + "@rolldown/binding-freebsd-x64": "1.2.9", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.9", + "@rolldown/binding-linux-arm64-gnu": "1.2.9", + "@rolldown/binding-linux-arm64-musl": "1.2.9", + "@rolldown/binding-linux-ppc64-gnu": "1.2.9", + "@rolldown/binding-linux-s390x-gnu": "1.2.9", + "@rolldown/binding-linux-x64-gnu": "1.2.9", + "@rolldown/binding-linux-x64-musl": "1.2.9", + "@rolldown/binding-openharmony-arm64": "1.2.9", + "@rolldown/binding-win32-arm64-msvc": "1.2.9", + "@rolldown/binding-win32-x64-msvc": "1.2.9" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.1.tgz", + "integrity": "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.1", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.1", + "@vitest/browser-preview": "5.0.1", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.1", + "@vitest/coverage-v8": "5.0.1", + "@vitest/ui": "5.0.1", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz", + "integrity": "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/vscode-uri": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz", + "integrity": "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.41.tgz", + "integrity": "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-sfc": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/server-renderer": "3.5.41", + "@vue/shared": "3.5.41" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-tsc": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.11.tgz", + "integrity": "sha512-gOb0B9rtU2+f1dszwPqSH5kAieIF9ReeLhD3kSRNHv5WZZUQz/JdVXW0RTdqhNTMlQkqKzrTTviqKr/4FYZraQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.28", + "@vue/language-core": "3.3.11" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 00000000..36b8326e --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,27 @@ +{ + "name": "treg-dashboard", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "test": "vitest run", + "check": "vue-tsc --noEmit", + "test:e2e": "playwright test" + }, + "dependencies": { + "vue": "3.5.41" + }, + "devDependencies": { + "@playwright/test": "1.63.0", + "@types/node": "26.6.2", + "@vitejs/plugin-vue": "6.0.9", + "typescript": "5.9.3", + "vite": "8.3.0", + "vitest": "5.0.1", + "vue-tsc": "3.3.11" + }, + "engines": { + "node": ">=22.12.0" + } +} diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 00000000..4d8fbd8c --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from '@playwright/test' +export default defineConfig({ + testDir: './e2e', + workers: 1, + timeout: 45000, + use: { + baseURL: 'http://127.0.0.1:18791', + viewport: { width: 1440, height: 1000 }, + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + ...(process.env.PLAYWRIGHT_CHANNEL ? { channel: process.env.PLAYWRIGHT_CHANNEL } : {}), + }, + webServer: { + command: 'bash ../scripts/frontend-e2e-server.sh', + url: 'http://127.0.0.1:18791/meta', + reuseExistingServer: false, + timeout: 90000, + }, +}) diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 00000000..40f61c05 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,253 @@ + + + diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 00000000..41fcd2c1 --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,61 @@ +/** Same-origin JSON transport. Credentials and team selection are supplied by the session. */ +export interface RequestOptions extends Omit { + headers?: Record + encode?: boolean +} + +export class ApiError extends Error { + constructor(public status: number, public detail: unknown = undefined) { + super(typeof detail === 'string' ? detail : `HTTP ${status}`) + } +} + +function encodeBody(body: string): string { + const bytes = new TextEncoder().encode(body) + // Avoid argument spreading: imported skill folders can exceed the engine's argument limit. + let binary = '' + for (const byte of bytes) binary += String.fromCharCode(byte) + return btoa(binary) +} + +export async function requestJson( + path: string, + opts: RequestOptions, + headers: Record, + expired: () => void, + sessionMode: () => boolean, +): Promise { + const { encode, ...request } = opts + const init: RequestInit = { + credentials: 'include', + ...request, + headers: { ...headers, ...opts.headers }, + } + if (encode && typeof opts.body === 'string') { + init.body = encodeBody(opts.body) + init.headers = { ...headers, ...opts.headers, 'X-Treg-Body-Encoding': 'base64' } + } + let response = await fetch(path, init) + if (response.status === 403 && typeof opts.body === 'string' && !encode && + response.headers.get('content-type')?.includes('html')) { + try { + response = await fetch(path, { + ...init, + body: encodeBody(opts.body), + headers: { ...headers, ...opts.headers, 'X-Treg-Body-Encoding': 'base64' }, + }) + } catch { + throw new ApiError(403, 'The edge blocked this request and the encoded retry failed - try a smaller folder') + } + } + if (response.status === 401 && sessionMode() && !path.startsWith('/auth/')) { + expired() + // Navigation replaces the application; do not run downstream success handlers meanwhile. + return new Promise(() => {}) + } + if (!response.ok) { + const body = await response.json().catch(() => null) + throw new ApiError(response.status, body?.detail) + } + return response.json() +} diff --git a/frontend/src/components/DashboardNavigation.vue b/frontend/src/components/DashboardNavigation.vue new file mode 100644 index 00000000..f65705d0 --- /dev/null +++ b/frontend/src/components/DashboardNavigation.vue @@ -0,0 +1,59 @@ + + + diff --git a/frontend/src/components/PublicNavigation.vue b/frontend/src/components/PublicNavigation.vue new file mode 100644 index 00000000..8d86a820 --- /dev/null +++ b/frontend/src/components/PublicNavigation.vue @@ -0,0 +1,18 @@ + + + diff --git a/frontend/src/components/SignInDialog.vue b/frontend/src/components/SignInDialog.vue new file mode 100644 index 00000000..ccea6d61 --- /dev/null +++ b/frontend/src/components/SignInDialog.vue @@ -0,0 +1,38 @@ + + + diff --git a/frontend/src/components/SignedOutPage.vue b/frontend/src/components/SignedOutPage.vue new file mode 100644 index 00000000..5b349d97 --- /dev/null +++ b/frontend/src/components/SignedOutPage.vue @@ -0,0 +1,70 @@ + + + diff --git a/frontend/src/dialogs/AcceptInvitesDialog.vue b/frontend/src/dialogs/AcceptInvitesDialog.vue new file mode 100644 index 00000000..b562b79e --- /dev/null +++ b/frontend/src/dialogs/AcceptInvitesDialog.vue @@ -0,0 +1,24 @@ + + + diff --git a/frontend/src/dialogs/AgentGuideDialog.vue b/frontend/src/dialogs/AgentGuideDialog.vue new file mode 100644 index 00000000..f00db034 --- /dev/null +++ b/frontend/src/dialogs/AgentGuideDialog.vue @@ -0,0 +1,37 @@ + + + diff --git a/frontend/src/dialogs/CallDetailsDialog.vue b/frontend/src/dialogs/CallDetailsDialog.vue new file mode 100644 index 00000000..f025f56b --- /dev/null +++ b/frontend/src/dialogs/CallDetailsDialog.vue @@ -0,0 +1,58 @@ + + + diff --git a/frontend/src/dialogs/ConnectTokenDialog.vue b/frontend/src/dialogs/ConnectTokenDialog.vue new file mode 100644 index 00000000..06d776de --- /dev/null +++ b/frontend/src/dialogs/ConnectTokenDialog.vue @@ -0,0 +1,36 @@ + + + diff --git a/frontend/src/dialogs/ConnectionMethodDialog.vue b/frontend/src/dialogs/ConnectionMethodDialog.vue new file mode 100644 index 00000000..33ef5acb --- /dev/null +++ b/frontend/src/dialogs/ConnectionMethodDialog.vue @@ -0,0 +1,35 @@ + + + diff --git a/frontend/src/dialogs/CopyToolDialog.vue b/frontend/src/dialogs/CopyToolDialog.vue new file mode 100644 index 00000000..22593516 --- /dev/null +++ b/frontend/src/dialogs/CopyToolDialog.vue @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/dialogs/EditToolDialog.vue b/frontend/src/dialogs/EditToolDialog.vue new file mode 100644 index 00000000..c8639392 --- /dev/null +++ b/frontend/src/dialogs/EditToolDialog.vue @@ -0,0 +1,75 @@ + + + diff --git a/frontend/src/dialogs/ExtraCredentialDialog.vue b/frontend/src/dialogs/ExtraCredentialDialog.vue new file mode 100644 index 00000000..f48d75b9 --- /dev/null +++ b/frontend/src/dialogs/ExtraCredentialDialog.vue @@ -0,0 +1,60 @@ + + + diff --git a/frontend/src/dialogs/ImportSkillDialog.vue b/frontend/src/dialogs/ImportSkillDialog.vue new file mode 100644 index 00000000..cf9e2c81 --- /dev/null +++ b/frontend/src/dialogs/ImportSkillDialog.vue @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/dialogs/RecipeDialog.vue b/frontend/src/dialogs/RecipeDialog.vue new file mode 100644 index 00000000..32167e35 --- /dev/null +++ b/frontend/src/dialogs/RecipeDialog.vue @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/dialogs/RequestToolDialog.vue b/frontend/src/dialogs/RequestToolDialog.vue new file mode 100644 index 00000000..923b4b65 --- /dev/null +++ b/frontend/src/dialogs/RequestToolDialog.vue @@ -0,0 +1,36 @@ + + + diff --git a/frontend/src/dialogs/ResourcePickerDialog.vue b/frontend/src/dialogs/ResourcePickerDialog.vue new file mode 100644 index 00000000..cf3a7564 --- /dev/null +++ b/frontend/src/dialogs/ResourcePickerDialog.vue @@ -0,0 +1,28 @@ + + + diff --git a/frontend/src/dialogs/RunToolDialog.vue b/frontend/src/dialogs/RunToolDialog.vue new file mode 100644 index 00000000..a541f5b0 --- /dev/null +++ b/frontend/src/dialogs/RunToolDialog.vue @@ -0,0 +1,42 @@ + + + diff --git a/frontend/src/dialogs/ShareDialog.vue b/frontend/src/dialogs/ShareDialog.vue new file mode 100644 index 00000000..68bab451 --- /dev/null +++ b/frontend/src/dialogs/ShareDialog.vue @@ -0,0 +1,41 @@ + + + diff --git a/frontend/src/dialogs/TopUpDialog.vue b/frontend/src/dialogs/TopUpDialog.vue new file mode 100644 index 00000000..680644ef --- /dev/null +++ b/frontend/src/dialogs/TopUpDialog.vue @@ -0,0 +1,73 @@ + + + diff --git a/frontend/src/dialogs/TryEndpointDialog.vue b/frontend/src/dialogs/TryEndpointDialog.vue new file mode 100644 index 00000000..f2fd0396 --- /dev/null +++ b/frontend/src/dialogs/TryEndpointDialog.vue @@ -0,0 +1,94 @@ + + + diff --git a/frontend/src/dialogs/WelcomeDialog.vue b/frontend/src/dialogs/WelcomeDialog.vue new file mode 100644 index 00000000..e2f66a39 --- /dev/null +++ b/frontend/src/dialogs/WelcomeDialog.vue @@ -0,0 +1,45 @@ + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 00000000..e2e9d1a7 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,11 @@ +import { createApp } from 'vue' +import App from './App.vue' +import './styles/base.css' +import '../../src/treg/web/media/redesign/dashboard.css' + +const app = createApp(App) +const setup = (window as unknown as { TregAgentSetup: Record }).TregAgentSetup +app.component('TregTryItOut', setup.TryItOut!) +app.component('TregAgentPicker', setup.AgentPicker!) +app.component('TregSetupInstructions', setup.SetupInstructions!) +app.mount('#app') diff --git a/frontend/src/pages/ActivityPage.vue b/frontend/src/pages/ActivityPage.vue new file mode 100644 index 00000000..258c1dff --- /dev/null +++ b/frontend/src/pages/ActivityPage.vue @@ -0,0 +1,98 @@ + + + diff --git a/frontend/src/pages/AdminPage.vue b/frontend/src/pages/AdminPage.vue new file mode 100644 index 00000000..60c4b623 --- /dev/null +++ b/frontend/src/pages/AdminPage.vue @@ -0,0 +1,47 @@ + + + diff --git a/frontend/src/pages/CatalogPage.vue b/frontend/src/pages/CatalogPage.vue new file mode 100644 index 00000000..bbccb6d5 --- /dev/null +++ b/frontend/src/pages/CatalogPage.vue @@ -0,0 +1,167 @@ + + + diff --git a/frontend/src/pages/DetailPage.vue b/frontend/src/pages/DetailPage.vue new file mode 100644 index 00000000..d188a561 --- /dev/null +++ b/frontend/src/pages/DetailPage.vue @@ -0,0 +1,108 @@ + + + diff --git a/frontend/src/pages/GettingStartedPage.vue b/frontend/src/pages/GettingStartedPage.vue new file mode 100644 index 00000000..f75a3e4b --- /dev/null +++ b/frontend/src/pages/GettingStartedPage.vue @@ -0,0 +1,172 @@ + + + diff --git a/frontend/src/pages/HelpPage.vue b/frontend/src/pages/HelpPage.vue new file mode 100644 index 00000000..df550ad6 --- /dev/null +++ b/frontend/src/pages/HelpPage.vue @@ -0,0 +1,132 @@ + + + diff --git a/frontend/src/pages/PlatformPage.vue b/frontend/src/pages/PlatformPage.vue new file mode 100644 index 00000000..3cc8f095 --- /dev/null +++ b/frontend/src/pages/PlatformPage.vue @@ -0,0 +1,262 @@ + + + diff --git a/frontend/src/pages/ProviderPage.vue b/frontend/src/pages/ProviderPage.vue new file mode 100644 index 00000000..c1193d78 --- /dev/null +++ b/frontend/src/pages/ProviderPage.vue @@ -0,0 +1,115 @@ + + + diff --git a/frontend/src/pages/ReferralsPage.vue b/frontend/src/pages/ReferralsPage.vue new file mode 100644 index 00000000..1030c4b1 --- /dev/null +++ b/frontend/src/pages/ReferralsPage.vue @@ -0,0 +1,119 @@ + + + diff --git a/frontend/src/pages/SecretsPage.vue b/frontend/src/pages/SecretsPage.vue new file mode 100644 index 00000000..41b1f7f5 --- /dev/null +++ b/frontend/src/pages/SecretsPage.vue @@ -0,0 +1,84 @@ + + + diff --git a/frontend/src/pages/TeamPage.vue b/frontend/src/pages/TeamPage.vue new file mode 100644 index 00000000..ac4db1e8 --- /dev/null +++ b/frontend/src/pages/TeamPage.vue @@ -0,0 +1,589 @@ + + + diff --git a/frontend/src/pages/ToolsPage.vue b/frontend/src/pages/ToolsPage.vue new file mode 100644 index 00000000..0748797e --- /dev/null +++ b/frontend/src/pages/ToolsPage.vue @@ -0,0 +1,91 @@ + + + diff --git a/frontend/src/state/activity.js b/frontend/src/state/activity.js new file mode 100644 index 00000000..6bd273bc --- /dev/null +++ b/frontend/src/state/activity.js @@ -0,0 +1,54 @@ + +export default { +async loadUsage(){ if(!this.canAdmin || !this.activeOrgId) return; this.usage=null; + try{ this.usage=await this.api('/orgs/'+this.activeOrgId+'/usage?days='+this.usageDays); } + catch(e){ this.err='Failed to load usage: '+(e.detail||e.status); } + await this.loadTagUsage(); }, +async loadTagUsage(){ if(!this.canAdmin || !this.activeOrgId) return; + this.tagUsage={}; + try{ + // What the team has actually SENT. Reporting works on any key, unlike enforcement, which + // needs a declared one — so this is deliberately NOT the budgetable list, which hid + // `feature=` and friends from the dashboard even though the API served them fine. + const k=await this.api('/orgs/'+this.activeOrgId+'/tag-keys'); + const primary=k.primary; + const keys=[...new Set([...(k.seen||[]), ...(k.budgetable||[])].filter(Boolean))]; + // Primary first — it is the one a reselling team bills on; the rest alphabetically. + keys.sort((a,b)=> a===primary ? -1 : b===primary ? 1 : a.localeCompare(b)); + this.tagKeys=keys; + // One request per key. Bounded by the 5-key cap on the header, and they run together. + const got=await Promise.all(keys.map(key=> + this.api('/orgs/'+this.activeOrgId+'/usage/by-tag?key='+encodeURIComponent(key)+ + '&days='+this.usageDays).catch(()=>null))); + const out={}; + keys.forEach((key,i)=>{ if(got[i]) out[key]=got[i]; }); + this.tagUsage=out; + }catch(e){ this.tagUsage={}; this.tagKeys=[]; } }, +// micro-USD -> a money string, EXACT. Cents round a $0.0006 charge to $0.00 and a $9.999 balance + // to "$10.00"; a fixed 4 decimals rounds $0.00015 to $0.0001 (float: 0.00015 is stored just + // under). So: whole-cent amounts render as normal cents, everything else renders all 6 micro + // digits with trailing zeros trimmed — a micro amount is always representable. Mirrors cli._usd. + // Async generation tasks on the Activity feed: the state chip and the artifact link's tooltip. + taskStateLabel(t){ return {pending:'generating…', settled:'done', released:'failed · refunded', timed_out:'timed out'}[t.status]||t.status; }, +taskStateTitle(t){ + if(t.status==='pending') return 'the task is still running upstream; the hold settles or is refunded when it finishes'; + if(t.status==='settled') return 'the task succeeded; charged '+this.money(t.settled_micro)+(t.completed_at?' at '+new Date(t.completed_at+(/(Z|[+-]\d{2}:?\d{2})$/.test(t.completed_at)?'':'Z')).toLocaleString():''); + if(t.status==='released') return 'the provider reported the task failed; the whole hold was refunded'+(t.error?' - '+t.error:''); + if(t.status==='timed_out') return 'no terminal state within 24 hours; the whole hold was refunded and treg absorbed any upstream charge (flagged for review)'; + return t.status; + }, +taskArtifactTitle(t){ return 'the provider\'s download URL - time-limited'+(t.ttl_note?' (lifetime: '+t.ttl_note+')':'')+', download promptly'+(t.completed_at?'; generated '+this.when(t.completed_at):''); }, +async openCall(a){ // Activity row → the drawer; details load from /calls/{id}/result + this.callViewFull=false; this.callCopied=''; + // `task` rides along from the row: a generation's artifact (the provider's time-limited URL) + // lives on the async task, not in the archived submission body, and the drawer shows it inline. + this.callView={id:a.id, tool:a.tool, endpoint_id:a.endpoint_id, call_ref:a.call_ref, created_at:a.created_at, status_code:a.status, credential_tier:a.tier, cached:a.cached, task:a.task||null, loading:true}; + try{ const d=await this.api('/calls/'+a.id+'/result'); this.callView={...this.callView, ...d, loading:false}; } + catch(e){ this.callView={...this.callView, loading:false, error:'Could not load this call.'}; } + }, +pretty(text){ try{ return JSON.stringify(JSON.parse(text), null, 2); }catch(e){ return text; } }, +isVideoUrl(u){ try{ return /\.(mp4|webm|mov|m4v)$/i.test(new URL(u).pathname); }catch(e){ return /\.(mp4|webm|mov|m4v)(\?|$)/i.test(u||''); } }, +fmtBytes(n){ if(n==null) return '—'; if(n<1024) return n+' B'; if(n<1048576) return (n/1024).toFixed(1)+' KB'; return (n/1048576).toFixed(2)+' MB'; }, +async copyCallBody(){ const t=this.callView&&this.callView.response&&this.callView.response.body_text; if(!t) return; if(await this.toClipboard(t)){ this.callCopied='Copied'; setTimeout(()=>{ this.callCopied=''; },1400); } }, +async loadCalls(){ try{ if(!this.apiKeys.length)await this.loadApiKeys(); const q='?limit=100'+(this.activityKey?'&api_key_id='+encodeURIComponent(this.activityKey):''); const [calls,runs]=await Promise.all([this.api('/calls'+q), this.api('/runs'+q).catch(()=>[])]); this.calls=calls; this.runs=runs; }catch(e){ this.err='Failed to load activity.'; } } +} diff --git a/frontend/src/state/admin.js b/frontend/src/state/admin.js new file mode 100644 index 00000000..3e192349 --- /dev/null +++ b/frontend/src/state/admin.js @@ -0,0 +1,14 @@ + +export default { +async loadAdmin(){ this.confirmAdmUser=null; this.confirmAdmOrg=null; + try{ this.adminStats=await this.api('/admin/stats'); this.adminOrgs=await this.api('/admin/orgs'); this.adminUsers=await this.api('/admin/users'); } + catch(e){ this.err='Admin: '+(e.detail||e.status); } }, +async _adm(path, method, body){ this.adminBusy=true; this.err=''; + try{ await this.api(path, {method, ...(body?{headers:{'content-type':'application/json'},body:JSON.stringify(body)}:{})}); await this.loadAdmin(); } + catch(e){ this.err='Admin action failed: '+(e.detail||e.status); } finally{ this.adminBusy=false; } }, +admGrant(u){ this._adm('/admin/users/'+u.id+'/superadmin','POST',{value:!u.is_superadmin}); }, +admSuspendUser(u){ this._adm('/admin/users/'+u.id+'/suspend','POST',{value:!u.suspended}); }, +admDeleteUser(u){ if(this.confirmAdmUser!==u.id){ this.confirmAdmUser=u.id; return; } this.confirmAdmUser=null; this._adm('/admin/users/'+u.id,'DELETE'); }, +admSuspendOrg(o){ this._adm('/admin/orgs/'+o.id+'/suspend','POST',{value:!o.suspended}); }, +admDeleteOrg(o){ if(this.confirmAdmOrg!==o.id){ this.confirmAdmOrg=o.id; return; } this.confirmAdmOrg=null; this._adm('/admin/orgs/'+o.id,'DELETE'); } +} diff --git a/frontend/src/state/agents.js b/frontend/src/state/agents.js new file mode 100644 index 00000000..8b732f4b --- /dev/null +++ b/frontend/src/state/agents.js @@ -0,0 +1,63 @@ + +export default { +// ---- agents: a member identity for a machine caller ---- + pollAgentConnected(){ + // Flip the token card to ✓ the moment the setup instruction's final check-in lands. + if(this._agentPoll) clearInterval(this._agentPoll); + let tries=0; + this._agentPoll=setInterval(async()=>{ + if(!this.newAgent || this.agentConnected || ++tries>40){ clearInterval(this._agentPoll); this._agentPoll=null; return; } + await this.loadOrgAdmin(); }, 3000); }, +promoteObserved(o){ + // Promotion = mint a real identity for a runtime we've only SEEN so far. Prefill the form; + // the admin still picks role/cap/projects and presses Create, then swaps the env key. + // promotePending links the mint to this (member, runtime) pair, so the detected row + // disappears the moment the agent exists (and returns if the agent is revoked). + const who=(o.member.split('@')[0]||'agent').replace(/[^a-zA-Z0-9-]/g,'-'); + this.promotePending={member:o.member, client:o.client}; + this.showAddAgent=true; this.showInvite=false; + this.agentAccessMode=null; this.agentToolSel={}; + this.agentName=who+'-'+o.client; this.promoteHint='Creating a token for "'+o.client+'" running as ' + +o.member+'. After Create, put the new token in that runtime\'s TREG_TOKEN — from then on it ' + +'acts as itself, with its own cap and scope, instead of as '+o.member+'.'; }, +openAddAgent(){ this.showAddAgent=!this.showAddAgent; if(this.showAddAgent){ this.showInvite=false; this.agentAccessMode=null; this.agentToolSel={}; } }, +async createAgent(){ const name=(this.agentName||'').trim(); + if(!name){ this.agentErr='Give the agent a name, e.g. ci-bot.'; return; } + if(!this.agentAccessMode){ this.agentErr='Choose All tools or Choose tools before creating the agent.'; return; } + // An admin agent can manage this team's tools, secrets and members. That is a real step up from + // 'can call things', so make it a deliberate choice rather than a dropdown you skimmed past. + if(this.agentRole==='admin' && !confirm( + 'Create "'+name+'" as an ADMIN agent?\n\nAn admin agent can register and delete tools and ' + +'secrets, invite members, and set access for this team — not just call tools.\n\n' + +'Most agents only need "member".')) return; + this.agentBusy=true; this.agentErr=''; + const body={name, role:this.agentRole, daily_call_cap:this.agentCap}; + body.tool_access=this.agentAccessMode==='all' ? null : this.accessNames.filter(n=>this.agentToolSel[n]); + // Only send project_access when the admin actually narrowed it — all checked = every project. + const picked=this.projects.filter(p=>this.agentProjSel[p.id]).map(p=>p.id); + if(this.projects.length && picked.lengths.part))]; }, +agentPromptText(){ return this.buildAgentPrompt(this.agentGuide); }, +// The whole vendor pitch is two sentences: the hosted /vendor-listing page carries the real + // instructions, so this prompt only has to name the repo, the page, and the contact email. + vendorPromptText(){ return `Help me create a PR to treg (https://github.com/superdesigndev/treg) that adds our API to its tool catalog. Follow the instructions at ${this.proxy}/vendor-listing — and include our contact email in the PR description so the maintainers can reach us to arrange live verification.`; }, +// What to actually DO with a freshly minted agent token, in the three shapes people need. + agentSnippet(){ const a=this.newAgent||this.snipAgent||((this.newApiKey&&this.newApiKey.assigned_type==='agent')?this.newApiKey:null); if(!a) return ''; + const base=this.proxy||location.origin, org=a.org||this.activeSlugNow; + const tok=a.token||a.secret||this.agentTokens[a.user_id]||'$TREG_TOKEN'; // hash-stored server-side; real value only if minted this page-load + if(this.agentSnip==='env'){ + return [ + '# Where to persist these, per agent harness:', + '# Claude Code -> the "env" map in ~/.claude/settings.json', + '# Codex CLI -> [shell_environment_policy] set = {...} in ~/.codex/config.toml', + '# Gemini CLI -> append KEY=value lines to ~/.gemini/.env (a project .env shadows it)', + '# Cursor -> ~/.zshrc: [ -n "$CURSOR_AGENT" ] && export ...', + '# pi -> ~/.zshrc guard on $PI_CODING_AGENT, or shellCommandPrefix in ~/.pi/agent/settings.json', + '# opencode -> shell.env plugin; also export TREG_CLIENT=opencode', + '# plain shell / CI -> the exports below, as-is', + 'export TREG_TOKEN='+tok, + 'export TREG_ORG='+org, + 'export TREG_URL='+base, + ].join('\n'); } + return [ + 'You have your own treg identity in the team "'+org+'" (you are "'+a.name+'").', + '', + 'FIRST, persist this identity into YOUR OWN global config, so every future session of', + 'yours, in any repo, runs as "'+a.name+'" — while other agents and the human keep theirs:', + ' TREG_TOKEN='+tok, + ' TREG_ORG='+org, + ' TREG_URL='+base, + '', + 'How, per runtime — do the one that matches you:', + '- Claude Code: add them to the "env" map in ~/.claude/settings.json:', + ' "env": { "TREG_TOKEN": "'+tok+'", "TREG_ORG": "'+org+'", "TREG_URL": "'+base+'" }', + '- Codex CLI: in ~/.codex/config.toml (a `set` entry bypasses Codex\'s default filtering', + ' of *TOKEN* names, so this works where plain shell exports get stripped):', + ' [shell_environment_policy]', + ' set = { TREG_TOKEN = "'+tok+'", TREG_ORG = "'+org+'", TREG_URL = "'+base+'" }', + '- Gemini CLI: append the three KEY=value lines to ~/.gemini/.env. Caveat: a project', + ' .env shadows that file entirely, so repos with their own .env must carry them too.', + '- Cursor: a guarded line in the shell profile (~/.zshrc or ~/.bashrc):', + ' [ -n "$CURSOR_AGENT" ] && export TREG_TOKEN='+tok+' TREG_ORG='+org+' TREG_URL='+base, + '- pi: same guarded profile line keyed on $PI_CODING_AGENT — or set', + ' "shellCommandPrefix": "source ~/.pi-env.sh" in ~/.pi/agent/settings.json and put the', + ' exports in that file.', + '- opencode: no global env config and no fingerprint var — use a shell.env plugin, or an', + ' unguarded profile export ONLY on a machine that runs no other agents; ALSO export', + ' TREG_CLIENT=opencode so your calls are attributed to your runtime.', + '- anything else: a profile line guarded by an env var only your runtime sets, plus', + ' TREG_CLIENT= if treg does not already detect you.', + '', + 'Do NOT put the token in a repo file (.env, settings committed to git) — it is a secret,', + 'and a repo-level file would also hand YOUR identity to every other agent in that repo.', + '', + 'From then on the treg CLI automatically acts as you (TREG_TOKEN beats the machine', + 'owner\'s ~/.treg config), and raw HTTP works anywhere:', + ' curl '+base+'/call// -H "X-Treg-Token: $TREG_TOKEN" -H "X-Treg-Org: '+org+'"', + '', + 'List what you may use: GET '+base+'/tools', + 'Your calls are capped and logged as "'+a.name+'", so keep them purposeful.', + '', + 'FINALLY, after persisting the env, confirm the identity landed by checking in AS yourself:', + ' curl -s -X POST '+base+'/agents/checkin -H "X-Treg-Token: '+tok+'" -H "X-Treg-Org: '+org+'"', + 'A {"connected": true, "you": "agent-…"} reply means treg registered the connection —', + 'the dashboard shows this agent as connected from that moment.', + ].join('\n'); }, +rosterMembers(){ // one roster: each person, then the agents they minted, then the runtimes + // DETECTED in their traffic (observed — not yet their own identity, hence "Scope this agent"). + // Orphaned agents (creator left, or pre-created_by rows) trail at the end. + const humans=(this.orgMembers||[]).filter(m=>!m.is_agent); + const agents=(this.orgMembers||[]).filter(m=>m.is_agent); + const obs=(this.observedAgents||[]); + const out=[]; + humans.forEach(h=>{ out.push({...h, key:'u'+h.user_id}); + agents.filter(a=>a.created_by===h.email).forEach(a=>out.push({...a, key:'u'+a.user_id})); + obs.filter(o=>o.member===h.email).forEach(o=>out.push({...o, is_observed:true, key:'o'+o.member+'/'+o.client})); }); + agents.filter(a=>!humans.some(h=>h.email===a.created_by)).forEach(a=>out.push({...a, key:'u'+a.user_id})); + return out; }, +agentConnected(){ const n=this.newAgent; if(!n) return false; + const row=(this.agents||[]).find(a=>a.user_id===n.user_id); return !!(row&&row.connected); }, +isOwner(){ return this.activeRole==='owner'; } +} diff --git a/frontend/src/state/analytics.js b/frontend/src/state/analytics.js new file mode 100644 index 00000000..8494823d --- /dev/null +++ b/frontend/src/state/analytics.js @@ -0,0 +1,42 @@ + +export default { +// Product analytics — only when this deployment opted in (meta.posthog_key present); self-hosters send nothing. + initAnalytics(){ + // /sitetrack.js (loaded at the bottom of this page, before Vue mounts) normally initialises + // PostHog already — with pageviews on, so first-touch source survives into the person. Then + // this only has to identify. The inline path below is the fallback for a stale bundle. + if(window.__phInit){ this.analyticsIdentify(); return; } + const key=this.meta && this.meta.posthog_key; if(!key) return; window.__phInit=true; + const host=this.meta.posthog_host || 'https://eu.i.posthog.com'; + !function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host.replace(".i.posthog.com","-assets.i.posthog.com")+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="init capture register register_once register_for_session unregister unregister_for_session getFeatureFlag getFeatureFlagPayload isFeatureEnabled reloadFeatureFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures on onFeatureFlags onSessionId getSurveys getActiveMatchingSurveys renderSurvey canRenderSurvey identify setPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSessionRecording stopSessionRecording sessionRecordingStarted captureException loadToolbar get_property getSessionProperty createPersonProfile opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing clear_opt_in_out_capturing debug getPageViewId captureTraceFeedback captureTraceMetric".split(" "),n=0;n blocks. Mask all inputs + // and every code block so a replay can never leak a credential. + window.posthog.init(key,{api_host:host, person_profiles:'identified_only', capture_pageview:false, + session_recording:{maskAllInputs:true, maskTextSelector:'pre, .lc-codewrap, .agent-copy'}}); + this.analyticsIdentify(); + }, +analyticsIdentify(){ if(!this.me)return; if(window.TregTracking){window.TregTracking.identify(this.me,this.activeSlugNow);return;} if(!window.posthog || !window.posthog.identify || !this.me) return; + try{ window.posthog.identify(this.me, {email:this.me}); if(this.activeSlugNow) window.posthog.group('team', this.activeSlugNow); }catch(e){} }, +track(name, props){ try{ if(window.posthog && window.posthog.capture) window.posthog.capture(name, props||{}); }catch(e){} }, +// Support chat (Intercom) — only when this deployment opted in (meta.intercom_app_id present); + // self-hosters load nothing. Booted AFTER /auth/me resolves (not at the /meta fetch, like + // analytics) so the common case boots identified once instead of anonymous→identified. + initIntercom(){ + const app=this.meta && this.meta.intercom_app_id; if(!app || window.__icInit) return; window.__icInit=true; + window.intercomSettings=this.intercomPayload(); + const s=document.createElement('script'); s.async=true; s.src='https://widget.intercom.io/widget/'+app; + const i=function(){i.c(arguments)}; i.q=[]; i.c=function(a){i.q.push(a)}; if(!window.Intercom) window.Intercom=i; + document.head.appendChild(s); + try{ window.Intercom('boot', this.intercomPayload()); }catch(e){} + }, +intercomPayload(){ + const p={app_id:this.meta.intercom_app_id}; + // Never send email without user_hash: an unhashed identify is exactly the impersonation + // vector identity verification exists to close. No hash (or no login) = anonymous visitor chat. + if(this.me && this.icHash){ p.email=this.me; p.user_hash=this.icHash; + if(this.activeSlugNow) p.company={id:this.activeSlugNow, name:this.activeSlugNow}; } + return p; + }, +intercomUpdate(){ try{ if(window.__icInit && window.Intercom) window.Intercom('update', this.intercomPayload()); }catch(e){} } +} diff --git a/frontend/src/state/billing.js b/frontend/src/state/billing.js new file mode 100644 index 00000000..171c42a8 --- /dev/null +++ b/frontend/src/state/billing.js @@ -0,0 +1,135 @@ + +export default { +money(micro){ const m=Math.round(Number(micro)||0), s=m<0?'-':'', a=Math.abs(m); + if(a%10000===0) return s+'$'+(a/1e6).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2}); + return s+'$'+(a/1e6).toFixed(6).replace(/0+$/,''); }, +async loadBilling(){ if(!this.canAdmin || !this.activeOrgId){ this.billing=null; return; } + this.autoOpen=false; this.autoConsent=false; + this.billing=await this.api('/billing').catch(()=>null); + if(this.billing){ this.topupAmount=this.billing.topup.default_usd; + this.autoAmount=Math.round(this.billing.autotopup.amount_usd); + this.autoThreshold=Math.round(this.billing.autotopup.threshold_usd); + this.loadBillingHistory(); } + // The daily cap lives beside the balance for the user even though it is an org setting. + this.capCfg=await this.api(`/orgs/${this.activeOrgId}/settings`).catch(()=>null); + if(this.capCfg){ this.capUsd=this.capCfg.daily_cap_micro ? (this.capCfg.daily_cap_micro/1e6).toFixed(2) : ''; + const k=await this.api(`/orgs/${this.activeOrgId}/tag-keys`).catch(()=>null); + this.budDims=[...new Set([...((k&&k.seen)||[]), ...(this.capCfg.budget_dims||[])])]; + if(!this.budDims.length) this.budDims=['customer']; + this.budDim=this.budDim||this.budDims[0]; + this.budgets=await this.api(`/orgs/${this.activeOrgId}/budgets`).catch(()=>[]); } }, +defaultFor(dim){ return (this.budgets||[]).find(b=>b.dim===dim && b.is_default) || null; }, +overridesFor(dim){ return (this.budgets||[]).filter(b=>b.dim===dim && !b.is_default); }, +fmtCap(b){ return b.daily_cap_micro==null ? 'no limit' : this.money(b.daily_cap_micro)+'/day'; }, +// The server's own words when it has any. Every budget refusal is one a builder must be able to + // act on — "above_platform_ceiling" carries the ceiling, "too_many_budget_dimensions" names the + // dimensions in use — so the generic fallback is the last resort, never the first. + _errMsg(e, fallback){ return (e&&e.detail&&e.detail.message) || (e&&typeof e.detail==='string'&&e.detail) + || (e&&e.message) || fallback; }, +// Blank `val` addresses the DIMENSION'S DEFAULT — `PUT /budgets/{dim}`, the route with no value. + _budgetUrl(dim, val){ const base=`/orgs/${this.activeOrgId}/budgets/${encodeURIComponent(dim)}`; + return val ? `${base}/${encodeURIComponent(val)}` : base; }, +async _putBudget(dim, val, body){ + return this.api(this._budgetUrl(dim, val), + {method:'PUT', headers:{'content-type':'application/json'}, body:JSON.stringify(body)}); + }, +async _reloadBudgets(){ this.budgets=await this.api(`/orgs/${this.activeOrgId}/budgets`).catch(()=>this.budgets); }, +async saveBudget(){ this.budBusy=true; this.budErr=''; + try{ + const body={}; + if(this.budDaily!=='') body.daily_cap_micro=Math.round(parseFloat(this.budDaily)*1e6); + await this._putBudget(this.budDim, this.budVal.trim(), body); + this.budVal=''; this.budDaily=''; + await this.loadBilling(); // re-reads settings: a first limit DECLARES the dimension + await this._reloadBudgets(); + }catch(e){ this.budErr=this._errMsg(e, 'could not save that limit'); } + finally{ this.budBusy=false; } }, +async toggleBlock(b){ this.budErr=''; + // A PARTIAL put: only `status` travels, so caps set earlier survive being blocked/unblocked. + try{ await this._putBudget(b.dim, b.val, {status: b.status==='blocked'?'active':'blocked'}); + await this._reloadBudgets(); + }catch(e){ this.budErr=this._errMsg(e, 'could not change that'); } }, +async removeBudget(b){ this.budErr=''; + try{ await this.api(this._budgetUrl(b.dim, b.val), {method:'DELETE'}); await this._reloadBudgets(); + }catch(e){ this.budErr=this._errMsg(e, 'could not remove that'); } }, +async saveCap(){ this.capBusy=true; this.capErr=''; + try{ + const micro=Math.round(parseFloat(this.capUsd||'0')*1e6); + this.capCfg=await this.api(`/orgs/${this.activeOrgId}/settings`, + {method:'PATCH', headers:{'content-type':'application/json'}, + body:JSON.stringify({daily_cap_micro:micro})}); + this.capUsd=this.capCfg.daily_cap_micro ? (this.capCfg.daily_cap_micro/1e6).toFixed(2) : ''; + }catch(e){ + this.capErr=this._errMsg(e, 'could not save that limit'); + }finally{ this.capBusy=false; } }, +// Amounts come from our own credit blocks, so this list can never disagree with the balance above + // it; Stripe supplies only the document links, and bhist.ok===false means those were unavailable. + async loadBillingHistory(){ if(!this.canAdmin || !this.billing || !this.billing.configured){ this.bhist={items:[],loading:false,ok:true}; return; } + this.bhist={items:[],loading:true,ok:true}; + const out=await this.api('/billing/history').catch(()=>null); + this.bhist={items:(out&&out.items)||[], loading:false, ok:!out||out.stripe_ok!==false}; }, +async openPortal(){ this.billingBusy=true; this.err=''; + try{ const out=await this.api('/billing/portal',{method:'POST'}); + // Stripe's hosted portal owns card, billing address, tax ID and the full invoice archive. Its + // return_url comes back to #billing, so the same tab is right here as it is for Checkout. + window.location.href=out.url; } + catch(e){ this.err='Could not open the billing portal: '+(e.detail||e.status); this.billingBusy=false; } }, +bhistLink(i){ return i.invoice_pdf || i.hosted_invoice_url || i.receipt_url || ''; }, +bhistLabel(i){ return (i.invoice_pdf||i.hosted_invoice_url) ? 'Invoice' : (i.receipt_url ? 'Receipt' : ''); }, +// created_at is naive UTC (the models._now convention), so it needs the Z that isoformat omits — + // without it the browser reads it as local time and a late-evening top-up shows the wrong day. + bhistDate(iso){ if(!iso) return ''; + const d=new Date(/(Z|[+-]\d{2}:?\d{2})$/.test(iso) ? iso : iso+'Z'); + return isNaN(d)?'':d.toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'}); }, +async addFunds(amount){ this.billingBusy=true; this.topupAmount=amount; this.err=''; + // Named differently from the server's topup_started (same click, two vantage points — + // a shared name would double-count in trends); this one exists to link session replays. + this.track('topup_checkout_opened',{amount_usd:amount}); + try{ const out=await this.api('/billing/topup',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({amount_usd:amount,checkout_source:window.TregTracking?.checkoutSource()||'app'})}); + // Stripe's own hosted page does the payment; the balance moves when its webhook lands, never + // on the return redirect (which a payer could simply type). Same tab, so the return lands here. + window.location.href=out.url; } + catch(e){ this.err='Could not start the payment: '+(e.detail||e.status); this.billingBusy=false; } }, +// ---- top-up modal ---- + openTopup(){ this.topupPick=this.billing.topup.default_usd; this.topupOther=null; this.topupErr=''; + // Default ON only for a team with no mandate; a team that already decided is not re-asked. + this.topupAuto=!(this.billing.autotopup.enabled||this.billing.autotopup.consented_at); + this.topupOpen=true; }, +// "Other" starts one rung above the biggest card, not blank: the card row ends at $200, so the + // amount a payer reaches for here is larger than that, and a prefilled number is one keystroke + // away from theirs. Only on first pick - a value they typed survives re-selecting the card. + pickOther(){ if(this.topupPick!=='other'&&!this.topupOther){ const top=Math.max(...this.billing.topup.presets); this.topupOther=Math.min(this.billing.topup.max_usd, top>=200?500:top*2); } this.topupPick='other'; }, +tierBonus(usd){ const t=this.billing&&this.billing.topup.bonus_tiers; if(!t||!usd) return 0; + // Keys arrive as strings from JSON; the highest floor at or below the amount applies. + let pct=0; Object.keys(t).map(Number).sort((a,b)=>a-b).forEach(k=>{ if(usd>=k) pct=t[k]; }); + return Math.floor(usd*1e6*pct/100); }, +async payTopup(){ const usd=this.topupUsd; if(!this.topupValid) return; + this.billingBusy=true; this.topupErr=''; this.topupAmount=usd; + this.track('topup_checkout_opened',{amount_usd:usd, auto_opt_in:this.topupAuto, bonus_micro:this.topupBonusMicro}); + try{ + const fresh=!(this.billing.autotopup.enabled||this.billing.autotopup.consented_at); + if(fresh&&this.topupAuto){ + // Consent first, Checkout second: the mandate has to exist before the card that will be + // charged under it. The server stores the numbers and marks it "no_card"; Checkout's + // saved card then arms it from the setup webhook. A failure here stops the payment too - + // paying without the auto top-up the user just agreed to would be a silent downgrade. + this.billing=await this.api('/billing/autotopup',{method:'POST',headers:{'content-type':'application/json'}, + body:JSON.stringify({enabled:true, consent:true, amount_usd:this.autoAmount, threshold_usd:this.autoThreshold, monthly_cap_usd:this.autoCapUsd, setup_url:false})}); + } + const out=await this.api('/billing/topup',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({amount_usd:usd,checkout_source:window.TregTracking?.checkoutSource()||'app'})}); + window.location.href=out.url; } + catch(e){ this.topupErr='Could not start the payment: '+(e.detail||e.status); this.billingBusy=false; } }, +// The toggle itself never arms auto top-up - it can only DISARM (off is safe) or open the + // settings panel; arming still goes through the consent checkbox + confirm button below. + autoToggled(){ if(this.billing.autotopup.enabled){ this.autoOpen=false; this.setAuto(false); return; } + this.autoOpen=!this.autoOpen; if(!this.autoOpen) this.autoConsent=false; }, +async setAuto(on){ this.billingBusy=true; this.err=''; + try{ const out=await this.api('/billing/autotopup',{method:'POST',headers:{'content-type':'application/json'}, + body:JSON.stringify({enabled:on, consent:on, amount_usd:on?this.autoAmount:null, threshold_usd:on?this.autoThreshold:null})}); + // No card yet: consent is stored, and Stripe's hosted card page finishes the job. Auto top-up + // arms itself from the setup_intent.succeeded webhook, so there's nothing more to click here. + if(out.setup_url){ window.location.href=out.setup_url; return; } + this.billing=out; this.autoConsent=false; this.autoOpen=false; } + catch(e){ this.err='Auto top-up change failed: '+(e.detail||e.status); } + this.billingBusy=false; } +} diff --git a/frontend/src/state/billingComputed.js b/frontend/src/state/billingComputed.js new file mode 100644 index 00000000..b40d361e --- /dev/null +++ b/frontend/src/state/billingComputed.js @@ -0,0 +1,36 @@ +export default { +topupUsd(){ if(!this.billing) return 0; if(this.topupPick==='other'){ const v=Number(this.topupOther); return Number.isInteger(v)&&v>0?v:0; } return this.topupPick; }, +topupValid(){ if(!this.billing||!this.topupUsd) return false; const t=this.billing.topup; return this.topupUsd>=t.min_usd&&this.topupUsd<=t.max_usd; }, +topupBonusMicro(){ return this.tierBonus(this.topupUsd)+this.refPresetBonus(this.topupUsd); }, +maxBonusPct(){ const t=this.billing&&this.billing.topup.bonus_tiers; return t?Math.max(0,...Object.values(t)):0; }, +// The monthly cap is a server-side runaway guardrail, not something the payer is asked to pick: + // the modal sets it to the single-top-up ceiling (effectively unlimited) so a big payer is never + // locked out mid-month by a default sized for $10 refills. The manage panel below can lower it. + autoCapUsd(){ return this.billing?this.billing.topup.max_usd:0; }, +// Marketplace shelves. /oauth/providers already returns providers grouped-then-alphabetical, so + // this walks the list once and starts a shelf whenever the category changes — re-sorting here + // would just be a second place to keep the order in step with the registry. + providerGroups(){ + const hints={ + 'SEO/AEO':'search visibility and site analytics — treg holds the approved app', + 'Advertising':'read spend and performance, or manage campaigns', + 'Social media':'publish and read back as the connected account', + 'Community':'bring your own workspace bot', + }; + const out=[]; + for(const p of this.providers){ + const cat=p.category||'Other'; + if(!out.length || out[out.length-1].category!==cat) out.push({category:cat, hint:hints[cat]||'', items:[]}); + out[out.length-1].items.push(p); + } + return out; + }, +// Filter chips narrow the shelves rather than flattening them — with one category picked the + // single remaining shelf header still says which, so the page never loses its place. + shownGroups(){ const gs=this.mkCat ? this.providerGroups.filter(g=>g.category===this.mkCat) : this.providerGroups; + const q=this.q.trim().toLowerCase(); + if(!q) return gs; + const hit=p=>((p.name||'')+' '+(p.service||'')+' '+(p.summary||'')).toLowerCase().includes(q); + return gs.map(g=>({...g, items:g.items.filter(hit)})).filter(g=>g.items.length); }, +connCount(){ const m={}; for(const c of this.connections){ if(c.provider) m[c.provider]=(m[c.provider]||0)+1; } return m; } +} diff --git a/frontend/src/state/boot.js b/frontend/src/state/boot.js new file mode 100644 index 00000000..3a2c61f8 --- /dev/null +++ b/frontend/src/state/boot.js @@ -0,0 +1,117 @@ +export default async function boot(){ + const lifecycle = new AbortController(); + let versionTimer; + this.stopLifecycle = () => { lifecycle.abort(); clearInterval(versionTimer); }; + const listen = (target, event, handler, capture=false) => target.addEventListener(event, handler, { signal: lifecycle.signal, capture }); + document.documentElement.dataset.theme=this.theme; + listen(document, 'click', ()=>{ if(this.mdMenu) this.mdMenu=false; if(this.keyMenu) this.keyMenu=null; }); // outside-click closes compact menus + listen(document, 'keydown', e=>{ + // Escape closes any open modal/drawer (a universal expectation the modals didn't honour) + if(e.key==='Escape'){ const orgWasOpen=this.orgMenu; this.closeOverlays(); if(orgWasOpen) this.elements.orgmain?.focus(); if(this.startAgentOpen){ this.startAgentOpen=false; this.elements.startAgentTrigger?.focus(); } else if(this.elements.accountMenu?.open){ this.elements.accountMenu.open=false; this.elements.accountMenu.querySelector('summary').focus(); } return; } + // "/" focuses the search box (the "/" glyph in the box advertised a shortcut that didn't exist) + const t=e.target, typing = t && (t.tagName==='INPUT'||t.tagName==='TEXTAREA'||t.tagName==='SELECT'||t.isContentEditable); + if(e.key==='/' && !typing && this.authed && (this.view==='tools'||this.view==='connections')){ e.preventDefault(); this.elements.search && this.elements.search.focus(); } + }); + listen(document, 'click', e=>{ // the org dropdown didn't close on an outside click + if(this.elements.accountMenu && !e.target.closest('.rd-account-menu')) this.elements.accountMenu.open=false; + if(this.startAgentOpen && !e.target.closest('.rd-agent-picker')) this.startAgentOpen=false; + if(this.orgMenu && !e.target.closest('.orgblock') && !e.target.closest('.dropdown')) this.orgMenu=false; + }); + // the fixed-position dropdown must follow its trigger when anything scrolls or resizes + listen(window, 'scroll', ()=>{ if(this.orgMenu) this.placeOrgMenu(); if(this.keyMenu)this.keyMenu=null; }, true); + listen(window, 'resize', ()=>{ if(this.orgMenu) this.placeOrgMenu(); if(this.keyMenu)this.keyMenu=null; }); + listen(window, 'popstate', e=>{ // browser Back moves between views (was exiting the app) + const mk=(e.state&&e.state.mk)||this.mkFromPath(location.pathname); + if(mk){ this.openProvider(mk, true); return; } + const pf=(e.state&&e.state.platform)||this.platformFromHash(); + if(pf){ this.openPlatform(pf, true); return; } + const d=(e.state&&e.state.detail)||this.routeFromPath(location.pathname); + if(d){ this.openDetail(d.kind, d.name, true); return; } + let v=(e.state&&e.state.view)||(location.hash||'').replace('#','')||'tools'; + if(v==='billing'){ this.orgTab='billing'; v='orgs'; } + if(['tools','orgs','activity','usage','admin','help','secrets','start','connections','referrals'].includes(v)) this.go(v, true); + }); + this.meta = await fetch('/meta',{headers:{'ngrok-skip-browser-warning':'1'}}).then(r=>r.json()).catch(()=>this.meta); + this.proxy = this.meta.public_url || location.origin; + this.initAnalytics(); + // deploy detection: long-lived tabs learn about a new bundle on tab focus + a slow poll, + // then offer a one-click refresh (index.html is no-cache, so a soft reload is enough) + this.bootVersion = this.meta.app_version || ''; + listen(document, 'visibilitychange', ()=>{ if(!document.hidden) this.checkVersion(); }); + versionTimer = setInterval(()=>this.checkVersion(), 5*60*1000); + // Invite-link params, parsed BEFORE the session check: ?invite_org= arrives freshly signed in + // from the email link's POST-confirm; ?invite= is the legacy code path (prefilled login). + const qs = new URLSearchParams(location.search); + const linkOrg = qs.get('invite_org'), inv = qs.get('invite'), ref = qs.get('ref'), oauthSignin = qs.get('signin')==='oauth'; + this.oauthSignin=oauthSignin; + if(ref){ try{ localStorage.setItem('treg-ref', ref); }catch(e){} } // survives the sign-in reload so the welcome can preselect the agent the landing was about (see maybeOnboard) + if(linkOrg || inv || qs.get('invite_expired') || ref || oauthSignin){ history.replaceState(null,'',location.pathname+location.hash); } // strip one-shot params so reload/share doesn't replay them + if(linkOrg){ this.inviteLinkOrg=parseInt(linkOrg,10)||null; } + // A shared detail deep link (/app/skills/, /app/tools/) — from the URL itself, or stashed + // before an OAuth hop (the callback always lands on /app, which would otherwise drop the path). + let route=this.routeFromPath(location.pathname); + let mkRoute=this.mkFromPath(location.pathname); + // A public catalog URL renders the marketplace views with or without a session. Set BEFORE the + // /auth/me check so the first paint is already in public mode, and drop the server-rendered + // fallback (see `_spa_catalog_page`) now that the real UI is about to take over. + const catRoute=this.catalogFromPath(location.pathname); + if(catRoute){ this.publicCatalog=true; document.getElementById('prerender')?.remove(); } + const stashed=localStorage.getItem('treg-next'); + if(stashed){ localStorage.removeItem('treg-next'); + if(!route && !mkRoute){ + route=this.routeFromPath(stashed); mkRoute=this.mkFromPath(stashed); + if(route||mkRoute) history.replaceState(null,'',stashed); + } } + this._restoreAgent(); + const me = await fetch('/auth/me',{credentials:'include',headers:{'ngrok-skip-browser-warning':'1'}}).then(r=>r.ok?r.json():null).catch(()=>null); + if(me){ this.sessionMode=true; this.me=me.email; this.isAdmin=!!me.is_superadmin; this.onboarded=!!me.onboarded; this.icHash=me.intercom_user_hash||''; await this.loadAll(); this.analyticsIdentify(); this.initIntercom(); + // Share-born arrival (/app/skills/x?invite_org=N from the invite email): accept silently and + // enter that team — the emailed "Sign in & accept" click was the consent. Otherwise the normal + // first-run / invite-banner flow. + if(mkRoute){ this.maybeOnboard(); this.openProvider(mkRoute, true); return; } + // Signed in on a /catalog URL: the same views, but as a member — so `publicCatalog` is + // dropped and the shell comes back in full (vault, activity, try-it). + if(catRoute){ this.publicCatalog=false; this.maybeOnboard(); + if(catRoute.slug) this.openPlatform(catRoute.slug, true); else this.go('connections', true); + return; } + const pfRoute=this.platformFromHash(); + if(pfRoute){ this.maybeOnboard(); this.openPlatform(pfRoute, true); return; } + let landed=false; + if(route) landed=await this.autoAcceptShare(route); + if(!landed) this.maybeOnboard(); + if(route){ this.openDetail(route.kind, route.name, true); + // a ?invite= share link opened by a DIFFERENT signed-in account: say why it won't resolve + if(!landed && inv && this.me && inv.toLowerCase()!==this.me.toLowerCase()) + this.detailNote='This share link was sent to '+inv+' — you’re signed in as '+this.me+'. Sign out (⏻) and sign in with that email to accept the invite.'; } + else { const hv=this.viewFromHash(); if(hv) this.go(hv, true); + else { this.go('start', true); history.replaceState({view:'start'},'','/app#start'); } } // no deep link → Getting started is the default landing + return; } // GitHub/email session + if(this.token){ await this.loadAll(); this.initIntercom(); // token mode: no email/hash → anonymous visitor chat + // A token holder on a /catalog URL is a member, not a public visitor: same treatment as the + // session branch. Without this the route falls through to viewFromHash() — which is null for + // a path route — and a shelf link lands on Getting started instead. + if(catRoute){ this.publicCatalog=false; + if(catRoute.slug) this.openPlatform(catRoute.slug, true); else this.go('connections', true); + return; } + const pfTok=this.platformFromHash(); + if(mkRoute) this.openProvider(mkRoute, true); else if(pfTok) this.openPlatform(pfTok, true); else if(route) this.openDetail(route.kind, route.name, true); + else { const hv=this.viewFromHash(); if(hv) this.go(hv, true); + else { this.go('start', true); history.replaceState({view:'start'},'','/app#start'); } } + return; } // token-in-browser fallback + // A marketplace link is only meaningful to a member, so a logged-out visitor gets the front + // door rather than the share gate (which exists to accept skill/tool shares). + // Logged out on a /catalog URL: render the catalog anyway. Its API is unauthenticated, so this + // is the SAME marketplace UI a member sees, minus what needs a session — not a second build of + // it. This is the branch that makes the catalog crawlable. + if(catRoute){ + // loadConnections, not just loadPlatforms: /oauth/providers is an open endpoint, so the + // Platform tab (the provider shelf) fills for a signed-out visitor too — only /connections + // needs a session, and its failure is caught. Without this the tab reads "Platform 0" and + // renders blank in an incognito window. + if(catRoute.slug) this.openPlatform(catRoute.slug, true); else { this.view='connections'; this.loadConnections(); } + return; } + if(!inv && !linkOrg && !route && !qs.get('invite_expired') && !ref && !oauthSignin){ location.replace('/'); return; } // logged-out plain visit → the marketing landing owns the front door. `ref` is a use-case page's CTA (/app?ref=p1), so keep that attribution while opening sign-in in place. + if(route){ this.shareGate=route; this.demo.signin=true; } // shared link while logged out: the focused gate (no sandbox mint, no tour); after sign-in the boot lands on it (email verify reloads in place; OAuth restores via the treg-next stash) + else this.demo.signin=true; // OAuth returns, use-case CTA arrivals (?ref=) and every other logged-out flow open sign-in; nothing mints a sandbox any more + if(inv){ this.invitePrefill=inv; this.emailInput=inv; this.emailStage=false; this.demo.signin=true; } // legacy code link while logged out: prefill + open sign-in; the invite auto-accepts after login (maybeOnboard) + } diff --git a/frontend/src/state/catalog.js b/frontend/src/state/catalog.js new file mode 100644 index 00000000..74c03dba --- /dev/null +++ b/frontend/src/state/catalog.js @@ -0,0 +1,329 @@ + +export default { +// ---- endpoint catalog (/catalog/*) ---- + // The catalog is additive: every failure here leaves the marketplace exactly as it was, so a + // server that predates these routes shows no platform shelf rather than an error. + async loadPlatforms(){ + if(this.plats.loaded || this.plats.loading) return; + this.plats.loading=true; + try{ const d=await this.api('/catalog/platforms'); this.plats.list=(d&&d.platforms)||[]; this.plats.loaded=true; } + catch(e){ this.plats.list=[]; } + finally{ this.plats.loading=false; } }, +// Platform pages are hash routes (/app#platform/): unlike /app/marketplace/ there + // is no server route to serve the SPA on a hard reload of a /app/platforms/ path. + platformFromHash(){ const m=/^#platform\/(.+)$/.exec(location.hash||''); return m?decodeURIComponent(m[1]):null; }, +// The PUBLIC catalog lives at real paths (/catalog, /catalog/), not hash routes, because + // a hash is never a distinct URL to a crawler and the whole catalog was therefore unindexable. + // Same Vue views as the signed-in marketplace — this is one UI, not a second implementation. + catalogFromPath(p){ + if(p==='/catalog' || p==='/catalog/') return {view:'connections', slug:null}; + const m=/^\/catalog\/([^/]+)\/?$/.exec(p||''); + return m ? {view:'platform', slug:decodeURIComponent(m[1])} : null; + }, +// A plain view hash (#usage, #orgs, …) so deep links land on the right pane on a FRESH load, + // not only via back/forward. '#billing' is the name 402 bodies and emails use for "add funds"; + // billing lives on the Team pane's Billing tab, so it aliases there. + viewFromHash(){ let v=(location.hash||'').replace('#',''); + if(v==='billing'){ this.orgTab='billing'; v='orgs'; } + return ['tools','orgs','activity','usage','admin','help','secrets','start','connections','referrals'].includes(v)?v:null; }, +openPlatform(slug, fromPop){ this.resetConfirms(); + this.detail=null; this.platSlug=slug; this.view='platform'; this.platOpen={}; this.epOpen={}; this.epTab={}; this.platEx={}; this.platActionsOpen=false; + this.platClearFilters(); this.platCopied=''; + // A public visitor stays on the indexable /catalog/ URL; a signed-in one keeps the + // in-app hash route. Same view either way — only the address bar differs. + if(!fromPop) history.pushState({platform:slug}, '', this.publicCatalog + ? '/catalog/'+encodeURIComponent(slug) + : '/app#platform/'+encodeURIComponent(slug)); + this.loadPlatforms(); // the header's provider links need the list + // Connected/not-connected is a member fact and the endpoint needs a session; a public + // visitor has none, so skip it rather than fire a guaranteed 401 on every shelf view. + if(!this.publicCatalog && !this.providers.length) this.loadConnections(); + this.loadPlatform(); + window.scrollTo(0,0); }, +async loadPlatform(){ if(!this.platSlug) return; + this.platErr=''; this.platLoading=true; this.platData=null; + // include_hidden=1: pull the account/utility endpoints too. They render behind a per-section + // "N management endpoints" expander rather than in the main ledger — the page decides that, + // client-side, off each endpoint's `kind` (see platRowsAll / platLedger). + try{ this.platData=await this.api('/catalog/platforms/'+encodeURIComponent(this.platSlug)+'?include_hidden=1'); } + catch(e){ this.platErr = e.status===404 + ? 'No catalog for this platform on this server yet.' + : 'Could not load the endpoint catalog'+(e.detail?': '+e.detail:'.'); } + finally{ this.platLoading=false; } }, +// Tile furniture. Catalog labels carry a parenthetical or an em-dash gloss ("Google Search + // (SERPs, keyword data)") that reads as noise under a logo — the tile shows the name, the + // title attribute keeps the whole thing. + platShort(label){ return String(label||'').split(' — ')[0].split(' (')[0].trim(); }, +platInitial(pl){ return (this.platShort(pl.label)||pl.slug||'?').slice(0,1).toUpperCase(); }, +// Deterministic hue from the slug: an undrawn platform keeps the same colour across reloads + // and differs from its neighbours, with no colour table to maintain. + platTileBg(slug){ let h=0; const s=String(slug||''); + for(let i=0;i=1) return n.toFixed(2).replace(/0+$/,'').replace(/\.$/,''); + const s=n.toPrecision(2); + if(s.indexOf('e')>=0) return n.toFixed(8).replace(/0+$/,'').replace(/\.$/,''); // < 1e-6 + return s.replace(/0+$/,'').replace(/\.$/,''); }, +priceUnit(type){ return {per_call:'call', per_success:'success', per_result:'result', quota_rows:'row'}[type] + || String(type||'').replace(/_/g,' '); }, +// "¥0.10" — the provider's own figure, shown next to the converted one, never instead of it. + nativeAmount(c){ + if(!c || c.currency==='USD' || c.value==null) return ''; + const n=Number(c.value); + // Counts read as counts ("5 credits", "40 API units"), money keeps its cents (¥0.10). + if(c.currency==='credit' || c.currency==='unit'){ + const count=Number.isInteger(n)?String(n):n.toFixed(2); + let u = c.currency==='credit' ? 'credit' : (c.unit||'unit').replace(/_/g,' ').replace(/\bapi\b/,'API'); + if(n!==1 && !/s$/.test(u)) u+='s'; + return count+' '+u; } + const sym={CNY:'¥'}[c.currency]; + const num=n>=0.01 ? n.toFixed(2) : n.toFixed(6).replace(/0+$/,'').replace(/\.$/,''); + return sym ? sym+num : num+' '+c.currency; }, +costNative(c){ return c && c.display_unit ? '' : this.nativeAmount(c); }, +// The card's starting price. `price_from` arrives as null OR as an empty object, and an empty + // one must read as "no price on the row" — otherwise it short-circuits the OAuth-free branch + // below and an OAuth-only platform silently loses its "free with your account". + platPrice(pl){ + const raw=pl && pl.price_from; + const pf=(raw && Object.keys(raw).length) ? raw : null; + const paid=(pf && typeof pf.usd==='number') + ? '$'+this.usdNum(typeof pf.display_usd==='number' ? pf.display_usd : pf.usd)+' / '+ + (pf.display_unit || this.priceUnit(pf.type)) + : null; + if(pf && pf.type==='free') return {free:true, text:'free with your account'}; + // An OAuth integration among the providers means the floor price is $0: the account you + // connect IS the licence. Metered providers may serve the same platform (that rate moves to + // the tooltip) — but "from" is a floor, and the floor is free. + const provs=(pl && pl.providers)||[]; + const hasOauth=provs.some(s=>{ + const p=this.providers.find(x=>x.service===s); + return p && p.auth_kind==='oauth' && !p.metered; }); + if(hasOauth) return {free:true, text:'free with your account', paid}; + if(paid) return {free:false, text:paid, native:this.nativeAmount(pf)}; + return null; }, +// priced with no published number, or key-auth with no rate — unknown, not free + platPriceTitle(pl){ + const p=this.platPrice(pl); if(!p) return ''; + if(p.free) return 'Connect the account and the calls cost nothing extra'+ + (p.paid ? ' — without it, metered providers serve this from '+p.paid : ''); + const pf=pl.price_from||{}; + return ['The cheapest published rate across this platform’s endpoints', + p.native ? 'billed as '+p.native+' / '+this.priceUnit(pf.type)+', converted at the catalog’s FX rate' : '', + pf.note].filter(Boolean).join(' — '); }, +endpointAccessLabel(e){ + if(e.kind==='routed') return 'Routed platform call'; + const p=this.providers.find(p=>p.service===e.provider) + || (this.platData&&this.platData.providers||{})[e.provider] || {}; + if(p.auth_kind==='oauth') return p.metered ? 'OAuth · metered' : 'OAuth connection'; + if(p.auth_kind==='key') return e.platform_eligible ? 'Platform + BYOK' : 'BYOK only'; + return e.platform_eligible ? 'Platform access' : 'Own connection required'; + }, +catConnected(service){ return !!this.connCount[service]; }, +endpointAuthMethods(e){ return [...new Set([e&&e.authorization_method, + ...((e&&e.authorization_methods)||[]), ...Object.keys((e&&e.authorization_paths)||{})].filter(Boolean))]; }, +endpointMethodSpec(e){ + const methods=this.endpointAuthMethods(e); if(methods.length!==1) return null; + const p=this.providers.find(x=>x.service===e.provider); + return p && (p.authorization_methods||[]).find(m=>m.name===methods[0]); + }, +catEndpointConnected(e){ + const methods=this.endpointAuthMethods(e); + if(!methods.length) return this.catConnected(e.provider); + return (this.connections||[]).some(c=>c.provider===e.provider && methods.includes(c.authorization_method)); + }, +endpointConnectLabel(e){ + const method=this.endpointMethodSpec(e); + return (method&&method.action_label)||('Connect '+(e.provider_display||e.provider)); + }, +// Whether a REGISTRY connection to this provider is still metered. Connecting usually ends the + // billing question — the account you connect is the licence — but a `metered` provider bills + // treg's own app per use (X since Feb 2026), so those calls are debited from the team balance + // no matter whose account made them. `/providers` carries the flag, and the deployment's + // TREG_OAUTH_BILLED_PROVIDERS decides whether it is set, so the price a browser shows follows + // the kill switch instead of hard-coding today's answer. + catMetered(service){ + const p=this.providers.find(x=>x.service===service); return !!(p && p.metered); }, +// A platform is callable today if ANY provider serving it is connected — the card is browsing, + // not routing, so which one it is stays a question for the platform page. + platConnected(pl){ return ((pl&&pl.providers)||[]).some(s=>this.catConnected(s)); }, +platConnNames(pl){ return ((pl&&pl.providers)||[]).filter(s=>this.catConnected(s)).map(s=>this.provName(s)).join(', '); }, +// The endpoint's inputs, grouped by where they go. Query first, then path, then body: the order + // you fill them in for the common GET, and the order the provider's own docs tend to use. + paramSections(e){ + const i=(e&&e.input)||{}, out=[]; + const add=(key,label,map,type)=>{ + const names=Object.keys(map||{}); + if(names.length) out.push({key, label, type, rows:names.map(n=>({name:n, ...map[n]}))}); }; + add('query','Query', i.queryParams); + add('path','Path', i.pathParams); + add('body','Body', i.body, i.bodyType); + return out; }, +// Examples arrive as real JSON values, so an array or object has to be stringified rather than + // rendered as "[object Object]". + fmtExample(v){ return (typeof v==='object') ? JSON.stringify(v) : String(v); }, +// The comparable price, in USD, straight from the server's computed `usd` — the FX table lives + // in the catalog so a rate refresh re-prices every screen at once, and the dashboard cannot + // drift from the CLI by carrying its own constant. Anything with no published number, and a row + // quota (which is not a price at all), can never win "cheapest": showing "from —" would be + // worse than showing the cheapest thing we do know the price of. + costUsd(c){ + if(!c || !c.type) return null; + if(c.type==='free') return 0; + if(c.type==='quota_rows') return null; + return typeof c.usd==='number' ? c.usd : null; }, +// An OAuth endpoint you have already connected costs nothing MORE to call — the account is the + // licence — so a connected own_account row is the free path, and usually the right answer. + capFree(e){ return this.catEndpointConnected(e) && !this.catMetered(e.provider) + && (e.scope==='own_account' || (e.cost&&e.cost.type==='free')); }, +capCheapest(eps){ + let best=null; + for(const e of (eps||[])){ + const free=this.capFree(e), n=free?0:this.costUsd(e.cost); + if(n==null) continue; + if(best===null || nthis.platShort(p.label)); + if(rest.length<=2) return 'See '+names.join(' and '); + return 'See '+names.join(', ')+', and '+(rest.length-2)+' more'; }, +// Cut at the last word boundary before the limit, so a clipped line ends on a word rather than + // mid-token. A curated `name` is short by construction, so this is a no-op on those rows. + clip(text, n){ const s=String(text||'').trim(); if(s.length<=n) return s; + const cut=s.slice(0,n); const sp=cut.lastIndexOf(' '); + return (sp>n*0.6 ? cut.slice(0,sp) : cut).replace(/[\s,;:.—-]+$/,'')+'…'; }, +platClearFilters(){ this.platDomain=''; this.platQ=''; this.platVerifiedOnly=false; }, +// Every ledger row expands, merged or not: the row says what it does, the expansion says how to + // call it, and which of the two a visitor needs is not something the row shape can decide. + toggleRow(r){ this.platOpen[r.key] = !this.platOpen[r.key]; }, +// The per-section "N management endpoints" expander: reveals the account/utility rows folded + // out of the browse ledger (see platLedger). + // Level two: a provider sub-row under a merged row opens its own instruction. Single rows have + // nothing to compare and skip this level entirely. + toggleEp(e){ this.epOpen[e.id] = !this.epOpen[e.id]; }, +// One pill per provider on a merged row, carrying that provider's CHEAPEST priced endpoint — + // the number a comparison turns on — and a ✓ if any of its endpoints is verified. + provPills(eps){ + const by=new Map(); + for(const e of eps){ + const cur=by.get(e.provider), n=this.costUsd(e.cost); + if(!cur) by.set(e.provider, {name:e.provider_display||e.provider, cost:e.cost, n, endpoint:e, verified:!!e.verified}); + else { cur.verified = cur.verified || !!e.verified; + if(n!=null && (cur.n==null || n(a.n==null)-(b.n==null) || (a.n-b.n) || (b.verified-a.verified)) + .map(p=>({name:p.name, verified:p.verified, price:p.endpoint.platform_eligible ? this.pillPrice(p.cost) : this.endpointAccessLabel(p.endpoint)})); }, +// A pill prices an endpoint only when there IS a price: a published number, or "free". A + // credit-metered or dashboard-only rate has no number to show, and saying so at length is what + // broke the row — the pill just drops it, and the sub-row below carries the full story. + pillPrice(c){ + if(!c || !c.type) return ''; + if(c.type==='free') return 'free'; + if(c.type==='quota_rows'){ const l=this.costLabel(c); return l.length<=8 ? l : ''; } + return typeof c.usd==='number' ? this.costLabel(c) : ''; }, +// A price small enough for a collapsed line: "$0.024/call", "2 rows", "free", "credit-priced". + // The long form ("per success · price in provider dashboard") is true but belongs in the + // expanded detail — inline it wraps a row onto three lines, which is what broke the merged rows. + costShort(c){ + if(!c || !c.type) return '—'; + if(c.type==='free') return 'free'; + // A price table has no scalar value but does have a range (and, for video, a per-second + // rate); only a truly unpublished number is "credit-priced". + if(c.value==null && !(c.table && typeof c.usd==='number')) return 'credit-priced'; + return this.costLabel(c); }, +// `providers` needs a session. Publicly, "is this a provider treg knows" is answered by the + // open catalog response instead — otherwise the whole action chain collapses and a signed-out + // reader sees no way to bring their own key. `mkOauth` stays false without the registry, so the + // public branch offers BYOK (which is true for every provider) rather than guessing Connect. + mkKnown(service){ return this.providers.some(p=>p.service===service) + || (this.publicCatalog && !!((this.platData&&this.platData.providers||{})[service])); }, +mkOauth(service){ const p=this.providers.find(x=>x.service===service); return !!p && p.auth_kind==='oauth'; }, +// `providers` comes from /connections, which needs a session. On a public catalog URL there is + // none, so fall back to the display name the OPEN catalog response already carries — otherwise + // every provider on a public shelf would render as its bare slug. + provName(service){ const p=this.providers.find(x=>x.service===service); if(p) return p.display_name; + const c=(this.platData&&this.platData.providers||{})[service]; + return (c&&c.display_name) || service; }, +// Provider-wide facts, served once per provider on the platform response rather than copied + // onto every row. + provFact(service, key){ const p=(this.platData&&this.platData.providers||{})[service]; return (p&&p[key])||''; }, +// The sentences an expanded row needs and a table cell can't hold: how it meters, what it + // rate-limits, where the rate card is. Only ones we actually have — an empty list hides the box. + epFacts(e){ + const out=[], c=e.cost; + // Where a credit-metered price actually lives. The chip can only say "credit-priced"; this is + // the sentence that tells you the unit and where to read the rate. + if(c && c.type!=='free' && c.value==null) + out.push('Billed per '+this.priceUnit(c.type)+' — the provider does not publish the rate, so ' + +'the number is in your plan on their dashboard.'); + if(c && c.note) out.push(c.note); + const limits=this.provFact(e.provider,'limits'); if(limits) out.push('Limits: '+limits); + const pricing=this.provFact(e.provider,'pricing_url'); if(pricing) out.push('Rate card: '+pricing); + return out; }, +async copyCall(e){ + if(await this.toClipboard(e.call_template||'')){ + this.platCopied=e.id; setTimeout(()=>{ if(this.platCopied===e.id) this.platCopied=''; },1500); } }, +// Which pane of an endpoint's detail is showing. What you SEND and what comes BACK are two + // documents; stacking them made the expansion a page you scrolled rather than read. + epTabOf(e){ const t=this.epTab[e.id]; + return (t==='res' && !e.has_example) ? 'req' : (t || 'req'); }, +setEpTab(e, tab){ + if(tab==='res' && !e.has_example) return; // no such tab; nothing to show + this.epTab[e.id]=tab; + if(tab==='res') this.loadExample(e); }, +// Fetched when the response tab is FIRST opened, never with the page: a platform can carry + // hundreds of endpoints and the captured responses are the heaviest thing in the catalog. + async loadExample(e){ + if(this.platEx[e.id]) return; // already loaded, loading, or failed + this.platEx[e.id]={loading:true, err:'', text:''}; + const slot=this.platEx[e.id]; + try{ const d=await this.api('/catalog/examples/'+encodeURIComponent(e.id)); slot.text=JSON.stringify(d,null,2); } + catch(err){ slot.err = err.status===404 ? 'No example was captured for this endpoint.' : 'Could not load the example response.'; } + finally{ slot.loading=false; } }, +// Price, compact enough to sit in a table cell, and always in USD: "$0.015/success", "1 row", + // "free", "—". The provider's own figure follows as a muted suffix (see `costNative`). + costLabel(c){ + if(!c || !c.type) return '—'; + if(c.display_unit && typeof c.display_usd==='number') return (c.display_prefix||'')+'$'+this.usdNum(c.display_usd)+(c.display_suffix||'')+'/'+c.display_unit; + if(c.type==='free') return 'free'; + // Known billing unit, unpublished number: say which, and where the number lives. A bare + // "per success" reads as free, and "—" hides that we do know how it's metered. + if(c.value==null && !c.table){ // a price table has no scalar `value`; its range renders below + const per={per_call:'call', per_success:'success', per_result:'result'}[c.type]; + return per ? 'per '+per+' · price in provider dashboard' : '—'; } + // Rows convert like any meter once fx carries a rate — dollars first, native as the fallback. + if(c.type==='quota_rows' && typeof c.usd!=='number'){ + const n=Number(c.value); return n+' row'+(n===1?'':'s')+'/call'; } + // A published number the FX table can't convert (an unknown currency) still beats silence — + // fall back to the native figure rather than dropping the price entirely. + if(typeof c.usd!=='number') return this.nativeAmount(c)+'/'+this.priceUnit(c.type); + const unit=(c.type==='quota_rows'?'call':this.priceUnit(c.type)); + // A duration-priced table (video models) is quoted per second, the way the model is sold. + if(c.rate_unit && typeof c.rate_usd==='number'){ + const lo=c.rate_usd_min, hi=c.rate_usd; + return (typeof lo==='number' && lop.service===this.mkService)||null; }, +mkConns(){ return this.connections.filter(c=>c.provider===this.mkService); }, +mkNeedsCred(){ return this.mkConns.filter(c=>c.extra_credential_note); }, +// Which capabilities ANY account here already holds — a page-level "you have this" summary, + // since the permission list describes the integration, not one account. + mkGranted(){ const s=new Set(); for(const c of this.mkConns) for(const cap of (c.capabilities||[])) s.add(cap); return s; }, +// ---- endpoint catalog ---- + // Platform tiles grouped by the category the catalog assigns each platform. "Other" (the + // taxonomy's bucket for things like `account`) gets no tile — those capabilities are only + // meaningful inside a platform page, not as a destination of their own. + // The canonical reading order. It is only an ORDER, not the list: the categories themselves come + // from the data, so a category the catalog invents tomorrow still gets a shelf and a tab (sorted + // to the end) instead of vanishing. A hint is optional for the same reason. + platCategories(){ + // The founder's reading order. The answer engines (ChatGPT, Perplexity, Gemini…) used to be + // their own shelf; they are SEO now, unfeatured, so they surface inside its "see more" row + // rather than as a category of six. The list is a lookup, not a schema — a category the + // catalog invents tomorrow still gets a shelf, sorted to the end. + const order=['Enrichment','SEO/AEO','Social','Advertising','E-commerce','Reviews & Apps','AI generation','Community']; + const hints={ + 'AI generation':'video, image and voice models, the same model over several routes priced side by side', + 'SEO/AEO':'rankings, keywords and backlinks — what search engines know, and what the answer engines say', + 'Social':'posts, profiles and comments, straight from the feeds', + 'Enrichment':'people and company records, resolved from an email or a domain', + 'Advertising':'ad libraries and creator marketplaces — what is being promoted, and for how much', + 'E-commerce':'listings, prices and sellers across the marketplaces', + 'Reviews & Apps':'app stores and review sites — what people rate, and what they say', + 'Community':'forums and chat, where people answer each other', + }; + const by={}; + for(const pl of this.plats.list){ const c=pl.category||'Other'; if(c==='Other') continue; (by[c]=by[c]||[]).push(pl); } + const known=order.filter(c=>by[c]); + const rest=Object.keys(by).filter(c=>!order.includes(c)).sort(); + return known.concat(rest).map(c=>({category:c, hint:hints[c]||'', items:by[c]})); + }, +mkTabs(){ + const out=[{key:'all', label:'All', n:this.platCategories.reduce((a,g)=>a+g.items.length,0)}]; + for(const g of this.platCategories) out.push({key:g.category, label:g.category, n:g.items.length}); + out.push({key:'platform', label:'Platform', n:this.providers.length}); + return out; + }, +// A build without /catalog has no tiles to show, so it falls back to the integration shelves + // rather than opening on an empty tab. + mkTabActive(){ return this.platCategories.length ? this.mkTab : 'platform'; }, +// Shelves, with the long ones cut down to their featured tiles. A category of 14 platforms is a + // wall you scroll past rather than read, so past PLAT_SHELF_MAX only the catalog's `featured` + // ranks get a full tile and the tail collapses into one "See X, Y, and N more" row. Rank first, + // then endpoint count — the tail sorts by size alone, which is the only signal it has left. + platCatGroups(){ + const groups = this.mkTabActive==='all' ? this.platCategories + : this.platCategories.filter(g=>g.category===this.mkTabActive); + // The top-nav search reaches here too: with a query, every match shows (no featured collapse — + // a hit hidden behind "N more" reads as no hit) and empty shelves drop away. + const q=this.q.trim().toLowerCase(); + if(q){ + const hit=p=>((p.label||'')+' '+(p.slug||'')+' '+(p.providers||[]).join(' ')).toLowerCase().includes(q); + return groups.map(g=>{ const items=g.items.filter(hit) + .sort((a,b)=>(b.endpoints||0)-(a.endpoints||0)); + return {...g, items, rest:[], total:items.length}; }).filter(g=>g.items.length); + } + return groups.map(g=>{ + const items=[...g.items].sort((a,b)=> + (a.featured==null?1e9:a.featured)-(b.featured==null?1e9:b.featured) || (b.endpoints||0)-(a.endpoints||0)); + const feat=items.filter(p=>p.featured!=null); + // Nothing ranked means nothing to feature — showing an empty shelf over a "more" row would + // hide the whole category behind a click. + // `total` stays the whole category so the shelf's count matches its tab — a header reading + // "SEO 5" under a tab reading "SEO 10" looks like tiles went missing. + if(items.length<=8 || !feat.length || this.platShelfOpen[g.category]) return {...g, items, rest:[], total:items.length}; + return {...g, items:feat, rest:items.filter(p=>p.featured==null), total:items.length}; + }); + }, +mkPlatforms(){ return this.plats.list.filter(pl=>(pl.providers||[]).includes(this.mkService)); }, +platRow(){ return this.plats.list.find(pl=>pl.slug===this.platSlug)||null; }, +platLabel(){ return (this.platData&&this.platData.platform&&this.platData.platform.label) + || (this.platRow&&this.platRow.label) || this.platSlug || 'Platform'; }, +platProviders(){ // providers with endpoints here, in catalog order + const seen=[]; for(const g of (this.platData&&this.platData.capabilities||[])) for(const e of (g.endpoints||[])) if(!seen.includes(e.provider)) seen.push(e.provider); + for(const e of (this.platData&&this.platData.extended||[])) if(!seen.includes(e.provider)) seen.push(e.provider); + // Navigation is a fact of THIS platform response, not of the separately-loaded connection + // registry. Depending on that async registry made the entire row flicker away locally. `treg` + // is the synthetic router, not a provider page a person can open. + return seen.filter(s=>s!=='treg'); }, +// ---- the ledger ---- + // Sections, their order and the merged/single split are all decided by the server (see + // catalog_store.domain_rows) so the CLI, the API and this page can't disagree about what the + // platform contains. Everything below is presentation the server has no business knowing: + // the price label, whether the row is callable TODAY (which depends on who is logged in), and + // the haystack the filter box searches. + platRowsAll(){ + if(!this.platData) return []; + const out=[]; + for(const sec of (this.platData.domains||[])) for(const r of (sec.rows||[])){ + const eps=r.endpoints||[]; if(!eps.length) continue; + const cheapest=this.capCheapest(eps); + const provs=[...new Set(eps.map(e=>e.provider_display||e.provider))]; + const pills=this.provPills(eps); + out.push({...r, domain:sec.domain, endpoints:eps, + // What the row SHOWS. The server already picked `name` over `summary` where a curated + // name exists; this is the guard for the rows where one doesn't yet — a DataForSEO + // summary is documentation prose and would render a paragraph in a table cell. The full + // text is never lost: the expansion shows it whole. + title:this.clip(r.description, 90), + // Who serves it, as plain text — the collapsed row names the providers and prices none + // of them. Three names is what fits on one line; the rest become a count, with the whole + // list in the title attribute. + // Three pills and a +N. Never four, never a wrap: the strip is what decides whether a + // merged row is one line, and one line is the rule. + pills:pills.slice(0,3), pillsMore:Math.max(0, pills.length-3), + pillsMoreTitle:pills.slice(3).map(x=>x.name).join(', '), + // Providers AND endpoints, always both — one provider can offer the same job three ways, + // so the two numbers differ and the endpoint count standing in for the provider count read + // "6 providers" on a 3-provider row. Both live in the cell's tooltip rather than on a + // second line, because a second line is exactly what made these rows look broken. + provN:provs.length, + provTitle:provs.length+' provider'+(provs.length===1?'':'s')+' · '+eps.length+ + ' endpoint'+(eps.length===1?'':'s')+' — '+provs.join(', '), + // the capability is the join key when there is one; an unmapped row is its endpoint + key:(r.capability||'')+'|'+eps[0].id, + // a "management" row is one whose every endpoint is plumbing (account/utility) — those + // fold behind a per-section expander instead of sitting in the main ledger. + mgmt: eps.every(e=>e.kind==='account'||e.kind==='utility'), + // "from $0.001" on a merged row, the flat label on a single one — and never "from free", + // which reads as a hedge on the one price that needs none. + price: cheapest ? ((r.kind==='merged'&&eps.length>1&&cheapest.n>0?'from ':'')+cheapest.label) + // No dollar figure anywhere, but a cost note: the credit-metered providers + // (Apollo, PDL, Hunter…) whose price IS documented, in their own units. + : (eps.some(e=>e.cost&&e.cost.note) ? 'see provider' : '—'), + // the provider's own figure, so nobody has to wonder whether we invented the converted one + priceNative: cheapest ? cheapest.native : '', + priceTitle: r.kind==='merged' + ? 'The cheapest of the '+eps.length+' providers on this row — open it for each one' + : this.costTitle(eps[0].cost), + verified: eps.some(e=>!!e.verified), + ready: eps.some(e=>this.catEndpointConnected(e)), + hay: (r.description+' '+r.domain+' '+(r.capability||'')+' '+eps.map(e=> + e.provider+' '+(e.provider_display||'')+' '+(e.name||'')+' '+e.path+' '+e.summary).join(' ')).toLowerCase()}); + } + return out; }, +// The text box and the verified checkbox narrow the row list; the domain chips then narrow it + // again. Splitting it here is what lets each chip carry the count it would actually show. + platRowsPreDomain(){ + const q=this.platQ.trim().toLowerCase(); + return this.platRowsAll.filter(r=>(!this.platVerifiedOnly||r.verified) && (!q||r.hay.includes(q))); }, +platDomainTabs(){ + // Only the browse surface counts on the chips: management rows (account/utility) live behind + // a per-section expander, not in the domain tally. + const n={}; for(const r of this.platRowsPreDomain) if(!r.mgmt) n[r.domain]=(n[r.domain]||0)+1; + // The server's order (busiest first, "other" last) is the one the chips keep — a chip bar + // that reshuffles as you type is unusable. + return (this.platData&&this.platData.domains||[]).filter(s=>n[s.domain]).map(s=>({domain:s.domain, n:n[s.domain]})); }, +platLedger(){ + // Browse rows (data/action) make the domain sections; a section with no visible row does not + // render AT ALL. Filing management rows per-domain conjured sections that existed only + // because a hidden endpoint carried that capability id — CAMPAIGNS 0, SCHOOL 0, TITLE 0. + const vis={}; + for(const r of this.platRowsPreDomain){ + if(r.mgmt || (this.platDomain && r.domain!==this.platDomain)) continue; + (vis[r.domain]=vis[r.domain]||[]).push(r); } + const out=(this.platData&&this.platData.domains||[]).filter(s=>vis[s.domain]) + .map(s=>({domain:s.domain, rows:vis[s.domain]})); + // ...and every management endpoint on the platform lands in ONE collapsed section at the + // bottom, its domain ignored: account/utility routes are the provider's own plumbing, and + // which capability id they happen to carry is not a fact worth a heading. + const acts=this.platActionRows; + if(acts.length) out.push({domain:'Actions', actions:true, count:acts.length, + rows:this.platActionsOpen?acts:[]}); + return out; }, +// What "All" counts: the same population the domain chips add up to. Counting the management + // rows here made the All chip disagree with both the chips beside it and the stat line under it. + platBrowseCount(){ return this.platRowsPreDomain.filter(r=>!r.mgmt).length; }, +// Platform-wide, so a domain chip (which is a browse axis) hides it rather than filtering it. + platActionRows(){ + return this.platDomain ? [] : this.platRowsPreDomain.filter(r=>r.mgmt); }, +// Two numbers, because a merged row stands for several endpoints — the browse surface only, so + // the header count never jumps when the management expander is opened. + platStats(){ + let rows=0, eps=0; + for(const r of this.platRowsPreDomain){ if(r.mgmt || (this.platDomain && r.domain!==this.platDomain)) continue; + rows++; eps+=r.endpoints.length; } + return {rows, eps}; }, +catalogDeny(){ const c=this.tForm.cli; return (c && this.catalogClis && this.catalogClis[c.bin]) || []; }, +catalogExtra(){ // catalog patterns not already in the own list — the union dedupes at run time, so showing both would double up + const own=new Set((this.tForm.cli&&this.tForm.cli.deny||[]).map(p=>p.trim())); return this.catalogDeny.filter(p=>!own.has(p)); }, +sortedInvites(){ // the clicked email link's team first, then newest-first (the API's order) + return [...this.pendingInvites].sort((a,b)=>(b.org_id===this.inviteLinkOrg?1:0)-(a.org_id===this.inviteLinkOrg?1:0)); }, +selectedInvites(){ return this.sortedInvites.filter(i=>this.inviteSel[i.id]); }, +inviteFirstRun(){ return !this.myOrgs.some(o=>!this.isPersonal(o)); }, +// no real team yet → decline offers create-team + activityRows(){ // proxy calls + server CLI runs, one time-sorted feed (ISO strings compare fine) + // Local runs now arrive via /runs (where:'local'); drop them from the calls feed so they aren't double-counted. + // Cost precedence: what was CHARGED (settle amount; 0 on a release — the estimate alone would + // over-report a refunded call as spend), else observed, else the estimate (old rows / own-key). + // A metered async task (video/image generation) is the one row whose charge is decided AFTER + // the call returned: the server nulls the charge while the task is pending, so show the hold. + const calls=(this.calls||[]).filter(c=>c.kind!=='local_run').map(c=>({kind:'call', id:c.id, created_at:c.created_at, user_email:c.user_email, client:c.client, tool:c.tool_name, action:c.method, status:c.status_code, ok:c.status_code<400, + task:c.async_task||null, held:!!(c.async_task&&c.async_task.status==='pending'), + cost:(c.async_task&&c.async_task.status==='pending')?c.async_task.reserved_micro:(c.cost_charged_micro!=null?c.cost_charged_micro:(c.cost_observed_micro!=null?c.cost_observed_micro:c.cost_estimated_micro)), tier:c.credential_tier, tags:c.tags, + has_result:!!c.has_result, endpoint_id:c.endpoint_id, call_ref:c.call_ref, path:c.path, cached:c.cached, api_key_id:c.api_key_id, api_key_name:c.api_key_name, api_key_prefix:c.api_key_prefix})); + const runs=(this.runs||[]).map(r=>({kind:'run', where:r.where, id:r.id, created_at:r.created_at, user_email:r.user_email, client:r.client, tool:r.tool, action:(r.argv||[]).join(' ').slice(0,48)||'-', status:(r.where==='local'?'local run':'exit '+r.exit_code), ok:(r.where==='local'?true:r.exit_code===0), api_key_id:r.api_key_id, api_key_name:r.api_key_name, api_key_prefix:r.api_key_prefix})); + return calls.concat(runs).sort((a,b)=>a.created_ata.kind==='call').length; }, +activityCachedCount(){ return this.activityRows.filter(a=>a.kind==='call' && a.cached).length; }, +apiKeyGroups(){ + const groups=[], humanByIdentity={}, standaloneAgents={}; + for(const row of this.apiKeys||[]){ + if(row.assigned_type!=='human') continue; + const identity=(row.identity||'').toLowerCase(); + let group=humanByIdentity[identity]; + if(!group){ group={identity:row.identity,name:row.assigned_name||row.identity,type:'human',rows:[]}; humanByIdentity[identity]=group; groups.push(group); } + group.rows.push(row); + } + for(const row of this.apiKeys||[]){ + if(row.assigned_type!=='agent') continue; + const owner=humanByIdentity[(row.created_by||'').toLowerCase()]; + if(owner){ owner.rows.push(row); continue; } + let group=standaloneAgents[row.identity]; + if(!group){ group={identity:row.identity,name:row.assigned_name||row.identity,type:'agent',rows:[]}; standaloneAgents[row.identity]=group; groups.push(group); } + group.rows.push(row); + } + return groups; + }, +activityShown(){ return this.actOkOnly ? this.activityRows.filter(a=>a.ok) : this.activityRows; }, +callBodyPretty(){ const t=this.callView&&this.callView.response&&this.callView.response.body_text; return t?this.pretty(t):''; }, +// JSON pretty-printed when it parses (a 2 MB parse is ~20 ms) + callBodyTruncated(){ return !this.callViewFull && this.callBodyPretty.length>262144; }, +// cap the RENDERED text at 256 KB unless asked for all of it + callBodyShown(){ const t=this.callBodyPretty; return this.callBodyTruncated ? t.slice(0,262144)+'\n… (truncated)' : t; }, +anyTagged(){ // hide the column entirely for teams that never send X-Treg-Meta + return (this.calls||[]).some(c=>c.tags && Object.keys(c.tags).length); + }, +// Try-it drawer: the filled query string + the three "how to run it" recipes (agent / CLI / API) + epTryAuthMethods(){ if(!this.epTry) return []; + return [...new Set([this.epTry.authorization_method, ...(this.epTry.authorization_methods||[]), + ...Object.keys(this.epTry.authorization_paths||{})].filter(Boolean))]; }, +epTryConnectedMethods(){ return this.epTryAuthMethods.filter(m=>{ + const a=this.epTryAccessByMethod[m]; return a && (a.tier==='tool'||a.tier==='credential'); }); }, +epTryShowAuthSelector(){ return this.epTryAuthMethods.length>1 && this.epTryConnectedMethods.length>1; }, +epTryDisplayPath(){ if(!this.epTry) return ''; + return (this.epTry.authorization_paths||{})[this.epTryAuthMethod]||this.epTry.path; }, +epTryVisibleParams(){ return (this.epTryParams||[]).filter(p=>this.epTryParamAllowed(p)); }, +epTryQuery(){ return this.epTryVisibleParams.filter(p=>p.value!=='' && p.value!=null) + .map(p=>encodeURIComponent(p.name)+'='+encodeURIComponent(p.value)).join('&'); }, +epTryShellBody(){ return "'"+String(this.epTryBody).replace(/'/g,"'\"'\"'")+"'"; }, +epTryCliCall(){ if(!this.epTry) return ''; + const args=this.epTryVisibleParams.filter(p=>p.value!=='' && p.value!=null) + .map(p=>`--query ${p.name}=${/\s/.test(String(p.value))?JSON.stringify(String(p.value)):p.value}`).join(' '); + const method=(this.epTry.method||'GET').toUpperCase(); + let s=`treg call ${this.epTry.id}${args?' '+args:''}`; + if(method!=='GET') s+=` --method ${method}`; + if(this.epTryAuthMethod) s+=` --authorization-method ${this.epTryAuthMethod}`; + if(method!=='GET' && this.epTryBody.trim()) s+=` --data ${this.epTryShellBody}`; + return s; }, +epTryCurl(){ if(!this.epTry) return ''; + const q=this.epTryQuery; const url=`${this.proxy}/call/${this.epTry.id}${q?'?'+q:''}`; + const tok=this.myToken||'$TREG_TOKEN', method=(this.epTry.method||'GET').toUpperCase(); + let s=`curl -X ${method} "${url}" \\\n -H "X-Treg-Token: ${tok}"`; + if(this.sessionMode && this.activeSlugNow) s+=` \\\n -H "X-Treg-Org: ${this.activeSlugNow}"`; // minted identity token needs the org header + if(this.epTryAuthMethod) s+=` \\\n -H "X-Treg-Authorization-Method: ${this.epTryAuthMethod}"`; + if(method!=='GET' && this.epTryBody.trim()) s+=` \\\n -H "Content-Type: application/json" \\\n --data ${this.epTryShellBody}`; + return s; }, +// token + team embedded HERE ONLY (a copy-and-run-now context) — the setup line elsewhere stays clean + epTrySetupLine(){ const S=this.activeSlugNow||'', T=this.myToken||''; + return `set up treg — ${this.proxy}/llms.txt with team ${S} token: ${T}`; }, +epTryAgentUse(){ if(!this.epTry) return ''; + const what=this.epTry.summary ? this.epTry.summary.replace(/\.$/,'') : this.epTry.id; + const auth=this.epTryAuthMethod ? ` Use authorization_method=${this.epTryAuthMethod}.` : ''; + return `Use treg to call ${this.epTry.id} — ${what}.${auth}`; } +} diff --git a/frontend/src/state/connections.js b/frontend/src/state/connections.js new file mode 100644 index 00000000..eb0a049e --- /dev/null +++ b/frontend/src/state/connections.js @@ -0,0 +1,206 @@ + +export default { +// ---- connections (registry OAuth) ---- + async loadConnections(){ + this.connErr=''; + try{ + const [ps, cs]=await Promise.all([ + fetch('/oauth/providers').then(r=>r.json()).catch(()=>[]), + this.api('/connections').catch(()=>[]), + ]); + this.providers=ps||[]; this.connections=cs||[]; + }catch(e){ this.connErr=String(e.message||e); } + this.loadPlatforms(); // fire-and-forget: the catalog must never hold up the connect UI + }, +authorizationMethodSpec(providerName, methodName){ + const provider=(this.providers||[]).find(item=>item.service===providerName); + return provider && (provider.authorization_methods||[]).find(method=>method.name===methodName); + }, +authorizationMethodLabel(providerName, methodName){ + const method=this.authorizationMethodSpec(providerName,methodName); + return (method&&method.display_name)||methodName; + }, +async connectProvider(p, capability, conn){ + // Consent happens in a popup so the dashboard keeps its state; we poll for the result + // rather than depending on the popup being able to talk back to us. + this.connErr=''; this.connBusy=true; + try{ + const d=await this.api('/oauth/start',{method:'POST',headers:{'content-type':'application/json'}, + // connection_id present = reconnect/widen THAT account; absent = attach another one. + body:JSON.stringify({provider:p.service, capability, connection_id:conn?conn.id:null})}); + const w=window.open(d.consent_url,'treg-connect','width=560,height=720'); + if(!w){ this.connErr='Popup blocked — allow popups, or open: '+d.consent_url; this.connBusy=false; return; } + for(let i=0;i<150;i++){ + await new Promise(r=>setTimeout(r,2000)); + let s; try{ s=await this.api('/oauth/status/'+d.state); }catch(e){ continue; } + if(s.status==='done'){ + try{w.close();}catch(e){} + await this.loadConnections(); await this.loadAll(); this.connBusy=false; + // Connecting is only half the job — a connection with no target can't be used. If this + // provider has something to choose between, ask straight away rather than leaving the + // user staring at a row that looks finished but isn't. + const fresh=(this.connections||[]).find(c=>c.id===s.secret_id); + if(fresh && fresh.supports_discovery && !fresh.resource_ref) await this.openResources(fresh); + return; + } + if(s.status==='error'){ this.connErr='Connect failed: '+(s.detail||'unknown'); this.connBusy=false; return; } + } + this.connErr='Timed out waiting for authorization.'; + }catch(e){ this.connErr=String(e.message||e); } + this.connBusy=false; + }, +async enableCapability(c, cap){ + // Providers never backfill scopes onto an issued grant, so widening access means re-running + // consent for the bigger scope set. The callback rebinds the existing tool, so nothing + // downstream is rewired — the user just sees the new capability appear. + const p=this.providers.find(x=>x.service===c.provider); + if(!p){ this.connErr='Unknown provider for this connection: '+c.provider; return; } + await this.connectProvider(p, cap); + }, +async reconnect(c){ + // Re-run consent for the same provider. The callback rebinds the existing tool to the new + // credential, so nothing downstream has to be rewired. + const p=this.providers.find(x=>x.service===c.provider); + if(!p){ this.connErr='Unknown provider for this connection: '+c.provider; return; } + const method=(p.authorization_methods||[]).find(m=>m.name===c.authorization_method); + const options=method ? method.capabilities : (c.capabilities||p.capabilities||['read']); + const granted=new Set(c.capabilities||[]); + const cap=[...options].filter(option=>granted.has(option)).sort((a,b)=> + ((p.scope_detail[a]||[]).length-(p.scope_detail[b]||[]).length)).slice(-1)[0] + || this.methodCapability(p,method); + await this.connectProvider(p, cap, c); + }, +capOptions(ask){ + // Authorization-method capabilities are declared narrowest → broadest for scope + // containment. Present the choice broadest → narrowest, matching every existing provider. + if(ask && ask.method) return [...(ask.method.capabilities||[])].reverse(); + if(!ask || !ask.conn) return (ask&&ask.provider.capabilities)||[]; + const method=(ask.provider.authorization_methods||[]).find(m=>m.name===ask.conn.authorization_method); + return method ? method.capabilities : (ask.provider.capabilities||[]); + }, +article(w){ return /^[aeiou]/i.test(w||'') ? 'an' : 'a'; }, +// "an account", not "a account" + mkCapabilityMethod(cap){ return ((this.mkProvider&&this.mkProvider.authorization_methods)||[]).find(m=> + m.capability_details && (m.capability_details[cap]||[]).length); }, +mkCapabilityLabel(cap){ const m=this.mkCapabilityMethod(cap); return (m&&m.capability_labels&&m.capability_labels[cap])||cap; }, +mkCapabilityIntro(cap){ const m=this.mkCapabilityMethod(cap); return (m&&m.capability_intros&&m.capability_intros[cap])||''; }, +mkCapabilityDetails(cap){ + const m=this.mkCapabilityMethod(cap), labels=m&&m.capability_details[cap]; + return labels ? labels.map(label=>({scope:'',label})) : (((this.mkProvider&&this.mkProvider.scope_detail)||{})[cap]||[]); + }, +capMethod(ask, cap){ + if(ask&&ask.method) return ask.method; + if(ask&&ask.conn) return (ask.provider.authorization_methods||[]).find(m=>m.name===ask.conn.authorization_method); + return (ask&&ask.provider.authorization_methods||[]).find(m=>(m.capabilities||[]).includes(cap)); + }, +capLabel(cap, ask){ const m=this.capMethod(ask,cap), custom=m&&m.capability_labels&&m.capability_labels[cap]; return custom || {read:'Read only', draft:'Read and draft', post:'Read and publish', write:'Read and write', manage:'Full access'}[cap] || cap; }, +capHelp(cap, ask){ const m=this.capMethod(ask,cap), custom=m&&m.capability_help&&m.capability_help[cap]; return custom || { + read:'Your agent can view data. It can never change anything.', + // TikTok's `draft` is a genuinely weaker grant, not a softer word for post: video.upload can + // only drop a video into the creator's inbox for them to finish by hand, and TikTok bins it + // after 24h if they don't. Nothing reaches the profile without a human. + draft:'Your agent can view data and prepare content, but only as a draft you finish and post yourself.', + // `post` is the YouTube middle ground: uploading a video and being able to edit or delete + // one are separate Google scopes, so publish-without-touching-the-back-catalogue is a real + // choice rather than a hedge. + post:'Your agent can view data and publish new content. It cannot edit or delete anything already there.', + write:'Your agent can view data and also create, update or publish.', + manage:'Your agent can view and manage everything in the account.', + }[cap] || 'Requests the '+cap+' scopes.'; }, +capInReview(cap, ask){ const m=this.capMethod(ask,cap); return !!(m&&(m.capabilities_in_review||[]).includes(cap)); }, +capDefault(ask){ + if(ask&&ask.method&&ask.method.connect_capability) return ask.method.connect_capability; + return ask&&(ask.provider.connect_default_capability||ask.provider.default_capability); + }, +isRecommendedMethod(p, method){ return !!(method && (method.capabilities||[]).includes(p.connect_default_capability||p.default_capability)); }, +selectedMethod(ask){ return ask && (ask.provider.authorization_methods||[]).find(m=>m.name===ask.selected); }, +methodCapability(p, method){ + const caps=(method&&method.capabilities)||[]; + const preferred=p.connect_default_capability||p.default_capability; + return method&&method.connect_capability || (caps.includes(preferred) ? preferred : (caps[caps.length-1]||preferred)); + }, +startConnect(p, conn){ + // A pasted-secret provider has no consent screen — the user brings their own bot token (Slack) + // or API key (Apollo, TikHub, …), so setup is a form, not a redirect. + if(p.auth_kind==='token' || p.auth_kind==='key') return void (this.tokenAsk={provider:p, token:'', err:'', busy:false, conn}); + // Several separate grants are one Add-account decision. Providers with zero or one method + // keep the old one-click behavior, so LinkedIn and every existing single-method flow do not + // inherit an extra dialog. Reconnects also stay pinned to their stored method. + const methods=p.authorization_methods||[]; + if(methods.length>1 && !conn){ + const available=methods.filter(method=>method.configured); + const recommended=available.find(m=>this.isRecommendedMethod(p,m)) || available[0] || methods[0]; + this.methodAsk={provider:p,selected:recommended.name}; return; + } + if(methods.length===1 && !conn) return this.connectProvider(p,this.methodCapability(p,methods[0])); + // One capability means there's nothing to ask — don't put a dialog in the way. + if((p.capabilities||[]).length<2) return this.connectProvider(p, p.default_capability, conn); + this.capAsk={provider:p, conn}; + }, +async continueMethod(){ + const ask=this.methodAsk, method=this.selectedMethod(ask); if(!method || !method.configured) return; + this.methodAsk=null; + if((method.capabilities||[]).length>1){ this.capAsk={provider:ask.provider,conn:null,method}; return; } + await this.connectProvider(ask.provider,this.methodCapability(ask.provider,method)); + }, +async submitToken(){ + const t=this.tokenAsk; if(!t.token.trim()) return; + t.busy=true; t.err=''; + try{ + await this.api('/connections/token',{method:'POST',headers:{'content-type':'application/json'}, + body:JSON.stringify({provider:t.provider.service, token:t.token.trim()})}); + this.tokenAsk=null; await this.loadConnections(); await this.loadAll(); + }catch(e){ t.err=(e.detail||e.message||e); t.busy=false; } + }, +async chooseCapability(cap){ + const p=this.capAsk.provider, conn=this.capAsk.conn; this.capAsk=null; + await this.connectProvider(p, cap, conn); + }, +connProvider(c){ return (this.providers||[]).find(p=>p.service===c.provider)||null; }, +async saveExtraCred(c){ + const v=(this.extraCred[c.id]||'').trim(); if(!v) return; + this.extraBusy=c.id; this.connErr=''; + try{ + await this.api('/connections/'+c.id+'/extra-credential',{method:'POST', + headers:{'content-type':'application/json'}, body:JSON.stringify({value:v})}); + this.extraCred[c.id]=''; await this.loadConnections(); await this.loadAll(); + }catch(e){ this.connErr=(e.detail||e.message||e); } + this.extraBusy=null; + }, +async openResources(c){ + this.connErr=''; + const pv=this.connProvider(c)||{}; const label=pv.resource_label||'resource'; const plural=pv.resource_plural||(label+'s'); + // Open FIRST. Discovery is a live upstream round-trip and can take seconds; waiting for it + // before showing anything makes the button look dead and invites a second click. + this.resPick={id:c.id, label, plural, rows:[], selected:c.resource_ref||'', loading:true, err:''}; + try{ + const d=await this.api('/connections/'+c.id+'/resources'); + if(!this.resPick || this.resPick.id!==c.id) return; // user closed it or opened another + this.resPick={id:c.id, label:d.resource_label||label, plural:d.resource_plural||plural, + rows:d.resources||[], selected:d.selected||'', loading:false, + err:d.setup_required?(d.setup_detail||'Account setup is required.'):''}; + // Discovery can change the row underneath us — it backfills a missing label and records + // that the credential works — so pull the list again rather than leaving stale text on screen. + this.loadConnections(); + }catch(e){ + // e.detail carries the server's (and often the upstream's) actual words; e.message is just 'http'. + if(!this.resPick || this.resPick.id!==c.id) return; + this.resPick.loading=false; + this.resPick.err='Could not list '+plural+': '+(e.detail||e.message||e); + } + }, +async chooseResource(r){ + try{ + await this.api('/connections/'+this.resPick.id+'/resource',{method:'POST', + headers:{'content-type':'application/json'}, + body:JSON.stringify({resource_ref:r.id, resource_name:r.label||''})}); + this.resPick=null; await this.loadConnections(); + }catch(e){ this.connErr=String(e.message||e); } + }, +async disconnect(c){ + if(this.confirmDisc!==c.id){ this.confirmDisc=c.id; setTimeout(()=>{ if(this.confirmDisc===c.id) this.confirmDisc=null; },4000); return; } + this.confirmDisc=null; + try{ await this.api('/connections/'+c.id,{method:'DELETE'}); await this.loadConnections(); await this.loadAll(); } + catch(e){ this.connErr=String(e.message||e); } + } +} diff --git a/frontend/src/state/constants.js b/frontend/src/state/constants.js new file mode 100644 index 00000000..54db3e86 --- /dev/null +++ b/frontend/src/state/constants.js @@ -0,0 +1 @@ +export const LS = 'treg-dash' diff --git a/frontend/src/state/context.ts b/frontend/src/state/context.ts new file mode 100644 index 00000000..6d2e8ee9 --- /dev/null +++ b/frontend/src/state/context.ts @@ -0,0 +1,35 @@ +import { inject, markRaw, type ComponentPublicInstance, type InjectionKey } from 'vue' + +// Compatibility boundary for the existing Options API feature modules. New components should +// expose typed props/events; the migrated pages share the original per-application state until +// their use cases can be moved independently. Never make this a module-level singleton. +type Dashboard = ComponentPublicInstance & Record +const dashboardKey: InjectionKey = Symbol('dashboard') + +export function provideDashboard(vm: Dashboard) { + return { [dashboardKey as symbol]: vm } +} + +export function useDashboard() { + const vm = inject(dashboardKey) + if (!vm) throw new Error('Dashboard provider is missing') + const bindings: Record = {} + const names = new Set([ + ...Object.keys(vm.$data), + ...Object.keys(vm.$options.computed ?? {}), + ...Object.keys(vm.$options.methods ?? {}), + ]) + for (const name of names) { + Object.defineProperty(bindings, name, { + enumerable: true, + get: () => vm[name], + set: value => { vm[name] = value }, + }) + } + bindings.$nextTick = vm.$nextTick.bind(vm) + return bindings +} + +export function createElements() { + return markRaw({} as Record) +} diff --git a/frontend/src/state/controller.js b/frontend/src/state/controller.js new file mode 100644 index 00000000..0661bae1 --- /dev/null +++ b/frontend/src/state/controller.js @@ -0,0 +1,50 @@ +import { provideDashboard } from './context' +import data from './data.js' +import boot from './boot.js' +import session from './session.js' +import team from './team.js' +import keys from './keys.js' +import agents from './agents.js' +import projects from './projects.js' +import governance from './governance.js' +import activity from './activity.js' +import billing from './billing.js' +import referrals from './referrals.js' +import secrets from './secrets.js' +import tools from './tools.js' +import skills from './skills.js' +import format from './format.js' +import onboarding from './onboarding.js' +import analytics from './analytics.js' +import help from './help.js' +import connections from './connections.js' +import sharing from './sharing.js' +import navigation from './navigation.js' +import catalog from './catalog.js' +import details from './details.js' +import admin from './admin.js' +import snippets from './snippets.js' +import tryTool from './tryTool.js' +import lifecycle from './lifecycle.js' +import billingComputed from './billingComputed.js' +import catalogComputed from './catalogComputed.js' +import sessionComputed from './sessionComputed.js' +import agentsComputed from './agentsComputed.js' +import onboardingComputed from './onboardingComputed.js' +import detailsComputed from './detailsComputed.js' +export default { + data, + computed: {...billingComputed, ...catalogComputed, ...sessionComputed, ...agentsComputed, ...onboardingComputed, ...detailsComputed}, + methods: {setElement(name, element) { this.elements[name] = element }, ...session, ...team, ...keys, ...agents, ...projects, ...governance, ...activity, ...billing, ...referrals, ...secrets, ...tools, ...skills, ...format, ...onboarding, ...analytics, ...help, ...connections, ...sharing, ...navigation, ...catalog, ...details, ...admin, ...snippets, ...tryTool, ...lifecycle}, + watch:{ + // a11y (WCAG 2.4.3): when a dialog/drawer opens, move focus INTO it (was left on the trigger) + newTool(v){ this.focusOverlay(v); }, newSkill(v){ this.focusOverlay(v); }, newOrg(v){ this.focusOverlay(v); }, + showJoin(v){ this.focusOverlay(v); }, addOrg(v){ this.focusOverlay(v); }, copyTool(v){ this.focusOverlay(v); }, + tryTool(v){ this.focusOverlay(v); }, 'welcome.on'(v){ this.focusOverlay(v); }, reqAsk(v){ this.focusOverlay(v); }, + 'welcome.agent'(v){ try{ localStorage.setItem('treg-agent', v); }catch(e){} }, // see _restoreAgent + activeOrgId(){ this.resetRenameForm(); }, // team switch or first load: prefill the rename form + }, + provide() { return provideDashboard(this) }, + mounted: boot, + beforeUnmount() { this.stopLifecycle?.() }, +} diff --git a/frontend/src/state/data.js b/frontend/src/state/data.js new file mode 100644 index 00000000..d1b3e362 --- /dev/null +++ b/frontend/src/state/data.js @@ -0,0 +1,118 @@ +import { createElements } from './context' +import { LS } from './constants.js' +export default function data(){ + let cfg={active:null,orgs:{}}; try{ cfg=JSON.parse(localStorage.getItem(LS))||cfg; }catch(e){} + return { + elements: createElements(), + theme: localStorage.getItem('treg-theme')||'light', + mobileNav: false, // mobile sidebar toggle + // True when this load is a PUBLIC catalog URL (/catalog, /catalog/). The catalog API is + // unauthenticated, so the same marketplace views render for a signed-out visitor — that is + // what makes the catalog indexable without maintaining a second copy of the UI. Everything + // that needs a session (org switcher, vault, activity, team, try-it) is hidden on `authed`. + publicCatalog:false, oauthSignin:false, + cfg, tokenInput:'', busy:false, loginErr:'', addOrg:false, orgMenu:false, orgMenuStyle:null, + emailInput:'', codeInput:'', emailStage:false, devCode:'', pendingInvites:[], invitePrefill:'', inviteChoice:false, inviteBusy:false, + inviteSel:{}, inviteLinkOrg:null, inviteErr:'', // multi-select accept: checked ids, the org_id the clicked email link was for, partial-failure note + tut:{i:0, panel:null}, tutCopied:false, tourI:0, helpMode:null, xtut:{i:0}, + newOrg:false, newOrgName:'', orgBusy:false, orgErr:'', orgMsg:'', + orgMembers:[], orgInvites:[], inviteEmail:'', inviteRole:'member', lastInvite:null, + editAccess:null, accessDraft:{}, inviteCustomize:false, inviteLocalRun:true, inviteToolSel:{}, accessNote:'', + // agents (machine identities), projects (sub-scope) and deny rules (policy) + orgTab:'members', showInvite:false, showAddAgent:false, + apiKeys:[], keyName:'', keyNameInvalid:false, keyBusy:false, keyErr:'', keyMsg:null, newApiKey:null, editKey:null, editKeyName:'', keyMenu:null, keyConfirm:null, activityKey:'', + agentSnip:'prompt', // which paste-ready snippet the agent card shows (prompt = hand-to-agent, first) + snipAgent:null, // an EXISTING agent whose setup snippets are open (no token — placeholder) + agents:[], agentName:'', agentRole:'member', agentCap:-1, agentBusy:false, agentErr:'', agentProjSel:{}, agentAccessMode:null, agentToolSel:{}, + observedAgents:[], promoteHint:'', promotePending:null, agentTokens:{}, // minted tokens, THIS page-load only — the server stores hashes + newAgent:null, confirmAgent:null, + projects:[], projectName:'', projBusy:false, confirmProj:null, projDraft:{}, + editProj:null, projToolDraft:{}, projToolBusy:false, + denyRules:[], denyForm:{host:'',path_prefix:'',method:'',user_id:null,project_id:null,note:''}, denyBusy:false, confirmDeny:null, cliDeny:[], + bootVersion:'', newVersion:false, + usage:null, usageDays:30, myUsage:null, actTab:'feed', buildTab:'vendor', // Activity page: 'feed' | 'usage' + tagUsage:{}, tagKeys:[], // spend per X-Treg-Meta key: {customer:{…}, workspace:{…}} + // usage-metering: rollups + the member's own used/cap + // Billing (Stripe top-ups). `billing` null = not loaded / not an admin; billing.configured + // false = this deployment sells no balance, so the whole block stays hidden. + billing:null, billingBusy:false, topupAmount:10, autoAmount:10, autoThreshold:5, autoConsent:false, autoOpen:false, + topupOpen:false, topupPick:10, topupOther:null, topupAuto:true, topupErr:'', + capCfg:null, capUsd:0, capBusy:false, capErr:'', + renameName:'', renameSlug:'', renameBusy:false, renameErr:'', + budgets:[], budDims:[], budDim:'', budVal:'', budDaily:'', budBusy:false, budErr:'', + bhist:{items:[],loading:false,ok:true}, // past top-ups + their invoice/receipt links; ok=false means Stripe was unreachable, amounts are still right + // Referral program. Seeded with the same SHAPE the API returns (terms/totals/cap present and + // zeroed) so the template can read ref.terms.hold_days on the very first paint — a v-if on + // `loading` guards the table, but the subtitle above it renders immediately. + refTab:'friend', // 'friend' | 'partner' — the fork at the top of the Referrals view + ref:{loading:false,eligible:false,code:'',link:'',credit_org:null,referrals:[], + terms:{referrer_micro:0,referred_micro:0,min_topup_micro:0,hold_days:0}, + totals:{signed_up:0,topped_up:0,earned_micro:0,pending_micro:0},cap:{paid:0,limit:0}}, + refCopied:false, + confirmDel:'', confirmLeave:false, confirmRemove:null, + showJoin:false, joinCode:'', joinBusy:false, joinErr:'', + secrets:[], showSecrets:false, secretRows:[{name:'',value:'',kind:'env'}], secretBusy:false, secretErr:'', runNote:'', + newTool:false, toolBusy:false, toolErr:'', confirmDelTool:null, confirmDelSecret:null, + tForm:{id:null, name:'', base_url:'', bindings:[]}, + addToolMenu:false, // the + Add tool [endpoint | cli] chooser + // Connections: registry OAuth connects (see oauth_providers.py). `providers` is what treg + // holds an approved app for; `connections` is what this org has actually connected. + providers:[], connections:[], connErr:'', connBusy:false, confirmDisc:null, resPick:null, + mkCat:'', mkService:null, // marketplace: active category filter, and the open integration + byokFocus:null, // provider row to flash after a "Bring your own key" jump to the Platform tab + // Marketplace tab bar: 'all' + one key per catalog category, plus 'platform' for the + // original integration shelves. Data-first is the default view. + mkTab:'all', + platLogoBad:{}, // platform slug → we have no /logos/platforms/.svg, so draw the initial tile + // Endpoint catalog (GET /catalog/*): the platform axis of the marketplace. Everything here is + // optional — a server without the catalog routes just renders no platform shelf. + plats:{list:[], loaded:false, loading:false}, + platSlug:null, platData:null, platErr:'', platLoading:false, + platShelfOpen:{}, // category → its featured shelf has been expanded to the full tile list + // The ledger's filter bar. All three narrow the SAME row list, and a section with no + // surviving rows disappears rather than showing an empty heading. + platDomain:'', platQ:'', platVerifiedOnly:false, + platOpen:{}, // ledger row key → row expanded + platActionsOpen:false, // the single platform-wide account/utility ("Actions") section is open + epOpen:{}, // endpoint id → its provider sub-row (level two, merged rows only) is open + epTab:{}, // endpoint id → which pane of its detail is showing ('req' | 'res') + platEx:{}, // endpoint id → {open, loading, err, text} for the lazily-fetched example + platCopied:'', // endpoint id whose `treg call` line was just copied + capAsk:null, // the access question, asked when a one-method provider has several scope levels + methodAsk:null, // separate grants behind one provider: one selected method, then Continue + tokenAsk:null, // bring-your-own-bot setup (Slack): a form, not a redirect + extraCred:{}, extraBusy:null, // second credential a provider needs on top of OAuth (Google Ads' developer token) + cfgMenu:false, // detail-page ⚙ Configure tool-picker (skills with >1 tool) + tryMenu:false, // detail-page ▶ Try it tool-picker (skills with >1 tool) + // marketplace endpoint Try-it drawer + callView:null, callViewFull:false, callCopied:'', // Activity → one call's request/response drawer + actOkOnly:true, // Activity feed: successes only by default; the toggle shows failed/refused too + epTry:null, epTryTab:'agent', epTryAccess:null, epTryAccessByMethod:{}, epTryAuthMethod:'', epTryParams:[], epTryBody:'', epTryResp:'', epTryStatus:null, + epTryMs:null, epTryCost:null, epTryBusy:false, + catalogClis:null, // bin → catalog default deny patterns (lazy, from /providers.json) + newSkill:false, skillJson:'', skillBusy:false, skillErr:'', skillMode:'folder', + skillFiles:[], detected:null, skillSel:{}, skillVals:{}, skillResults:null, + sessionMode:false, activeSlug: localStorage.getItem('treg-active')||null, meta:{github:false, public_url:location.origin}, + view:'tools', q:'', err:'', loading:false, toolTab:'all', bundles:[], confirmDelBundle:null, installCopied:null, + copyRecipe:null, recipeTab:'cURL', viewRecipe:null, recipeSaved:false, + // detail pages (shareable /app/skills/ + /app/tools/ deep links) + detail:null, detailData:null, detailErr:'', detailLoading:false, detailFile:'SKILL.md', detailCopied:'', detailNote:'', + shareGate:null, // logged-out arrival at a shared detail link: {kind,name} → focused sign-in page (no sandbox/tour) + share:{on:false, email:'', role:'viewer', full:true, busy:false, err:'', sent:null, member:null}, // detail-page "Share…" (invite + land on this page) + me:'', icHash:'', myOrgs:[], isAdmin:false, + onboarded:true, // first-run onboarding done (server flag; gates the welcome modal) + welcome:{on:false, step:0, name:'', agent:'openclaw', moreOpen:false, busy:false, err:''}, // first-run: name your team → pick your agent → setup line + emptyTab:'agent', + tools:[], health:{}, calls:[], runs:[], adminStats:null, adminOrgs:[], adminUsers:[], + adminBusy:false, confirmAdmUser:null, confirmAdmOrg:null, + proxy: location.origin, copyTool:null, snippetTab:'cURL', snippetTabs:['cURL','CLI','Claude Code','Python','Node'], copied:false, + exPath:'', exMethod:'GET', + tryTool:null, tryMethod:'GET', tryPath:'', tryBody:'', trying:false, tryResp:null, tryStatus:0, tryMs:0, + useMode:'call', runArgsStr:'', running:false, runOut:null, + startCopyError:'', startCopied:'', startTab:'access', startAgentOpen:false, startTokenShow:false, mdMenu:false, llmsCopied:false, myToken:null, _myTokenOrg:null, defaultKeyId:null, defaultKeyState:null, tokenCopied:false, + agentGuide:null, agentGuideCopied:false, agentRowCopied:null, + vendorAsk:false, vendorCopied:false, + reqAsk:false, reqDone:false, reqBusy:false, reqErr:'', reqForm:{capability:'', note:'', contact:''}, + demo:{signin:false}, + }; + } diff --git a/frontend/src/state/details.js b/frontend/src/state/details.js new file mode 100644 index 00000000..05efec37 --- /dev/null +++ b/frontend/src/state/details.js @@ -0,0 +1,89 @@ + +export default { +openDetail(kind, name, fromPop){ this.resetConfirms(); + this.detail={kind, name}; this.view='detail'; this.detailFile='SKILL.md'; this.detailCopied=''; this.detailNote=''; + if(!fromPop) history.pushState({detail:{kind,name}}, '', '/app/'+(kind==='skill'?'skills':'tools')+'/'+encodeURIComponent(name)); + this.loadDetail(); }, +async loadDetail(){ if(!this.detail) return; this.detailErr=''; this.detailLoading=true; this.detailData=null; + try{ this.detailData=await this.api((this.detail.kind==='skill'?'/bundles/by-name/':'/tools/by-name/')+encodeURIComponent(this.detail.name)); } + catch(e){ + if(e.status===404 && await this.findDetailOrg()) return void (this.detailLoading=false); // it lives in another of my teams — switched + this.detailErr = e.status===404 + ? 'Not found in your teams. This link needs an invite — ask the person who shared it to invite you (Share… on their side), then click the link again.' + : 'Could not load: '+(e.detail||e.status); } + finally{ this.detailLoading=false; } }, +async fullTool(t){ // skill pages carry tool SUMMARIES — resolve the full record (bindings + cli) before acting on it + let full=(this.tools||[]).find(x=>x.id===t.id); + if(!full && !(t.bindings||t.cli)){ try{ full=(await this.api('/tools')).find(x=>x.id===t.id); }catch(e){} } + return full||t; }, +async configureTool(t){ this.cfgMenu=false; this.openEditTool(await this.fullTool(t)); }, +async tryDetailTool(t){ this.tryMenu=false; this.openUse(await this.fullTool(t)); }, +async copyDetail(text, tag){ if(!(await this.toClipboard(text))) return; this.detailCopied=tag; setTimeout(()=>{ if(this.detailCopied===tag) this.detailCopied=''; },1500); }, +rowTarget(t){ // where a tools-list row leads: a skill-born tool opens its SKILL (the shareable thing), a bare endpoint opens the tool page + if(t.bundle_id){ const b=this.bundles.find(x=>x.id===t.bundle_id); if(b) return {kind:'skill', name:b.name}; } + return {kind:'tool', name:t.name}; }, +rowHref(t){ const d=this.rowTarget(t); return '/app/'+(d.kind==='skill'?'skills':'tools')+'/'+encodeURIComponent(d.name); }, +rowOpen(t){ const d=this.rowTarget(t); this.openDetail(d.kind, d.name); }, +async autoAcceptShare(route){ // a share-link invite: clicking the emailed "Sign in & accept" IS the consent — accept silently, enter that team + // Match by the email link's org param, or — for a DM'd bare link — by an invite whose landing IS this page. + const path=route?'/app/'+(route.kind==='skill'?'skills':'tools')+'/'+encodeURIComponent(route.name):null; + const inv=this.pendingInvites.find(i=>i.org_id===this.inviteLinkOrg) + ||(path&&this.pendingInvites.find(i=>i.landing===path)); + if(!inv) return false; + try{ + const r=await this.api('/invites/'+inv.id+'/accept',{method:'POST'}); + this.pendingInvites=this.pendingInvites.filter(i=>i.id!==inv.id); + this.onboarded=true; try{ await this.api('/onboard/skip',{method:'POST'}); }catch(e){} + if(r&&r.org){ this.activeSlug=r.org; localStorage.setItem('treg-active',r.org); } + await this.loadAll(); + this.inviteLinkOrg=null; + this.orgMsg='You joined '+((r&&r.name)||inv.name)+' — this page was shared with you.'; + return true; + }catch(e){ return false; } // revoked/used → the detail page's not-found message explains + }, +async findDetailOrg(){ // the link may belong to another of MY teams — probe them, switch silently on a unique hit + if(!this.detail) return false; + const others=(this.myOrgs||[]).filter(o=>o.slug!==this.activeSlugNow); + const path=(this.detail.kind==='skill'?'/bundles/by-name/':'/tools/by-name/')+encodeURIComponent(this.detail.name); + for(const o of others){ + let h; + if(this.sessionMode) h={'X-Treg-Org':o.slug}; + else { const t=(this.cfg.orgs[o.slug]||{}).token; if(!t) continue; h={'X-Treg-Token':t}; } + try{ + const r=await fetch(path,{credentials:'include',headers:{'ngrok-skip-browser-warning':'1',...h}}); + if(!r.ok) continue; + const data=await r.json(); + if(this.sessionMode){ this.activeSlug=o.slug; localStorage.setItem('treg-active',o.slug); } + else { this.cfg.active=o.slug; this.save(); } + this.detailData=data; this.detailErr=''; + this.orgMsg='Switched to '+(o.name||o.slug)+' — this '+this.detail.kind+' lives there.'; + await this.loadAll(); + return true; + }catch(_){} + } + return false; + }, +openShare(){ + this.share={on:true, email:'', role:'viewer', full:true, busy:false, err:'', sent:null, member:null}; + if(this.canAdmin) this.loadOrgAdmin().catch(()=>{}); }, +async sendShare(){ const email=(this.share.email||'').trim(); if(!email){ this.share.err='Enter their email.'; return; } + this.share.member=(this.orgMembers||[]).find(m=>(m.email||'').toLowerCase()===email.toLowerCase())||null; + if(this.share.member) return; // already on the team — the modal now says "just send the link" + this.share.busy=true; this.share.err=''; + const landing='/app/'+(this.detail.kind==='skill'?'skills':'tools')+'/'+encodeURIComponent(this.detail.name); + // Full access (default) = no restriction. Unchecked = scope to the skill itself + its + // bundled tools (the access list also gates which skills a restricted member can SEE). + let tool_access=null; + if(!this.share.full){ + tool_access = this.detail.kind==='skill' + ? [this.detail.name].concat((((this.detailData&&this.detailData.tools)||[]).map(t=>t.name))) + : [this.detail.name]; + tool_access=[...new Set(tool_access)]; + } + try{ + const r=await this.api('/orgs/'+this.activeOrgId+'/invites',{method:'POST',headers:{'content-type':'application/json'}, + body:JSON.stringify({email, role:this.share.role, landing, tool_access, local_run_enabled:false})}); + this.share.sent=r; + }catch(e){ this.share.err='Invite failed: '+(e.detail||e.status); } + finally{ this.share.busy=false; } } +} diff --git a/frontend/src/state/detailsComputed.js b/frontend/src/state/detailsComputed.js new file mode 100644 index 00000000..c64a7e7f --- /dev/null +++ b/frontend/src/state/detailsComputed.js @@ -0,0 +1,44 @@ +export default { +detailShareUrl(){ if(!this.detail) return ''; return location.origin+'/app/'+(this.detail.kind==='skill'?'skills':'tools')+'/'+encodeURIComponent(this.detail.name); }, +shareInviteUrl(){ // the DM-able one-click link: page URL + the invited email (prefills sign-in; the + // invite then auto-accepts on landing-match). Deliberately NOT the emailed token — that secret can + // mint a session and must never leave the inbox; this link still requires proving the email. + return this.share.sent ? this.detailShareUrl+'?invite='+encodeURIComponent(this.share.sent.email) : ''; }, +detailTree(){ // the bundle's files as a flat, indent-rendered tree: synthesized dir rows + file rows + const files=Object.keys((this.detailData&&this.detailData.files)||{}).sort(); + const rows=[{path:'SKILL.md', name:'SKILL.md', depth:0, dir:false}]; const seen=new Set(); + for(const p of files){ const parts=p.split('/'); + for(let i=0;ib.id===this.detailData.bundle_id)||null; }, +detailPrompt(){ // the copyable "give this to your agent" instruction — no token embedded, the + // recipient signs in as themselves (this text is meant to be forwarded) + if(!this.detail) return ''; + const B=(this.proxy||location.origin).replace(/\/$/,''); const n=this.detail.name; + const head=[`I want to use the shared ${this.detail.kind==='skill'?'skill':'tool'} "${n}" from my team's treg at ${B} .`,``, + `1. Install the treg CLI (skip if \`treg\` already works) and sign me in:`, + ` curl -fsSL ${B}/install.sh | sh`,` treg login`,``]; + if(this.detail.kind==='skill'){ + const t=(this.detailData&&this.detailData.tools&&this.detailData.tools[0])||null; + return head.concat([ + `2. Install the skill into this project (writes the skill folder into my agent skills dir):`, + ` treg skill install ${n}`,``, + ...(t?[`3. The skill's API calls go through treg's proxy — the credential is injected server-side, never on this machine:`, + ` treg call ${t.name} # or prefix the real URL: ${B}/call/`,``]:[]), + `Then use the "${n}" skill for my request.`,``, + `Full protocol reference: ${B}/llms.txt`]).join('\n'); + } + const t=this.detailData||{}; + return head.concat([ + `2. Call it through the proxy — the credential is injected server-side, never on this machine:`, + ` treg call ${n} # PATH = the ${t.host||'upstream'} path you'd normally call`, + ...(t.cli?[``,` # it's also a CLI — run it with the key injected:`,` treg run ${n} -- `]:[]),``, + `Full protocol reference: ${B}/llms.txt`]).join('\n'); + } +} diff --git a/frontend/src/state/format.js b/frontend/src/state/format.js new file mode 100644 index 00000000..f01f9715 --- /dev/null +++ b/frontend/src/state/format.js @@ -0,0 +1,11 @@ + +export default { +dot(s){ return s==='unknown'||s==='-'?'○':'●'; }, +short(e){ return (e||'').split('@')[0]; }, +activityAgentKey(a){ return (this.apiKeys||[]).find(k=>k.id===a.api_key_id && k.assigned_type==='agent')||null; }, +activityWho(a){ const k=this.activityAgentKey(a); return k?(k.assigned_name||this.short(k.identity)):this.short(a.user_email); }, +activityOwner(a){ const k=this.activityAgentKey(a); return k&&k.created_by?this.short(k.created_by):''; }, +isPersonal(o){ return !!o && o.name===this.me; }, +// personal org = the auto-created one named after your email + jumpToTeam(){ const t=this.myOrgs.find(o=>!this.isPersonal(o)); if(t) this.switchOrg(t); } +} diff --git a/frontend/src/state/governance.js b/frontend/src/state/governance.js new file mode 100644 index 00000000..2023cf95 --- /dev/null +++ b/frontend/src/state/governance.js @@ -0,0 +1,16 @@ + +export default { +// ---- deny rules ---- + denyWho(uid){ const m=(this.orgMembers||[]).find(x=>x.user_id===uid); return m?m.email:('user '+uid); }, +async addDeny(){ const f=this.denyForm; + if(!f.host && !f.path_prefix && !f.method){ this.orgErr='Give at least a host, a path, or a method.'; return; } + this.denyBusy=true; this.orgErr=''; + try{ await this.api('/orgs/'+this.activeOrgId+'/deny',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(f)}); + this.denyForm={host:'',path_prefix:'',method:'',user_id:null,project_id:null,note:''}; } + catch(e){ this.orgErr='Add rule failed: '+(e.detail||e.status); } + this.denyBusy=false; await this.loadOrgAdmin(); }, +async removeDeny(r){ if(this.confirmDeny!==r.id){ this.confirmDeny=r.id; return; } + try{ await this.api('/orgs/'+this.activeOrgId+'/deny/'+r.id,{method:'DELETE'}); } + catch(e){ this.orgErr='Remove rule failed: '+(e.detail||e.status); } + this.confirmDeny=null; await this.loadOrgAdmin(); } +} diff --git a/frontend/src/state/help.js b/frontend/src/state/help.js new file mode 100644 index 00000000..e45ea7ad --- /dev/null +++ b/frontend/src/state/help.js @@ -0,0 +1,21 @@ + +export default { +async resetDemo(){ try{ await this.api('/onboard/reset',{method:'POST'}); this.onboarded=true; await this.loadAll(); this.orgMsg='Demo teammates removed.'; } + catch(e){ this.err='Reset failed: '+(e.detail||e.status); } }, +readMore(section){ try{ window.open('/tutorial'+(section?('#'+section):''),'_blank'); }catch(e){} }, +tutGo(i){ this.tut.i=Math.max(0,Math.min(this.tutSteps.length-1,i)); this.tutCopied=false; }, +xtutGo(i){ this.xtut.i=Math.max(0,Math.min(this.xtutSteps.length-1,i)); this.tutCopied=false; }, +tutHL(text,kind){ return (window.tregHL?window.tregHL(text, window.tregLang(text,kind)):text); }, +async tutCopy(text){ if(!(await this.toClipboard(text))) return; this.tutCopied=true; setTimeout(()=>this.tutCopied=false,1500); }, +async toClipboard(text){ // guarded copy with a legacy fallback - navigator.clipboard is undefined on non-secure contexts + try{ if(navigator.clipboard){ await navigator.clipboard.writeText(text); return true; } }catch(e){} + try{ const ta=document.createElement('textarea'); ta.value=text; ta.style.position='fixed'; ta.style.opacity='0'; document.body.appendChild(ta); ta.select(); const ok=document.execCommand('copy'); document.body.removeChild(ta); return ok; }catch(e){ return false; } }, +personaLabel(who){ return ((this.tutData.personas||{})[who]||{}).label || who; }, +personaTour(who){ return (this.tourData.personas||{})[who] || who; }, +tourMatColor(part){ const c=this.tourData.colors; return `var(${c[this.tourParts.indexOf(part)%c.length]})`; }, +when(iso){ try{ const d=new Date(/(Z|[+-]\d{2}:?\d{2})$/.test(iso)?iso:iso+'Z'); const s=(Date.now()-d)/1000; if(s<90)return 'just now'; if(s<5400)return Math.round(s/60)+'m ago'; if(s<172800)return Math.round(s/3600)+'h ago'; return d.toISOString().slice(0,10);}catch(e){return iso;} }, +until(iso){ try{ const d=new Date(/(Z|[+-]\d{2}:?\d{2})$/.test(iso)?iso:iso+'Z'); const s=(d-Date.now())/1000; if(s<=0)return 'expired'; if(s<5400)return 'in '+Math.round(s/60)+'m'; if(s<172800)return 'in '+Math.round(s/3600)+'h'; return 'in '+Math.round(s/86400)+'d';}catch(e){return iso;} }, +// future countdown - when() is an "ago" formatter and mislabels a future expiry as "just now" + toolStatus(t){ const ids=(t.bindings||[]).map(b=>b.secret_id).filter(x=>x!=null); if(!ids.length) return '-'; + const st=ids.map(i=>this.health[i]||'unknown'); if(st.includes('invalid'))return 'invalid'; if(st.every(s=>s==='ok'))return 'ok'; return 'unknown'; } +} diff --git a/frontend/src/state/keys.js b/frontend/src/state/keys.js new file mode 100644 index 00000000..ef246db8 --- /dev/null +++ b/frontend/src/state/keys.js @@ -0,0 +1,37 @@ + +export default { +async loadApiKeys(){ if(!this.activeOrgId) return; this.keyErr=''; + try{ this.apiKeys=await this.api('/orgs/'+this.activeOrgId+'/api-keys'); } + catch(e){ this.keyErr='Could not load keys: '+(e.detail||e.status); } }, +async loadDefaultToken(){ + const issued=await this.api('/auth/cli-token').catch(()=>null); + this._myTokenOrg=this.activeSlugNow; + this.defaultKeyId=issued&&issued.default_key_id; + this.defaultKeyState=issued&&issued.default_key_state; + this.myToken=(this.defaultKeyState==='disabled')?null:((issued&&issued.token)||null); + }, +async enableDefaultKey(){ if(!this.defaultKeyId) return; this.keyBusy=true; this.keyErr=''; + try{ await this.api('/orgs/'+this.activeOrgId+'/api-keys/'+this.defaultKeyId+'/enable',{method:'POST'}); await this.loadDefaultToken(); await this.loadApiKeys(); } + catch(e){ this.keyErr='Could not enable key: '+(e.detail||e.status); } finally{ this.keyBusy=false; } }, +keyKind(kind){ return ({default_human:'Default',additional_human:'Additional',legacy_human:'Legacy',agent:'Agent'})[kind]||kind; }, +maskedKey(k){ return k.safe_prefix ? k.safe_prefix+'••••••••' : (k.kind==='legacy_human'?'prefix unavailable — older key':'prefix unavailable'); }, +async createApiKey(){ const name=(this.keyName||'').trim(); if(!name){ this.keyErr=''; this.keyNameInvalid=true; return; } this.keyBusy=true; this.keyErr=''; + try{ this.newApiKey=await this.api('/orgs/'+this.activeOrgId+'/api-keys',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name})}); this.keyName=''; this.keyNameInvalid=false; await this.loadApiKeys(); } + catch(e){ this.keyErr='Could not create key: '+(e.detail||e.status); } finally{ this.keyBusy=false; } }, +async renameApiKey(k){ const name=(this.editKeyName||'').trim(); if(!name){ this.keyErr='Enter a key name.'; return; } this.keyBusy=true; + try{ await this.api('/orgs/'+this.activeOrgId+'/api-keys/'+k.id,{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({name})}); this.editKey=null; await this.loadApiKeys(); } + catch(e){ this.keyErr='Could not rename key: '+(e.detail||e.status); } finally{ this.keyBusy=false; } }, +keyHasMore(k){ return k.can_rename||k.can_disable||k.can_enable||k.can_revoke||(k.can_hide&&k.state==='revoked'); }, +toggleKeyMenu(k,e){ if(this.keyMenu&&this.keyMenu.key.id===k.id){ this.keyMenu=null; return; } const r=e.currentTarget.getBoundingClientRect(); this.keyMenu={key:k,top:r.bottom+6,right:Math.max(12,innerWidth-r.right)}; }, +requestKeyAction(k,action){ this.keyMenu=null; if(['rotate','disable','revoke','hide'].includes(action)){ this.keyConfirm={key:k,action}; return; } this.keyAction(k,action); }, +confirmKeyAction(){ if(!this.keyConfirm)return; const c=this.keyConfirm; this.keyConfirm=null; this.keyAction(c.key,c.action); }, +async keyAction(k,action){ this.keyBusy=true; this.keyErr=''; this.keyMsg=null; + try{ const r=await this.api('/orgs/'+this.activeOrgId+'/api-keys/'+k.id+'/'+action,{method:'POST'}); + if(r.secret && k.kind==='default_human'){ this.myToken=r.secret; this.defaultKeyId=k.id; this.defaultKeyState='active'; this._myTokenOrg=this.activeSlugNow; this.startTokenShow=false; this.newApiKey={...r,rotated:true}; } + else if(r.secret){ this.newAgent=null; this.snipAgent=null; this.agentSnip='prompt'; this.newApiKey={...r, assigned_name:k.assigned_name, assigned_type:k.assigned_type, user_id:k.user_id, org:this.activeSlugNow, rotated:true}; } + if(r.agent_revoked) this.keyMsg={agent:true,text:k.assigned_name+' was removed from this team, and all its keys were revoked. Historical Activity remains available.'}; + if(k.kind==='default_human' && action!=='rotate') await this.loadDefaultToken(); + if(r.agent_revoked) await this.loadOrgAdmin(); else await this.loadApiKeys(); } + catch(e){ this.keyErr='Key action failed: '+(e.detail||e.status); } finally{ this.keyBusy=false; } }, +showKeyActivity(k){ this.activityKey=String(k.id); this.go('activity'); } +} diff --git a/frontend/src/state/lifecycle.js b/frontend/src/state/lifecycle.js new file mode 100644 index 00000000..156bdd0a --- /dev/null +++ b/frontend/src/state/lifecycle.js @@ -0,0 +1,20 @@ + +export default { +reloadApp(){ location.reload(); }, +async checkVersion(){ // index.html is served no-cache, so a plain reload picks up a new deploy — + // this just detects one: compare /meta's bundle stamp against the one this tab booted with + if(!this.bootVersion || this.newVersion) return; + try{ + const m = await fetch('/meta',{headers:{'ngrok-skip-browser-warning':'1'}}).then(r=>r.json()); + if(m.app_version && m.app_version !== this.bootVersion) this.newVersion = true; + }catch(_){} + }, +placeOrgMenu(){ // the sidebar is a scroll container that clips overflow, so the dropdown is + // position:fixed (viewport coords) and glued to its trigger here on open/scroll/resize + const t=this.elements.orgmain; if(!t) return; + const r=t.getBoundingClientRect(); + this.orgMenuStyle={position:'fixed', top:(r.bottom+6)+'px', left:r.left+'px'}; + }, +toggleOrgMenu(){ this.orgMenu=!this.orgMenu; if(this.orgMenu) this.placeOrgMenu(); }, +closeOverlays(){ this.newTool=false; this.newSkill=false; this.showJoin=false; this.newOrg=false; this.addOrg=false; this.tryTool=null; this.copyTool=null; this.orgMenu=false; this.keyMenu=null; this.keyConfirm=null; this.share.on=false; this.methodAsk=null; } +} diff --git a/frontend/src/state/navigation.js b/frontend/src/state/navigation.js new file mode 100644 index 00000000..d79d354f --- /dev/null +++ b/frontend/src/state/navigation.js @@ -0,0 +1,54 @@ + +export default { +switchTo(o){ this.switchOrg(o); }, +// dropdown "Switch" button - just switch, stay where you are + orgSettings(o){ this.orgMenu=false; // dropdown ⚙ - go INTO that team, then open its settings + if(o.slug!==this.activeSlugNow){ if(!this.sessionMode && !this.connected(o.slug)){ this.addOrg=true; return; } this.switchOrg(o); } + this.go('orgs'); }, +resetConfirms(){ this.confirmDelTool=null; this.confirmDelSecret=null; this.confirmDelBundle=null; this.confirmRemove=null; this.confirmAgent=null; this.keyMenu=null; this.keyConfirm=null; this.confirmLeave=false; this.confirmDel=''; this.confirmAdmUser=null; this.confirmAdmOrg=null; }, +go(v, fromPop){ this.resetConfirms(); this.mobileNav=false; // stale inline "Confirm" states must not survive a view switch (accidental-delete risk) + // `usage` is a TAB of the activity page now, not a view of its own — keep the old route + // working so an existing /app#usage link, and the balance card's deep link, still land right. + if(v==='usage'){ this.actTab='usage'; v='activity'; this.loadUsage(); } + else if(v==='activity'){ this.actTab='feed'; } + this.detail=null; this.view=v; if(v==='activity')this.loadCalls(); if(v==='admin')this.loadAdmin(); if(v==='orgs'){this.loadOrgAdmin(); this.loadMyUsage(); this.loadBilling();} if(v==='usage')this.loadUsage(); if(v==='secrets'){this.loadSecrets(); if(!this.providers.length)this.loadConnections();} if(v==='connections')this.loadConnections(); if(v==='referrals')this.loadReferrals(); + // push history so browser Back navigates BETWEEN views instead of leaving the app; the '/app' + // pathname also walks back from a /app/skills/ detail URL so reload doesn't reopen the detail + if(!fromPop) history.pushState({view:v}, '', + this.publicCatalog && v==='connections' ? '/catalog' : '/app#'+v); + if(!fromPop) window.scrollTo(0,0); + this.startAgentOpen=false; this.orgMenu=false; + if(this.elements.accountMenu) this.elements.accountMenu.open=false; }, +// ---- detail pages (shareable deep links) ---- + routeFromPath(path){ const m=/^\/app\/(skills|tools)\/(.+)$/.exec(path||''); + return m ? {kind:m[1]==='skills'?'skill':'tool', name:decodeURIComponent(m[2])} : null; }, +// Marketplace deep links are their own route: they resolve against `providers` (already in + // memory) rather than fetching a detail payload, so they can't share openDetail's loader. + mkFromPath(path){ const m=/^\/app\/marketplace\/([^/]+)$/.exec(path||''); + return m ? decodeURIComponent(m[1]) : null; }, +// "Bring your own key", from anywhere: land on the Catalog's Platform tab — the shelf of every + // integration a key can be pasted into — rather than a single provider's detail page. With a + // service, the shelf scrolls to that provider's row and flashes it; the flash clears itself so + // a later visit to the tab doesn't replay a stale highlight. + // A signed-out visitor who opens a provider goes to its PUBLIC page (/tools/) — a + // real navigation, as a method because Vue template expressions cannot reach the `location` + // global (it is not on the template-expression allowlist, so an inline use fails silently). + goPublicTool(service){ location.href='/tools/'+encodeURIComponent(service); }, +goByok(service){ + this.mkTab='platform'; this.mkCat=''; + this.byokFocus=service||null; this.epTry=null; + this.go('connections'); + if(!service) return; + this.$nextTick(()=>{ const el=document.getElementById('prov-'+service); + if(el) el.scrollIntoView({block:'center', behavior:'smooth'}); }); + setTimeout(()=>{ if(this.byokFocus===service) this.byokFocus=null; }, 4000); + }, +openProvider(service, fromPop){ this.resetConfirms(); + this.detail=null; this.mkService=service; this.view='provider'; + if(!fromPop) history.pushState({mk:service}, '', '/app/marketplace/'+encodeURIComponent(service)); + // The consent popup can return before /connections has been re-read, and a deep link may + // arrive before the first load — either way the page needs the data it renders from. + if(!this.connections.length || !this.providers.length) this.loadConnections(); + this.loadPlatforms(); + window.scrollTo(0,0); } +} diff --git a/frontend/src/state/onboarding.js b/frontend/src/state/onboarding.js new file mode 100644 index 00000000..f73774a8 --- /dev/null +++ b/frontend/src/state/onboarding.js @@ -0,0 +1,85 @@ + +export default { +maybeOnboard(){ // first-run: a brand-new user with no team yet is asked to NAME THEIR TEAM upfront + // An invite LINK landed here (?invite_org= from /auth/invite-signin's POST)? Open the accept + // modal even for an onboarded user already in teams — a second-team invite must surface too. + if(this.inviteLinkOrg!==null && this.pendingInvites.length){ this.openInviteChoice(); return; } + if(this.inviteLinkOrg!==null && !this.pendingInvites.length && this.sessionMode){ + this.orgMsg='That invite was already used or revoked — ask your teammate to re-invite you if you still need access.'; } + if(this.onboarded || !this.sessionMode) return; + if(this.myOrgs.some(o=>!this.isPersonal(o))) return; + // Invited here? Show an ACCEPT-INVITE page (join those teams) instead of forcing them to create a + // throwaway team of their own (confusing: they'd end up with two). Decline → create-team. + if(this.pendingInvites.length){ this.openInviteChoice(); return; } + this.welcome.name=this._suggestTeamName(); this._welcomeAgentFromRef(); this.welcome.on=true; }, +_welcomeAgentFromRef(){ // /grokbot's "Setup treg" CTA → the welcome already has Grok Bot picked; any other ref is ignored + let r=null; try{ r=localStorage.getItem('treg-ref'); localStorage.removeItem('treg-ref'); }catch(e){} + if(r && this.welcomeAgents.concat(this.welcomeMoreAgents).some(a=>a.id===r)) this.welcome.agent=r; }, +_restoreAgent(){ // the picked agent survives a reload, so Getting started keeps showing the right setup steps + let r=null; try{ r=localStorage.getItem('treg-agent'); }catch(e){} + if(r && this.welcomeAgents.concat(this.welcomeMoreAgents).some(a=>a.id===r)) this.welcome.agent=r; }, +openInviteChoice(){ // seed the multi-select: ALL pending invites checked by default + this.inviteErr=''; this.inviteSel={}; this.pendingInvites.forEach(i=>{ this.inviteSel[i.id]=true; }); + this.inviteChoice=true; }, +declineInvite(){ this.inviteChoice=false; this.inviteLinkOrg=null; + if(this.inviteFirstRun){ this.welcome.name=this._suggestTeamName(); this._welcomeAgentFromRef(); this.welcome.on=true; } }, +// first-run: "create my own team instead"; otherwise just close + async acceptSelectedInvites(){ // accept every checked invite, then drop into the linked (or first) team + const picked=this.selectedInvites; if(!picked.length) return; + this.inviteBusy=true; this.inviteErr=''; + const joined=[], failed=[]; + for(const inv of picked){ + try{ const r=await this.api('/invites/'+inv.id+'/accept',{method:'POST'}); + joined.push({inv, r}); this.pendingInvites=this.pendingInvites.filter(i=>i.id!==inv.id); delete this.inviteSel[inv.id]; } + catch(e){ failed.push(inv.name); } + } + try{ + if(!joined.length){ // everything failed → keep the modal open with the error (or fall back on first run) + this.inviteErr='Could not join '+failed.join(', ')+' — the invite may have been revoked.'; + if(this.inviteFirstRun && !this.pendingInvites.length){ this.inviteChoice=false; this.welcome.name=this._suggestTeamName(); this.welcome.on=true; } + return; } + this.onboarded=true; try{ await this.api('/onboard/skip',{method:'POST'}); }catch(e){} + await this.loadAll(); + // Enter the team the email link pointed at when it was accepted; otherwise the first accepted. + const linked=joined.find(j=>j.inv.org_id===this.inviteLinkOrg)||joined[0]; + if(linked.r&&linked.r.org) this.switchOrg({slug:linked.r.org}); + this.inviteChoice=false; this.inviteLinkOrg=null; + if(this.detail) this.openDetail(this.detail.kind, this.detail.name, true); else this.go('tools'); // accepting while on a shared page = stay on it + this.orgMsg='You joined '+joined.map(j=>(j.r&&j.r.name)||j.inv.name).join(', ') + +(failed.length?' (could not join '+failed.join(', ')+')':'') + +'. Here are the shared tools & skills — call any with no key on your machine.'; + }finally{ this.inviteBusy=false; } + }, +_suggestTeamName(){ // a friendly default from the email domain: sam@acme.dev → "Acme" + const dom=((this.me||'').split('@')[1]||'').split('.')[0]||''; + const generic=['gmail','outlook','hotmail','yahoo','icloud','proton','protonmail','me','qq','163']; + return (dom && !generic.includes(dom.toLowerCase())) ? dom.charAt(0).toUpperCase()+dom.slice(1) : ''; }, +async welcomeCreate(){ const name=(this.welcome.name||'').trim(); if(!name){ this.welcome.err='Give your team a name.'; return; } + this.welcome.busy=true; this.welcome.err=''; + try{ const o=await this.api('/orgs',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name})}); + this.onboarded=true; try{ await this.api('/onboard/skip',{method:'POST'}); }catch(e){} // don't re-prompt + await this.loadAll(); this.switchOrg({slug:o.org}); + this.analyticsIdentify(); this.intercomUpdate(); this.track('onboarding_team_created',{team:o.org}); + this.welcome.step=1; } // stay in the modal: pick your agent → get the setup line + catch(e){ this.welcome.err='Could not create the team: '+(e.detail||e.status); } + finally{ this.welcome.busy=false; } }, +welcomeFinish(){ this.track('onboarding_finished',{agent:this.welcome.agent, step:this.welcome.step}); this.welcome.on=false; this.go('start'); + this.orgMsg='Team created. Send your agent the setup line any time — it lives on Getting started.'; }, +agentIcon(icon){ if(icon.startsWith('/')) return icon; // bundled under /logos — same mark in both themes + return 'https://unpkg.com/@lobehub/icons-static-png@latest/'+(this.theme==='dark'?'dark':'light')+'/'+icon+'.png'; }, +// For logos that sit ON a .btn.primary: its background is the theme's INVERSE, so the icon + // variant has to flip too or a dark glyph lands on a dark button. + agentIconInv(icon){ if(icon.startsWith('/')) return icon; + return 'https://unpkg.com/@lobehub/icons-static-png@latest/'+(this.theme==='dark'?'light':'dark')+'/'+icon+'.png'; }, +welcomeTryProvider(service,group){this.track('tryit_oauth_clicked',{service,group,from:'onboarding'});this.welcome.on=false;this.openProvider(service);}, +async copyStart(text, tag){ + this.startCopyError=''; this.startCopied=''; + try { + await navigator.clipboard.writeText(text); + this.startCopied=tag; + setTimeout(()=>{ if(this.startCopied===tag) this.startCopied=''; },1400); + } catch(_) { + this.startCopyError='Could not copy. Select the text and copy it manually. For an API key, choose Show key first.'; + } + } +} diff --git a/frontend/src/state/onboardingComputed.js b/frontend/src/state/onboardingComputed.js new file mode 100644 index 00000000..06521cc5 --- /dev/null +++ b/frontend/src/state/onboardingComputed.js @@ -0,0 +1,14 @@ +export default { +// first-run welcome: the agent picker (step 1) and the per-agent setup line (step 2) + welcomeAgents(){ return TregAgentSetup.agents; }, +welcomeMoreAgents(){ return TregAgentSetup.moreAgents; }, +welcomeAgent(){ return this.welcomeAgents.concat(this.welcomeMoreAgents).find(a=>a.id===this.welcome.agent) || this.welcomeAgents[0]; }, +welcomeIsMore(){ return this.welcomeMoreAgents.some(a=>a.id===this.welcome.agent); }, +welcomeSetupCmd(){ return this.buildAgentPrompt('agent', true); }, +// onboarding modal: the setup line + team/token as ONE copyable block (token masked until shown) + welcomeSetupFull(){ return this.welcomeSetupCmd+'\n\nwith team '+(this.activeSlugNow||'')+' token: '+(this.myToken||''); }, +welcomeSetupMasked(){ const t=this.myToken?(this.startTokenShow?this.myToken:(this.myToken.slice(0,14)+'••••••••••••••••')):''; + return this.welcomeSetupCmd+'\n\nwith team '+(this.activeSlugNow||'')+' token: '+t; }, +tryExamples(){ return TregAgentSetup.examples; }, +tryOauth(){ return TregAgentSetup.oauthGroups; } +} diff --git a/frontend/src/state/projects.js b/frontend/src/state/projects.js new file mode 100644 index 00000000..7849bf66 --- /dev/null +++ b/frontend/src/state/projects.js @@ -0,0 +1,29 @@ + +export default { +async createProject(){ const name=(this.projectName||'').trim(); + if(!name){ this.orgErr='Give the project a name.'; return; } + this.projBusy=true; this.orgErr=''; + try{ await this.api('/orgs/'+this.activeOrgId+'/projects',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name})}); + this.projectName=''; } + catch(e){ this.orgErr='Add project failed: '+(e.detail||e.status); } + this.projBusy=false; await this.loadOrgAdmin(); }, +async deleteProject(p){ if(this.confirmProj!==p.id){ this.confirmProj=p.id; return; } + try{ await this.api('/orgs/'+this.activeOrgId+'/projects/'+p.id,{method:'DELETE'}); } + catch(e){ this.orgErr='Delete project failed: '+(e.detail||e.status); } + this.confirmProj=null; this.editProj=null; await this.loadOrgAdmin(); }, +projName(pid){ const p=(this.projects||[]).find(x=>x.id===pid); return p?p.name:('project '+pid); }, +openProjTools(p){ if(this.editProj===p.id){ this.editProj=null; return; } + const d={}; (this.tools||[]).forEach(t=>{ d[t.id]=(t.project_id===p.id); }); + this.projToolDraft=d; this.editProj=p.id; }, +async saveProjTools(p){ this.projToolBusy=true; this.orgErr=''; + try{ for(const t of (this.tools||[])){ + const inP=(t.project_id===p.id), want=!!this.projToolDraft[t.id]; + if(inP===want) continue; + // unchecking only frees a tool that is IN this project — never touches another project's tools + const body={project: want ? p.slug : null}; + await this.api('/tools/'+t.id,{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify(body)}); + t.project_id = want ? p.id : null; } + this.editProj=null; } + catch(e){ this.orgErr='Save project tools failed: '+(e.detail||e.status); } + this.projToolBusy=false; await this.loadOrgAdmin(); } +} diff --git a/frontend/src/state/referrals.js b/frontend/src/state/referrals.js new file mode 100644 index 00000000..f6ff9366 --- /dev/null +++ b/frontend/src/state/referrals.js @@ -0,0 +1,36 @@ + +export default { +// ---- referrals ---- + // GET /referrals also runs the payout sweep server-side, so simply opening this page is what + // makes a reward whose hold has elapsed actually land. That is deliberate: treg has no + // scheduler, so the work rides on a request someone is already making. + async loadReferrals(){ if(!this.authed) return; + this.ref={...this.ref, loading:true}; + // One call: GET mints the code if this is the first visit (asking for the page IS the lazy + // trigger), so there is no POST-then-GET round trip and no window where `link` is empty. + const out=await this.api('/referrals').catch(()=>null); + if(out) this.ref={...out, loading:false}; else this.ref={...this.ref, loading:false}; }, +// How much extra THIS preset earns a referred team, or 0. Guarded on the offer existing, so a + // team that arrived on its own sees the buttons exactly as before. + refPresetBonus(usd){ const o=this.billing&&this.billing.referral_offer; + // NULL-GUARD FIRST. `referral_offer` is null for every team that was not referred — i.e. most + // of them — and reading through it throws inside a render, which in Vue blanks the ENTIRE + // dashboard rather than just this row. Cost one blank page during review; keep it on its own + // line rather than folded into the expression below, where deleting a clause drops it silently. + if(!o) return 0; + // Against what is REMAINING, not the full minimum: a team that already added $5 unlocks the + // bonus with another $5, and marking that button "one-time" would be simply wrong. + return (usd*1000000 >= o.remaining_micro) ? o.referred_micro : 0; }, +async copyRefLink(){ try{ await navigator.clipboard.writeText(this.ref.link); }catch(e){} + this.refCopied=true; this.track('referral_link_copied'); + setTimeout(()=>{ this.refCopied=false; }, 1600); }, +// The row's own status, in the words a referrer thinks in. `capped`/`rejected` say WHY rather + // than showing a blank — "I referred someone and got nothing" is the support ticket this + // program generates, and the answer belongs on the page, not in an email to us. + refStatus(r){ + if(r.status==='paid') return 'Paid'; + if(r.status==='qualified') return 'Added funds — pays '+this.bhistDate(r.pays_at); + if(r.status==='pending') return 'Signed up'; + if(r.status==='capped') return 'Over your limit'; + return 'Not eligible'; } +} diff --git a/frontend/src/state/secrets.js b/frontend/src/state/secrets.js new file mode 100644 index 00000000..f542f891 --- /dev/null +++ b/frontend/src/state/secrets.js @@ -0,0 +1,55 @@ + +export default { +// ---- Phase 2b: resource registration (secrets + tools) ---- + async loadSecrets(){ try{ this.secrets=await this.api('/secrets'); }catch(e){ this.secrets=[]; } }, +// Paste a whole .env into the name/value field (Render/Vercel-style): it splits into rows + // client-side — comments/blank lines skipped, `export ` stripped, one balanced quote pair removed. + parseEnvText(t){ const out=[]; + for(let line of t.split(/\r?\n/)){ line=line.trim(); + if(!line||line.startsWith('#')||!line.includes('=')) continue; + let k=line.slice(0,line.indexOf('=')).trim().replace(/^export\s+/i,''), v=line.slice(line.indexOf('=')+1).trim(); + if(v.length>=2&&v[0]===v[v.length-1]&&(v[0]==='"'||v[0]==="'")) v=v.slice(1,-1); + if(k) out.push({name:k,value:v,kind:'env'}); } + return out; }, +// The nudge under a secret row: an exact provider name gets a confirmation, a near miss + // (APOLLO_API_KEY, tikhub-key, DATAFORSEO_TOKEN) gets the exact name the ladder matches on. + // Anything else — a genuinely custom secret — gets silence, not a warning: most secrets are + // for the org's own tools and have every right to any name. + secretNameHint(row){ + const raw=(row.name||'').trim(); if(!raw) return null; + const provs=this.keyNameSuggestions; + const exact=provs.find(p=>p.service===raw); // the ladder compares exactly — "Apollo" is a near miss, not a hit + if(exact) return {ok:true, service:exact.service, + text:(exact.display_name||exact.service)+' catalog calls will use this key automatically'}; + const norm=raw.toLowerCase().replace(/[^a-z0-9]+/g,''); + const near=provs.find(p=>{ const s=p.service.replace(/[^a-z0-9]+/g,''); + return norm===s+'apikey'||norm===s+'key'||norm===s+'token'||norm===s+'api'||norm===s; }); + if(near) return {ok:false, service:near.service, + text:(near.display_name||near.service)+' catalog calls only find a key named exactly “'+near.service+'” —'}; + return null; + }, +pasteEnv(e,i,field){ const t=(e.clipboardData||window.clipboardData).getData('text')||''; + const rows=this.parseEnvText(t); + if(!rows.length) return; // not env-shaped → normal paste + // Single-line pastes: only a NAME=value into the *name* field splits; into the value field it's + // a secret that happens to contain '=' (base64 pad, connection strings) → normal paste. + if(!t.includes('\n')&&(field!=='name'||t.indexOf('=')<1)) return; + e.preventDefault(); + const seen=new Set(this.secretRows.filter((r,j)=>j!==i&&r.name).map(r=>r.name)); + const fresh=rows.filter(r=>!seen.has(r.name)); + this.secretRows.splice(i,1,...(fresh.length?fresh:[{name:'',value:'',kind:'env'}])); }, +async addSecrets(){ const rows=this.secretRows.filter(r=>(r.name||'').trim()&&r.value); + if(!rows.length){ this.secretErr='A secret needs both a name and a value.'; return; } + this.secretBusy=true; this.secretErr=''; + const errs=[]; + for(const r of rows){ const n=r.name.trim(); + if(this.secrets.some(s=>s.name===n)){ errs.push(n+': already exists'); continue; } // dup-name guard + try{ await this.api('/secrets',{method:'POST',encode:true,headers:{'content-type':'application/json'},body:JSON.stringify({name:n,value:r.value,kind:r.kind})}); } + catch(e){ errs.push(n+': '+(e.detail||e.status)); } } + this.secretRows=[{name:'',value:'',kind:'env'}]; await this.loadSecrets(); + if(errs.length) this.secretErr='Some secrets failed — '+errs.join(' · '); + this.secretBusy=false; }, +async deleteSecret(s){ if(this.confirmDelSecret!==s.id){ this.confirmDelSecret=s.id; return; } this.confirmDelSecret=null; this.secretErr=''; + try{ await this.api('/secrets/'+s.id,{method:'DELETE'}); await this.loadSecrets(); } + catch(e){ this.secretErr='Delete secret failed: '+(e.detail||e.status); } } +} diff --git a/frontend/src/state/session.js b/frontend/src/state/session.js new file mode 100644 index 00000000..2b029fc5 --- /dev/null +++ b/frontend/src/state/session.js @@ -0,0 +1,82 @@ +import { requestJson } from '../api' +import { LS } from './constants.js' + +export default { +focusOverlay(open){ if(!open) return; this.$nextTick(()=>{ const el=document.querySelector('.scrim input,.scrim textarea,.scrim button,.drawer input,.drawer button'); el&&el.focus(); }); }, +servedOn(tier){ return ({anonymous:'public provider route (no key)',platform:'treg key',credential:'your key',tool:'your registered tool','platform-overflow':'treg overflow'})[tier]||tier; }, +headers(tok){ + const h={'ngrok-skip-browser-warning':'1'}; + if(tok){ h['X-Treg-Token']=tok; } // explicit (token login validation) + else if(this.sessionMode){ const a=this.activeOrg; if(a) h['X-Treg-Org']=a.slug; } // cookie carries identity + else if(this.token){ h['X-Treg-Token']=this.token; } // token mode + return h; }, +async api(path, opts={}){ + return requestJson(path, opts, this.headers(), () => { + this.sessionMode=false; location.reload(); + }, () => this.sessionMode); + }, +save(){ localStorage.setItem(LS, JSON.stringify(this.cfg)); }, +connected(slug){ return this.sessionMode || !!this.cfg.orgs[slug]; }, +toggleTheme(){ this.theme=this.theme==='dark'?'light':'dark'; document.documentElement.dataset.theme=this.theme; localStorage.setItem('treg-theme',this.theme); }, +_stashNext(){ // OAuth callbacks land on /app, losing a /app/skills/ deep link — stash it to restore after boot + const d=this.routeFromPath(location.pathname)||this.mkFromPath(location.pathname); + if(d) localStorage.setItem('treg-next', location.pathname); }, +githubLogin(){ this._stashNext(); location.href='/auth/github'; }, +googleLogin(){ this._stashNext(); location.href='/auth/google'; }, +openSignin(){ this.demo.signin=true; }, +async emailStart(){ const e=(this.emailInput||'').trim(); if(!e){ this.loginErr='Enter your email address.'; return; } this.busy=true; this.loginErr=''; + try{ const r=await this.api('/auth/email/start',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({email:e})}); + this.emailStage=true; this.devCode=r.dev_code||''; this.codeInput=''; } + catch(err){ this.loginErr='Could not send a code - try again.'; } finally{ this.busy=false; } }, +async emailVerify(){ const code=(this.codeInput||'').trim(); if(!code) return; this.busy=true; this.loginErr=''; + try{ await this.api('/auth/email/verify',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({email:this.emailInput.trim(),code})}); + location.reload(); } // verify sets the session cookie → reload lands in session mode (like GitHub) + catch(err){ this.loginErr = err.status===401?'Wrong or expired code.':'Sign-in failed.'; } finally{ this.busy=false; } }, +async acceptInvite(inv){ try{ await this.api('/invites/'+inv.id+'/accept',{method:'POST'}); + this.pendingInvites=this.pendingInvites.filter(i=>i.id!==inv.id); await this.loadAll(); } + catch(e){ this.err='Accept failed: '+(e.detail||e.status); } }, +logout(){ window.TregTracking?.identify('',''); try{ if(window.Intercom) window.Intercom('shutdown'); }catch(e){} // drop the Intercom cookie so the next user on this machine can't read these conversations + if(this.sessionMode){ fetch('/auth/logout',{method:'POST',credentials:'include'}).finally(()=>{localStorage.removeItem('treg-active');location.reload();}); } else { this.cfg={active:null,orgs:{}}; this.save(); location.reload(); } }, +async addToken(tok, isAdd){ tok=(tok||'').trim(); if(!tok) return; this.busy=true; this.loginErr=''; + try{ const orgs=await this.api('/orgs',{headers:this.headers(tok)}); const active=orgs.find(o=>o.active)||orgs[0]; + if(!active){ this.loginErr='Token has no org.'; return; } + this.cfg.orgs[active.slug]={token:tok, role:active.role, name:active.name, org_id:active.org_id}; + this.cfg.active=active.slug; this.save(); this.tokenInput=''; this.addOrg=false; await this.loadAll(); + }catch(e){ this.loginErr = e.status===401?'Invalid token.':('Error: '+(e.detail||e.status)); } + finally{ this.busy=false; } }, +switchOrg(o){ this.orgMenu=false; this.newAgent=null; this.snipAgent=null; this.newApiKey=null; this.keyMsg=null; + if(this.sessionMode){ this.activeSlug=o.slug; localStorage.setItem('treg-active',o.slug); this.loadAll(); this.intercomUpdate(); return; } + if(!this.connected(o.slug)){ this.addOrg=true; return; } + this.cfg.active=o.slug; this.save(); this.loadAll(); this.intercomUpdate(); }, +async loadAll(){ this.err=''; this.loading=true; + try{ + this.myOrgs=await this.api('/orgs'); + if(!this.sessionMode && !this.me){ const who=await this.api('/auth/me').catch(()=>null); if(who){ this.me=who.email; this.isAdmin=!!who.is_superadmin; } } // token mode: learn our own email + superadmin flag (isPersonal / join-by-code) + // Re-mint the bearer whenever the ACTIVE org changes: the token now bakes the org slug in + // (so it works as a bare MCP Authorization bearer), and a stale one would name the old team. + // Signed derivation — cheap; the selected Default row contributes its team-local generation. + if(this.myToken===null || this._myTokenOrg!==this.activeSlugNow) await this.loadDefaultToken(); + if(this.sessionMode){ this.pendingInvites=await this.api('/invites/mine').catch(()=>[]); } // BEFORE the no-orgs early return: an invited user has 0 orgs but DOES have a pending invite — maybeOnboard needs it to offer joining instead of forcing create-team + if(!this.myOrgs.length){ this.tools=[]; this.bundles=[]; this.health={}; return; } // brand-new user: no team yet → the mandatory welcome (maybeOnboard) creates the first one; skip org-scoped fetches (they'd 400). finally{} clears loading. + if(this.sessionMode && (!this.activeSlug || !this.myOrgs.some(o=>o.slug===this.activeSlug)) && this.myOrgs.length){ + // Land on the org that actually has tools (most first). Tie / all-empty -> prefer a TEAM over + // the personal org (first-run confusion killer). Fixes: imports living in the personal space + // while the default opened an empty team. + const byTools=[...this.myOrgs].sort((a,b)=> (b.tool_count||0)-(a.tool_count||0) + || ((this.isPersonal(a)?1:0)-(this.isPersonal(b)?1:0)) ); + this.activeSlug=byTools[0].slug; localStorage.setItem('treg-active',this.activeSlug); + } + this.loadConnections(); // fire-and-forget: connections must never block the tools view + const [tools, health, bundles]=await Promise.all([this.api('/tools'), this.api('/health').catch(()=>[]), this.api('/bundles').catch(()=>[])]); + this.tools=tools; this.bundles=bundles||[]; this.health={}; (health||[]).forEach(h=>this.health[h.secret_id]=h.status); + // isAdmin (super-admin) comes from /auth/me at boot - NOT a /admin/stats probe, which 403s on + // every load/switch for the 99% of users who aren't super-admins (console-error noise + wasted request). + this.loadBilling(); // fire-and-forget, self-guards on canAdmin — feeds the sidebar balance card on every load/switch + if(this.view==='orgs'){ this.loadOrgAdmin(); this.loadMyUsage(); } // refresh team panel after a switch + // Activity is one view with two tabs now, so a team switch has to refresh whichever is open + // — both are org-scoped and would otherwise keep showing the previous team's numbers. + if(this.view==='activity'){ this.loadCalls(); if(this.actTab==='usage') this.loadUsage(); } + if(this.view==='secrets') this.loadSecrets(); // …and the Secrets view (was showing the previous org's) + }catch(e){ this.err='Failed to load: '+(e.detail||e.status); } + finally{ this.loading=false; } } +} diff --git a/frontend/src/state/sessionComputed.js b/frontend/src/state/sessionComputed.js new file mode 100644 index 00000000..9fdf8213 --- /dev/null +++ b/frontend/src/state/sessionComputed.js @@ -0,0 +1,59 @@ +export default { +token(){ return this.cfg.active ? (this.cfg.orgs[this.cfg.active]||{}).token : null; }, +authed(){ return this.sessionMode || !!this.token; }, +activeSlugNow(){ return this.sessionMode ? this.activeSlug : this.cfg.active; }, +activeOrg(){ + if(this.sessionMode){ return this.myOrgs.find(o=>o.slug===this.activeSlug) || this.myOrgs[0] || null; } + const a=this.cfg.orgs[this.cfg.active]; return a ? {slug:this.cfg.active, name:a.name, role:a.role, org_id:a.org_id} : null; + }, +activeName(){ const a=this.activeOrg; return a ? (a.name||a.slug) : ''; }, +renameDirty(){ const a=this.activeOrg||{}, n=this.renameName.trim(), s=this.renameSlug.trim(); + return !!((n && n!==a.name) || (s && s!==a.slug)); }, +activeRole(){ const a=this.activeOrg; return a ? a.role : 'member'; }, +initials(){ return (this.me||'?').slice(0,2).toUpperCase(); }, +filteredTools(){ const q=this.q.toLowerCase(); return this.tools.filter(t=>!q||t.name.toLowerCase().includes(q)||t.base_url.toLowerCase().includes(q)); }, +// Home is segregated into three groups. Endpoints = tools you added directly (no bundle); Skills = + // tools that came from a skill package (a bundle, so they carry a recipe); Recipes = recipe-only + // bundles (a knowledge skill with no callable tool). + endpoints(){ const q=this.q.toLowerCase(); return this.tools.filter(t=>!t.bundle_id && (!q||t.name.toLowerCase().includes(q)||(t.base_url||'').toLowerCase().includes(q))); }, +skillTools(){ const q=this.q.toLowerCase(); return this.tools.filter(t=>t.bundle_id && (!q||t.name.toLowerCase().includes(q)||(t.base_url||'').toLowerCase().includes(q))); }, +recipes(){ const q=this.q.toLowerCase(); const withTool=new Set(this.tools.filter(t=>t.bundle_id!=null).map(t=>t.bundle_id)); + return this.bundles.filter(b=>!withTool.has(b.id) && (!q||b.name.toLowerCase().includes(q))); }, +hasAnyTools(){ return this.endpoints.length||this.skillTools.length||this.recipes.length; }, +staleConns(){ return (this.connections||[]).filter(c=>c.needs_reconnect); }, +// dying credentials treg can't renew itself + needSecondCred(){ return (this.connections||[]).filter(c=>c.extra_credential_note); }, +// connected, but not yet callable (Google Ads' developer token) + toolGroups(){ return [ + {key:'endpoints', label:'Endpoints/CLI', hint:'APIs and CLIs you registered directly', rows:this.endpoints}, + {key:'skills', label:'Integration Skills', hint:'tools from a skill package (carry a recipe)', rows:this.skillTools}, + ]; }, +cliShowHtml(){ + const t = [ + '$ treg login', + '✓ Signed in as you@team.dev (org: team)', + '', + '$ treg add stripe --base-url https://api.stripe.com --secret STRIPE_KEY', + "✓ Registered 'stripe' - the key is stored server-side, never on your machine", + '', + '$ treg call https://api.stripe.com/v1/balance', + '{ "object": "balance", "available": [ { "amount": 4210, "currency": "usd" } ] }', + ].join('\n'); + return this.tutHL(t,'cmd'); + }, +activeOrgId(){ return this.activeOrg ? this.activeOrg.org_id : null; }, +canAdmin(){ return ['admin','owner'].includes(this.activeRole); }, +canRegister(){ return this.activeRole!=='viewer'; }, +// members+ may create secrets/tools + secretRowsReady(){ return this.secretRows.filter(r=>(r.name||'').trim()&&r.value).length; }, +// The names the marketplace credential ladder actually matches: tier 2 finds an org secret + // NAMED exactly for the provider (Secret.name == service), so these are the names worth + // suggesting — a key called APOLLO_API_KEY works as a plain secret but the catalog never sees it. + keyNameSuggestions(){ return (this.providers||[]) + .filter(p=>p.auth_kind==='key'||p.auth_kind==='token') + .slice().sort((a,b)=>a.service.localeCompare(b.service)); }, +// ---- detail pages ---- + accessNames(){ // the grantable universe: tool names + recipe-only skill names (an integration + // skill is reachable through its tool's name; recipe-only bundles need their own entry) + return [...new Set([...this.tools.map(t=>t.name), ...this.recipes.map(r=>r.name)])].sort(); } +} diff --git a/frontend/src/state/sharing.js b/frontend/src/state/sharing.js new file mode 100644 index 00000000..903e16fd --- /dev/null +++ b/frontend/src/state/sharing.js @@ -0,0 +1,30 @@ + +export default { +copyMd(url){ fetch(url).then(r=>r.text()).then(t=>navigator.clipboard.writeText(t)).catch(()=>{}); }, +// copy raw markdown + openMd(url){ window.open(url,'_blank'); }, +// open the .md in a new tab (method, not a template global) + copyLlms(){ navigator.clipboard.writeText(this.proxy+'/llms.txt').then(()=>{ this.llmsCopied=true; setTimeout(()=>{ this.llmsCopied=false; },1500); }).catch(()=>{}); }, +async copyToken(){ if(!this.myToken) return; if(!(await this.toClipboard(this.myToken))) return; this.tokenCopied=true; setTimeout(()=>{ this.tokenCopied=false; },1500); }, +// ONE instruction, not two. The old pair (admin "Setup" / consumer "Connect") each re-explained + // the catalog, the credential ladder, prices and the balance — knowledge that now lives in the + // skill install.sh installs, and in llms.txt. A prompt can only carry what a FILE cannot: the + // token, the team, permission to run things, and "do it now". `kind` is ignored (call sites keep + // their argument) so there is a single text to keep true. + buildAgentPrompt(kind, inclToken){ // ONE line — llms.txt carries the whole setup flow (auth included) + return TregAgentSetup.command(this.proxy); + }, +async copyAgentPrompt(){ if(!(await this.toClipboard(this.agentPromptText))) return; this.agentGuideCopied=true; setTimeout(()=>this.agentGuideCopied=false,1500); }, +async copyVendorPrompt(){ if(!(await this.toClipboard(this.vendorPromptText))) return; this.vendorCopied=true; setTimeout(()=>this.vendorCopied=false,1500); }, +openToolRequest(){ this.reqDone=false; this.reqErr=''; this.reqForm.capability=(this.q||'').trim(); this.reqAsk=true; }, +// pre-fill with the search that came up short + async submitToolRequest(){ const cap=(this.reqForm.capability||'').trim(); + if(!cap){ this.reqErr='Say what tool or capability you need.'; return; } + this.reqBusy=true; this.reqErr=''; + try{ await this.api('/tool-requests',{method:'POST',headers:{'content-type':'application/json'}, + body:JSON.stringify({capability:cap, query:(this.q||'').trim(), note:this.reqForm.note, contact:this.reqForm.contact, source:'web'})}); + this.reqDone=true; this.reqForm={capability:'', note:'', contact:''}; } + catch(e){ this.reqErr = e.status===429?'Too many requests from here — try again later.':('Could not send: '+(e.detail||e.status)); } + finally{ this.reqBusy=false; } }, +async copyAgentGuide(kind){ if(!(await this.toClipboard(this.buildAgentPrompt(kind, true)))) return; this.agentRowCopied=kind; setTimeout(()=>{ if(this.agentRowCopied===kind) this.agentRowCopied=null; },1500); } +} diff --git a/frontend/src/state/skills.js b/frontend/src/state/skills.js new file mode 100644 index 00000000..4f7bfa09 --- /dev/null +++ b/frontend/src/state/skills.js @@ -0,0 +1,79 @@ + +export default { +openRecipeCopy(r){ this.copyRecipe=r; this.recipeTab='cURL'; this.copied=false; }, +async openRecipeView(r){ this.err=''; + try{ const b=await this.api('/bundles/'+r.id); const rec=b.recipe||''; this.viewRecipe={id:r.id, name:r.name, recipe:rec, orig:rec}; this.copied=false; this.recipeSaved=false; } + catch(e){ this.err='Could not load recipe: '+(e.detail||e.status); } }, +async saveRecipe(){ if(!this.viewRecipe) return; this.err=''; + try{ await this.api('/bundles/'+this.viewRecipe.id,{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({recipe:this.viewRecipe.recipe})}); + this.viewRecipe.orig=this.viewRecipe.recipe; this.recipeSaved=true; setTimeout(()=>{ this.recipeSaved=false; },1400); await this.loadAll(); } + catch(e){ this.err='Save failed: '+(e.detail||e.status); } }, +recipeSnippet(tab){ const r=this.copyRecipe; if(!r) return {text:'',html:''}; + const proxy=(this.proxy||location.origin).replace(/\/$/,''); + const tok=this.myToken||'$TREG_TOKEN', tokS=tok; // full token in the preview — matches the copied text + const esc=s=>String(s).replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c])); + const P=s=>`${esc(s)}`, A=s=>`${esc(s)}`, + T=s=>`${esc(s)}`, C=s=>`${esc(s)}`, K=s=>`${esc(s)}`; + switch(tab){ + case 'cURL': return { + text:`TREG_API_TOKEN=${tok}\ncurl -fsSL "${proxy}/skills/${r.name}/install.sh?token=$TREG_API_TOKEN" | sh`, + html:`TREG_API_TOKEN=${T(tokS)}\n${K('curl')} -fsSL "${P(proxy)}${C('/skills/')}${A(r.name)}${C('/install.sh?token=')}${T('$TREG_API_TOKEN')}" | sh` }; + case 'CLI': return { text:`treg skill install ${r.name}`, html:`${P('treg skill install')} ${A(r.name)}` }; + case 'Claude Code': return { + text:`# Install once - Claude Code then loads it from .claude/skills/ automatically.\ntreg skill install ${r.name}\n# ...then just ask Claude to use the "${r.name}" skill.`, + html:`${C('# Install once - Claude Code then loads it from .claude/skills/ automatically.')}\n${P('treg skill install')} ${A(r.name)}\n${C('# ...then just ask Claude to use the "'+r.name+'" skill.')}` }; + } }, +openAddSkill(){ this.skillErr=''; this.skillMode='folder'; this.skillFiles=[]; this.detected=null; + this.skillSel={}; this.skillVals={}; this.skillResults=null; this.newSkill=true; this._seedSkillJson(); }, +_seedSkillJson(){ this.skillJson = JSON.stringify({ + name:"my-skill", recipe:"# my-skill\nWhat it does.", + secrets:[{local_name:"key", value:"sk-…", kind:"env"}], + tools:[{name:"my-tool", base_url:"https://api.example.com", + bindings:[{secret:"key", injector:"env", location:"header", name:"Authorization", format:"Bearer {secret}"}]}] + }, null, 2); this.newSkill=true; }, +async addSkill(){ let payload; try{ payload=JSON.parse(this.skillJson); }catch(e){ this.skillErr='Not valid JSON: '+e.message; return; } + // Structural validation (the tour promises the JSON is checked before it's sent): a name, at + // least one well-formed tool, and every binding.secret must match a declared local_name. + if(!payload || !payload.name){ this.skillErr='A skill needs a "name".'; return; } + if(!Array.isArray(payload.tools) || !payload.tools.length){ this.skillErr='A skill needs at least one tool.'; return; } + const locals=new Set((payload.secrets||[]).map(s=>s.local_name)); + for(const t of payload.tools){ + if(!t.name || !t.base_url){ this.skillErr='Each tool needs a name and base_url.'; return; } + for(const b of (t.bindings||[])){ + if(b.secret!=null && !locals.has(b.secret)){ this.skillErr='Binding references unknown secret "'+b.secret+'" - declare it in secrets[].local_name.'; return; } + } + } + this.skillBusy=true; this.skillErr=''; + try{ await this.api('/skills',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload)}); + this.newSkill=false; await this.loadAll(); } + catch(e){ this.skillErr='Register failed: '+(e.detail||e.status); } finally{ this.skillBusy=false; } }, +async onSkillFolder(e){ this.skillErr=''; this.detected=null; this.skillResults=null; + const want=p=>/(^|\/)(SKILL\.md|skill\.md|treg\.json)$/i.test(p) || /\.(sh|js|mjs|ts|py)$/i.test(p) + || /(^|\/)\.env$/i.test(p) || /(^|\/)\.secrets?\//i.test(p) || /(^|\/)(client_secret|token|developer_token)/i.test(p); + const picked=[...e.target.files].filter(f=>want(f.webkitRelativePath) && f.size<524288).slice(0,600); + if(!picked.length){ this.skillErr='No skill files here - a skill folder needs a SKILL.md.'; return; } + this.skillFiles=await Promise.all(picked.map(async f=>({path:f.webkitRelativePath, content:await f.text()}))); + await this.analyzeSkills(); }, +async analyzeSkills(){ if(!this.skillFiles.length) return; + const payload=JSON.stringify({files:this.skillFiles}); + if(payload.length>8*1048576){ this.skillErr='That folder is large ('+Math.round(payload.length/1048576)+' MB, '+this.skillFiles.length+' files). Pick a single skill folder or a smaller subfolder.'; return; } + this.skillBusy=true; this.skillErr=''; + try{ const r=await this.api('/skills/analyze',{method:'POST',headers:{'content-type':'application/json'},body:payload,encode:true}); + this.detected=r.skills; this.skillSel={}; this.skillVals={}; + for(const s of r.skills){ this.skillSel[s.name]=s.ready && !s.already; } + if(!r.skills.length) this.skillErr='No SKILL.md found in that folder.'; } + catch(e){ this.skillErr='Analyze failed: '+(e.detail||e.message||(e.status?('HTTP '+e.status):'request failed - try a single skill or a smaller subfolder')); } finally{ this.skillBusy=false; } }, +skillCliNote(s){ const c=s.cli; if(!c) return ''; + if(c.source==='unsupported') return '⌘ '+c.bin+' CLI: not supported for local runs - '+c.reason; + if(c.source==='contract') return '⌘ local runs: members can `treg run '+s.name+'`'+(c.enabled?'':' (enable after import)'); + return '⌘ '+c.bin+' CLI: local runs available via `treg run` once an owner enables it'+(c.verified?' (verified)':''); }, +skillSummary(s){ return s.kind==='recipe_only' ? 'Recipe-only - installs the SKILL.md as a shareable recipe.' + : 'API tool - registers a tool calling '+(s.base_url||'?')+' + the recipe.'; }, +async importSkills(){ const select=Object.keys(this.skillSel).filter(n=>this.skillSel[n]); + if(!select.length){ this.skillErr='Select at least one skill to register.'; return; } + this.skillBusy=true; this.skillErr=''; + try{ const r=await this.api('/skills/import',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({files:this.skillFiles, select, env_values:this.skillVals}),encode:true}); + this.skillResults=r.results; await this.loadAll(); + if(r.results.length && r.results.every(x=>x.ok)){ setTimeout(()=>{ this.newSkill=false; }, 1000); } } + catch(e){ this.skillErr='Import failed: '+(e.detail||e.message||(e.status?('HTTP '+e.status):'request failed - try registering fewer skills at once')); } finally{ this.skillBusy=false; } } +} diff --git a/frontend/src/state/snippets.js b/frontend/src/state/snippets.js new file mode 100644 index 00000000..93e0d4e0 --- /dev/null +++ b/frontend/src/state/snippets.js @@ -0,0 +1,57 @@ + +export default { +samplePath(host){ // known-good, runnable GET paths (relative to each tool's base_url) for common hosts + return ({'api.trykitt.ai':'credit','httpbin.org':'headers','postman-echo.com':'get', + 'api.intercom.io':'me','api.stripe.com':'balance','api.render.com':'services?limit=1', + 'api.vercel.com':'v2/user','app.posthog.com':'api/projects/@current','eu.posthog.com':'api/projects/@current', + 'searchconsole.googleapis.com':'webmasters/v3/sites','googleads.googleapis.com':'v25/customers:listAccessibleCustomers', + 'tagmanager.googleapis.com':'tagmanager/v2/accounts', + // part=snippet rather than the probe's part=id: same 1 quota unit, but it returns the channel + // title, so the panel shows something a human recognises instead of an opaque UC… id. + 'youtube.googleapis.com':'youtube/v3/channels?part=snippet&mine=true', + // identity endpoints: cheap, and they return a handle/name a human recognises + 'api.x.com':'2/users/me','api.linkedin.com':'v2/userinfo', + // Both Meta providers share this host, so one entry covers Facebook Pages and Instagram. + // /me is the person behind the token and needs no scope, so it prefills usefully whichever + // asset the connection ended up bound to. + 'graph.facebook.com':'me?fields=id,name', + 'mybusinessaccountmanagement.googleapis.com':'v1/accounts','slack.com':'auth.test'})[host]||''; }, +openCopy(t){ this.copyTool=t; this.snippetTab='cURL'; this.copied=false; + // Prefer a real, runnable sample path so a copied snippet returns a result: a declared example, + // else the health-check path, else a known-good sample for a well-known host, else the placeholder. + const ex=(t.examples||[])[0], hp=t.health_check&&t.health_check.path; + this.exPath=ex?ex.path:(hp||this.samplePath(t.host)||''); this.exMethod=ex?(ex.method||'GET'):'GET'; }, +pickEx(ex){ this.exPath=ex.path; this.exMethod=ex.method||'GET'; }, +buildSnippet(tab){ + const t=this.copyTool; if(!t) return {html:'',text:''}; + const proxy=(this.proxy||location.origin).replace(/\/$/,''); const up=t.base_url.replace(/\/$/,''); + const path=this.exPath||''; const m=this.exMethod||'GET'; + // Escape before wrapping - the html goes through v-html, so a literal "" (or any '<' in a + // path/name) would otherwise be parsed as an (empty) element and vanish from the preview. + const esc=s=>String(s).replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c])); + const P=s=>`${esc(s)}`, H=s=>`${esc(s)}`, + A=s=>`${esc(s)}`, T=s=>`${esc(s)}`, + C=s=>`${esc(s)}`, K=s=>`${esc(s)}`; + const urlT=`${proxy}/call/${up}/${path}`; + const urlH=`${P(proxy)}${C('/call/')}${H(up)}/${A(path)}`; + const hint=(t.examples||[]).length?`e.g. ${t.examples[0].path}`:'the part after the domain'; + const tok=this.myToken||'$TREG_TOKEN', org=(this.activeOrg&&this.activeOrg.slug)||'$TREG_ORG'; // real token+org so a copied snippet runs as-is + const tokShort=tok; // show the full real token — the preview must match what Copy puts on the clipboard + switch(tab){ + case 'Claude Code': return { + text:`# Call "${t.name}" through treg - your key is injected server-side.\n${m} ${urlT}\nX-Treg-Token: ${tok}\nX-Treg-Org: ${org}\n\n# PATH = the ${t.host} path you'd normally call (${hint}).`, + html:`${C('# Call "'+t.name+'" through treg - your key is injected server-side.')}\n${K(m)} ${urlH}\n${C('X-Treg-Token:')} ${T(tokShort)}\n${C('X-Treg-Org:')} ${T(org)}\n\n${C('# PATH = the '+t.host+" path you'd normally call ("+hint+').')}` }; + case 'CLI': return { text:`treg call ${t.name} ${path}${m!=='GET'?' --method '+m:''}`, html:`${P('treg call')} ${H(t.name)} ${A(path)}${m!=='GET'?' '+K('--method '+m):''}` }; + case 'Python': return { + text:`import httpx\nr = httpx.request("${m}", "${urlT}", headers={"X-Treg-Token": "${tok}", "X-Treg-Org": "${org}"})\nprint(r.json())`, + html:`import httpx\nr = httpx.request(${A('"'+m+'"')}, "${urlH}", headers={"X-Treg-Token": ${T('"'+tokShort+'"')}, "X-Treg-Org": ${T('"'+org+'"')}})\nprint(r.json())` }; + case 'Node': return { + text:`const r = await fetch("${urlT}", {\n method: "${m}",\n headers: { "X-Treg-Token": "${tok}", "X-Treg-Org": "${org}" },\n});\nconsole.log(await r.json());`, + html:`const r = await fetch("${urlH}", {\n method: ${A('"'+m+'"')},\n headers: { "X-Treg-Token": ${T('"'+tokShort+'"')}, "X-Treg-Org": ${T('"'+org+'"')} },\n});\nconsole.log(await r.json());` }; + case 'cURL': return { + text:`TREG_API_TOKEN=${tok}\ncurl -X ${m} \\\n -H "X-Treg-Token: $TREG_API_TOKEN" \\\n -H "X-Treg-Org: ${org}" \\\n "${urlT}"`, + html:`TREG_API_TOKEN=${T(tokShort)}\n${K('curl')} -X ${K(m)} \\\n -H "X-Treg-Token: ${T('$TREG_API_TOKEN')}" \\\n -H "X-Treg-Org: ${T(org)}" \\\n "${urlH}"` }; + } + }, +async copy(text){ if(!(await this.toClipboard(text))) return; this.copied=true; setTimeout(()=>this.copied=false,1500); } +} diff --git a/frontend/src/state/team.js b/frontend/src/state/team.js new file mode 100644 index 00000000..986b366a --- /dev/null +++ b/frontend/src/state/team.js @@ -0,0 +1,105 @@ + +export default { +// ---- Phase 2a: org lifecycle (writes; all endpoints already exist) ---- + async joinByCode(){ const code=(this.joinCode||'').trim(); if(!code){ this.joinErr='Enter the invite code.'; return; } this.joinBusy=true; this.joinErr=''; + try{ const r=await this.api('/invites/accept',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({code, email:this.me})}); + this.showJoin=false; this.joinCode=''; await this.loadAll(); this.switchOrg({slug:r.org}); } + catch(e){ this.joinErr = e.status===404?'Invalid or already-used code.':(e.status===403?'That code is for a different email.':(e.status===409?'You are already a member.':('Join failed: '+(e.detail||e.status)))); } + finally{ this.joinBusy=false; } }, +async createOrg(){ const name=(this.newOrgName||'').trim(); if(!name){ this.orgErr='Enter a team name.'; return; } this.orgBusy=true; this.orgErr=''; + try{ const o=await this.api('/orgs',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name})}); + // Token mode has no session - persist the new org's token (returned once) or switchOrg would + // pop the "paste a token" modal for a token we just discarded. + if(!this.sessionMode && o.token){ this.cfg.orgs[o.org]={token:o.token, role:o.role, name:o.name, org_id:o.org_id}; this.save(); } + this.newOrg=false; this.newOrgName=''; await this.loadAll(); this.switchOrg({slug:o.org}); } + catch(e){ this.orgErr='Could not create: '+(e.detail||e.status); } finally{ this.orgBusy=false; } }, +async loadOrgAdmin(){ this.orgMembers=[]; this.orgInvites=[]; this.lastInvite=null; + if(!this.canAdmin && !['keys','danger'].includes(this.orgTab)) this.orgTab='keys'; + this.orgErr=''; this.confirmDel=''; this.confirmLeave=false; this.confirmRemove=null; + if(!this.activeOrgId) return; const id=this.activeOrgId; + await this.loadApiKeys(); + if(!this.canAdmin) return; + this.agentErr=''; this.confirmAgent=null; + try{ this.orgMembers=await this.api('/orgs/'+id+'/members'); this.orgInvites=await this.api('/orgs/'+id+'/invites'); + this.projects=await this.api('/orgs/'+id+'/projects'); this.denyRules=await this.api('/orgs/'+id+'/deny'); + this.cliDeny=await this.api('/orgs/'+id+'/policy/cli-deny').catch(()=>[]); + // agents live in the same roster now (an agent IS a membership) + this.agents=await this.api('/orgs/'+id+'/agents').catch(()=>[]); + const sel={}; this.projects.forEach(p=>{ sel[p.id]=true; }); this.agentProjSel=sel; + this.observedAgents=await this.api('/orgs/'+id+'/agents/observed').catch(()=>[]); } + catch(e){ this.orgErr='Load team failed: '+(e.detail||e.status); } }, +async loadMyUsage(){ if(!this.activeOrgId){ this.myUsage=null; return; } + this.myUsage=await this.api('/usage/me').catch(()=>null); }, +// the caller's own used/cap (any member) + async setCap(m, val){ const cap=parseInt(val,10); + if(isNaN(cap)||cap<-1){ this.orgErr='Daily cap must be -1 (unlimited) or 0 and above.'; await this.loadOrgAdmin(); return; } + if(cap===m.daily_call_cap) return; + try{ await this.api('/orgs/'+this.activeOrgId+'/members/'+m.user_id+'/cap',{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({daily_call_cap:cap})}); } + catch(e){ this.orgErr='Set cap failed: '+(e.detail||e.status); } + await this.loadOrgAdmin(); }, +// reload → reverts the input to the true cap on failure + async sendInvite(){ const email=(this.inviteEmail||'').trim(); if(!email){ this.orgErr='Enter an email address to invite.'; return; } + // Invites attach to an email - the server only trims/lowercases, so guard the format here or a + // typo like "foo bar" becomes a permanently-unacceptable dead invite cluttering the list. + if(!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)){ this.orgErr='Enter a valid email address.'; return; } + this.orgBusy=true; this.orgErr=''; + const chosen=this.accessNames.filter(n=>this.inviteToolSel[n]); + const tool_access = !this.inviteCustomize ? null : (chosen.length===this.accessNames.length ? null : chosen); + try{ this.lastInvite=await this.api('/orgs/'+this.activeOrgId+'/invites',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({email,role:this.inviteRole,tool_access,local_run_enabled:this.inviteLocalRun})}); + this.inviteEmail=''; this.inviteCustomize=false; this.orgInvites=await this.api('/orgs/'+this.activeOrgId+'/invites'); } + catch(e){ this.orgErr='Invite failed: '+(e.detail||e.status); } finally{ this.orgBusy=false; } }, +openInviteCustomize(){ this.inviteCustomize=true; const d={}; this.accessNames.forEach(n=>d[n]=true); this.inviteToolSel=d; }, +openAccess(m){ if(m.role==='owner') return; + if(this.editAccess===m.user_id){ this.editAccess=null; return; } + this.editAccess=m.user_id; const d={}; this.accessNames.forEach(n=>{ d[n]=(m.tool_access===null || m.tool_access.includes(n)); }); this.accessDraft=d; + const pd={}; this.projects.forEach(p=>{ pd[p.id]=(m.project_access===null || m.project_access.includes(p.id)); }); this.projDraft=pd; }, +setAllAccess(v){ const d={}; this.accessNames.forEach(n=>d[n]=v); this.accessDraft=d; }, +async saveAccess(m){ const names=this.accessNames.filter(n=>this.accessDraft[n]); + const tool_access = (names.length===this.accessNames.length) ? null : names; // all checked → 'all' (NULL) + const picked=this.projects.filter(p=>this.projDraft[p.id]).map(p=>p.id); + const project_access = (!this.projects.length || picked.length===this.projects.length) ? null : picked; // all checked → 'all' + try{ await this.api('/orgs/'+this.activeOrgId+'/members/'+m.user_id+'/access',{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({tool_access, project_access, local_run_enabled:m.local_run_enabled})}); this.editAccess=null; + if(m.is_agent) this.orgMsg=m.name+' access updated. Its key is unchanged, and the new tool and project permissions apply immediately.'; } + catch(e){ this.orgErr='Set access failed: '+(e.detail||e.status); } + await this.loadOrgAdmin(); }, +async setLocalRun(m, val){ + try{ await this.api('/orgs/'+this.activeOrgId+'/members/'+m.user_id+'/access',{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({tool_access:m.tool_access, local_run_enabled:val})}); } + catch(e){ this.orgErr='Set local-run failed: '+(e.detail||e.status); } + await this.loadOrgAdmin(); }, +async setRole(m, role){ if(role===m.role) return; + let err=''; + try{ await this.api('/orgs/'+this.activeOrgId+'/members/'+m.user_id,{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({role})}); } + catch(e){ err='Role change failed: '+(e.detail||e.status); } + await this.loadOrgAdmin(); // ALWAYS reload → reverts the (:value-bound) select to the true role on failure + if(err) this.orgErr=err; // set the message AFTER the reload (loadOrgAdmin resets orgErr) + }, +async removeMember(m){ if(this.confirmRemove!==m.user_id){ this.confirmRemove=m.user_id; return; } this.confirmRemove=null; + try{ await this.api('/orgs/'+this.activeOrgId+'/members/'+m.user_id,{method:'DELETE'}); await this.loadOrgAdmin(); } + catch(e){ this.orgErr='Remove failed: '+(e.detail||e.status); } }, +async revokeInvite(inv){ try{ await this.api('/orgs/'+this.activeOrgId+'/invites/'+inv.id,{method:'DELETE'}); + this.orgInvites=this.orgInvites.filter(i=>i.id!==inv.id); } + catch(e){ this.orgErr='Revoke failed: '+(e.detail||e.status); } }, +forgetActiveOrg(){ // drop the now-dead active org in whichever mode we're in, then fall back to another + if(this.sessionMode){ this.activeSlug=null; localStorage.removeItem('treg-active'); } + else { const s=this.cfg.active; if(s) delete this.cfg.orgs[s]; this.cfg.active=Object.keys(this.cfg.orgs)[0]||null; this.save(); } }, +async leaveOrg(){ if(!this.confirmLeave){ this.confirmLeave=true; return; } this.confirmLeave=false; + try{ await this.api('/orgs/'+this.activeOrgId+'/leave',{method:'POST'}); + this.forgetActiveOrg(); await this.loadAll(); } + catch(e){ this.orgErr='Leave failed: '+(e.detail||e.status); } }, +resetRenameForm(){ const a=this.activeOrg||{}; this.renameName=a.name||''; this.renameSlug=a.slug||''; this.renameErr=''; }, +async renameOrg(){ this.renameBusy=true; this.renameErr=''; + const body={}, a=this.activeOrg||{}, n=this.renameName.trim(), sl=this.renameSlug.trim(); + if(n && n!==a.name) body.name=n; if(sl && sl!==a.slug) body.slug=sl; + try{ const r=await this.api('/orgs/'+this.activeOrgId,{method:'PATCH', headers:{'content-type':'application/json'}, body:JSON.stringify(body)}); + const old=this.activeSlugNow; + if(this.sessionMode){ this.activeSlug=r.org; localStorage.setItem('treg-active',r.org); } + else if(r.org!==old){ this.cfg.orgs[r.org]=Object.assign({},this.cfg.orgs[old],{name:r.name}); delete this.cfg.orgs[old]; this.cfg.active=r.org; this.save(); } + else { this.cfg.orgs[old].name=r.name; this.save(); } + await this.loadAll(); this.resetRenameForm(); } + catch(e){ this.renameErr=this._errMsg(e, 'could not rename the team'); } + finally{ this.renameBusy=false; } }, +async deleteOrg(){ if(this.confirmDel!==this.activeSlugNow) return; + try{ await this.api('/orgs/'+this.activeOrgId+'?confirm='+encodeURIComponent(this.activeSlugNow),{method:'DELETE'}); + this.confirmDel=''; this.forgetActiveOrg(); await this.loadAll(); } + catch(e){ this.orgErr='Delete failed: '+(e.detail||e.status); } } +} diff --git a/frontend/src/state/tools.js b/frontend/src/state/tools.js new file mode 100644 index 00000000..47c9fdfd --- /dev/null +++ b/frontend/src/state/tools.js @@ -0,0 +1,100 @@ + +export default { +defaultBinding(){ return {secret_id:(this.secrets[0]?this.secrets[0].id:null), injector:'env', location:'header', name:'Authorization', format:'Bearer {secret}', secret_field:'access_token'}; }, +async loadProjectsIfNeeded(){ if(this.projects.length || !this.activeOrgId || !this.canAdmin) return; + this.projects = await this.api('/orgs/'+this.activeOrgId+'/projects').catch(()=>[]); }, +openAddTool(mode){ this.addToolMenu=false; this.loadProjectsIfNeeded(); this.loadSecrets().then(()=>{ + this.tForm = mode==='cli' + ? {id:null, mode:'cli', name:'', base_url:'', bindings:[], + cli:{bin:'', package:'', enabled:true, auth_mechanism:'env', deny:[], deny_defaults:true, + inject:[{via:'env', name:'', secret_id:(this.secrets[0]?this.secrets[0].id:null)}]}} + : {id:null, mode:'endpoint', name:'', base_url:'', bindings:[this.defaultBinding()], project:null}; + if(mode==='cli') this.tForm.project=null; + if(mode==='cli') this.loadCatalogClis(); + this.toolErr=''; this.newTool=true; }); }, +openEditTool(t){ this.loadProjectsIfNeeded(); this.loadSecrets().then(()=>{ const cur=(this.projects||[]).find(p=>p.id===t.project_id); + this.tForm={ id:t.id, mode:(t.cli?'cli':'endpoint'), name:t.name, base_url:t.base_url, project:(cur?cur.slug:null), + bindings:(t.bindings||[]).map(b=>({secret_id:b.secret_id, injector:b.injector||'env', location:b.location||'header', name:b.name||'Authorization', format:b.format||'Bearer {secret}', secret_field:b.secret_field||'access_token'})), + // deep-copy the cli profile — the PATCH replaces it wholesale, so every field must round-trip + cli: t.cli ? Object.assign(JSON.parse(JSON.stringify(t.cli)), {package:t.cli.package||'', deny:(t.cli.deny||[]).slice(), deny_defaults:t.cli.deny_defaults!==false, + inject:(t.cli.inject||[]).map(e=>Object.assign({via:'env',name:'',secret_id:null},e))}) : null }; + if(this.tForm.cli){ if(!this.tForm.cli.inject.length) this.tForm.cli.inject=[{via:'env', name:'', secret_id:null}]; this.loadCatalogClis(); } + else if(!this.tForm.bindings.length) this.tForm.bindings=[this.defaultBinding()]; + this.toolErr=''; this.newTool=true; }); }, +async loadCatalogClis(){ if(this.catalogClis) return; // bin → catalog deny patterns, fetched once + try{ const cat=await (await fetch('/providers.json')).json(); + const m={}; (cat.providers||cat||[]).forEach(e=>{ const c=e.cli; if(c&&c.bin) m[c.bin]=(c.deny||[]); }); this.catalogClis=m; } + catch(e){ this.catalogClis={}; } }, +addBinding(){ const b=this.defaultBinding(); + // avoid cloning a duplicate header name (which would fail on save) - pick a fresh one + const used=new Set(this.tForm.bindings.filter(x=>(x.location||'header')==='header').map(x=>(x.name||'Authorization').toLowerCase())); + if(used.has('authorization')){ b.name='X-Api-Key'; b.format='{secret}'; } + this.tForm.bindings.push(b); }, +removeBinding(i){ this.tForm.bindings.splice(i,1); }, +async saveTool(){ const f=this.tForm; + if(!/^https?:\/\/.+/i.test(f.base_url.trim())){ this.toolErr=(f.mode==='cli'?'The provider API base URL':'Base URL')+' must start with http:// or https://'; return; } // client-side URL check + let body; + if(f.mode==='cli'){ + const bin=(f.cli.bin||'').trim(); + if(!f.id && !bin){ this.toolErr='The CLI command (e.g. gh) is required.'; return; } + const inject=f.cli.inject.filter(e=>(e.name||'').trim()||e.secret_id).map(e=>({via:'env', name:(e.name||'').trim(), secret_id:e.secret_id})); + if(inject.some(e=>!e.name||!e.secret_id)){ this.toolErr='Each binding key needs a secret AND the env var to inject it as.'; return; } + const deny=f.cli.deny.map(p=>p.trim()).filter(Boolean); + for(const p of deny){ try{ new RegExp(p); }catch(e){ this.toolErr='Deny pattern is not a valid regex: '+p; return; } } + const cli=Object.assign({},f.cli,{bin, inject, deny, deny_defaults:!!f.cli.deny_defaults, enabled:!!f.cli.enabled}); + if(!(cli.package||'').trim()) delete cli.package; else cli.package=cli.package.trim(); + // PATCH omits `bindings` on purpose: editing the CLI must not clobber any HTTP bindings the tool also has + body = f.id ? {base_url:f.base_url.trim(), cli, project:f.project||null} : {name:bin.replace(/_/g,'-'), base_url:f.base_url.trim(), bindings:[], cli, project:f.project||null}; + } else { + if(!f.id && !f.name.trim()){ this.toolErr='Name and base URL are required.'; return; } + if(!f.bindings.length || f.bindings.some(b=>!b.secret_id)){ this.toolErr='Every binding needs a secret.'; return; } + const hdr=f.bindings.filter(b=>(b.location||'header')==='header').map(b=>(b.name||'Authorization').toLowerCase()); + if(new Set(hdr).size!==hdr.length){ this.toolErr='Two bindings target the same header - give each a distinct name.'; return; } // catch the collision inline, clearly + const bindings=f.bindings.map(b=>({secret_id:b.secret_id, injector:b.injector, location:b.location, name:b.name, format:b.format, secret_field:b.secret_field})); + body = f.id ? {base_url:f.base_url.trim(), bindings, project:f.project||null} : {name:f.name.trim(), base_url:f.base_url.trim(), bindings, project:f.project||null}; + } + this.toolBusy=true; this.toolErr=''; + try{ + if(f.id){ await this.api('/tools/'+f.id,{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify(body)}); } + else { await this.api('/tools',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)}); } + const created = f.id ? null : body.name; + this.newTool=false; await this.loadAll(); + if(this.view==='detail') this.loadDetail(); // a detail-page ⚙ Configure save must show the new values + if(created) await this.remindCustomizedAccess(created); } + catch(e){ this.toolErr='Save tool failed: '+(e.detail||e.status); } finally{ this.toolBusy=false; } }, +async remindCustomizedAccess(toolName){ // a NEW tool auto-applies to 'all-tools' members but NOT to customized ones + try{ const members=await this.api('/orgs/'+this.activeOrgId+'/members'); + const n=(members||[]).filter(m=>m.tool_access!==null && m.role!=='owner').length; + if(n>0) this.accessNote='New tool "'+toolName+'" is available to members who have access to all tools. '+n+' member(s) have a customized selection and won\'t see it until you add it (Team → their Tools).'; + }catch(e){/* non-admin creator can't list members → no reminder */} }, +localRunTitle(t){ const bin=(t.cli&&t.cli.bin)||t.name; return t.cli&&t.cli.enabled ? ('Local runs ON - members can: treg run '+t.name+'. Click to disable.') : ('Local runs OFF - click to let members run '+bin+' locally with this key injected.'); }, +async toggleLocalRun(t){ if(!t.cli) return; const turningOn=!t.cli.enabled; const cli=Object.assign({},t.cli,{enabled:turningOn}); + try{ await this.api('/tools/'+t.id,{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({cli})}); await this.loadAll(); + this.runNote = turningOn ? t.name : ''; } + catch(e){ this.err='Could not change local-run: '+(e.detail||e.status); } }, +canCall(t){ return !t.cli || (t.bindings&&t.bindings.length); }, +// an HTTP verb needs a binding (or it's a plain HTTP tool) + canRun(t){ return !!t.server_runnable; }, +// server-computed: cli profile + allow-listed bin + credChips(t){ // one chip per credential KIND (env/oauth/…), covering HTTP bindings + cli.inject alike + const kinds=new Map(); + (t.bindings||[]).forEach(b=>{ const k=b.injector||'env'; kinds.set(k,(kinds.get(k)||0)+1); }); + ((t.cli&&t.cli.inject)||[]).forEach(()=>{ kinds.set('env',(kinds.get('env')||0)+1); }); + return [...kinds.keys()].map(k=>({label:k, kind:k, title:'credential injected by the registry ('+kinds.get(k)+' wire'+(kinds.get(k)>1?'s':'')+': proxy call and/or CLI run) - the value never appears here'})); + }, +openUse(t){ this.tryTool=t; this.useMode=this.canRun(t)?'run':(this.canCall(t)?'call':'run'); this.runOut=null; // last resort: run mode, whose server 422 explains itself (never an unauthenticated HTTP call) + const ex=(t.examples||[])[0], hp=t.health_check&&t.health_check.path; // prefill a real, runnable path so Send just works + this.tryPath=ex?ex.path:(hp||this.samplePath(t.host)||''); this.tryMethod=ex?(ex.method||'GET'):'GET'; this.tryResp=null; this.tryBody=''; }, +async doRun(){ if(this.running || !this.tryTool) return; this.running=true; this.runOut=null; + // shell-ish tokenizer so quoted arguments ("a b") survive as one argv entry + const args=(this.runArgsStr.match(/"([^"]*)"|'([^']*)'|\S+/g)||[]).map(a=>a.replace(/^["']|["']$/g,'')); + try{ this.runOut=await this.api('/run',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({tool:this.tryTool.name,args,timeout_s:60}),encode:true}); } + catch(e){ this.runOut={exit_code:-1, duration_ms:0, detail:(e&&e.detail)||('request failed: '+(e&&e.status||e))}; } + finally{ this.running=false; } }, +async deleteTool(t){ if(this.confirmDelTool!==t.id){ this.confirmDelTool=t.id; return; } this.confirmDelTool=null; this.err=''; this.toolErr=''; + try{ await this.api('/tools/'+t.id,{method:'DELETE'}); await this.loadAll(); } + catch(e){ this.err='Delete tool failed: '+(e.detail||e.status); } }, +async deleteRecipe(r){ if(this.confirmDelBundle!==r.id){ this.confirmDelBundle=r.id; return; } this.confirmDelBundle=null; this.err=''; + try{ await this.api('/bundles/'+r.id,{method:'DELETE'}); await this.loadAll(); } + catch(e){ this.err='Delete recipe failed: '+(e.detail||e.status); } } +} diff --git a/frontend/src/state/tryTool.js b/frontend/src/state/tryTool.js new file mode 100644 index 00000000..5f31d7b0 --- /dev/null +++ b/frontend/src/state/tryTool.js @@ -0,0 +1,92 @@ + +export default { +// ---- marketplace endpoint Try-it ---- + openEpTry(e){ + this.epTry=e; this.epTryTab='agent'; this.epTryResp=''; this.epTryStatus=null; this.epTryMs=null; this.epTryCost=null; + this.epTryAccess=null; this.epTryAccessByMethod={}; this.epTryBusy=false; + this.epTryAuthMethod=e.authorization_method||((e.authorization_methods||[])[0])||''; + const secs=this.paramSections(e); + const tr=e.test_request||{}; // the live-verified request — the best possible prefill + const trq={...(tr.queryParams||{}), ...(tr.pathParams||{})}; + const qp=[]; // query + path params (path placeholders are consumed from query server-side) + for(const s of secs) if(s.key!=='body') for(const p of s.rows) + qp.push({name:p.name, required:!!p.required, authorization_methods:p.authorization_methods||[], + value: trq[p.name]!=null ? this.fmtExample(trq[p.name]) + : (p.example!=null ? this.fmtExample(p.example) : '')}); + // test_request may carry params the input spec doesn't list — keep them, they made the call work + for(const [k,v] of Object.entries(trq)) if(!qp.some(p=>p.name===k)) qp.push({name:k, required:false, value:this.fmtExample(v)}); + this.epTryParams=qp; + let body=''; + if((e.method||'GET')!=='GET'){ + if(tr.body!=null){ body=JSON.stringify(tr.body,null,2); } // verbatim — array-vs-object is ground truth here + else{ + const bodySec=secs.find(s=>s.key==='body'); + if(bodySec){ const o={}; for(const p of bodySec.rows) if(p.example!=null||p.required) o[p.name]=p.example!=null?p.example:null; + body=JSON.stringify(o,null,2); } + } + } + this.epTryBody=body; + // the authenticated dry-run: which ladder rung would serve this org, and at what price + this.loadEpTryAccessPolicy(); + }, +epTryParamAllowed(p){ return !p.authorization_methods || !p.authorization_methods.length + || p.authorization_methods.includes(this.epTryAuthMethod); }, +async loadEpTryAccessPolicy(){ + const e=this.epTry; if(!e) return; + const methods=this.epTryAuthMethods; + if(!methods.length){ this.loadEpTryAccess(); return; } + const rows=await Promise.all(methods.map(async m=>{ + try{ return [m, await this.api('/catalog/endpoints/'+e.id+'/access?authorization_method='+encodeURIComponent(m))]; } + catch(_){ return [m, null]; } + })); + if(this.epTry!==e) return; + this.epTryAccessByMethod=Object.fromEntries(rows); + const connected=this.epTryConnectedMethods; + if(connected.length===1) this.epTryAuthMethod=connected[0]; + else this.epTryAuthMethod=e.authorization_method||methods[0]||''; + this.epTryAccess=this.epTryAccessByMethod[this.epTryAuthMethod]||null; + }, +loadEpTryAccess(){ + if(!this.epTry) return; + if(this.epTryAccessByMethod[this.epTryAuthMethod]){ + this.epTryAccess=this.epTryAccessByMethod[this.epTryAuthMethod]; return; + } + const q=this.epTryAuthMethod?'?authorization_method='+encodeURIComponent(this.epTryAuthMethod):''; + this.epTryAccess=null; + this.api('/catalog/endpoints/'+this.epTry.id+'/access'+q).then(a=>{ + this.epTryAccess=a; + if(this.epTryAuthMethod) this.epTryAccessByMethod={...this.epTryAccessByMethod,[this.epTryAuthMethod]:a}; + }).catch(()=>{}); + }, +async runEpTry(){ + const e=this.epTry; if(!e) return; this.epTryBusy=true; const t0=performance.now(); + try{ + const qs=this.epTryVisibleParams.filter(p=>String(p.value)!=='').map(p=>encodeURIComponent(p.name)+'='+encodeURIComponent(p.value)).join('&'); + const opts={method:e.method||'GET', credentials:'include', headers:{...this.headers()}}; + if(this.epTryAuthMethod) opts.headers['X-Treg-Authorization-Method']=this.epTryAuthMethod; + if(opts.method!=='GET' && this.epTryBody.trim()){ opts.body=this.epTryBody; opts.headers['content-type']='application/json'; } + const r=await fetch('/call/'+e.id+(qs?'?'+qs:''), opts); + this.epTryStatus=r.status; this.epTryMs=Math.round(performance.now()-t0); + const txt=await r.text(); + if(!txt){ this.epTryResp='(empty response body - status '+r.status+')'; } + else{ try{ this.epTryResp=JSON.stringify(JSON.parse(txt),null,2).slice(0,20000); }catch(_){ this.epTryResp=txt.slice(0,8000); } } + // The charge, read back from the same telemetry Activity renders. The audit writer is + // fire-and-forget, so give it a beat; also refresh the balance pill. + setTimeout(async()=>{ try{ + const rows=await this.api('/calls?limit=5'); + const row=(rows||[]).find(c=>c.endpoint_id===e.id); + if(row && row.cost_charged_micro!=null) this.epTryCost=row.cost_charged_micro; + this.loadBilling(); + }catch(_){}} , 900); + }catch(err){ this.epTryResp='Request failed: '+err; this.epTryStatus=0; this.epTryMs=Math.round(performance.now()-t0); } + finally{ this.epTryBusy=false; } + }, +async runTry(){ this.trying=true; const t0=performance.now(); + try{ const opts={method:this.tryMethod, credentials:'include', headers:{...this.headers()}}; + if(this.tryMethod!=='GET' && (this.tryBody||'').trim()){ opts.body=this.tryBody; opts.headers['content-type']='application/json'; } // send the body for POST/PUT/DELETE + const r=await fetch(`/call/${this.tryTool.name}/${this.tryPath}`,opts); + this.tryStatus=r.status; this.tryMs=Math.round(performance.now()-t0); const txt=await r.text(); + if(!txt){ this.tryResp='(empty response body - status '+r.status+')'; } // e.g. a 404 with no body left the box blank + else { try{ this.tryResp=JSON.stringify(JSON.parse(txt),null,2); }catch(_){ this.tryResp=txt.slice(0,4000); } } + }catch(e){ this.tryResp='Request failed: '+e; this.tryStatus=0; this.tryMs=Math.round(performance.now()-t0); } finally{ this.trying=false; } } +} diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css new file mode 100644 index 00000000..32fae2ba --- /dev/null +++ b/frontend/src/styles/base.css @@ -0,0 +1,1017 @@ + + [v-cloak]{display:none} /* hide the raw template until Vue mounts — else a hard refresh flashes the un-compiled markup while the script downloads. Vue drops the attribute on mount, which is also how the loader guard above detects that it never did. */ + /* ===== Ledger: warm paper + clay, mono-forward. Dark default. ===== */ + :root{ --bg:#211d16;--panel:#2a251d;--panel2:#332d23;--ink:#f0e9d9;--muted:#a99e88;--line:#3b352a; + --accent:#e0703f;--onAccent:#231c14;--green:#7fae72;--amber:#d0a24a;--teal:#6aa89c;--red:#e08a7a;--r:14px;--rb:9px; + --mono:ui-monospace,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace; + --sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;} + [data-theme="light"]{ --bg:#f7f3ea;--panel:#fffdf8;--panel2:#f0eadc;--ink:#201c15;--muted:#877e6c; + --line:#e3dac7;--accent:#b8461f;--onAccent:#fff;--green:#3f6f3f;--amber:#a8720a;--teal:#2f7d74;--red:#a5382b;} + *{box-sizing:border-box} + /* a11y: a visible keyboard-focus indicator (there were none) + a skip-to-content link */ + :focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:2px} + .skip{position:absolute;left:-9999px;top:8px;z-index:100;background:var(--accent);color:var(--onAccent);padding:8px 12px;border-radius:var(--rb)} + .skip:focus{left:8px} + body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--mono);font-size:14px;line-height:1.5} + .sub,.lead,.card p,.help p,.loginbox p{font-family:var(--sans)} + button{font:inherit;cursor:pointer} .muted{color:var(--muted)} .mono{font-family:var(--mono)} + .btn{background:var(--panel2);border:1px solid var(--line);color:var(--ink);padding:6px 12px;border-radius:var(--rb);font-family:var(--mono);font-size:12.5px;line-height:1;display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:34px;box-sizing:border-box;white-space:nowrap} + .btn:hover{border-color:var(--accent)} .btn.primary{background:var(--accent);border-color:var(--accent);color:var(--onAccent);font-weight:600} + .btn.sm{padding:4px 9px;font-size:12px} .btn:disabled{opacity:.45;cursor:not-allowed} + .btn.ico{width:34px;min-width:34px;padding:0;flex:0 0 auto} .btn.sm.ico{width:30px;min-width:30px;min-height:30px} + .btn.danger{border-color:var(--red);color:var(--red)} .btn.danger:hover{background:color-mix(in srgb,var(--red) 15%,transparent);border-color:var(--red)} + .msel{background:var(--bg);border:1px solid var(--line);color:var(--ink);border-radius:6px;padding:3px 7px;font-family:var(--mono);font-size:12px} + .frow{display:flex;align-items:center;gap:10px;margin:8px 0} .frow label{width:78px;font-size:10.5px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;flex:none} + .frow input,.frow select{flex:1;background:var(--bg);border:1px solid var(--line);color:var(--ink);border-radius:var(--rb);padding:7px 10px;font-family:var(--mono);font-size:12.5px} + .frow input:disabled{opacity:.6} + .bindrow{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin:7px 0;padding:8px;background:var(--bg);border:1px solid var(--line);border-radius:8px} + .bindrow .msel{flex:none} .bindinput{background:var(--panel);border:1px solid var(--line);color:var(--ink);border-radius:6px;padding:5px 8px;font-family:var(--mono);font-size:12px;flex:1;min-width:110px} + /* Provider logos sit in a uniform near-white tile in BOTH themes. Brand marks are drawn for + white, they differ wildly in ink coverage (LinkedIn is a full-bleed square, Analytics is three + thin bars), and monochrome ones like X vanish on one theme if left bare — one shared surface + fixes all three and keeps the column optically even. object-fit keeps a mark with odd + proportions (YouTube is wider than tall) inside the same box. */ + /* Marketplace. minmax(0,1fr) on the tracks, not just auto-fill: a long base_url or summary in + one card must not widen every column and push the grid past the viewport. */ + .mk-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,290px),1fr));gap:12px} + .mk-card{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:14px; + display:flex;flex-direction:column;gap:10px;cursor:pointer;min-width:0; + transition:border-color .12s ease,transform .12s ease} + .mk-card:hover{border-color:var(--muted);transform:translateY(-1px)} + .mk-card:focus-visible{outline:2px solid var(--muted);outline-offset:2px} + .mk-top{display:flex;align-items:center;gap:11px;min-width:0} + .mk-top b{display:block;line-height:1.35;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + .mk-cat{display:block;font-size:11px;color:var(--muted)} + /* Clamped so a long summary can't make one card twice the height of its neighbours. */ + .mk-sum{margin:0;font-size:12.5px;line-height:1.5;color:var(--muted);flex:1; + display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden} + .mk-foot{display:flex;align-items:center;justify-content:space-between;gap:8px; + border-top:1px solid var(--line);padding-top:10px;font-size:11px} + .mk-quiet{color:var(--muted)} .mk-auth{color:var(--muted)} + .mk-filters{display:flex;flex-wrap:wrap;gap:7px;margin:16px 0 4px} + .mk-chip{background:var(--panel);border:1px solid var(--line);color:var(--ink);border-radius:999px; + padding:5px 12px;font-size:12px;font-family:inherit;cursor:pointer} + .mk-chip span{color:var(--muted);margin-left:5px} + .mk-chip.on{background:var(--ink);color:var(--bg);border-color:var(--ink)} + .mk-chip.on span{color:var(--bg);opacity:.65} + .mk-empty{border:1px dashed var(--line);border-radius:var(--r);padding:16px;color:var(--muted);font-size:13px} + /* A disclosure the user has to read before consenting, not a failure — so it borrows the panel + surface rather than .banner, which is red and would read as "something went wrong". */ + .mk-notice{margin:12px 0 0;background:var(--panel2);border:1px solid var(--line2); + border-radius:8px;padding:9px 12px;color:var(--muted);font-size:12.5px;line-height:1.5} + .mk-perms{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,300px),1fr));gap:12px} + .mk-perm{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:13px} + .mk-perm-h{display:flex;align-items:center;gap:8px;margin-bottom:9px;font-size:12px} + .mk-perm-l{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px} + .mk-perm-l li{font-size:12.5px;line-height:1.45;color:var(--muted);display:flex;gap:7px;align-items:flex-start} + .mk-tick{color:var(--ok,#7bbf7b);flex:0 0 auto} + /* Endpoint catalog (marketplace → Browse by platform). Deliberately reuses .mk-card and .ttable: + the only genuinely new furniture is the METHOD+path line and the expandable example row. */ + /* own_account vs any_account is the load-bearing distinction in a capability list, so the + OAuth-only rows are the ones that get colour. */ + .chip.own{color:var(--teal);border-color:color-mix(in srgb,var(--teal) 42%,var(--line));background:color-mix(in srgb,var(--teal) 10%,transparent)} + /* "You can call this right now." The one green in the marketplace, so it cannot be confused with + the verified/health greens elsewhere: filled, weighted, and led by a haloed dot. + (`.chip.ok` alone is unstyled and renders muted grey — which is how a ready capability came to + look identical to an unavailable one.) */ + .chip.go{display:inline-flex;align-items:center;gap:6px;font-weight:600;color:var(--green); + border-color:color-mix(in srgb,var(--green) 48%,var(--line)); + background:color-mix(in srgb,var(--green) 14%,transparent)} + .godot{width:6px;height:6px;border-radius:50%;background:var(--green);flex:0 0 6px; + box-shadow:0 0 0 3px color-mix(in srgb,var(--green) 24%,transparent)} + .chip.any{color:var(--muted)} + /* onboarding "waiting for your agent" status: a soft pulsing dot */ + .wc-wait{display:inline-flex;align-items:center;gap:8px;font-family:var(--sans);font-size:13px;color:var(--muted)} + .wc-waitdot{width:8px;height:8px;border-radius:50%;background:var(--accent);flex:none;animation:wcpulse 1.4s ease-in-out infinite} + @keyframes wcpulse{0%,100%{opacity:.35;box-shadow:0 0 0 0 color-mix(in srgb,var(--accent) 40%,transparent)}50%{opacity:1;box-shadow:0 0 0 5px color-mix(in srgb,var(--accent) 0%,transparent)}} + /* Marketplace tab bar: All + one tab per catalog category + Platform (the provider shelves). + Scrolls rather than wraps — ten tabs and a narrow window must not become two ragged rows. + The rule under the tabs lives on the WRAPPER, not the scroller: the scroller carries a mask, + and a masked border would fade out 26px short of the right edge and read as a rendering bug. */ + .mk-tabs-wrap{border-bottom:1px solid var(--line);margin:6px 0 16px} + /* Scrollbar hidden, scrolling kept. The right edge fades instead, so a cut-off tab hints that + there is more — and because the tabs are left-aligned, the fade falls on empty space (and + changes nothing) whenever they all fit. Fading the LEFT edge too would clip "All" at rest. */ + .mk-tabs{display:flex;gap:2px;overflow-x:auto;margin-bottom:-1px; + scrollbar-width:none;-ms-overflow-style:none; + -webkit-mask-image:linear-gradient(90deg,#000 calc(100% - 26px),transparent); + mask-image:linear-gradient(90deg,#000 calc(100% - 26px),transparent)} + .mk-tabs::-webkit-scrollbar{display:none} + .mk-tabs button{background:none;border:0;color:var(--muted);padding:9px 10px;white-space:nowrap; + border-bottom:2px solid transparent;font-family:var(--mono);font-size:12.5px;cursor:pointer} + .mk-tabs button:hover{color:var(--ink)} + .mk-tabs button.on{color:var(--accent);border-bottom-color:var(--accent);font-weight:600} + .mk-tabs button span{color:var(--muted);font-size:11px;margin-left:5px} + /* A section heading is a real heading, not a caption: the page is browsed by category, so the + category has to be the loudest thing on it after the page title. */ + .tgroup.shelf{margin-bottom:34px} + .sec-head{margin:0 0 13px} + .sec-h{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;margin:0} + .sec-h b{font-size:19px;font-weight:600;letter-spacing:-.35px;line-height:1.25;color:var(--ink)} + .sec-n{background:var(--panel2);border:1px solid var(--line);border-radius:999px; + padding:1px 9px;font-size:11.5px;color:var(--muted);font-family:var(--mono)} + .sec-hint{display:block;margin-top:4px;font-size:12.5px;color:var(--muted);line-height:1.5} + /* Platform cards. The card IS the platform: its mark and name, who serves it, what it covers, + how much of it there is and what the cheapest call costs — enough to choose without opening it. + Providers appear only as a logo stack; the capability x provider matrix lives inside. */ + .pt-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,320px),1fr));gap:12px} + .pt-card{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:16px 17px; + display:flex;flex-direction:column;gap:11px;cursor:pointer;min-width:0; + color:var(--ink);font-family:inherit;text-align:left; + transition:border-color .12s ease,transform .12s ease} + .pt-card:hover{border-color:var(--muted);transform:translateY(-1px)} + .pt-card:focus-visible{outline:2px solid var(--muted);outline-offset:2px} + .pt-top{display:flex;align-items:flex-start;gap:11px;min-width:0} + /* Same near-white surface rule as .plogo-tile: brand marks are drawn for white and half of them + vanish on one of our two themes if left bare. The generated fallback keeps its own colour. */ + .pt-logo{width:40px;height:40px;flex:0 0 40px;border-radius:11px;background:#fbfaf7; + border:1px solid rgba(0,0,0,.07);display:grid;place-items:center;overflow:hidden} + .pt-logo img{width:25px;height:25px;object-fit:contain;display:block} + .pt-logo.gen{border-color:transparent} + .pt-logo .pt-i{font-size:17px;font-weight:700;color:#fff;font-family:var(--sans);line-height:1} + /* With the description gone there is room for the whole name: "Google Search Console" wraps to + a second line rather than ellipsizing to "Google Search Con…", which is a card that can't say + what it is. Two lines is the cap — past that the shelf stops being a grid of equals. */ + .pt-name{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden; + font-size:14.5px;font-weight:600;line-height:1.3;letter-spacing:-.2px} + .pt-cat{display:block;font-size:11.5px;color:var(--muted);line-height:1.4;margin-top:1px} + /* Can I call this today? The card corner answers that and nothing else. */ + .pt-conn{margin-left:auto;flex:0 0 auto;padding-left:8px} + .pt-quiet{font-size:11px;color:var(--muted);opacity:.72;white-space:nowrap} + .pt-foot{display:flex;align-items:center;justify-content:space-between;gap:10px; + margin-top:auto;padding-top:10px;border-top:1px solid var(--line);font-size:11.5px} + .pt-eps{color:var(--muted);display:inline-flex;align-items:center;gap:6px;font-family:var(--mono)} + .pt-dot{width:5px;height:5px;border-radius:50%;background:var(--muted);opacity:.65;flex:0 0 5px} + .pt-price{color:var(--muted);font-family:var(--mono);white-space:nowrap} + .pt-price b{color:var(--ink);font-weight:600} + .pt-price.free b{color:var(--teal)} + /* The provider's own figure, alongside the converted one. Muted and small: it is provenance, + not the number you compare on — but leaving it out invites "where did that price come from?" */ + .cost-nat{margin-left:5px;color:var(--muted);opacity:.8;font-weight:400;font-size:.92em} + /* The tail of a long shelf: one quiet row — a stack of the marks it is hiding, two of their + names, and an arrow. NOT a dashed box: a dashed border reads as a drop zone or a placeholder, + and it drew more attention than the cards it sits under. */ + .pt-more{display:flex;align-items:center;gap:11px;width:100%;margin-top:10px; + background:none;border:0;border-radius:var(--r);padding:9px 13px; + color:var(--muted);font-family:inherit;font-size:12.5px;text-align:left;cursor:pointer; + transition:color .12s ease} + .pt-more:hover{color:var(--ink)} + .pt-more:focus-visible{outline:2px solid var(--muted);outline-offset:2px} + /* The arrow says the row expands; it slides a little on hover, which is the whole affordance. */ + .pt-more-a{flex:0 0 auto;margin-left:-5px;font-size:13px;color:var(--muted2); + transition:transform .18s var(--ease),color .18s var(--ease)} + .pt-more:hover .pt-more-a{transform:translateX(2px);color:var(--ink)} + .pt-stack{display:flex;flex:0 0 auto} + .pt-mini{width:22px;height:22px;flex:0 0 22px;border-radius:6px;background:#fbfaf7; + border:1px solid rgba(0,0,0,.07);display:grid;place-items:center;overflow:hidden; + margin-right:-6px;box-shadow:0 0 0 1px var(--bg)} + .pt-mini:last-child{margin-right:0} + .pt-mini img{width:14px;height:14px;object-fit:contain;display:block} + .pt-mini.gen{border-color:transparent} + .pt-mini .pt-i{font-size:10px;font-weight:700;color:#fff;font-family:var(--sans);line-height:1} + .pt-more-t{min-width:0} + /* Platform page header: one full-width column. See the template for why it is not .tut-head. */ + .plat-head{margin-bottom:4px} + .plat-title{display:flex;align-items:center;gap:13px;min-width:0} + .plat-title h1{margin:0;min-width:0} + .plat-intro{margin:10px 0 0;max-width:74ch} + .plat-provs{display:flex;flex-wrap:wrap;align-items:center;gap:7px;margin-top:14px} + .plat-provs-l{font-size:11px;text-transform:uppercase;letter-spacing:.1em;color:var(--muted);margin-right:2px} + /* What you have to SEND, one block per location. Dense on purpose: it sits inside an expanded + table row, and a param list that needs its own scroll is a param list nobody reads. */ + .prm{margin:0 0 12px;border:1px solid var(--line);border-radius:var(--rb); + background:color-mix(in srgb,var(--panel) 55%,transparent);padding:11px 12px} + .prm-h{font-size:11px;text-transform:uppercase;letter-spacing:.1em;color:var(--muted);margin-bottom:8px} + .prm-note{margin:0 0 9px;font-size:12px;color:var(--muted);line-height:1.5;max-width:82ch} + .prm-sec + .prm-sec{margin-top:11px} + .prm-loc{font-size:11.5px;font-weight:600;color:var(--ink);margin-bottom:5px} + .prm-type{margin-left:7px;font-weight:400;font-size:10.5px;color:var(--muted)} + /* The global `table` rule paints a panel, a border and a radius, and `th` a filled header bar — + all of which fight the .prm box this already sits inside, and clip the first column against + the table's own border. This is a bare grid; the box around it is the chrome. */ + .prm-t{width:100%;border-collapse:collapse;font-size:11.5px;table-layout:fixed; + background:none;border:0;border-radius:0} + .prm-t th{background:none;text-align:left;font-weight:500;color:var(--muted);font-size:10.5px; + text-transform:uppercase;letter-spacing:.06em;padding:0 10px 5px 0;border-bottom:1px solid var(--line)} + .prm-t th:nth-child(1){width:23%} .prm-t th:nth-child(2){width:13%} .prm-t th:nth-child(3){width:13%} + .prm-t td{padding:6px 10px 6px 0;vertical-align:top;border-bottom:1px solid color-mix(in srgb,var(--line) 55%,transparent)} + .prm-t tr:last-child td{border-bottom:0} + .prm-n{color:var(--ink);font-weight:600;word-break:break-word} + .prm-ty{color:var(--muted)} + .prm-req{color:var(--amber);font-size:10.5px} + .prm-opt{color:var(--muted);font-size:10.5px;opacity:.75} + .prm-d{color:var(--muted);line-height:1.5} + .prm-ex{display:block;margin-top:2px;color:var(--teal);word-break:break-word} + .prm-none{margin:0 0 12px;font-size:12px;color:var(--muted);opacity:.8} + /* The path is the fact you came for, so it keeps ink colour where .ttable .th would mute it, and + wraps rather than widening the table (some provider paths are 70+ chars). */ + .cat-path{color:var(--ink);white-space:normal;word-break:break-all} + .cat-m{color:var(--teal);font-weight:600;margin-right:7px} + .cat-d td{background:var(--panel2)} + .cat-sum{margin:0 0 10px;color:var(--muted);line-height:1.5;max-width:78ch;font-size:12.5px} + .cat-ex{margin-top:10px} .cat-ex pre{max-height:340px} + .chip.ver{color:var(--green);border-color:color-mix(in srgb,var(--green) 42%,var(--line));background:color-mix(in srgb,var(--green) 10%,transparent)} + .plogo-tile{width:30px;height:30px;flex:0 0 30px;border-radius:8px;background:#fbfaf7; + border:1px solid rgba(0,0,0,.07);display:flex;align-items:center;justify-content:center} + .plogo{width:19px;height:19px;object-fit:contain;display:block} + h3 .plogo-tile{display:inline-flex;vertical-align:-9px;margin-right:9px} + .pl-row{display:flex;align-items:center;gap:11px;min-width:0} + .chip{display:inline-block;font-size:11px;padding:2px 8px;border-radius:6px;border:1px solid var(--line);color:var(--muted)} + .chips{display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-top:3px} + .chip.env{color:var(--amber);border-color:color-mix(in srgb,var(--amber) 42%,var(--line));background:color-mix(in srgb,var(--amber) 10%,transparent)} + .chip.oauth{color:var(--teal);border-color:color-mix(in srgb,var(--teal) 42%,var(--line));background:color-mix(in srgb,var(--teal) 10%,transparent)} + .chip.secret_file{color:var(--accent);border-color:color-mix(in srgb,var(--accent) 42%,var(--line));background:color-mix(in srgb,var(--accent) 10%,transparent)} + .chip.run{color:var(--teal);border-color:color-mix(in srgb,var(--teal) 42%,var(--line));background:color-mix(in srgb,var(--teal) 10%,transparent)} + .btn.ico.on{color:var(--teal);border-color:color-mix(in srgb,var(--teal) 50%,var(--line))} + .badge{display:inline-flex;align-items:center;gap:5px;font-size:11px;padding:2px 9px;border-radius:999px;font-weight:600} + .badge.ok{color:var(--green);background:color-mix(in srgb,var(--green) 15%,transparent)} + .badge.unknown{color:var(--muted);background:color-mix(in srgb,var(--muted) 14%,transparent)} + .badge.invalid{color:var(--red);background:color-mix(in srgb,var(--red) 15%,transparent)} + .role{font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.05em} + .role.owner{color:var(--amber)} .role.admin{color:var(--accent)} .role.member{color:var(--green)} .role.viewer{color:var(--muted)} + .top{display:flex;align-items:center;gap:14px;padding:11px 20px;border-bottom:1px solid var(--line);background:var(--panel);position:sticky;top:0;z-index:20} + .brand{font-weight:700;letter-spacing:-.3px} .spacer{flex:1} + .orgsel{display:flex;align-items:center;gap:8px;background:var(--panel2);border:1px solid var(--line);border-radius:var(--rb);padding:6px 10px;cursor:pointer;position:relative} + .dropdown{position:absolute;top:108%;left:0;width:300px;max-width:calc(100vw - 32px);background:var(--panel);border:1px solid var(--line);border-radius:12px;box-shadow:0 12px 34px rgba(0,0,0,.28);padding:6px;z-index:30} + .dropdown .row{padding:8px 10px;border-radius:7px;display:flex;justify-content:space-between;gap:10px;align-items:center;cursor:pointer} + .dropdown .row:hover{background:var(--panel2)} .dropdown .row.off{opacity:.6} + .row-actions{display:inline-flex;align-items:center;justify-content:flex-end;gap:6px} + .key-actions-menu{position:fixed;min-width:150px;background:var(--panel);border:1px solid var(--line);border-radius:10px; + box-shadow:0 12px 34px rgba(0,0,0,.28);padding:5px;z-index:70} + .key-actions-menu button{display:block;width:100%;border:0;background:none;color:var(--ink);text-align:left; + padding:8px 10px;border-radius:7px;font:inherit;font-size:12px;cursor:pointer} + .key-actions-menu button:hover,.key-actions-menu button:focus-visible{background:var(--panel2);outline:0} + .key-actions-menu button.danger{color:var(--red)} + .key-table .key-identity{min-width:220px} + .key-table td{padding-top:12px;padding-bottom:12px;vertical-align:middle} + .key-primary{display:flex;align-items:center;gap:7px;white-space:nowrap} + .key-primary b{font-size:13px} + .key-meta{margin-top:4px;color:var(--muted);font-size:11.5px;line-height:1.3} + .key-table th:last-child,.key-table td:last-child{width:1%} + .search{flex:1;max-width:520px;display:flex;align-items:center;gap:8px;background:var(--panel2);border:1px solid var(--line);border-radius:var(--rb);padding:7px 11px} + .search input{flex:1;border:0;background:none;color:var(--ink);outline:none;font-family:var(--mono);font-size:13px} + .iconbtn{background:var(--panel2);border:1px solid var(--line);border-radius:var(--rb);width:34px;height:34px;display:grid;place-items:center;color:var(--ink);box-sizing:border-box} + .ava{width:32px;height:32px;border-radius:8px;background:var(--panel2);border:1px solid var(--line);display:grid;place-items:center;font-weight:700;font-size:12px} + /* minmax(0,1fr): a plain 1fr track's implied min-width:auto lets a wide child (the marketplace + tab strip) stretch the track past the viewport, putting a horizontal scrollbar on the PAGE + instead of inside the strip's own overflow-x container. */ + .layout{display:grid;grid-template-columns:230px minmax(0,1fr);min-height:calc(100vh - 57px)} + /* Public catalog: no sidebar, so the main column takes the whole width. */ + .layout.solo{grid-template-columns:minmax(0,1fr)} + .pubnav{position:sticky;top:0;z-index:50;display:flex;align-items:center;gap:22px; + padding:11px 22px;background:color-mix(in srgb,var(--bg) 86%,transparent); + backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);border-bottom:1px solid var(--line)} + .pubnav .brand{font-family:var(--mono);font-weight:600;font-size:16px;color:var(--ink);text-decoration:none} + .pubnav-links{margin-left:auto;display:flex;align-items:center;gap:20px;font-size:14px} + .pubnav-links a{color:var(--muted);font-weight:500;text-decoration:none;cursor:pointer} + .pubnav-links a:hover,.pubnav-links a[aria-current]{color:var(--ink)} + @media(max-width:640px){ .pubnav{gap:12px;padding:10px 14px} .pubnav-links{gap:13px} .pubnav-links .hidem{display:none} } + .side{border-right:1px solid var(--line);padding:12px 10px;background:var(--panel);display:flex;flex-direction:column} + .side .nav{display:flex;gap:10px;align-items:center;width:100%;text-align:left;background:none;border:0;color:var(--ink);padding:9px 11px;border-radius:var(--rb);font-size:13.5px} + .side .nav:hover{background:var(--panel2)} .side .nav.active{background:var(--panel2);color:var(--accent);font-weight:600} + .side .grp{font-size:10px;text-transform:uppercase;letter-spacing:.11em;color:var(--muted);margin:14px 11px 6px} + .side .agent-copy{display:flex;align-items:center;justify-content:space-between;padding:5px 11px;font-size:13px;border-radius:var(--rb)} + .side .agent-copy:hover{background:var(--panel2)} + .side .agent-copy .ac-label{font-family:var(--mono);color:var(--ink)} + .side .agent-copy .ac-label.ac-link{cursor:pointer;flex:1} .side .agent-copy:hover .ac-label.ac-link{color:var(--accent)} + .side .ac-btn{background:none;border:0;color:var(--muted);cursor:copy;font-size:14px;line-height:1;padding:5px 7px;border-radius:6px;display:inline-flex;align-items:center} + .side .ac-btn:hover{color:var(--accent);background:var(--panel2)} .side .ac-btn.done{color:var(--good,#7fae72)} + /* org block (top of sidebar) */ + .orgblock{position:relative;background:var(--panel2);border:1px solid var(--line);border-radius:var(--rb);margin-bottom:12px} + .orgblock .orgmain{display:flex;align-items:center;gap:8px;min-width:0;cursor:pointer;padding:8px 10px} + .orgblock .orgname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:700} + .orgcaret{margin-left:auto;color:var(--muted);flex:0 0 auto;font-size:12px} + /* team-switcher dropdown rows: name truncates, actions stay pinned right */ + .orgrow{display:flex;align-items:center;gap:8px;padding:7px 10px;border-radius:8px} + .orgrow:hover{background:var(--panel2)} .orgrow.off{opacity:.6} + .orgrow-main{flex:1;min-width:0} .orgrow-name{font-weight:700;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + .orgrow-meta{display:flex;gap:6px;align-items:center;margin-top:3px} + .orgrow-btn{border:1px solid var(--line);background:var(--panel);color:var(--ink);border-radius:7px;padding:4px 8px;font-size:11.5px;line-height:1;cursor:pointer;flex:0 0 auto} .orgrow-btn:hover{border-color:var(--accent);color:var(--accent)} + .orgrow-active{font-size:11px;color:var(--green);flex:0 0 auto;white-space:nowrap;padding:0 2px} + .orgrow-sep{height:1px;background:var(--line);margin:6px 8px} + .side-nav{display:flex;flex-direction:column;gap:2px;flex:1} + .side-acct{margin-top:auto;display:flex;align-items:center;gap:8px;padding:7px 8px;border:1px solid var(--line);border-radius:var(--rb);background:var(--panel2)} + .acct-you{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;color:var(--muted)} + .side-acct .iconbtn{width:30px;height:30px;font-size:13px} + main{padding:22px 26px;max-width:1080px;width:100%;margin:0 auto} /* centered in the content column, not left-hugging */ + h1{font-size:19px;margin:0 0 3px;letter-spacing:-.3px} .sub{color:var(--muted);margin:0 0 18px;font-size:13px} + .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px} + .seg{display:inline-flex;gap:3px;background:var(--panel2);border:1px solid var(--line);border-radius:9px;padding:3px;margin-bottom:18px} + .seg button{background:none;border:0;color:var(--muted);padding:5px 12px;border-radius:7px;font-size:12.5px;cursor:pointer;font-family:var(--mono);display:inline-flex;gap:6px;align-items:center;line-height:1} + .seg button:hover{color:var(--ink)} .seg button.on{background:var(--panel);color:var(--ink)} .seg button span{color:var(--muted);font-size:11px} + .tgroup{margin-bottom:22px} + .tgh{font-size:11px;text-transform:uppercase;letter-spacing:.1em;color:var(--muted);margin:0 0 9px;display:flex;align-items:center;gap:9px} + .tgh-n{background:var(--panel2);border:1px solid var(--line);border-radius:999px;padding:1px 8px;font-size:11px;color:var(--ink);letter-spacing:0} + .tgh-hint{text-transform:none;letter-spacing:0;color:var(--muted);font-size:11.5px} + .ttable-wrap{border:1px solid var(--line);border-radius:10px;overflow:hidden;background:var(--panel)} + .ttable{border:0;border-radius:0;background:none} + .ttable td{padding:9px 12px;border-bottom:1px solid var(--line);vertical-align:middle;font-size:13px} + .ttable tr:last-child td{border-bottom:0} .ttable tr:hover td{background:var(--panel2)} + .ttable .tn{width:22%;white-space:nowrap} .ttable .th{color:var(--muted)} + /* The global .sub carries margin-bottom:18px for prose. Inside a table cell that is invisible + dead space BELOW the text, so vertical-align:middle centres the block 9px too high and the + row grows for nothing. Reset it here, and pin both line-heights so the two-line stack is a + predictable height for the logo tile to centre against. */ + .ttable .sub{margin:0;line-height:1.4} + .ttable .tn b{display:block;line-height:1.4} + .ttable .ta{width:1%;white-space:nowrap} .ttable .ta .chip{margin-right:4px} + .ttable .tx{text-align:right;white-space:nowrap;width:1%} .ttable .tx .btn{margin-left:5px} + .card{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:15px} + .card h3{margin:0;font-size:15px;display:flex;align-items:center;gap:8px;letter-spacing:-.2px} + .card .host{font-size:11.5px;color:var(--muted);margin:4px 0 10px;word-break:break-all} + .card .foot{display:flex;gap:8px;margin-top:13px} + .kv{display:flex;gap:18px;flex-wrap:wrap;margin:9px 0} .kv div{font-size:13px} .kv b{display:block;color:var(--muted);font-weight:500;font-size:10px;text-transform:uppercase;letter-spacing:.06em} + table{width:100%;border-collapse:collapse;font-size:12.5px;background:var(--panel);border:1px solid var(--line);border-radius:var(--r);overflow:hidden} + th,td{text-align:left;padding:10px 13px;border-bottom:1px solid var(--line);overflow-wrap:anywhere} th{color:var(--muted);font-weight:600;font-size:10px;text-transform:uppercase;letter-spacing:.06em;background:var(--panel2)} + tr:last-child td{border-bottom:0} + .statgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px;margin-bottom:18px} + .stat{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:14px} .stat .n{font-size:26px;font-weight:700;color:var(--accent)} .stat .l{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.06em} + .tabs{display:flex;gap:4px;border-bottom:1px solid var(--line);margin-bottom:14px} .tabs button{background:none;border:0;color:var(--muted);padding:9px 13px;border-bottom:2px solid transparent;font-family:var(--mono)} .tabs button.active{color:var(--accent);border-bottom-color:var(--accent);font-weight:600} + .tswitch{display:inline-flex;align-items:center;flex:none;cursor:pointer} .tswitch input{display:none} + .tswitch .knob{width:30px;height:17px;border-radius:9px;background:var(--line);position:relative;transition:background .15s;flex:none} + .tswitch .knob::after{content:'';position:absolute;top:2px;left:2px;width:13px;height:13px;border-radius:50%;background:var(--muted);transition:transform .15s,background .15s} + .tswitch input:checked+.knob{background:var(--accent)} .tswitch input:checked+.knob::after{transform:translateX(13px);background:#fff} + /* Billing: one-click top-up amounts. Each card IS the buy button - no dropdown. */ + .fundgrid{display:grid;grid-template-columns:repeat(4,minmax(86px,1fr));gap:8px;max-width:460px} + .fundcard{background:var(--panel2);border:1px solid var(--line);border-radius:var(--rb);padding:13px 8px 11px;text-align:center;cursor:pointer;color:var(--ink);font-family:var(--mono);transition:border-color .12s,transform .12s} + .fundcard:hover:not(:disabled){border-color:var(--accent);transform:translateY(-1px)} + .fundcard:disabled{opacity:.55;cursor:default} + .fundcard.sel{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent) inset} + .fundcard b{display:block;font-size:17px;letter-spacing:-.3px} + .fundcard span{display:block;font-size:10.5px;color:var(--muted);margin-top:3px} + @media(max-width:520px){.fundgrid{grid-template-columns:repeat(2,1fr)}} + /* a switch-option card: knob + [title / caption], vertically centered on the title line */ + .cliopt{display:flex;align-items:flex-start;gap:10px;margin:12px 0;padding:10px 12px;border:1px solid var(--line);border-radius:10px;background:var(--bg);cursor:pointer} + .cliopt .tswitch{margin-top:1px} .cliopt b{font-size:12.5px;line-height:1.4} .cliopt .sub{font-size:11px;line-height:1.5;margin:0} + /* OAuth authorization-method picker. One logical provider can have several genuinely separate + grants; make that one choice behind Add account instead of scattering secondary actions around + the page. The native radio stays visible so the selection is unambiguous and keyboard-safe. */ + .method-grid{display:grid;gap:9px;margin:14px 0 18px} + .method-card{display:flex;align-items:flex-start;gap:11px;padding:13px 14px;border:1px solid var(--line);border-radius:10px;background:var(--panel2);cursor:pointer;transition:border-color .12s,background .12s} + .method-card:hover{border-color:var(--accent)} + .method-card.on{border-color:var(--ink);box-shadow:inset 0 0 0 1px var(--ink);background:var(--bg)} + .method-card.off{opacity:.58;cursor:not-allowed} + .method-card input{margin:3px 0 0;accent-color:var(--accent);flex:none} + .method-copy{min-width:0;flex:1}.method-title{display:flex;align-items:center;gap:7px;flex-wrap:wrap} + .method-copy .sub{display:block;margin:4px 0 0;line-height:1.5} + .scrim{position:fixed;inset:0;background:rgba(20,16,10,.55);display:grid;place-items:center;z-index:50} + /* Inline spinner for waits on a live upstream (resource discovery), so a slow round-trip reads + as "working" rather than "broken". */ + .spin{display:inline-block;width:11px;height:11px;margin-right:7px;vertical-align:-1px;border:2px solid var(--line);border-top-color:var(--accent);border-radius:50%;animation:treg-spin .7s linear infinite} + @keyframes treg-spin{to{transform:rotate(360deg)}} + .modal{background:var(--panel);border:1px solid var(--line);border-radius:14px;width:min(680px,94vw);max-height:88vh;overflow:auto} + /* first-run welcome: agent picker cards + the Skip/Next footer */ + .agent-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px} + @media(max-width:560px){ .agent-grid{grid-template-columns:repeat(2,1fr)} } + .agent-card{display:flex;align-items:center;gap:9px;padding:12px 13px;border:1px solid var(--line);border-radius:10px;background:var(--panel2);color:var(--ink);font-family:inherit;font-size:13.5px;cursor:pointer;text-align:left} + .agent-card img{width:20px;height:20px;flex:none} + .agent-card.on{border-color:var(--ink);box-shadow:inset 0 0 0 1px var(--ink);background:var(--bg)} + .wc-foot{display:flex;justify-content:space-between;align-items:center;margin-top:22px;padding-top:16px;border-top:1px solid var(--line)} + /* Getting started: numbered step cards, the agent dropdown, and the try-it example grid */ + .start-card{border:1px solid var(--line);border-radius:12px;background:var(--panel)} + .start-hd{display:flex;align-items:center;gap:12px;padding:14px 18px;border-bottom:1px solid var(--line)} + .start-num{display:grid;place-items:center;width:26px;height:26px;border-radius:50%;background:var(--ink);color:var(--bg);font-size:13px;font-weight:700;flex:none} + .start-bd{padding:18px} + .agent-menu{position:absolute;top:calc(100% + 6px);left:0;z-index:40;min-width:200px;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:6px;display:flex;flex-direction:column;gap:2px;box-shadow:0 10px 30px rgba(0,0,0,.18)} + .agent-menu .agent-card{border:0;background:none;width:100%;padding:8px 10px;box-shadow:none} + .agent-menu .agent-card:hover,.agent-menu .agent-card.on{background:var(--panel2)} + .try-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px} + @media(max-width:700px){ .try-grid{grid-template-columns:1fr} } + .try-card{display:block;text-align:left;padding:14px 16px;border:1px solid var(--line);border-radius:10px;background:var(--panel2);color:var(--ink);font-family:inherit;cursor:pointer;transition:border-color .15s} + .try-card:hover{border-color:var(--accent)} + .try-cat{display:flex;align-items:center;justify-content:space-between;font-size:10.5px;font-weight:600;letter-spacing:.09em;text-transform:uppercase;color:var(--muted);margin-bottom:8px} + .try-ico{width:15px;height:15px;flex:none} + .try-ico.avatar{border-radius:50%} + .try-copy{font-weight:400;letter-spacing:0;text-transform:none;font-size:11.5px;color:var(--muted)} + .try-card:hover .try-copy{color:var(--accent)} .try-copy.done{color:var(--green)} + .try-txt{display:block;font-family:var(--sans);font-size:13px;line-height:1.55;color:var(--ink)} + .oauth-grp{font-family:var(--sans);font-size:12.5px;font-weight:600;color:var(--ink);margin-bottom:8px} + .prov-chip{display:inline-flex;align-items:center;gap:7px;padding:7px 12px;border:1px solid var(--line);border-radius:999px;background:var(--panel2);font-family:var(--sans);font-size:12.5px;color:var(--ink);cursor:pointer;transition:border-color .15s} + .prov-chip img{width:15px;height:15px;flex:none} + .prov-chip:hover{border-color:var(--accent)} + .oauth-div{display:flex;align-items:center;gap:14px;margin-top:28px;margin-bottom:4px;color:var(--muted);font-family:var(--sans);font-size:12.5px;white-space:nowrap} + .oauth-div::before,.oauth-div::after{content:'';flex:1;border-top:1px solid var(--line);min-width:20px} + .soon-note{position:relative;font-family:var(--sans);font-size:12px;color:var(--muted);cursor:default;border-bottom:1px dotted var(--muted)} + .soon-note::after{content:attr(data-tip);position:absolute;bottom:calc(100% + 7px);left:50%;transform:translateX(-50%);white-space:nowrap;background:var(--ink);color:var(--bg);font-size:11.5px;padding:5px 10px;border-radius:7px;opacity:0;pointer-events:none;transition:opacity .12s} + .soon-note:hover::after{opacity:1} + .chip.demo{border-color:var(--accent);color:var(--accent)} + .modal .hd{display:flex;justify-content:space-between;align-items:center;padding:15px 18px;border-bottom:1px solid var(--line)} + .tabs .act-toggle{margin-left:auto;align-self:center;margin-bottom:6px;padding:4px 11px;font-family:inherit;font-size:12px;color:var(--ink);background:var(--panel);border:1px solid var(--line);border-radius:999px} .tabs .act-toggle:hover{border-color:var(--accent);color:var(--accent)} + tr.act-row{cursor:pointer} tr.act-row:hover td{background:var(--panel2)} .act-view{font-size:11px;color:var(--accent);margin-left:6px;white-space:nowrap} + .drawer .hd{display:flex;justify-content:space-between;align-items:center;gap:12px} .drawer .hd b{min-width:0;overflow-wrap:anywhere} + .drawer{position:fixed;top:0;right:0;height:100vh;width:min(520px,96vw);background:var(--panel);border-left:1px solid var(--line);z-index:50;box-shadow:-12px 0 44px rgba(20,16,10,.34);display:flex;flex-direction:column} + pre{margin:0;padding:14px;background:var(--bg);border:1px solid var(--line);border-radius:10px;font-family:var(--mono);font-size:12.5px;overflow:auto;white-space:pre-wrap;word-break:break-word;max-height:50vh} + pre.code{white-space:pre;overflow-x:auto;word-break:normal;overflow-wrap:normal;line-height:1.55} + .field{display:flex;gap:8px;margin:8px 0} .field input,.field select,.field textarea{background:var(--bg);border:1px solid var(--line);color:var(--ink);border-radius:var(--rb);padding:8px 10px;font-family:var(--mono);font-size:13px} .field input,.field textarea{flex:1} + .field input.field-invalid{border-color:var(--red)} + .field.auth-method-field{flex-direction:column;align-items:flex-start;gap:6px}.auth-method-field select{min-width:170px} + .login{min-height:100vh;display:grid;place-items:center} .loginbox{width:min(420px,92vw);background:var(--panel);border:1px solid var(--line);border-radius:16px;padding:30px;text-align:center} + .phase2{opacity:.5} .hint{font-family:var(--mono);font-size:11px;color:var(--amber);border:1px dashed var(--amber);border-radius:6px;padding:2px 7px} + a{color:var(--accent);text-decoration:none;cursor:pointer} + .banner{background:color-mix(in srgb,var(--red) 15%,transparent);color:var(--red);border:1px solid var(--red);border-radius:8px;padding:8px 12px;margin-bottom:14px;font-size:12.5px} + .explain{font-family:var(--sans);font-size:12px;color:var(--muted);margin:2px 0 12px} + .skill-drop{display:flex;gap:14px;align-items:center;padding:18px;border:1.5px dashed var(--line);border-radius:10px;cursor:pointer;background:var(--panel2)} + .skill-drop:hover{border-color:var(--accent)} .skill-drop .sd-ic{font-size:24px;line-height:1} + .skill-item{border:1px solid var(--line);border-radius:9px;padding:10px 12px;margin-bottom:8px;background:var(--panel2)} + .skill-item.off{opacity:.5} .si-head{display:flex;align-items:center;gap:8px;cursor:pointer} .si-head input{cursor:pointer} + .si-body{margin:7px 0 0 22px;display:flex;flex-direction:column;gap:4px} + .si-sec{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:12px} + .si-input{background:var(--bg);border:1px solid var(--line);color:var(--ink);border-radius:6px;padding:4px 8px;font-family:var(--mono);font-size:12px;min-width:180px} + .si-ok{color:var(--green);font-size:12px} .si-warn{color:var(--amber);font-size:12px} + .adv-link{color:var(--muted);font-size:12px;text-decoration:underline;cursor:pointer;margin-left:auto} .adv-link:hover{color:var(--accent)} + .exrow{display:flex;gap:6px;flex-wrap:wrap;margin:6px 0 12px} + .exchip{font-family:var(--mono);font-size:11.5px;padding:3px 9px;border-radius:7px;border:1px solid var(--line);background:var(--panel2);color:var(--ink);cursor:pointer} + .exchip:hover{border-color:var(--accent)} .exchip.on{border-color:var(--accent);color:var(--accent)} + .exchip .m{color:var(--muted);margin-right:5px} + .lbl{font-family:var(--sans);font-size:10.5px;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin:2px 0 5px} + /* ===== interactive tutorial (Help view) ===== */ + .tut-head{display:flex;align-items:center;gap:12px;margin-bottom:20px} .tut-actions{margin-left:auto;display:flex;gap:6px} + .tut-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:10px;margin:12px 0} + .tut-cards.wide{grid-template-columns:repeat(auto-fit,minmax(360px,1fr))} + .tut-cards .card h4{margin:0 0 5px;font-size:13px;color:var(--accent);font-family:var(--mono)} + .tut-cards .card p{margin:0;font-size:12px;color:var(--muted);font-family:var(--sans)} + .tut-cards .card p code,.tut-cards .card p .mono{color:var(--accent);font-family:var(--mono)} + .tut-cards .card pre{background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:8px 10px;margin:6px 0;font-size:11px;line-height:1.5;color:var(--ink);overflow-x:auto;white-space:pre} + .tut-body{display:grid;grid-template-columns:248px 1fr;gap:22px;margin-top:14px;align-items:start} + .tut-nav{border-right:1px solid var(--line);padding-right:10px;max-height:74vh;overflow:auto;position:sticky;top:70px} + .tut-part{font-size:10px;text-transform:uppercase;letter-spacing:.09em;color:var(--muted);margin:14px 8px 5px} + .tut-step{display:block;width:100%;text-align:left;background:none;border:0;color:var(--ink);padding:7px 9px;border-radius:7px;font-size:12px;border-left:2px solid transparent} + .tut-step:hover{background:var(--panel2)} .tut-step.active{background:var(--panel2);border-left-color:var(--accent);color:var(--accent);font-weight:600} + .tut-step .n{color:var(--muted);margin-right:8px;font-variant-numeric:tabular-nums} .tut-step.active .n{color:var(--accent)} + .tut-main{min-width:0;max-width:760px} .tut-main .crumbs{color:var(--muted);font-size:11px;margin-bottom:4px} + .tut-main h2{font-size:20px;margin:0 0 8px;letter-spacing:-.3px} + .persona{display:inline-block;font-size:11px;padding:2px 9px;border-radius:999px;border:1px solid var(--line);margin-bottom:14px} + .persona.tom{color:var(--accent);border-color:color-mix(in srgb,var(--accent) 45%,var(--line))} + .persona.bob{color:var(--green);border-color:color-mix(in srgb,var(--green) 45%,var(--line))} + .persona.alice{color:var(--amber);border-color:color-mix(in srgb,var(--amber) 45%,var(--line))} + .persona.sys{color:var(--muted)} .persona.ops{color:var(--red);border-color:color-mix(in srgb,var(--red) 45%,var(--line))} + .persona.you{color:var(--green);border-color:color-mix(in srgb,var(--green) 45%,var(--line))} + .persona.sam{color:var(--amber);border-color:color-mix(in srgb,var(--amber) 45%,var(--line))} + .tut-label{display:flex;align-items:center;gap:8px;font-family:var(--sans);font-size:10.5px;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin:16px 0 6px} + .tut-label .copy{margin-left:auto;border:1px solid var(--line);background:var(--panel2);color:var(--muted);border-radius:6px;padding:2px 9px;font-size:11px;font-family:var(--mono)} + .tut-label .copy:hover{color:var(--ink);border-color:var(--accent)} + pre.term{max-height:none} + /* the "expected result" pane is secondary, but it is still body copy in a code block — + --muted lands at 3.7:1 on the light surface, the syntax ramp's comment grey at 4.7 */ + pre.term.out{color:var(--sx-cmt)} + .tut-notice{border-left:3px solid var(--amber);background:color-mix(in srgb,var(--amber) 9%,transparent);padding:10px 13px;border-radius:0 8px 8px 0;margin:16px 0;font-size:12px;font-family:var(--sans)} .tut-notice b{color:var(--amber)} + .tgl{display:inline-flex;align-items:center;gap:5px;font-size:12px;cursor:pointer;user-select:none} .tgl input{margin:0;cursor:pointer} .btn.sm.active{border-color:var(--accent);color:var(--accent)} + .tut-foot{display:flex;gap:10px;align-items:center;margin-top:20px;padding-top:14px;border-top:1px solid var(--line)} .tut-foot .pos{color:var(--muted);font-size:12px;margin:0 auto} + .tour-mat{border-radius:14px;padding:min(4vw,36px);display:flex;justify-content:center;margin:2px 0 4px;transition:background .3s} + .tour-mat img{width:100%;max-width:960px;height:auto;border-radius:9px;box-shadow:0 16px 44px rgba(0,0,0,.36),0 3px 9px rgba(0,0,0,.22);display:block} + @media(max-width:760px){.tut-body{grid-template-columns:1fr}.tut-nav{border-right:0;border-bottom:1px solid var(--line);position:static;max-height:210px}} + /* Mobile hamburger menu: hidden on desktop, visible on mobile */ + .mob-menu{display:none;background:none;border:0;color:var(--ink);padding:6px;cursor:pointer;font-size:18px;line-height:1} + /* Mobile sidebar overlay backdrop */ + .mob-backdrop{display:none;position:fixed;inset:0;background:rgba(20,16,10,.55);z-index:40} + /* Mobile sidebar close button: hidden on desktop */ + .mob-close{display:none;background:none;border:0;color:var(--muted);font-size:20px;cursor:pointer;padding:4px 8px;margin-left:auto} + /* App shell: collapse the fixed sidebar to a single column on narrow viewports + let wide tables + scroll inside themselves so the page body never scrolls horizontally on mobile. */ + @media(max-width:760px){ + /* minmax(0,1fr) stops the grid column from expanding to its content's min-width (the classic + 1fr blowout); min-width:0 + overflow-wrap let long nav text / the "Tools in " heading + wrap instead of forcing horizontal page scroll. */ + .layout{grid-template-columns:minmax(0,1fr);min-height:0} + /* Hide sidebar by default on mobile; show it as a slide-in overlay when .mob-open is set */ + .side{display:none;position:fixed;top:0;left:0;width:min(280px,85vw);height:100vh;z-index:45;border-right:1px solid var(--line);overflow-y:auto} + .side.mob-open{display:flex} + .mob-backdrop.mob-open{display:block} + main{min-width:0} + .side-acct{margin-top:auto} + h1,.tut-head,.brand{overflow-wrap:anywhere} + .tut-head{flex-wrap:wrap} .tut-head>div:first-child{flex:1 1 100%} .tut-actions{flex:1 1 auto;justify-content:flex-start;margin-left:0} + table{display:block;overflow-x:auto;white-space:nowrap}td,th{word-break:break-word} + /* header wraps instead of forcing horizontal page scroll: the search drops to its own full-width + row and the flex spacer is removed so brand/org/theme/avatar/sign-out fit the viewport. */ + .top{flex-wrap:wrap;gap:8px;padding:10px 12px}.top .spacer{display:none}.search{flex:1 1 100%;max-width:none;order:9}.orgsel{min-width:0} + /* Show hamburger menu button on mobile */ + .mob-menu{display:grid;place-items:center;order:-1} + /* Show close button in sidebar on mobile */ + .mob-close{display:block}} + /* Medium viewports: sidebar visible but content area is narrow — reduce padding so tabs fit. + Remove the fade mask here since it cuts into visible tab text; scrolling is still available. */ + @media(min-width:761px) and (max-width:1200px){ + main{padding:18px 14px} + .mk-tabs{-webkit-mask-image:none;mask-image:none}} + /* ---- landing page (logged-out) ---- */ + .lp{max-width:1040px;margin:0 auto;padding:0 20px 60px} + .lp-nav{display:flex;align-items:center;justify-content:space-between;padding:16px 0;border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--bg);z-index:20} + .lp-nav .brand{font-family:var(--mono);font-weight:700;font-size:15px} + .lp-links{display:flex;align-items:center;gap:16px} .lp-links a{font-family:var(--mono);font-size:13px;color:var(--muted)} .lp-links a:hover{color:var(--ink)} + .lp-hero{text-align:center;padding:46px 0 22px} + .lp-eyebrow{font-family:var(--mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--accent);margin-bottom:14px} + .lp-h1{font-size:clamp(29px,4.8vw,46px);line-height:1.05;letter-spacing:-.02em;margin:0;font-family:var(--mono);font-weight:700;text-wrap:balance} + /* Desktop: the whole headline on ONE line. The mono face needs 41× its font-size for this copy + (measured in Chrome: scrollWidth 1288px at 31.4px) — + wider than the 1040px shell — so the h1 breaks out toward the viewport edge and its size tracks + the viewport ((100vw-64)/42 leaves slack for the scrollbar; 32px is shaved off the box for the + same reason) until the 46px cap fits. Below 1140px it re-wraps (balanced) inside the shell. + The divisor is tuned to the COPY LENGTH (64 chars today): change the headline, re-tune it, or + the nowrap line runs past the viewport edge. */ + @media(min-width:1140px){.lp-h1{white-space:nowrap;width:calc(100vw - 32px);margin-left:calc(50% - 50vw + 16px);font-size:min(46px,calc((100vw - 64px)/42))}} + .lp-lead{font-family:var(--sans);font-size:16.5px;line-height:1.55;color:var(--muted);max-width:590px;margin:16px auto 0} .lp-lead b{color:var(--ink);font-weight:600} + .lp-cta{display:flex;gap:12px;justify-content:center;flex-wrap:wrap;margin-top:22px} + .btn.lg{padding:12px 20px;font-size:14px} + .lp-section{padding:44px 0;border-top:1px solid var(--line);margin-top:44px} + .lp-head{text-align:center;max-width:640px;margin:0 auto 26px} + .lp-kicker{display:inline-block;font-family:var(--mono);font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--onAccent);background:var(--accent);border-radius:999px;padding:3px 11px;margin-bottom:12px} + .lp-head h2{font-family:var(--mono);font-size:26px;margin:0 0 8px;letter-spacing:-.01em} + .lp-head p{font-family:var(--sans);font-size:15px;line-height:1.5;color:var(--muted);margin:8px 0 0} .lp-head p b{color:var(--ink)} + .lp-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px} @media(max-width:720px){.lp-grid{grid-template-columns:1fr}} + .lp-panel{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);overflow:hidden} + .lp-panel-t{font-family:var(--mono);font-size:12px;color:var(--muted);padding:11px 14px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:8px} + .lp-panel pre{margin:0;border:0;border-radius:0;background:transparent;max-height:340px;min-height:120px} + .lp-empty{padding:30px 14px;text-align:center;color:var(--muted);font-family:var(--mono);font-size:12.5px;min-height:120px;display:grid;place-items:center} + .tag{font-family:var(--mono);font-size:10.5px;padding:1px 7px;border-radius:999px;border:1px solid var(--line)} .tag.ok{color:var(--green);border-color:var(--green)} + .badge{font-family:var(--mono);font-size:11px;padding:1px 7px;border-radius:6px} .badge.ok{color:var(--green);border:1px solid var(--green)} .badge.invalid{color:var(--red);border:1px solid var(--red)} + .lp-aha{margin-top:16px;background:color-mix(in srgb,var(--accent) 12%,transparent);border:1px solid var(--accent);border-radius:var(--r);padding:14px 16px;font-family:var(--sans);font-size:15px;line-height:1.5;text-align:center} + .lp-aha code{font-family:var(--mono);font-size:13px;color:var(--accent);background:var(--bg);padding:2px 6px;border-radius:5px;word-break:break-all} + .lp-actions{display:flex;align-items:center;gap:14px;justify-content:center;margin-top:20px;flex-wrap:wrap} + .term{background:#161310;border:1px solid var(--line);border-radius:var(--r);padding:18px 20px;font-family:var(--mono);font-size:13px;line-height:1.7;color:var(--ink);overflow:auto;white-space:pre;max-width:720px;margin:0 auto} + :root[data-theme=light] .term{background:#201c15;color:#f0e9d9} + .lp-signin{padding-top:44px} .lp-signin .loginbox{margin:0 auto} + .lp-foot{text-align:center;font-family:var(--mono);font-size:12px;color:var(--muted);padding-top:36px} .lp-foot a{color:var(--muted)} .lp-foot a:hover{color:var(--ink)} + /* ---- logged-out landing (lc-*) ---- */ + .lc-chips{display:flex;gap:8px;justify-content:center;flex-wrap:wrap;margin-top:20px} + .lc-chips span{font-size:11px;letter-spacing:.09em;text-transform:uppercase;color:var(--muted);border:1px solid var(--line);border-radius:999px;padding:6px 13px} + .md-dd{position:relative;display:inline-block} + .md-menu{position:absolute;left:0;top:calc(100% + 6px);background:var(--panel);border:1px solid var(--line);border-radius:var(--rb);box-shadow:0 12px 30px rgba(0,0,0,.3);padding:5px;display:none;flex-direction:column;gap:2px;width:max-content;min-width:170px;z-index:30} + .md-menu.show{display:flex} + .md-menu button{background:none;border:0;color:var(--ink);text-align:left;padding:8px 12px;border-radius:6px;font-family:var(--mono);font-size:12.5px;cursor:pointer;white-space:nowrap} + .md-menu button:hover{background:var(--panel2)} + .lc-codewrap{position:relative;background:#161310;border:1px solid var(--line);border-radius:var(--rb);overflow:hidden} + :root[data-theme=light] .lc-codewrap{background:#201c15} + .lc-codewrap pre{margin:0;background:transparent;border:0;color:#f0e9d9;padding:14px 46px 14px 16px;white-space:pre-wrap;word-break:break-word;font-family:var(--mono);font-size:12px} + .lc-cp{position:absolute;top:9px;right:9px;font-size:11px;background:#2a251d;color:#f0e9d9;border:1px solid #3b352a;border-radius:6px;padding:3px 9px;cursor:pointer} .lc-cp:hover{border-color:var(--accent);color:var(--accent)} + /* triptych */ + .lc-tri{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-top:24px} @media(max-width:760px){.lc-tri{grid-template-columns:1fr}} + .lc-pain{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:18px} + .lc-pain .t{font-size:12px;letter-spacing:.1em;text-transform:uppercase;color:var(--accent)} + .lc-pain .p{font-family:var(--sans);margin-top:10px;color:var(--ink);font-size:14px;line-height:1.5} + .lc-pain .f{font-family:var(--sans);margin-top:10px;color:var(--muted);font-size:13px;padding-top:10px;border-top:1px solid var(--line);line-height:1.5} .lc-pain .f b{color:var(--green)} + /* modal */ + .lc-scrim{position:fixed;inset:0;background:rgba(0,0,0,.55);display:none;place-items:center;z-index:60;padding:20px} .lc-scrim.open{display:grid} + .lc-modal{width:min(400px,94vw);background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:26px;text-align:center;position:relative} + .lc-modal .cls{position:absolute;top:10px;right:14px;background:none;border:0;color:var(--muted);font-size:18px;cursor:pointer} +/* ================================================================ + MONOLOGUE / PIXEL RESKIN — matches the new landing (landing.html). + Token-level override: charcoal + grain, DM Mono, Geist Pixel display, + cyan accent, mint success, paper primary buttons, pill radii. + The light theme below keeps its own palette and is untouched. + ================================================================ */ +:root{ + --bg:#151412; --panel:#1c1b19; --panel2:#252422; --ink:#f2efe8; + --muted:rgba(242,239,232,.55); --line:rgba(255,255,255,.09); + --accent:#19D0E8; --onAccent:#06282e; + --green:#A6EE98; --amber:#d8d2c2; --teal:#19D0E8; --red:#f08a7a; + --mono:"DM Mono",ui-monospace,"SF Mono",Menlo,monospace; + --sans:"DM Mono",ui-monospace,"SF Mono",Menlo,monospace; + --display:"Geist Pixel",var(--mono); +} +body::before{content:"";position:fixed;inset:0;pointer-events:none;z-index:90; + background:repeating-linear-gradient(180deg,transparent 0 3px,rgba(255,255,255,.012) 3px 4px)} +h1{font-family:var(--display);font-weight:400;font-size:23px;letter-spacing:0} +.tut-main h2{font-family:var(--display);font-weight:400;letter-spacing:0} +.stat .n{font-family:var(--display);font-weight:400} +.top{background:linear-gradient(180deg,#1b1a18,#171614)} +.side{background:#181715} +.btn{border-radius:999px} +.btn.primary{background:linear-gradient(180deg,#fdfcf7 0%,#eae7de 100%);border-color:transparent;color:#1c1b19; + box-shadow:rgba(178,168,165,.2) -1.3px -1.3px 2.5px 0, rgba(0,0,0,.4) 1.5px 1.5px 1.5px 0} +.btn.primary:hover{border-color:transparent; + box-shadow:rgba(178,168,165,.2) -1.3px -1.3px 2.5px 0, rgba(0,0,0,.5) 2px 2px 2px 0} +.btn.ico,.btn.sm.ico{border-radius:999px} +.iconbtn{border-radius:999px} +.seg{border-radius:999px} .seg button{border-radius:999px} +.seg button.on{background:linear-gradient(180deg,#fdfcf7,#eae7de);color:#1c1b19} +.seg button.on span{color:rgba(28,27,25,.55)} +th{background:rgba(0,0,0,.25)} +.ttable-wrap,table{box-shadow:rgba(255,255,255,.07) 0 1px 0 inset} +.stat{background:linear-gradient(180deg,#201f1d,#181715); + box-shadow:rgba(255,255,255,.07) 0 1px 0 inset, rgba(0,0,0,.35) 0 10px 24px -14px} +.card{background:linear-gradient(180deg,#1e1d1b 0%,#171614 100%); + box-shadow:rgba(255,255,255,.07) 0 1px 0 inset} +.modal,.drawer{background:linear-gradient(180deg,#201f1d,#171614)} +.term{background:#0d0c0b;border-color:var(--line)} +.lc-codewrap{background:#0d0c0b} +:root:not([data-theme=light]) pre{background:rgba(0,0,0,.28); + box-shadow:rgba(0,0,0,.4) 0 2px 8px 0 inset;border-color:var(--line)} +.frow input,.frow select,.field input,.field select,.field textarea,.search,.msel,.bindinput,.si-input{ + background:#0d0c0b;box-shadow:rgba(0,0,0,.35) 0 2px 6px 0 inset} +.chip{border-radius:999px} +.hint{color:var(--amber);border-color:rgba(216,210,194,.4)} +/* sidebar: pinned below the top bar with its own scroll, so HELP + the account + footer stay on screen no matter how long the main column gets */ +.side{position:sticky;top:57px;height:calc(100vh - 57px);overflow-y:auto;align-self:start} +@media(max-width:760px){.side{position:static;height:auto;overflow:visible}} +/* light theme keeps its paper palette but adopts the new fonts + shapes */ +[data-theme="light"] body::before{display:none} + +/* ================================================================ + RESTYLE — Phase 1 (tokens) + the platform-card grid. + Reference: https://rows.gg/demo (values read off the live site). + The agentreach prototype is a second-hand implementation of the + same look; where the two disagree, rows.gg wins. + See docs/context/interface/restyle-plan.md for the full mapping, + the remaining phases, and the open questions. + + Last block in the sheet on purpose: it overrides the pixel reskin + above by source order alone, so the whole change reverts by + deleting from here to the end of the sheet. + ================================================================ */ +:root{ + --bg:#161518; --surface:#1e1d20; --panel:#1c1b1e; --panel2:#232226; + --ink:#f0f0ee; --muted:#989898; --muted2:#6f6f6d; + --line:#f3f3f512; --line2:#f3f3f524; --hover:#f4f4f50a; + --accent:#6fcdf0; --onAccent:#06282e; --teal:#6fcdf0; + --green:#45e39e; --amber:#f0c249; --red:#f08a84; + --inverse:#f8f8f7; --inverse-ink:#1a1a1a; + /* rows.gg layers its elevation instead of using one blur; on dark the alphas + are raised, since 4–8% black is invisible against a #161518 page. */ + --shadow-sm:0 1px 2px #00000052; + --shadow-md:0 1px 2px -1px #0000003d,0 4px 6px -1px #00000052; + --shadow-lg:0 1px 2px -1px #0000003d,0 4px 6px -1px #00000052,0 8px 16px #00000052; + /* The two curves rows.gg actually ships: --ease drives colour/shadow/size, + --ease-out is reserved for transforms. Both are far quicker than they look. */ + --ease:cubic-bezier(.2,.72,.25,1); --ease-out:cubic-bezier(.22,1,.36,1); + --r:15px; --rb:10px; + --sans:"Suisse Intl","Inter","Segoe UI",system-ui,sans-serif; +} +[data-theme="light"]{ + --bg:#f4f4f1; --surface:#fff; --panel:#fafaf9; --panel2:#f0f0ee; + --ink:#1a1a1a; --muted:#7c7c7c; --muted2:#989898; + --line:#2626231a; --line2:#25252233; --hover:#2a2a250a; + --accent:#1a7da6; --onAccent:#fff; --teal:#1a7da6; + --green:#118453; --amber:#ba6603; --red:#c0362f; + --inverse:#1a1a1a; --inverse-ink:#f8f8f7; + --shadow-sm:0 1px 2px #00000014; + --shadow-md:0 1px 2px -1px #0000000a,0 4px 6px -1px #0000000f; + --shadow-lg:0 1px 2px -1px #0000000a,0 4px 6px -1px #0000000f,0 8px 16px #0000000a; +} +/* The theme toggle cross-fades rather than cutting. */ +body{transition:background .24s var(--ease),color .24s var(--ease)} +/* rows.gg's focus ring: a hole punched in the page colour, then the ink — it + reads on a card and on the page background without an offset outline. */ +:focus-visible{outline:0;box-shadow:0 0 0 2px var(--bg),0 0 0 3.5px var(--ink)} +@media (prefers-reduced-motion: reduce){body{transition:none}} + +/* ---- platform cards (marketplace shelves) ---- */ +/* The shelf heading: a title, and the count as the source's quiet mono pill. */ +.sec-h b{font-family:var(--sans);font-size:17px;font-weight:500;letter-spacing:-.3px} +.sec-n{background:none;border-color:var(--line2);font-size:11px;font-weight:450; + color:var(--muted);padding:2px 10px;letter-spacing:.1px} +.sec-hint{font-family:var(--sans);font-size:12px;color:var(--muted);max-width:64ch} +/* A card is a surface, not a box: no border, an ambient shadow at rest, and a deeper one + on hover. The lift is the shadow itself — no translate, so a 40-card grid stays still. */ +.pt-card{background:var(--surface);border:0;border-radius:var(--r);padding:16px 18px;gap:10px; + box-shadow:var(--shadow-sm); + transition:box-shadow .24s var(--ease),background-color .2s var(--ease)} +.pt-card:hover{border-color:transparent;transform:none;box-shadow:var(--shadow-md)} +.pt-card:focus-visible{outline:0;box-shadow:0 0 0 2px var(--bg),0 0 0 3.5px var(--ink)} +.pt-name{font-family:var(--sans);font-size:14px;font-weight:500;letter-spacing:-.2px} +.pt-cat{font-family:var(--sans);font-size:11px;color:var(--muted);letter-spacing:.1px} +.pt-quiet{font-size:11px;color:var(--muted2);opacity:1} +.pt-foot{border-top:1px solid var(--line);padding-top:10px;font-size:11px} +/* Denser without the description: a name, its category, and the two facts. */ +.pt-card{padding:14px 16px;gap:12px} +.pt-eps,.pt-price{color:var(--muted2);font-variant-numeric:tabular-nums} +.pt-price b{font-weight:500;color:var(--muted)} +/* The loud green "Connected" chip is a deliberate exception to rows.gg's quiet pills — + "you can call this right now" has to survive being skimmed across 40 cards. It keeps + its fill and its haloed dot; every OTHER chip goes quiet (see the pill rule below). */ +.pt-more{border:0;border-radius:var(--r);color:var(--muted); + font-family:var(--sans);font-size:12px; + transition:color .2s var(--ease),background-color .2s var(--ease)} +.pt-more:hover{color:var(--ink);background:var(--hover)} +.pt-mini{box-shadow:0 0 0 1px var(--bg)} + +/* ================================================================ + RESTYLE — Phase 2 (marketplace) + Phase 3 (the rest). + Reference: https://rows.gg/demo. Decisions locked with the founder: + light is the default theme, Geist Pixel on h1 only, NO brand accent + (colour is reserved for status), 10px buttons, the green Connected + chip stays loud, neutral charcoal, card density unchanged. + ================================================================ */ + +/* ---- 3.0 the light theme was half-tokenised ---- + The pixel reskin above hardcodes a dark value on nine surfaces with no theme + guard, which is why light mode rendered a dark top bar and sidebar with dark + text on them, and dark inputs inside light content. Every rule below either + routes through a token or is scoped to a theme. Nothing here is a colour + literal except the code surfaces, which are intentionally dark in both. */ +/* Native controls — popups, checkboxes, scrollbars — are painted by the UA, - not by our tokens. Without color-scheme they stay light-on-light in dark mode. */ -:root{color-scheme:dark} [data-theme=light]{color-scheme:light} -input[type=checkbox],input[type=radio]{accent-color:var(--ink)} -.top{background:var(--panel);border-bottom:1px solid var(--line)} -.side{background:var(--panel)} -.stat{background:var(--surface);border-color:transparent;box-shadow:var(--shadow-sm)} -.card{background:var(--surface);border-color:transparent;box-shadow:var(--shadow-sm)} -.modal,.drawer{background:var(--surface);box-shadow:var(--shadow-lg)} -.ttable-wrap,table{box-shadow:none} -.ttable-wrap{background:var(--surface);border-color:var(--line)} -table{background:var(--surface)} -/* rows.gg's column heads are label text over a rule, not a filled bar. */ -th{background:none;color:var(--muted2);font-weight:450;border-bottom:1px solid var(--line2)} -.frow input,.frow select,.field input,.field select,.field textarea,.search,.msel,.bindinput,.si-input{ - background:var(--surface);box-shadow:none;border:1px solid var(--line2);color:var(--ink)} -.frow input::placeholder,.field input::placeholder,.field textarea::placeholder,.search input::placeholder{color:var(--muted2)} -:root:not([data-theme=light]) pre{background:var(--panel2);box-shadow:none;border-color:var(--line)} -[data-theme=light] pre{background:var(--panel2);border:1px solid var(--line);color:var(--ink)} -/* The scanline grain is a dark-theme texture; on paper it reads as dirt. */ -[data-theme=light] body::before{display:none} -.scrim,.lc-scrim{background:color-mix(in srgb,var(--inverse-ink) 55%,transparent)} - -/* ---- 3.1 no brand accent ---- - rows.gg has no accent colour: emphasis is ink, and colour is reserved for status. - Rather than rewrite 66 call sites, --accent becomes ink and --onAccent the page — - which turns every accent FILL into rows.gg's inverse button and every accent TEXT - into ink. Status tokens (--green/--amber/--red/--teal) are untouched. */ -:root,[data-theme=light]{--accent:var(--ink);--onAccent:var(--bg)} -/* Syntax highlighting is the one place colour is not status and not decoration: - it is how a long command stays readable. It keeps its own ramp — see 3.8, where the - ramp is themed, because a ramp drawn for a dark block is invisible on a light one. */ - -/* ---- 3.2 type: Geist Pixel on h1 only, everything else sans ---- - Mono is kept for what it is good at — identifiers, counts, commands, code. */ -body{font-family:var(--sans)} -h1{font-family:var(--display)} -h2,h3,h4,.tut-main h2{font-family:var(--sans);font-weight:500;letter-spacing:-.2px} -.btn,.seg button,.tabs button,.mk-tabs button,.side .nav,.frow input,.frow select, -.field input,.field select,.search input,.msel,.bindinput,.sub,.lead,p,label, -.side .grp,.acct-you,.kv,.chip,.badge{font-family:var(--sans)} -.stat .n{font-family:var(--mono);font-weight:500;font-variant-numeric:tabular-nums} -.mono,pre,code,.term,.cli,.ttable .tn b,.side .ac-label{font-family:var(--mono)} -h1{font-size:22px;letter-spacing:-.44px} h2{font-size:17px} h3{font-size:15px} -.sub{font-size:12.5px;color:var(--muted);max-width:74ch} -/* 450 "book" is a real weight in this design — it is why secondary labels sit - between regular and medium instead of going grey-and-bold. */ -.side .grp,th,.kv b,.tgh,.stat .l{font-weight:450;text-transform:none;letter-spacing:.1px} - -/* ---- 3.3 controls: 10px, not pills ---- - Chips and badges stay round; only the BUTTONS square off. */ -.btn,.btn.ico,.btn.sm.ico,.iconbtn,.seg,.seg button{border-radius:var(--rb)} -.btn{border:1px solid var(--line2);background:none;color:var(--ink);font-weight:450; - transition:background-color .18s var(--ease),border-color .18s var(--ease), - color .18s var(--ease),transform .12s var(--ease-out)} -.btn:hover{border-color:var(--line2);background:var(--hover)} -.btn:active{transform:scale(.97)} -.btn.primary{background:var(--inverse);border-color:var(--inverse);color:var(--inverse-ink); - box-shadow:none;font-weight:500} -.btn.primary:hover{background:var(--inverse);border-color:var(--inverse);opacity:.88} -.seg button.on{background:var(--inverse);color:var(--inverse-ink);box-shadow:none} -.seg button.on span{color:color-mix(in srgb,var(--inverse-ink) 55%,transparent)} -.iconbtn{background:none;border:1px solid var(--line2);color:var(--muted); - transition:background-color .18s var(--ease),color .18s var(--ease)} -.iconbtn:hover{background:var(--hover);color:var(--ink)} - -/* ---- 3.4 tabs and nav: ink, not accent ---- */ -.tabs button.active,.mk-tabs button.on{color:var(--ink);border-bottom-color:var(--ink);font-weight:500} -.side .nav{transition:background-color .18s var(--ease),color .18s var(--ease)} -.side .nav:hover{background:var(--hover)} -.side .nav.active{background:var(--panel2);color:var(--ink);font-weight:500} -a{color:var(--ink);text-decoration:underline;text-underline-offset:3px; - text-decoration-color:var(--line2)} -a:hover{text-decoration-color:var(--muted)} - -/* ---- 3.5 quiet pills (the Connected chip keeps its fill — see above) ---- - One status token drives both text and border; the border is that colour at 30%. */ -.chip{border-radius:999px;font-size:10px;font-weight:450;letter-spacing:.09px;padding:2px 9px; - border:1px solid var(--line2);color:var(--muted);background:none} -.chip.env{color:var(--amber);border-color:color-mix(in srgb,var(--amber) 30%,transparent);background:none} -.chip.oauth,.chip.run,.chip.own{color:var(--teal);border-color:color-mix(in srgb,var(--teal) 30%,transparent);background:none} -.chip.ver{color:var(--green);border-color:color-mix(in srgb,var(--green) 30%,transparent);background:none} -.chip.secret_file,.chip.demo{color:var(--muted);border-color:var(--line2);background:none} -.chip.cached{color:var(--muted);border-color:var(--line2);background:var(--panel2)} -.badge{border-radius:999px;font-size:10px;font-weight:450;padding:2px 9px;border:1px solid transparent} -.badge.ok{color:var(--green);border-color:color-mix(in srgb,var(--green) 30%,transparent);background:none} -.badge.invalid{color:var(--red);border-color:color-mix(in srgb,var(--red) 30%,transparent);background:none} -.badge.unknown{color:var(--muted);border-color:var(--line2);background:none} -.role{font-weight:500;font-size:10px;letter-spacing:.09px} -.role.admin{color:var(--muted)} -.sec-n,.tgh-n{border-radius:999px} - -/* ---- 3.6 surfaces: the marketplace's other cards, matched to .pt-card ---- */ -.mk-card,.mk-perm{background:var(--surface);border:0;border-radius:var(--r); - box-shadow:var(--shadow-sm); - transition:box-shadow .24s var(--ease),background-color .2s var(--ease)} -.mk-card:hover{border-color:transparent;transform:none;box-shadow:var(--shadow-md)} -/* ---- the Platform tab as a LIST ---- - One integration per row: name and summary on a shared left edge, auth kind and connect state as - quiet columns, the action inline. Cards made every name start somewhere else; a shelf you scan - for the one provider you hold an account with wants a column. */ -.prov-list{width:100%} -/* Outrank the base table rules: .ttable .tn is a nowrap 22% column with a MONO name — right for - tool names, wrong for "Google Business Profile" next to a summary. */ -.prov-list .tn{width:34%;white-space:nowrap} -.prov-list .tn b{font-family:var(--sans)} -.prov-row{cursor:pointer} -.prov-row td{transition:background-color .2s var(--ease)} -.prov-row:hover td{background:var(--hover)} -.prov-row:focus-visible{outline:0;box-shadow:inset 0 0 0 2px var(--ink)} -.prov-n-i{display:flex;align-items:center;gap:11px;min-width:0} -.prov-name{min-width:0} -.prov-name b{font-family:var(--sans);font-size:13px;font-weight:500;letter-spacing:-.1px;display:block;white-space:nowrap} -.prov-sum{display:block;font-family:var(--sans);font-size:11.5px;color:var(--muted); - overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:62ch} -.prov-auth{font-family:var(--sans);font-size:11.5px;color:var(--muted2);white-space:nowrap} -.prov-st{white-space:nowrap} -/* The landing flash after a "Bring your own key" jump: the row you were sent to, then it fades. */ -.prov-row.focus td{background:color-mix(in srgb,var(--teal) 14%,transparent)} -@media (max-width:760px){.prov-sum{display:none}.prov-auth{display:none}} -.mk-card:focus-visible{outline:0; - box-shadow:0 0 0 2px var(--bg),0 0 0 3.5px var(--ink)} -.mk-chip{border-radius:999px;padding:4px 12px;font-size:11px;font-weight:450; - background:none;border:1px solid var(--line2);color:var(--muted); - transition:background-color .2s var(--ease),color .2s var(--ease),border-color .2s var(--ease)} -.mk-chip:hover{background:var(--hover);color:var(--ink)} -.mk-chip.on{background:var(--inverse);color:var(--inverse-ink);border-color:var(--inverse)} -.mk-chip.on span{color:color-mix(in srgb,var(--inverse-ink) 55%,transparent)} -.mk-empty{border-color:var(--line2);color:var(--muted2);font-size:12px} -.ttable tr:hover td{background:var(--hover)} -.dropdown{background:var(--surface);border-color:var(--line2);box-shadow:var(--shadow-lg)} -.orgblock,.side-acct,.orgsel,.search{background:none;border:1px solid var(--line2)} -.tswitch input:checked+.knob{background:var(--ink)} -.tswitch input:checked+.knob::after{background:var(--bg)} -.spin{border-top-color:var(--ink)} -.loginbox{background:var(--surface);border:0;border-radius:var(--r);box-shadow:var(--shadow-md)} - -/* ---- 3.7 the platform ledger ---- - ONE table per platform, filed into sticky domain sections. Two sticky layers stack under the - 57px top bar: the filter bar, then the section headings beneath it — so --lsec-top is the top - bar plus the bar's own height, and the bar's height is fixed by scrolling its chips instead of - wrapping them. */ -:root{--lbar-top:57px;--lsec-top:107px} -.lbar{position:sticky;top:var(--lbar-top);z-index:6;background:var(--bg); - display:flex;align-items:center;gap:8px;padding:12px 0 9px;margin-top:8px} -.lchips{display:flex;gap:6px;min-width:0;overflow-x:auto;scrollbar-width:none;-ms-overflow-style:none; - -webkit-mask-image:linear-gradient(90deg,#000 calc(100% - 20px),transparent); - mask-image:linear-gradient(90deg,#000 calc(100% - 20px),transparent)} -.lchips::-webkit-scrollbar{display:none} -.lbar .mk-chip{padding:3px 11px;font-size:11.5px;white-space:nowrap;cursor:pointer} -.lchk{flex:0 0 auto;display:inline-flex;align-items:center;gap:6px;font-size:11.5px;color:var(--muted); - cursor:pointer;user-select:none;white-space:nowrap} -.lfind{flex:0 0 auto;width:190px;background:var(--surface);border:1px solid var(--line2); - border-radius:var(--rb);color:var(--ink);font-family:var(--sans);font-size:12.5px;padding:6px 11px} -.lfind::placeholder{color:var(--muted2)} -.lstat{margin:0 0 10px;font-size:11.5px;color:var(--muted2);font-variant-numeric:tabular-nums} -.lclear{background:none;border:0;padding:0 0 0 6px;color:var(--muted);font:inherit;cursor:pointer; - text-decoration:underline;text-underline-offset:2px} -.lclear:hover{color:var(--ink)} -/* Both the wrapper and the table drop their overflow clip: an `overflow:hidden` ancestor is a - scroll container, and a sticky heading inside one never leaves it. */ -.lwrap{overflow:visible;border-radius:var(--r)} -/* Fixed layout, so a nowrap path or `treg call` line inside an expanded row scrolls INSIDE its - cell instead of widening the table past the page. */ -.ledger{overflow:visible;table-layout:fixed} -.ledger th{padding:9px 12px 7px;font-size:10px;text-transform:uppercase;letter-spacing:.06em; - white-space:nowrap;color:var(--muted2)} -.lth-w{width:33%} .lth-p{width:170px} .lth-v{width:38px} -.ledger td{padding:8px 12px;font-size:12.5px;vertical-align:top;border-bottom:1px solid var(--line)} -.ledger tr:last-child td{border-bottom:0} -.lsec td{position:sticky;top:var(--lsec-top);z-index:4;background:var(--panel2);padding:6px 12px; - font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.09em;color:var(--muted)} -.lsec td span{margin-left:7px;font-weight:450;letter-spacing:0;color:var(--muted2)} -.lrow{cursor:pointer} -.lrow:hover td{background:var(--hover)} -/* The management-endpoints expander at the foot of a section — the same muted "show more" gesture - as the platform-tile .pt-more, keeping account/utility plumbing one click out of the browse view. */ -/* The one collapsed section at the foot of the ledger. Its heading is a button, so it reads as a - section that opens rather than a row that does something. */ -.lsec-btn{display:inline-flex;align-items:center;gap:7px;padding:0;border:0;background:none; - font:inherit;color:inherit;letter-spacing:inherit;text-transform:inherit;cursor:pointer} -.lsec-btn:hover{color:var(--ink)} -.lsec-btn .lcar{transition:transform .12s} -.lsec-btn[aria-expanded="true"] .lcar{transform:rotate(90deg)} -/* Same rule down the leading edge the capability list used: the row you can call today is the - reason to read the table, so it is findable without reading. */ -.lrow.go td:first-child{box-shadow:inset 3px 0 0 color-mix(in srgb,var(--green) 75%,transparent)} -.lsum-i{display:flex;align-items:flex-start;gap:7px;max-width:430px} -.lsum b{font-weight:500;color:var(--ink);line-height:1.45} -/* DataForSEO's summaries are paragraphs. Two lines keeps the ledger scannable; the full text is - one click away, in the expansion. */ -.lsum b{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} -.lcar{flex:0 0 auto;display:inline-block;font-size:9px;line-height:2;color:var(--muted2); - transition:transform .15s var(--ease)} -.lrow.open .lcar{transform:rotate(90deg)} -.ln{margin-left:7px;font-size:11px;color:var(--muted2);white-space:nowrap} -/* A merged row's providers, priced side by side — the comparison the merge exists to make. Short - pills only (see `pillPrice`), so a six-provider row costs a second line at worst. */ -.lprovs{display:flex;flex-wrap:nowrap;overflow:hidden;gap:4px} -.pchip.more{color:var(--muted2)} -.pchip{display:inline-flex;align-items:center;gap:5px;background:var(--panel2);border-radius:7px; - padding:2px 8px;font-size:11px;color:var(--muted);white-space:nowrap;font-variant-numeric:tabular-nums} -.pchip b{color:var(--ink);font-weight:500} -.lpath{display:block;max-width:360px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; - font-size:11px;color:var(--muted)} -.lpath b{margin-right:7px;font-family:var(--sans);font-weight:500;color:var(--ink)} -/* What KIND of plumbing an Actions row is — account (the provider's own records) or utility - (a helper). Only ever rendered inside the Actions section, where the distinction is the only - thing separating one row from the next. */ -.lkind{margin-left:8px;font-family:var(--sans);vertical-align:1px} -.lprice{font-family:var(--mono);font-size:11.5px;color:var(--muted);white-space:nowrap; - overflow:hidden;text-overflow:ellipsis;font-variant-numeric:tabular-nums} -.vmark{color:var(--green);font-weight:600} -.xmark{color:var(--muted2)} -/* The expansion: one block per endpoint — a merged row shows its providers stacked, a single row - shows the same block alone, so the two can never present the instruction differently. */ -/* Level two: one collapsed line per provider, and the detail under whichever one is opened. */ -.lgrp + .lgrp{margin-top:4px} -.lsub{display:flex;align-items:center;gap:8px;width:100%;padding:6px 9px; - background:var(--surface);border:1px solid var(--line2);border-radius:var(--rb); - color:var(--ink);font-family:inherit;font-size:12px;text-align:left;cursor:pointer; - transition:border-color .2s var(--ease),background-color .2s var(--ease)} -.lsub:hover{background:var(--hover)} -.lsub.on{border-color:var(--line2);background:var(--hover)} -.lsub.on .lcar{transform:rotate(90deg)} -.lsub b{font-weight:500;white-space:nowrap} -.lsub-label{min-width:0;text-align:left} -.lsub-name{display:block;font-size:12px;white-space:normal;overflow-wrap:anywhere} -.lsub-price{font-family:var(--mono);font-size:11px;color:var(--muted);white-space:nowrap; - font-variant-numeric:tabular-nums} -.lsub-path{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; - text-align:right;font-size:11px;color:var(--muted2)} -.lsub .plogo-tile{width:20px;height:20px;flex:0 0 20px;border-radius:6px} -.lsub .plogo{width:13px;height:13px} -.lsub .chip.go{font-size:9.5px;padding:1px 7px} -.lgrp .lep{padding:12px 9px 2px} -/* The detail's tab bar: two panes on the left, and on the right the two things you should never - have to scroll for — the provider's docs and the Connect button. */ -.ltabs{display:flex;align-items:center;gap:4px;margin:0 0 11px; - border-bottom:1px solid var(--line2);padding-bottom:7px} -.ltab{background:none;border:0;border-radius:var(--rb);padding:5px 11px; - font-family:inherit;font-size:12px;font-weight:450;color:var(--muted);cursor:pointer; - transition:background-color .2s var(--ease),color .2s var(--ease)} -.ltab:hover:not(:disabled){background:var(--hover);color:var(--ink)} -.ltab.on{background:var(--inverse);color:var(--inverse-ink);font-weight:500} -.ltab:disabled{color:var(--muted2);opacity:.55;cursor:default} -.ltabs-r{margin-left:auto;display:flex;align-items:center;gap:7px;flex:0 0 auto} -/* Connect is the one action that unblocks every row on the page, so it wears the strongest - treatment the design language has — the same ink fill as the active tab. */ -.ltabs-r .btn.primary{background:var(--inverse);color:var(--inverse-ink);border-color:var(--inverse)} -.lep-id{margin-top:8px;font-size:10.5px;color:var(--muted2)} -.lep + .lep{margin-top:15px;padding-top:15px;border-top:1px solid var(--line2)} -.lep-h{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:9px} -.lep-h b{font-size:12.5px;font-weight:500} -.lep-chips{display:flex;align-items:center;gap:5px;flex-wrap:wrap} -.lfacts{margin:0 0 11px;padding-left:17px;max-width:82ch;font-size:11.5px;line-height:1.6;color:var(--muted)} -/* A DataForSEO body can carry thirty parameters. Capped and scrolled, the expansion stays a card - you read; uncapped it becomes a page that pushes every row below it off the screen. The captured - response gets the SAME cap, so the two panes are the same size box. */ -.prm{max-height:320px;overflow-y:auto} -.cat-ex pre{max-height:320px} -/* The line that actually runs it — `treg call` proxies the key server-side, so it is paste-ready - on a machine that holds no credential. */ -.lcall{display:flex;align-items:center;gap:9px;margin:0 0 11px;padding:7px 9px; - background:var(--surface);border:1px solid var(--line2);border-radius:var(--rb)} -.lcall code{flex:1;min-width:0;overflow-x:auto;white-space:nowrap;font-size:11.5px;color:var(--ink)} - -/* ================================================================ - RESTYLE — 3.8 code surfaces follow the theme. - Light mode used to paint the code BLOCK light (the generic - `[data-theme=light] pre` rule outranks `.lc-codewrap pre`) while the - syntax ramp stayed drawn for a dark block — a near-white command on a - near-white background, bright cyan URLs, a dark copy pill. Two fixes, - both structural: the ramp becomes a themed variable set, and the code - surface is a token as well, so a block and the ink on it can never be - specified by different generations of this sheet again. - ================================================================ */ -:root{ - --code-bg:#161518; --code-ink:#f0f0ee; --code-line:var(--line2); --code-btn:#2c2b30; - --sx-cmd:#f0f0ee; --sx-var:#6fcdf0; --sx-str:#45e39e; --sx-flag:#f0c249; - --sx-cmt:#9a9a9a; --sx-punct:#9a9a9a; -} -[data-theme=light]{ - --code-bg:#f4f4f1; --code-ink:#1a1a1a; --code-line:var(--line2); --code-btn:#e6e6e2; - /* Every value below clears 4.5:1 on --code-bg: ink command, a deep green URL, a burnt - amber flag, a slate identifier, a grey comment that is still readable italic. */ - --sx-cmd:#1a1a1a; --sx-var:#16688a; --sx-str:#0f7346; --sx-flag:#9a5502; - --sx-cmt:#6b6b6b; --sx-punct:#6b6b6b; -} -/* The surface. Both selectors are :root-qualified so they outrank the older - `:root[data-theme=light] .term` / `.lc-codewrap` rules earlier in the sheet. */ -.term,.lc-codewrap, -:root[data-theme=light] .term,:root[data-theme=light] .lc-codewrap{ - background:var(--code-bg);color:var(--code-ink);border:1px solid var(--code-line)} -/* The inner
 is a PANE of its wrapper, not a block of its own — it must beat both
-   generic `pre` rules, which is what painted a light panel inside a dark wrapper. */
-.lc-codewrap pre,
-:root[data-theme=light] .lc-codewrap pre,
-:root:not([data-theme=light]) .lc-codewrap pre{
-  background:transparent;border:0;box-shadow:none;color:var(--code-ink)}
-.lc-cp{background:var(--code-btn);color:var(--code-ink);border:1px solid var(--code-line)}
-.lc-cp:hover{background:var(--code-btn);border-color:var(--code-ink);color:var(--code-ink)}
-/* The shared onboarding component (agent-setup.js) uses Arena's .text-button for "Show key";
-   this sheet never had one, so the browser painted a native button. */
-.agent-setup-instructions .text-button{background:none;border:0;padding:0;font:inherit;font-size:12px;color:var(--muted);text-decoration:underline;cursor:pointer}
-.agent-setup-instructions .text-button:hover{color:var(--ink)}
-/* One ramp, themed, covering all three generations of token class in this file. */
-.hl-cmd,.hl-kw,.s-kw{color:var(--sx-cmd);font-weight:600}
-.s-token,.hl-var,.hl-key,.s-proxy{color:var(--sx-var)}
-.s-proxy{font-weight:600}
-.hl-str,.hl-string,.s-host{color:var(--sx-str)}
-.hl-flag,.hl-num,.s-path{color:var(--sx-flag)}
-.s-path{font-weight:600}
-.hl-comment,.s-cmt{color:var(--sx-cmt);font-style:italic}
-.hl-punct,.hl-env{color:var(--sx-punct)}
-
-
-
-
-
-
- -
- - - - -
- - - - -
- -
-
-
- - - - - diff --git a/tests/dashboard_source.py b/tests/dashboard_source.py new file mode 100644 index 00000000..695850f5 --- /dev/null +++ b/tests/dashboard_source.py @@ -0,0 +1,34 @@ +"""Read the authored dashboard for existing source-level regression checks. + +Component templates are expanded at their actual mount sites, preserving conditional ancestry. +Runtime navigation and event bindings are covered separately by the browser suite. +""" +from pathlib import Path +import re + +FRONTEND = Path(__file__).resolve().parents[1] / "frontend" / "src" + + +def template_source(path: Path) -> str: + source = path.read_text() + template = source.split("", 1)[0] + imports = re.findall(r"import (\w+) from ['\"](.+?\.vue)['\"]", source) + for name, relative in imports: + child = template_source((path.parent / relative).resolve()) + template = re.sub( + rf"<{name}\b([^>]*?)/>", + lambda match: "" + child + "", + template, + ) + return template + + +def dashboard_source() -> str: + state = FRONTEND / "state" + computed = "\n".join(p.read_text() for p in sorted(state.glob("*Computed.js"))) + methods = "\n".join(p.read_text() for p in sorted(state.glob("*.js")) if not p.stem.endswith("Computed")) + return ( + (FRONTEND / "styles/base.css").read_text() + + template_source(FRONTEND / "App.vue") + + "\ncomputed:{\n" + computed + "\nmethods:{\n" + methods + ) diff --git a/tests/snapshots/routes.json b/tests/snapshots/routes.json index 8c82c4f1..51b92bdc 100644 --- a/tests/snapshots/routes.json +++ b/tests/snapshots/routes.json @@ -457,6 +457,15 @@ "name": "landing", "path": "/" }, + { + "kind": "APIRoute", + "methods": [ + "GET", + "HEAD" + ], + "name": "dashboard_asset", + "path": "/app/ui/assets/{name}" + }, { "kind": "APIRoute", "methods": [ diff --git a/tests/test_dashboard_assets.py b/tests/test_dashboard_assets.py index 4a9aef48..4e5a60fe 100644 --- a/tests/test_dashboard_assets.py +++ b/tests/test_dashboard_assets.py @@ -12,7 +12,18 @@ async def test_dashboard_redesign_assets_are_served_from_the_same_origin(clients page = await clients.get('/app') assert page.status_code == 200 paths = set(re.findall(r'(?:href|src)="(/media/redesign/[^"\']+)"', page.text)) - assert '/media/redesign/dashboard.css' in paths + assets = set(re.findall(r'(?:href|src)="(/app/ui/assets/[^"\']+)"', page.text)) + assert any(p.endswith('.js') for p in assets) + assert any(p.endswith('.css') for p in assets) + assert page.headers['cache-control'] == 'no-cache' + for asset in assets: + response = await clients.get(asset) + assert response.status_code == 200 + assert 'immutable' in response.headers['cache-control'] + assert not response.headers['content-type'].startswith('text/html') + missing = await clients.get('/app/ui/assets/missing.js') + assert missing.status_code == 404 + assert 'Something Else Entirely") + (tmp_path / "dashboard").mkdir() + (tmp_path / "dashboard" / "index.html").write_text("Something Else Entirely") monkeypatch.setattr(web, "_WEB_DIR", tmp_path) r = await clients.get("/app/skills/intercom") assert 'og:title' in r.text and "Something Else Entirely" not in r.text diff --git a/tests/test_enrich_arena.py b/tests/test_enrich_arena.py index 4fdf6215..38d39386 100644 --- a/tests/test_enrich_arena.py +++ b/tests/test_enrich_arena.py @@ -1009,7 +1009,10 @@ async def test_arena_and_dashboard_share_setup_components(clients): page = (await clients.get('/enrich-arena')).text dashboard = (await clients.get('/app')).text assert '/agent-setup.js' in page and '/agent-setup.js' in dashboard - assert 'treg-setup-instructions' in page and 'treg-setup-instructions' in dashboard + from dashboard_source import FRONTEND + assert 'treg-setup-instructions' in page + assert 'treg-setup-instructions' in (FRONTEND / 'dialogs/WelcomeDialog.vue').read_text() + assert "app.component('TregSetupInstructions', setup.SetupInstructions!)" in (FRONTEND / 'main.ts').read_text() assert 'Setup treg in' in page and 'ref="setupDialog"' in page diff --git a/tests/test_intercom.py b/tests/test_intercom.py index 4e0f4d1a..34f6612d 100644 --- a/tests/test_intercom.py +++ b/tests/test_intercom.py @@ -48,8 +48,10 @@ async def test_auth_me_user_hash_is_hmac_of_the_email(clients, monkeypatch): def test_pages_gate_on_meta_app_id(): + from dashboard_source import FRONTEND + assert "intercom_app_id" in (FRONTEND / "state/analytics.js").read_text() # each Intercom-bearing page keys its loader off /meta's intercom_app_id (empty = inert) - for page in ("index.html", "landing.html", "support.html"): + for page in ("landing.html", "support.html"): assert "intercom_app_id" in (WEB / page).read_text(), page @@ -57,5 +59,5 @@ def test_no_hardcoded_workspace_id_anywhere(): # the only allowed form of the widget URL is the config-driven concatenation pat = re.compile(r"widget\.intercom\.io/widget/(?!'\+app)") for f in WEB.rglob("*"): - if f.is_file() and f.suffix in {".html", ".js", ".md", ".txt"}: + if "dashboard" not in f.relative_to(WEB).parts and f.is_file() and f.suffix in {".html", ".js", ".md", ".txt"}: assert not pat.search(f.read_text()), f"hardcoded Intercom workspace id in {f.name}" diff --git a/tests/test_seo.py b/tests/test_seo.py index fdeb7b5f..850531db 100644 --- a/tests/test_seo.py +++ b/tests/test_seo.py @@ -158,7 +158,7 @@ async def test_catalog_urls_serve_the_dashboard_spa(clients: AsyncClient): for path in ("/catalog", "/catalog/google"): body = (await clients.get(path)).text assert '
str: - from treg.routers.web import _WEB_DIR - return (_WEB_DIR / "index.html").read_text(encoding="utf-8") + from dashboard_source import dashboard_source + return dashboard_source() def test_public_catalog_drops_the_workspace_chrome(): """A catalog visitor is reading a website, not operating an app. The org switcher, the global tool search and the member nav are furniture for a job they have not started.""" spa = _spa() - assert '
' in spa # marketing nav instead - member_header = '
' + assert '