diff --git a/.github/workflows/linear-triage.lock.yml b/.github/workflows/linear-triage.lock.yml
deleted file mode 100644
index b88d6c6922b..00000000000
--- a/.github/workflows/linear-triage.lock.yml
+++ /dev/null
@@ -1,1141 +0,0 @@
-#
-# ___ _ _
-# / _ \ | | (_)
-# | |_| | __ _ ___ _ __ | |_ _ ___
-# | _ |/ _` |/ _ \ '_ \| __| |/ __|
-# | | | | (_| | __/ | | | |_| | (__
-# \_| |_/\__, |\___|_| |_|\__|_|\___|
-# __/ |
-# _ _ |___/
-# | | | | / _| |
-# | | | | ___ _ __ _ __| |_| | _____ ____
-# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___|
-# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \
-# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/
-#
-# This file was automatically generated by gh-aw (v0.51.5). DO NOT EDIT.
-#
-# To update this file, edit the corresponding .md file and run:
-# gh aw compile
-# Not all edits will cause changes to this file.
-#
-# For more information: https://github.github.com/gh-aw/introduction/overview/
-#
-# Triage new Linear issues for the Berlin Bureau (BER) team — classify type, assign priority, tag product area, and post reasoning comments.
-#
-# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"c42f30d0477c02ecc7ccee1daa2da631c7f63302d41e2d4beb79839fcddcaca2","compiler_version":"v0.51.5"}
-
-name: "Linear Issue Triage Agent"
-"on":
- schedule:
- - cron: "17 16 * * 1-5"
- # Friendly format: daily on weekdays (scattered)
- workflow_dispatch:
-
-permissions: {}
-
-concurrency:
- group: "gh-aw-${{ github.workflow }}"
-
-run-name: "Linear Issue Triage Agent"
-
-jobs:
- activation:
- if: github.repository == 'TryGhost/Ghost'
- runs-on: ubuntu-slim
- permissions:
- contents: read
- outputs:
- comment_id: ""
- comment_repo: ""
- model: ${{ steps.generate_aw_info.outputs.model }}
- secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }}
- steps:
- - name: Setup Scripts
- uses: github/gh-aw/actions/setup@ce1794953e0ec42adc41b6fca05e02ab49ee21c3 # v0.68.3
- with:
- destination: /opt/gh-aw/actions
- - name: Generate agentic run info
- id: generate_aw_info
- env:
- GH_AW_INFO_ENGINE_ID: "copilot"
- GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI"
- GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || '' }}
- GH_AW_INFO_VERSION: ""
- GH_AW_INFO_AGENT_VERSION: "0.0.420"
- GH_AW_INFO_CLI_VERSION: "v0.51.5"
- GH_AW_INFO_WORKFLOW_NAME: "Linear Issue Triage Agent"
- GH_AW_INFO_EXPERIMENTAL: "false"
- GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true"
- GH_AW_INFO_STAGED: "false"
- GH_AW_INFO_ALLOWED_DOMAINS: '["defaults","node","mcp.linear.app"]'
- GH_AW_INFO_FIREWALL_ENABLED: "true"
- GH_AW_INFO_AWF_VERSION: "v0.23.0"
- GH_AW_INFO_AWMG_VERSION: ""
- GH_AW_INFO_FIREWALL_TYPE: "squid"
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
- with:
- script: |
- const { main } = require('/opt/gh-aw/actions/generate_aw_info.cjs');
- await main(core, context);
- - name: Validate COPILOT_GITHUB_TOKEN secret
- id: validate-secret
- run: /opt/gh-aw/actions/validate_multi_secret.sh COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default
- env:
- COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
- - name: Checkout .github and .agents folders
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- sparse-checkout: |
- .github
- .agents
- sparse-checkout-cone-mode: true
- fetch-depth: 1
- persist-credentials: false
- - name: Check workflow file timestamps
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
- env:
- GH_AW_WORKFLOW_FILE: "linear-triage.lock.yml"
- with:
- script: |
- const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io);
- const { main } = require('/opt/gh-aw/actions/check_workflow_timestamp_api.cjs');
- await main();
- - name: Create prompt with built-in context
- env:
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }}
- GH_AW_GITHUB_ACTOR: ${{ github.actor }}
- GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }}
- GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }}
- GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
- GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }}
- GH_AW_GITHUB_REPOSITORY: ${{ github.repository }}
- GH_AW_GITHUB_RUN_ID: ${{ github.run_id }}
- GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }}
- run: |
- bash /opt/gh-aw/actions/create_prompt_first.sh
- {
- cat << 'GH_AW_PROMPT_EOF'
-
- GH_AW_PROMPT_EOF
- cat "/opt/gh-aw/prompts/xpia.md"
- cat "/opt/gh-aw/prompts/temp_folder_prompt.md"
- cat "/opt/gh-aw/prompts/markdown.md"
- cat "/opt/gh-aw/prompts/cache_memory_prompt.md"
- cat "/opt/gh-aw/prompts/safe_outputs_prompt.md"
- cat << 'GH_AW_PROMPT_EOF'
-
- Tools: create_issue, missing_tool, missing_data, noop
-
-
- The following GitHub context information is available for this workflow:
- {{#if __GH_AW_GITHUB_ACTOR__ }}
- - **actor**: __GH_AW_GITHUB_ACTOR__
- {{/if}}
- {{#if __GH_AW_GITHUB_REPOSITORY__ }}
- - **repository**: __GH_AW_GITHUB_REPOSITORY__
- {{/if}}
- {{#if __GH_AW_GITHUB_WORKSPACE__ }}
- - **workspace**: __GH_AW_GITHUB_WORKSPACE__
- {{/if}}
- {{#if __GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ }}
- - **issue-number**: #__GH_AW_GITHUB_EVENT_ISSUE_NUMBER__
- {{/if}}
- {{#if __GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ }}
- - **discussion-number**: #__GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__
- {{/if}}
- {{#if __GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ }}
- - **pull-request-number**: #__GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__
- {{/if}}
- {{#if __GH_AW_GITHUB_EVENT_COMMENT_ID__ }}
- - **comment-id**: __GH_AW_GITHUB_EVENT_COMMENT_ID__
- {{/if}}
- {{#if __GH_AW_GITHUB_RUN_ID__ }}
- - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__
- {{/if}}
-
-
- GH_AW_PROMPT_EOF
- cat << 'GH_AW_PROMPT_EOF'
-
- GH_AW_PROMPT_EOF
- cat << 'GH_AW_PROMPT_EOF'
- {{#runtime-import .github/workflows/linear-triage.md}}
- GH_AW_PROMPT_EOF
- } > "$GH_AW_PROMPT"
- - name: Interpolate variables and render templates
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
- env:
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- with:
- script: |
- const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io);
- const { main } = require('/opt/gh-aw/actions/interpolate_prompt.cjs');
- await main();
- - name: Substitute placeholders
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
- env:
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- GH_AW_ALLOWED_EXTENSIONS: ''
- GH_AW_CACHE_DESCRIPTION: ''
- GH_AW_CACHE_DIR: '/tmp/gh-aw/cache-memory/'
- GH_AW_GITHUB_ACTOR: ${{ github.actor }}
- GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }}
- GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }}
- GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
- GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }}
- GH_AW_GITHUB_REPOSITORY: ${{ github.repository }}
- GH_AW_GITHUB_RUN_ID: ${{ github.run_id }}
- GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }}
- with:
- script: |
- const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io);
-
- const substitutePlaceholders = require('/opt/gh-aw/actions/substitute_placeholders.cjs');
-
- // Call the substitution function
- return await substitutePlaceholders({
- file: process.env.GH_AW_PROMPT,
- substitutions: {
- GH_AW_ALLOWED_EXTENSIONS: process.env.GH_AW_ALLOWED_EXTENSIONS,
- GH_AW_CACHE_DESCRIPTION: process.env.GH_AW_CACHE_DESCRIPTION,
- GH_AW_CACHE_DIR: process.env.GH_AW_CACHE_DIR,
- GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR,
- GH_AW_GITHUB_EVENT_COMMENT_ID: process.env.GH_AW_GITHUB_EVENT_COMMENT_ID,
- GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: process.env.GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER,
- GH_AW_GITHUB_EVENT_ISSUE_NUMBER: process.env.GH_AW_GITHUB_EVENT_ISSUE_NUMBER,
- GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: process.env.GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER,
- GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY,
- GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID,
- GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE
- }
- });
- - name: Validate prompt placeholders
- env:
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- run: bash /opt/gh-aw/actions/validate_prompt_placeholders.sh
- - name: Print prompt
- env:
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- run: bash /opt/gh-aw/actions/print_prompt_summary.sh
- - name: Upload activation artifact
- if: success()
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
- with:
- name: activation
- path: |
- /tmp/gh-aw/aw_info.json
- /tmp/gh-aw/aw-prompts/prompt.txt
- retention-days: 1
-
- agent:
- needs: activation
- runs-on: ubuntu-latest
- permissions:
- contents: read
- concurrency:
- group: "gh-aw-copilot-${{ github.workflow }}"
- env:
- DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
- GH_AW_ASSETS_ALLOWED_EXTS: ""
- GH_AW_ASSETS_BRANCH: ""
- GH_AW_ASSETS_MAX_SIZE_KB: 0
- GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs
- GH_AW_SAFE_OUTPUTS: /opt/gh-aw/safeoutputs/outputs.jsonl
- GH_AW_SAFE_OUTPUTS_CONFIG_PATH: /opt/gh-aw/safeoutputs/config.json
- GH_AW_SAFE_OUTPUTS_TOOLS_PATH: /opt/gh-aw/safeoutputs/tools.json
- GH_AW_WORKFLOW_ID_SANITIZED: lineartriage
- outputs:
- checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }}
- detection_conclusion: ${{ steps.detection_conclusion.outputs.conclusion }}
- detection_success: ${{ steps.detection_conclusion.outputs.success }}
- has_patch: ${{ steps.collect_output.outputs.has_patch }}
- model: ${{ needs.activation.outputs.model }}
- output: ${{ steps.collect_output.outputs.output }}
- output_types: ${{ steps.collect_output.outputs.output_types }}
- steps:
- - name: Setup Scripts
- uses: github/gh-aw/actions/setup@ce1794953e0ec42adc41b6fca05e02ab49ee21c3 # v0.68.3
- with:
- destination: /opt/gh-aw/actions
- - name: Checkout repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- persist-credentials: false
- - name: Setup Node.js
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
- with:
- node-version: '24'
- package-manager-cache: false
- - name: Create gh-aw temp directory
- run: bash /opt/gh-aw/actions/create_gh_aw_tmp_dir.sh
- # Cache memory file share configuration from frontmatter processed below
- - name: Create cache-memory directory
- run: bash /opt/gh-aw/actions/create_cache_memory_dir.sh
- - name: Restore cache-memory file share data
- uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
- with:
- key: memory-${{ env.GH_AW_WORKFLOW_ID_SANITIZED }}-${{ github.run_id }}
- path: /tmp/gh-aw/cache-memory
- restore-keys: |
- memory-${{ env.GH_AW_WORKFLOW_ID_SANITIZED }}-
- - name: Configure Git credentials
- env:
- REPO_NAME: ${{ github.repository }}
- SERVER_URL: ${{ github.server_url }}
- run: |
- git config --global user.email "github-actions[bot]@users.noreply.github.com"
- git config --global user.name "github-actions[bot]"
- git config --global am.keepcr true
- # Re-authenticate git with GitHub token
- SERVER_URL_STRIPPED="${SERVER_URL#https://}"
- git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git"
- echo "Git configured with standard GitHub Actions identity"
- - name: Checkout PR branch
- id: checkout-pr
- if: |
- (github.event.pull_request) || (github.event.issue.pull_request)
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
- env:
- GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io);
- const { main } = require('/opt/gh-aw/actions/checkout_pr_branch.cjs');
- await main();
- - name: Install GitHub Copilot CLI
- run: /opt/gh-aw/actions/install_copilot_cli.sh 0.0.420
- - name: Install awf binary
- run: bash /opt/gh-aw/actions/install_awf_binary.sh v0.23.0
- - name: Determine automatic lockdown mode for GitHub MCP Server
- id: determine-automatic-lockdown
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
- env:
- GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }}
- GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }}
- with:
- script: |
- const determineAutomaticLockdown = require('/opt/gh-aw/actions/determine_automatic_lockdown.cjs');
- await determineAutomaticLockdown(github, context, core);
- - name: Download container images
- run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.23.0 ghcr.io/github/gh-aw-firewall/api-proxy:0.23.0 ghcr.io/github/gh-aw-firewall/squid:0.23.0 ghcr.io/github/gh-aw-mcpg:v0.1.6 ghcr.io/github/github-mcp-server:v0.31.0 node:lts-alpine
- - name: Write Safe Outputs Config
- run: |
- mkdir -p /opt/gh-aw/safeoutputs
- mkdir -p /tmp/gh-aw/safeoutputs
- mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs
- cat > /opt/gh-aw/safeoutputs/config.json << 'GH_AW_SAFE_OUTPUTS_CONFIG_EOF'
- {"create_issue":{"max":1},"missing_data":{},"missing_tool":{},"noop":{"max":1}}
- GH_AW_SAFE_OUTPUTS_CONFIG_EOF
- cat > /opt/gh-aw/safeoutputs/tools.json << 'GH_AW_SAFE_OUTPUTS_TOOLS_EOF'
- [
- {
- "description": "Create a new GitHub issue for tracking bugs, feature requests, or tasks. Use this for actionable work items that need assignment, labeling, and status tracking. For reports, announcements, or status updates that don't require task tracking, use create_discussion instead. CONSTRAINTS: Maximum 1 issue(s) can be created.",
- "inputSchema": {
- "additionalProperties": false,
- "properties": {
- "body": {
- "description": "Detailed issue description in Markdown. Do NOT repeat the title as a heading since it already appears as the issue's h1. Include context, reproduction steps, or acceptance criteria as appropriate.",
- "type": "string"
- },
- "labels": {
- "description": "Labels to categorize the issue (e.g., 'bug', 'enhancement'). Labels must exist in the repository.",
- "items": {
- "type": "string"
- },
- "type": "array"
- },
- "parent": {
- "description": "Parent issue number for creating sub-issues. This is the numeric ID from the GitHub URL (e.g., 42 in github.com/owner/repo/issues/42). Can also be a temporary_id (e.g., 'aw_abc123', 'aw_Test123') from a previously created issue in the same workflow run.",
- "type": [
- "number",
- "string"
- ]
- },
- "temporary_id": {
- "description": "Unique temporary identifier for referencing this issue before it's created. Format: 'aw_' followed by 3 to 8 alphanumeric characters (e.g., 'aw_abc1', 'aw_Test123'). Use '#aw_ID' in body text to reference other issues by their temporary_id; these are replaced with actual issue numbers after creation.",
- "pattern": "^aw_[A-Za-z0-9]{3,8}$",
- "type": "string"
- },
- "title": {
- "description": "Concise issue title summarizing the bug, feature, or task. The title appears as the main heading, so keep it brief and descriptive.",
- "type": "string"
- }
- },
- "required": [
- "title",
- "body"
- ],
- "type": "object"
- },
- "name": "create_issue"
- },
- {
- "description": "Report that a tool or capability needed to complete the task is not available, or share any information you deem important about missing functionality or limitations. Use this when you cannot accomplish what was requested because the required functionality is missing or access is restricted.",
- "inputSchema": {
- "additionalProperties": false,
- "properties": {
- "alternatives": {
- "description": "Any workarounds, manual steps, or alternative approaches the user could take (max 256 characters).",
- "type": "string"
- },
- "reason": {
- "description": "Explanation of why this tool is needed or what information you want to share about the limitation (max 256 characters).",
- "type": "string"
- },
- "tool": {
- "description": "Optional: Name or description of the missing tool or capability (max 128 characters). Be specific about what functionality is needed.",
- "type": "string"
- }
- },
- "required": [
- "reason"
- ],
- "type": "object"
- },
- "name": "missing_tool"
- },
- {
- "description": "Log a transparency message when no significant actions are needed. Use this to confirm workflow completion and provide visibility when analysis is complete but no changes or outputs are required (e.g., 'No issues found', 'All checks passed'). This ensures the workflow produces human-visible output even when no other actions are taken.",
- "inputSchema": {
- "additionalProperties": false,
- "properties": {
- "message": {
- "description": "Status or completion message to log. Should explain what was analyzed and the outcome (e.g., 'Code review complete - no issues found', 'Analysis complete - all tests passing').",
- "type": "string"
- }
- },
- "required": [
- "message"
- ],
- "type": "object"
- },
- "name": "noop"
- },
- {
- "description": "Report that data or information needed to complete the task is not available. Use this when you cannot accomplish what was requested because required data, context, or information is missing.",
- "inputSchema": {
- "additionalProperties": false,
- "properties": {
- "alternatives": {
- "description": "Any workarounds, manual steps, or alternative approaches the user could take (max 256 characters).",
- "type": "string"
- },
- "context": {
- "description": "Additional context about the missing data or where it should come from (max 256 characters).",
- "type": "string"
- },
- "data_type": {
- "description": "Type or description of the missing data or information (max 128 characters). Be specific about what data is needed.",
- "type": "string"
- },
- "reason": {
- "description": "Explanation of why this data is needed to complete the task (max 256 characters).",
- "type": "string"
- }
- },
- "required": [],
- "type": "object"
- },
- "name": "missing_data"
- }
- ]
- GH_AW_SAFE_OUTPUTS_TOOLS_EOF
- cat > /opt/gh-aw/safeoutputs/validation.json << 'GH_AW_SAFE_OUTPUTS_VALIDATION_EOF'
- {
- "create_issue": {
- "defaultMax": 1,
- "fields": {
- "body": {
- "required": true,
- "type": "string",
- "sanitize": true,
- "maxLength": 65000
- },
- "labels": {
- "type": "array",
- "itemType": "string",
- "itemSanitize": true,
- "itemMaxLength": 128
- },
- "parent": {
- "issueOrPRNumber": true
- },
- "repo": {
- "type": "string",
- "maxLength": 256
- },
- "temporary_id": {
- "type": "string"
- },
- "title": {
- "required": true,
- "type": "string",
- "sanitize": true,
- "maxLength": 128
- }
- }
- },
- "missing_data": {
- "defaultMax": 20,
- "fields": {
- "alternatives": {
- "type": "string",
- "sanitize": true,
- "maxLength": 256
- },
- "context": {
- "type": "string",
- "sanitize": true,
- "maxLength": 256
- },
- "data_type": {
- "type": "string",
- "sanitize": true,
- "maxLength": 128
- },
- "reason": {
- "type": "string",
- "sanitize": true,
- "maxLength": 256
- }
- }
- },
- "missing_tool": {
- "defaultMax": 20,
- "fields": {
- "alternatives": {
- "type": "string",
- "sanitize": true,
- "maxLength": 512
- },
- "reason": {
- "required": true,
- "type": "string",
- "sanitize": true,
- "maxLength": 256
- },
- "tool": {
- "type": "string",
- "sanitize": true,
- "maxLength": 128
- }
- }
- },
- "noop": {
- "defaultMax": 1,
- "fields": {
- "message": {
- "required": true,
- "type": "string",
- "sanitize": true,
- "maxLength": 65000
- }
- }
- }
- }
- GH_AW_SAFE_OUTPUTS_VALIDATION_EOF
- - name: Generate Safe Outputs MCP Server Config
- id: safe-outputs-config
- run: |
- # Generate a secure random API key (360 bits of entropy, 40+ chars)
- # Mask immediately to prevent timing vulnerabilities
- API_KEY=$(openssl rand -base64 45 | tr -d '/+=')
- echo "::add-mask::${API_KEY}"
-
- PORT=3001
-
- # Set outputs for next steps
- {
- echo "safe_outputs_api_key=${API_KEY}"
- echo "safe_outputs_port=${PORT}"
- } >> "$GITHUB_OUTPUT"
-
- echo "Safe Outputs MCP server will run on port ${PORT}"
-
- - name: Start Safe Outputs MCP HTTP Server
- id: safe-outputs-start
- env:
- DEBUG: '*'
- GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-config.outputs.safe_outputs_port }}
- GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-config.outputs.safe_outputs_api_key }}
- GH_AW_SAFE_OUTPUTS_TOOLS_PATH: /opt/gh-aw/safeoutputs/tools.json
- GH_AW_SAFE_OUTPUTS_CONFIG_PATH: /opt/gh-aw/safeoutputs/config.json
- GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs
- run: |
- # Environment variables are set above to prevent template injection
- export DEBUG
- export GH_AW_SAFE_OUTPUTS_PORT
- export GH_AW_SAFE_OUTPUTS_API_KEY
- export GH_AW_SAFE_OUTPUTS_TOOLS_PATH
- export GH_AW_SAFE_OUTPUTS_CONFIG_PATH
- export GH_AW_MCP_LOG_DIR
-
- bash /opt/gh-aw/actions/start_safe_outputs_server.sh
-
- - name: Start MCP Gateway
- id: start-mcp-gateway
- env:
- GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }}
- GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-start.outputs.api_key }}
- GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-start.outputs.port }}
- GITHUB_MCP_LOCKDOWN: ${{ steps.determine-automatic-lockdown.outputs.lockdown == 'true' && '1' || '0' }}
- GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
- run: |
- set -eo pipefail
- mkdir -p /tmp/gh-aw/mcp-config
-
- # Export gateway environment variables for MCP config and gateway script
- export MCP_GATEWAY_PORT="80"
- export MCP_GATEWAY_DOMAIN="host.docker.internal"
- MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=')
- echo "::add-mask::${MCP_GATEWAY_API_KEY}"
- export MCP_GATEWAY_API_KEY
- export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads"
- mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}"
- export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288"
- export DEBUG="*"
-
- export GH_AW_ENGINE="copilot"
- export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_LOCKDOWN -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -e LINEAR_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.6'
-
- mkdir -p /home/runner/.copilot
- cat << GH_AW_MCP_CONFIG_EOF | bash /opt/gh-aw/actions/start_mcp_gateway.sh
- {
- "mcpServers": {
- "github": {
- "type": "stdio",
- "container": "ghcr.io/github/github-mcp-server:v0.31.0",
- "env": {
- "GITHUB_LOCKDOWN_MODE": "$GITHUB_MCP_LOCKDOWN",
- "GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}",
- "GITHUB_READ_ONLY": "1",
- "GITHUB_TOOLSETS": "context,repos,issues,pull_requests"
- }
- },
- "linear": {
- "type": "stdio",
- "container": "node:lts-alpine",
- "entrypoint": "npx",
- "entrypointArgs": [
- "npx",
- "-y",
- "mcp-remote",
- "https://mcp.linear.app/mcp",
- "--header",
- "Authorization:Bearer \${LINEAR_API_KEY}"
- ],
- "tools": [
- "*"
- ],
- "env": {
- "LINEAR_API_KEY": "\${LINEAR_API_KEY}"
- }
- },
- "safeoutputs": {
- "type": "http",
- "url": "http://host.docker.internal:$GH_AW_SAFE_OUTPUTS_PORT",
- "headers": {
- "Authorization": "\${GH_AW_SAFE_OUTPUTS_API_KEY}"
- }
- }
- },
- "gateway": {
- "port": $MCP_GATEWAY_PORT,
- "domain": "${MCP_GATEWAY_DOMAIN}",
- "apiKey": "${MCP_GATEWAY_API_KEY}",
- "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}"
- }
- }
- GH_AW_MCP_CONFIG_EOF
- - name: Download activation artifact
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
- with:
- name: activation
- path: /tmp/gh-aw
- - name: Clean git credentials
- run: bash /opt/gh-aw/actions/clean_git_credentials.sh
- - name: Execute GitHub Copilot CLI
- id: agentic_execution
- # Copilot CLI tool arguments (sorted):
- timeout-minutes: 20
- run: |
- set -o pipefail
- # shellcheck disable=SC1003
- sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains "*.jsr.io,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.npms.io,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,bun.sh,cdn.jsdelivr.net,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,deb.nodesource.com,deno.land,esm.sh,get.pnpm.io,github.com,googleapis.deno.dev,googlechromelabs.github.io,host.docker.internal,json-schema.org,json.schemastore.org,jsr.io,keyserver.ubuntu.com,mcp.linear.app,nodejs.org,npm.pkg.github.com,npmjs.com,npmjs.org,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.bower.io,registry.npmjs.com,registry.npmjs.org,registry.yarnpkg.com,repo.yarnpkg.com,s.symcb.com,s.symcd.com,security.ubuntu.com,skimdb.npmjs.com,storage.googleapis.com,telemetry.enterprise.githubcopilot.com,telemetry.vercel.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.npmjs.com,www.npmjs.org,yarnpkg.com" --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.23.0 --skip-pull --enable-api-proxy \
- -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-all-tools --add-dir /tmp/gh-aw/cache-memory/ --allow-all-paths --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"${GH_AW_MODEL_AGENT_COPILOT:+ --model "$GH_AW_MODEL_AGENT_COPILOT"}' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log
- env:
- COPILOT_AGENT_RUNNER_TYPE: STANDALONE
- COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
- GH_AW_MCP_CONFIG: /home/runner/.copilot/mcp-config.json
- GH_AW_MODEL_AGENT_COPILOT: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || '' }}
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }}
- GITHUB_API_URL: ${{ github.api_url }}
- GITHUB_HEAD_REF: ${{ github.head_ref }}
- GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- GITHUB_REF_NAME: ${{ github.ref_name }}
- GITHUB_SERVER_URL: ${{ github.server_url }}
- GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }}
- GITHUB_WORKSPACE: ${{ github.workspace }}
- XDG_CONFIG_HOME: /home/runner
- - name: Configure Git credentials
- env:
- REPO_NAME: ${{ github.repository }}
- SERVER_URL: ${{ github.server_url }}
- run: |
- git config --global user.email "github-actions[bot]@users.noreply.github.com"
- git config --global user.name "github-actions[bot]"
- git config --global am.keepcr true
- # Re-authenticate git with GitHub token
- SERVER_URL_STRIPPED="${SERVER_URL#https://}"
- git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git"
- echo "Git configured with standard GitHub Actions identity"
- - name: Copy Copilot session state files to logs
- if: always()
- continue-on-error: true
- run: |
- # Copy Copilot session state files to logs folder for artifact collection
- # This ensures they are in /tmp/gh-aw/ where secret redaction can scan them
- SESSION_STATE_DIR="$HOME/.copilot/session-state"
- LOGS_DIR="/tmp/gh-aw/sandbox/agent/logs"
-
- if [ -d "$SESSION_STATE_DIR" ]; then
- echo "Copying Copilot session state files from $SESSION_STATE_DIR to $LOGS_DIR"
- mkdir -p "$LOGS_DIR"
- cp -v "$SESSION_STATE_DIR"/*.jsonl "$LOGS_DIR/" 2>/dev/null || true
- echo "Session state files copied successfully"
- else
- echo "No session-state directory found at $SESSION_STATE_DIR"
- fi
- - name: Stop MCP Gateway
- if: always()
- continue-on-error: true
- env:
- MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }}
- MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }}
- GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }}
- run: |
- bash /opt/gh-aw/actions/stop_mcp_gateway.sh "$GATEWAY_PID"
- - name: Redact secrets in logs
- if: always()
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
- with:
- script: |
- const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io);
- const { main } = require('/opt/gh-aw/actions/redact_secrets.cjs');
- await main();
- env:
- GH_AW_SECRET_NAMES: 'COPILOT_GITHUB_TOKEN,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN,LINEAR_API_KEY'
- SECRET_COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
- SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }}
- SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }}
- SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- SECRET_LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
- - name: Upload Safe Outputs
- if: always()
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
- with:
- name: safe-output
- path: ${{ env.GH_AW_SAFE_OUTPUTS }}
- if-no-files-found: warn
- - name: Ingest agent output
- id: collect_output
- if: always()
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
- env:
- GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }}
- GH_AW_ALLOWED_DOMAINS: "*.jsr.io,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.npms.io,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,bun.sh,cdn.jsdelivr.net,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,deb.nodesource.com,deno.land,esm.sh,get.pnpm.io,github.com,googleapis.deno.dev,googlechromelabs.github.io,host.docker.internal,json-schema.org,json.schemastore.org,jsr.io,keyserver.ubuntu.com,mcp.linear.app,nodejs.org,npm.pkg.github.com,npmjs.com,npmjs.org,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.bower.io,registry.npmjs.com,registry.npmjs.org,registry.yarnpkg.com,repo.yarnpkg.com,s.symcb.com,s.symcd.com,security.ubuntu.com,skimdb.npmjs.com,storage.googleapis.com,telemetry.enterprise.githubcopilot.com,telemetry.vercel.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.npmjs.com,www.npmjs.org,yarnpkg.com"
- GITHUB_SERVER_URL: ${{ github.server_url }}
- GITHUB_API_URL: ${{ github.api_url }}
- with:
- script: |
- const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io);
- const { main } = require('/opt/gh-aw/actions/collect_ndjson_output.cjs');
- await main();
- - name: Upload sanitized agent output
- if: always() && env.GH_AW_AGENT_OUTPUT
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
- with:
- name: agent-output
- path: ${{ env.GH_AW_AGENT_OUTPUT }}
- if-no-files-found: warn
- - name: Upload engine output files
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
- with:
- name: agent_outputs
- path: |
- /tmp/gh-aw/sandbox/agent/logs/
- /tmp/gh-aw/redacted-urls.log
- if-no-files-found: ignore
- - name: Parse agent logs for step summary
- if: always()
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
- env:
- GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/
- with:
- script: |
- const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io);
- const { main } = require('/opt/gh-aw/actions/parse_copilot_log.cjs');
- await main();
- - name: Parse MCP Gateway logs for step summary
- if: always()
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
- with:
- script: |
- const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io);
- const { main } = require('/opt/gh-aw/actions/parse_mcp_gateway_log.cjs');
- await main();
- - name: Print firewall logs
- if: always()
- continue-on-error: true
- env:
- AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs
- run: |
- # Fix permissions on firewall logs so they can be uploaded as artifacts
- # AWF runs with sudo, creating files owned by root
- sudo chmod -R a+r /tmp/gh-aw/sandbox/firewall/logs 2>/dev/null || true
- # Only run awf logs summary if awf command exists (it may not be installed if workflow failed before install step)
- if command -v awf &> /dev/null; then
- awf logs summary | tee -a "$GITHUB_STEP_SUMMARY"
- else
- echo 'AWF binary not installed, skipping firewall log summary'
- fi
- - name: Upload cache-memory data as artifact
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
- if: always()
- with:
- name: cache-memory
- path: /tmp/gh-aw/cache-memory
- - name: Upload agent artifacts
- if: always()
- continue-on-error: true
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
- with:
- name: agent-artifacts
- path: |
- /tmp/gh-aw/aw-prompts/prompt.txt
- /tmp/gh-aw/mcp-logs/
- /tmp/gh-aw/sandbox/firewall/logs/
- /tmp/gh-aw/agent-stdio.log
- /tmp/gh-aw/agent/
- if-no-files-found: ignore
- # --- Threat Detection (inline) ---
- - name: Check if detection needed
- id: detection_guard
- if: always()
- env:
- OUTPUT_TYPES: ${{ steps.collect_output.outputs.output_types }}
- HAS_PATCH: ${{ steps.collect_output.outputs.has_patch }}
- run: |
- if [[ -n "$OUTPUT_TYPES" || "$HAS_PATCH" == "true" ]]; then
- echo "run_detection=true" >> "$GITHUB_OUTPUT"
- echo "Detection will run: output_types=$OUTPUT_TYPES, has_patch=$HAS_PATCH"
- else
- echo "run_detection=false" >> "$GITHUB_OUTPUT"
- echo "Detection skipped: no agent outputs or patches to analyze"
- fi
- - name: Clear MCP configuration for detection
- if: always() && steps.detection_guard.outputs.run_detection == 'true'
- run: |
- rm -f /tmp/gh-aw/mcp-config/mcp-servers.json
- rm -f /home/runner/.copilot/mcp-config.json
- rm -f "$GITHUB_WORKSPACE/.gemini/settings.json"
- - name: Prepare threat detection files
- if: always() && steps.detection_guard.outputs.run_detection == 'true'
- run: |
- mkdir -p /tmp/gh-aw/threat-detection/aw-prompts
- cp /tmp/gh-aw/aw-prompts/prompt.txt /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt 2>/dev/null || true
- cp /tmp/gh-aw/agent_output.json /tmp/gh-aw/threat-detection/agent_output.json 2>/dev/null || true
- for f in /tmp/gh-aw/aw-*.patch; do
- [ -f "$f" ] && cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true
- done
- echo "Prepared threat detection files:"
- ls -la /tmp/gh-aw/threat-detection/ 2>/dev/null || true
- - name: Setup threat detection
- if: always() && steps.detection_guard.outputs.run_detection == 'true'
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
- env:
- WORKFLOW_NAME: "Linear Issue Triage Agent"
- WORKFLOW_DESCRIPTION: "Triage new Linear issues for the Berlin Bureau (BER) team — classify type, assign priority, tag product area, and post reasoning comments."
- HAS_PATCH: ${{ steps.collect_output.outputs.has_patch }}
- with:
- script: |
- const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io);
- const { main } = require('/opt/gh-aw/actions/setup_threat_detection.cjs');
- await main();
- - name: Ensure threat-detection directory and log
- if: always() && steps.detection_guard.outputs.run_detection == 'true'
- run: |
- mkdir -p /tmp/gh-aw/threat-detection
- touch /tmp/gh-aw/threat-detection/detection.log
- - name: Execute GitHub Copilot CLI
- if: always() && steps.detection_guard.outputs.run_detection == 'true'
- id: detection_agentic_execution
- # Copilot CLI tool arguments (sorted):
- # --allow-tool shell(cat)
- # --allow-tool shell(grep)
- # --allow-tool shell(head)
- # --allow-tool shell(jq)
- # --allow-tool shell(ls)
- # --allow-tool shell(tail)
- # --allow-tool shell(wc)
- timeout-minutes: 20
- run: |
- set -o pipefail
- # shellcheck disable=SC1003
- sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,github.com,host.docker.internal,raw.githubusercontent.com,registry.npmjs.org,telemetry.enterprise.githubcopilot.com" --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.23.0 --skip-pull --enable-api-proxy \
- -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-tool '\''shell(cat)'\'' --allow-tool '\''shell(grep)'\'' --allow-tool '\''shell(head)'\'' --allow-tool '\''shell(jq)'\'' --allow-tool '\''shell(ls)'\'' --allow-tool '\''shell(tail)'\'' --allow-tool '\''shell(wc)'\'' --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"${GH_AW_MODEL_DETECTION_COPILOT:+ --model "$GH_AW_MODEL_DETECTION_COPILOT"}' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log
- env:
- COPILOT_AGENT_RUNNER_TYPE: STANDALONE
- COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
- GH_AW_MODEL_DETECTION_COPILOT: ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || '' }}
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- GITHUB_API_URL: ${{ github.api_url }}
- GITHUB_HEAD_REF: ${{ github.head_ref }}
- GITHUB_REF_NAME: ${{ github.ref_name }}
- GITHUB_SERVER_URL: ${{ github.server_url }}
- GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }}
- GITHUB_WORKSPACE: ${{ github.workspace }}
- XDG_CONFIG_HOME: /home/runner
- - name: Parse threat detection results
- id: parse_detection_results
- if: always() && steps.detection_guard.outputs.run_detection == 'true'
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
- with:
- script: |
- const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io);
- const { main } = require('/opt/gh-aw/actions/parse_threat_detection_results.cjs');
- await main();
- - name: Upload threat detection log
- if: always() && steps.detection_guard.outputs.run_detection == 'true'
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
- with:
- name: threat-detection.log
- path: /tmp/gh-aw/threat-detection/detection.log
- if-no-files-found: ignore
- - name: Set detection conclusion
- id: detection_conclusion
- if: always()
- env:
- RUN_DETECTION: ${{ steps.detection_guard.outputs.run_detection }}
- DETECTION_SUCCESS: ${{ steps.parse_detection_results.outputs.success }}
- run: |
- if [[ "$RUN_DETECTION" != "true" ]]; then
- echo "conclusion=skipped" >> "$GITHUB_OUTPUT"
- echo "success=true" >> "$GITHUB_OUTPUT"
- echo "Detection was not needed, marking as skipped"
- elif [[ "$DETECTION_SUCCESS" == "true" ]]; then
- echo "conclusion=success" >> "$GITHUB_OUTPUT"
- echo "success=true" >> "$GITHUB_OUTPUT"
- echo "Detection passed successfully"
- else
- echo "conclusion=failure" >> "$GITHUB_OUTPUT"
- echo "success=false" >> "$GITHUB_OUTPUT"
- echo "Detection found issues"
- fi
-
- conclusion:
- needs:
- - activation
- - agent
- - safe_outputs
- - update_cache_memory
- if: (always()) && (needs.agent.result != 'skipped')
- runs-on: ubuntu-slim
- permissions:
- contents: read
- issues: write
- outputs:
- noop_message: ${{ steps.noop.outputs.noop_message }}
- tools_reported: ${{ steps.missing_tool.outputs.tools_reported }}
- total_count: ${{ steps.missing_tool.outputs.total_count }}
- steps:
- - name: Setup Scripts
- uses: github/gh-aw/actions/setup@ce1794953e0ec42adc41b6fca05e02ab49ee21c3 # v0.68.3
- with:
- destination: /opt/gh-aw/actions
- - name: Download agent output artifact
- continue-on-error: true
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
- with:
- name: agent-output
- path: /tmp/gh-aw/safeoutputs/
- - name: Setup agent output environment variable
- run: |
- mkdir -p /tmp/gh-aw/safeoutputs/
- find "/tmp/gh-aw/safeoutputs/" -type f -print
- echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV"
- - name: Process No-Op Messages
- id: noop
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
- env:
- GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }}
- GH_AW_NOOP_MAX: "1"
- GH_AW_WORKFLOW_NAME: "Linear Issue Triage Agent"
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io);
- const { main } = require('/opt/gh-aw/actions/noop.cjs');
- await main();
- - name: Record Missing Tool
- id: missing_tool
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
- env:
- GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }}
- GH_AW_WORKFLOW_NAME: "Linear Issue Triage Agent"
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io);
- const { main } = require('/opt/gh-aw/actions/missing_tool.cjs');
- await main();
- - name: Handle Agent Failure
- id: handle_agent_failure
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
- env:
- GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }}
- GH_AW_WORKFLOW_NAME: "Linear Issue Triage Agent"
- GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
- GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }}
- GH_AW_WORKFLOW_ID: "linear-triage"
- GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.activation.outputs.secret_verification_result }}
- GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }}
- GH_AW_GROUP_REPORTS: "false"
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io);
- const { main } = require('/opt/gh-aw/actions/handle_agent_failure.cjs');
- await main();
- - name: Handle No-Op Message
- id: handle_noop_message
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
- env:
- GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }}
- GH_AW_WORKFLOW_NAME: "Linear Issue Triage Agent"
- GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
- GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }}
- GH_AW_NOOP_MESSAGE: ${{ steps.noop.outputs.noop_message }}
- GH_AW_NOOP_REPORT_AS_ISSUE: "true"
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io);
- const { main } = require('/opt/gh-aw/actions/handle_noop_message.cjs');
- await main();
-
- safe_outputs:
- needs: agent
- if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (needs.agent.outputs.detection_success == 'true')
- runs-on: ubuntu-slim
- permissions:
- contents: read
- issues: write
- timeout-minutes: 15
- env:
- GH_AW_ENGINE_ID: "copilot"
- GH_AW_WORKFLOW_ID: "linear-triage"
- GH_AW_WORKFLOW_NAME: "Linear Issue Triage Agent"
- outputs:
- code_push_failure_count: ${{ steps.process_safe_outputs.outputs.code_push_failure_count }}
- code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }}
- create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }}
- create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }}
- created_issue_number: ${{ steps.process_safe_outputs.outputs.created_issue_number }}
- created_issue_url: ${{ steps.process_safe_outputs.outputs.created_issue_url }}
- process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }}
- process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }}
- steps:
- - name: Setup Scripts
- uses: github/gh-aw/actions/setup@ce1794953e0ec42adc41b6fca05e02ab49ee21c3 # v0.68.3
- with:
- destination: /opt/gh-aw/actions
- - name: Download agent output artifact
- continue-on-error: true
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
- with:
- name: agent-output
- path: /tmp/gh-aw/safeoutputs/
- - name: Setup agent output environment variable
- run: |
- mkdir -p /tmp/gh-aw/safeoutputs/
- find "/tmp/gh-aw/safeoutputs/" -type f -print
- echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV"
- - name: Process Safe Outputs
- id: process_safe_outputs
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
- env:
- GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }}
- GH_AW_ALLOWED_DOMAINS: "*.jsr.io,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.npms.io,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,bun.sh,cdn.jsdelivr.net,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,deb.nodesource.com,deno.land,esm.sh,get.pnpm.io,github.com,googleapis.deno.dev,googlechromelabs.github.io,host.docker.internal,json-schema.org,json.schemastore.org,jsr.io,keyserver.ubuntu.com,mcp.linear.app,nodejs.org,npm.pkg.github.com,npmjs.com,npmjs.org,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.bower.io,registry.npmjs.com,registry.npmjs.org,registry.yarnpkg.com,repo.yarnpkg.com,s.symcb.com,s.symcd.com,security.ubuntu.com,skimdb.npmjs.com,storage.googleapis.com,telemetry.enterprise.githubcopilot.com,telemetry.vercel.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.npmjs.com,www.npmjs.org,yarnpkg.com"
- GITHUB_SERVER_URL: ${{ github.server_url }}
- GITHUB_API_URL: ${{ github.api_url }}
- GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_issue\":{\"max\":1},\"missing_data\":{},\"missing_tool\":{}}"
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io);
- const { main } = require('/opt/gh-aw/actions/safe_output_handler_manager.cjs');
- await main();
- - name: Upload safe output items manifest
- if: always()
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
- with:
- name: safe-output-items
- path: /tmp/safe-output-items.jsonl
- if-no-files-found: warn
-
- update_cache_memory:
- needs: agent
- if: always() && needs.agent.outputs.detection_success == 'true'
- runs-on: ubuntu-latest
- permissions: {}
- env:
- GH_AW_WORKFLOW_ID_SANITIZED: lineartriage
- steps:
- - name: Setup Scripts
- uses: github/gh-aw/actions/setup@ce1794953e0ec42adc41b6fca05e02ab49ee21c3 # v0.68.3
- with:
- destination: /opt/gh-aw/actions
- - name: Download cache-memory artifact (default)
- id: download_cache_default
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
- continue-on-error: true
- with:
- name: cache-memory
- path: /tmp/gh-aw/cache-memory
- - name: Check if cache-memory folder has content (default)
- id: check_cache_default
- shell: bash
- run: |
- if [ -d "/tmp/gh-aw/cache-memory" ] && [ "$(ls -A /tmp/gh-aw/cache-memory 2>/dev/null)" ]; then
- echo "has_content=true" >> "$GITHUB_OUTPUT"
- else
- echo "has_content=false" >> "$GITHUB_OUTPUT"
- fi
- - name: Save cache-memory to cache (default)
- if: steps.check_cache_default.outputs.has_content == 'true'
- uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
- with:
- key: memory-${{ env.GH_AW_WORKFLOW_ID_SANITIZED }}-${{ github.run_id }}
- path: /tmp/gh-aw/cache-memory
-
diff --git a/.github/workflows/linear-triage.md b/.github/workflows/linear-triage.md
deleted file mode 100644
index 1550fd8e8ce..00000000000
--- a/.github/workflows/linear-triage.md
+++ /dev/null
@@ -1,232 +0,0 @@
----
-description: Triage new Linear issues for the Berlin Bureau (BER) team — classify type, assign priority, tag product area, and post reasoning comments.
-on:
- workflow_dispatch:
- schedule: daily on weekdays
-permissions:
- contents: read
-if: github.repository == 'TryGhost/Ghost'
-tools:
- cache-memory: true
-mcp-servers:
- linear:
- command: "npx"
- args: ["-y", "mcp-remote", "https://mcp.linear.app/mcp", "--header", "Authorization:Bearer ${{ secrets.LINEAR_API_KEY }}"]
- env:
- LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
-network:
- allowed:
- - defaults
- - node
- - mcp.linear.app
-safe-outputs:
- create-issue:
- noop:
----
-
-# Linear Issue Triage Agent
-
-You are an AI agent that triages new Linear issues for the **Berlin Bureau (BER)** team. Your goal is to reduce the time a human needs to complete triage by pre-classifying issues, assigning priority, tagging product areas, and recommending code investigations where appropriate.
-
-**You do not move issues out of Triage** — a human still makes the final call on status transitions.
-
-## Your Task
-
-1. Use the Linear MCP tools to find the BER team and list all issues currently in the **Triage** state
-2. Check your cache-memory to see which issues you have already triaged — skip those
-3. For each untriaged issue, apply the triage rubric below to:
- - Classify the issue type
- - Assign priority (both a priority label and Linear's built-in priority field)
- - Tag the product area
- - Post a triage comment explaining your reasoning
-4. Update your cache-memory with the newly triaged issue IDs
-5. After processing, call the `noop` safe output with a summary of what you did — e.g. "Triaged 1 issue: BER-3367 (Bug, P3)" or "No new BER issues in Triage state" if there was nothing to triage
-
-## Linear MCP Tools
-
-You have access to the official Linear MCP server. Use its tools to:
-
-- **Find issues**: Search for BER team issues in Triage state
-- **Read issue details**: Get title, description, labels, priority, and comments
-- **Update issues**: Add labels and set priority
-- **Create comments**: Post triage reasoning comments
-
-Start by listing available tools to discover the exact tool names and parameters.
-
-**Important:** When updating labels, preserve existing labels. Fetch the issue's current labels first, then include both old and new label IDs in the update.
-
-## Cache-Memory Format
-
-Store and read a JSON file at the **exact path** `cache-memory/triage-cache.json`. Always use this filename — never rename it or create alternative files.
-
-```json
-{
- "triaged_issue_ids": ["BER-3150", "BER-3151"],
- "last_run": "2025-01-15T10:00:00Z"
-}
-```
-
-On each run:
-1. Read `cache-memory/triage-cache.json` to get previously triaged issue identifiers
-2. Skip any issues already in the list
-3. After processing, write the updated list back to `cache-memory/triage-cache.json` (append newly triaged IDs)
-
-## Triage Rubric
-
-### Decision 1: Type Classification
-
-Classify each issue based on its title, description, and linked context:
-
-| Type | Signal words / patterns | Label to apply |
-|------|------------------------|----------------|
-| **Bug** | "broken", "doesn't work", "regression", "error", "crash", stack traces, Sentry links, "unexpected behaviour" | `🐛 Bug` (`e51776f7-038e-474b-86ec-66981c9abb4f`) |
-| **Security** | "vulnerability", "exploit", "bypass", "SSRF", "XSS", "injection", "authentication bypass", "2FA", CVE references | `🔒 Security` (`28c5afc1-8063-4e62-af11-e42d94591957`) — also apply Bug if applicable |
-| **Feature** | "add support for", "it would be nice", "can we", "new feature", Featurebase links | `✨ Feature` (`db8672e2-1053-4bc7-9aab-9d38c5b01560`) |
-| **Improvement** | "improve", "enhance", "optimise", "refactor", "clean up", "polish" | `🎨 Improvement` (`b36579e6-62e1-4f55-987d-ee1e5c0cde1a`) |
-| **Performance** | "slow", "latency", "timeout", "memory", "CPU", "performance", load time complaints | `⚡️ Performance` (`9066d0ea-6326-4b22-b6f5-82fe7ce2c1d1`) |
-| **Maintenance** | "upgrade dependency", "tech debt", "remove deprecated", "migrate" | `🛠️ Maintenance` (`0ca27922-3646-4ab7-bf03-e67230c0c39e`) |
-| **Documentation** | "docs", "README", "guide", "tutorial", missing documentation | `📝 Documentation` (`25f8988a-5925-44cd-b0df-c0229463925f`) |
-
-If an issue matches multiple types (e.g. a security bug), apply all relevant labels.
-
-### Decision 2: Priority Assignment
-
-Assign priority to all issue types. Set both the Linear priority field and the corresponding priority label.
-
-**For bugs and security issues**, use these criteria:
-
-#### P1 — Urgent (Linear priority: 1, Label: `📊 Priority → P1 - Urgent` `11de115f-3e40-46c6-bf42-2aa2b9195cbd`)
-- Security vulnerability with a clear exploit path
-- Data loss or corruption (MySQL, disk) — actual or imminent (exception: small lexical data issues can be P2)
-- Multiple customers' businesses immediately affected (broken payment collection, broken emails, broken member login)
-
-#### P2 — High (Linear priority: 2, Label: `📊 Priority → P2 - High` `aeda47fa-9db9-4f4d-a446-3cccf92c8d12`)
-- Triggering monitoring alerts that wake on-call engineers (if recurring, bump to P1)
-- Security vulnerability without a clear exploit
-- Regression that breaks currently working core functionality
-- Crashes the server or browser
-- Significantly disrupts customers' members/end-users (e.g. incorrect pricing or access)
-- Bugs with members, subscriptions, or newsletters without immediate business impact
-
-#### P3 — Medium (Linear priority: 3, Label: `📊 Priority → P3 - Medium` `10ec8b7b-725f-453f-b5d2-ff160d3b3c1e`)
-- Bugs with members, subscriptions, or newsletters affecting only a few customers
-- Bugs in recently released features that significantly affect usability
-- Issues with setup/upgrade flows
-- Broken features (dashboards, line charts, analytics, etc.)
-- Correctness issues (e.g. timezones)
-
-#### P4 — Low (Linear priority: 4, Label: `📊 Priority → P4 - Low` `411a21ea-c8c0-4cb1-9736-7417383620ff`)
-- Not quite working as expected, but little overall impact
-- Not related to payments, email, or security
-- Significantly more complex to fix than the value of fixing
-- Purely cosmetic
-- Has a clear and straightforward workaround
-
-**For non-bug issues** (features, improvements, performance, maintenance, documentation), assign a **provisional priority** based on estimated impact and urgency. Clearly mark it as provisional in the triage comment.
-
-#### Bump Modifiers
-
-**Bump UP one level if:**
-- It causes regular alerts for on-call engineers
-- It affects lots of users or VIP customers
-- It prevents users from carrying out a critical use case or workflow
-- It prevents rolling back to a previous release
-
-**Bump DOWN one level if:**
-- Reported by a single, non-VIP user
-- Only impacts an edge case or obscure use case
-
-Note in your comment if a bump modifier was applied and why.
-
-### Decision 3: Product Area Tagging
-
-Apply the most relevant `Product Area →` label:
-
-| Label | Covers |
-|-------|--------|
-| `Product Area → Editor` | Post/page editor, Koenig, Lexical, content blocks |
-| `Product Area → Dashboard` | Admin dashboard, stats, overview |
-| `Product Area → Analytics` | Analytics, charts, reporting |
-| `Product Area → Memberships` | Member management, segmentation, member data |
-| `Product Area → Portal` | Member-facing portal, signup/login flows |
-| `Product Area → Newsletters` | Email newsletters, sending, email design |
-| `Product Area → Admin` | General admin UI, settings, navigation |
-| `Product Area → Settings area` | Settings screens specifically |
-| `Product Area → Billing App` | Billing, subscription management |
-| `Product Area → Themes` | Theme system, Handlebars, theme marketplace |
-| `Product Area → Publishing` | Post publishing, scheduling, distribution |
-| `Product Area → Growth` | Growth features, recommendations |
-| `Product Area → Comments` | Comment system |
-| `Product Area → Imports / Exports` | Data import/export |
-| `Product Area → Welcome emails / Automations` | Automated emails, welcome sequences |
-| `Product Area → Social Web` | ActivityPub, federation |
-| `Product Area → i18n` | Internationalisation, translations |
-| `Product Area → Sodo Search` | Search functionality |
-| `Product Area → Admin-X Offers` | Offers system in Admin-X |
-
-If the issue spans multiple areas, apply all relevant labels. If no product area is clearly identifiable, don't force a label — note this in the comment.
-
-**Important:** Use the Linear MCP tools to look up product area label IDs before applying them.
-
-### Decision 4: Triage Comment
-
-Post a comment on the issue with your reasoning. Use this format:
-
-```
-🤖 **Automated Triage**
-
-**Type:** Bug (Security)
-**Priority:** P2 — High
-**Product Area:** Memberships
-**Bump modifiers applied:** UP — affects multiple customers
-
-**Reasoning:**
-This appears to be a security vulnerability in the session handling that could allow
-2FA bypass. While no clear exploit path has been reported, the potential for
-authentication bypass affecting all staff accounts warrants P2. Bumped up from P3
-because it affects all customers with 2FA enabled.
-
-**Recommended action:** Code investigation recommended — this is a security bug
-that needs code-level analysis.
-```
-
-For non-bug issues, mark priority as provisional:
-
-```
-🤖 **Automated Triage**
-
-**Type:** Improvement
-**Priority:** P3 — Medium *(provisional)*
-**Product Area:** Admin
-**Bump modifiers applied:** None
-
-**Reasoning:**
-This is a refactoring task to share logic between two related functions. No user-facing
-impact, but reduces maintenance burden for the retention offers codebase. Provisional
-P3 based on moderate codebase impact and alignment with active project work.
-
-**Recommended action:** Code investigation recommended — small refactoring task with
-clear scope, no design input needed.
-```
-
-### Decision 5: Code Investigation Recommendation
-
-Flag an issue for code investigation in your comment if **all** of these are true:
-
-1. Classified as a bug, security issue, performance issue, or small improvement/maintenance task
-2. Does not require design input (no UI mockups needed, no UX decisions)
-3. Has enough description to investigate (not just a title with no context)
-
-Do **not** recommend investigation for:
-- Feature requests (need product/design input)
-- Issues with vague descriptions and no reproduction steps — instead note "Needs more info" in the comment
-- Issues that are clearly large architectural changes
-
-## Guidelines
-
-- Process issues one at a time, applying all decisions before moving to the next
-- Be concise but include enough reasoning that a human can quickly validate or override
-- When in doubt about classification, pick the closest match and note your uncertainty
-- If an issue already has triage labels or a triage comment from a previous run, skip it
-- Never move issues out of the Triage state
-- After processing all issues, update cache-memory with the full list of triaged identifiers
diff --git a/apps/admin-x-settings/src/components/settings/email/enable-newsletters.tsx b/apps/admin-x-settings/src/components/settings/email/enable-newsletters.tsx
index f246b486fa3..c9facbd101f 100644
--- a/apps/admin-x-settings/src/components/settings/email/enable-newsletters.tsx
+++ b/apps/admin-x-settings/src/components/settings/email/enable-newsletters.tsx
@@ -35,8 +35,9 @@ const EnableNewsletters: React.FC<{ keywords: string[] }> = ({keywords}) => {
const enableToggle = (
<>
>
@@ -55,22 +56,24 @@ const EnableNewsletters: React.FC<{ keywords: string[] }> = ({keywords}) => {
values={[
{
key: 'private',
- value: (newslettersEnabled !== 'disabled') ? (
+ value: (newslettersEnabled !== 'disabled' && !isDisabled) ? (
Enabled
- {isDisabled &&
-
- Your {
- updateRoute('members');
- }}>Subscription access is set to ‘Nobody’, only existing members will receive newsletters.
-
- }
) :
-
-
-
Disabled
+
+
+
+ Disabled
+
+ {isDisabled &&
+
+ Your {
+ updateRoute('members');
+ }}>Subscription access is set to ‘Nobody’, which disables all newsletter sending. Change to ‘Invite-only’ to send newsletters to existing members without allowing new signups.
+
+ }
}
]}
diff --git a/apps/admin-x-settings/test/acceptance/membership/access.test.ts b/apps/admin-x-settings/test/acceptance/membership/access.test.ts
index 3bcbafa7e52..d0d8f961b44 100644
--- a/apps/admin-x-settings/test/acceptance/membership/access.test.ts
+++ b/apps/admin-x-settings/test/acceptance/membership/access.test.ts
@@ -127,7 +127,7 @@ test.describe('Access settings', async () => {
await expect(section.getByTestId('subscription-access-select')).toContainText('Nobody');
await expect(page.getByTestId('portal').getByRole('button', {name: 'Customize'})).toBeDisabled();
- await expect(page.getByTestId('enable-newsletters')).toContainText('only existing members will receive newsletters');
+ await expect(page.getByTestId('enable-newsletters')).toContainText('which disables all newsletter sending');
});
test('Supports selecting specific tiers', async ({page}) => {
diff --git a/apps/admin/package.json b/apps/admin/package.json
index 9b03d82906c..159e3e5f95e 100644
--- a/apps/admin/package.json
+++ b/apps/admin/package.json
@@ -49,7 +49,7 @@
"tailwindcss": "^4.2.2",
"typescript": "5.9.3",
"typescript-eslint": "8.58.0",
- "vite": "7.1.12",
+ "vite": "7.3.2",
"vite-tsconfig-paths": "5.1.4",
"vitest": "4.1.2"
},
diff --git a/apps/portal/package.json b/apps/portal/package.json
index 74ac87f0455..a817618c5c5 100644
--- a/apps/portal/package.json
+++ b/apps/portal/package.json
@@ -1,6 +1,6 @@
{
"name": "@tryghost/portal",
- "version": "2.68.27",
+ "version": "2.68.28",
"license": "MIT",
"repository": "https://github.com/TryGhost/Ghost",
"author": "Ghost Foundation",
diff --git a/apps/portal/src/app.js b/apps/portal/src/app.js
index 9abeed56b8b..11a241aa0bc 100644
--- a/apps/portal/src/app.js
+++ b/apps/portal/src/app.js
@@ -600,13 +600,17 @@ export default class App extends React.Component {
if (qParams.get('stripe') === 'gift-purchase-success') {
const token = qParams.get('gift_token');
- clearURLParams(['stripe', 'gift_token']);
+ const tierId = qParams.get('gift_tier');
+ const cadence = qParams.get('gift_cadence');
+ clearURLParams(['stripe', 'gift_token', 'gift_tier', 'gift_cadence']);
if (token) {
return {
showPopup: true,
page: 'giftSuccess',
pageData: {
- token
+ token,
+ tierId,
+ cadence
}
};
}
diff --git a/apps/portal/src/components/pages/gift-page.js b/apps/portal/src/components/pages/gift-page.js
index fb4b9b66964..d33f6032b0f 100644
--- a/apps/portal/src/components/pages/gift-page.js
+++ b/apps/portal/src/components/pages/gift-page.js
@@ -1,357 +1,637 @@
-import {useContext, useState} from 'react';
+import {useContext, useEffect, useRef, useState} from 'react';
import AppContext from '../../app-context';
import CloseButton from '../common/close-button';
-import SiteTitleBackButton from '../common/site-title-back-button';
import ActionButton from '../common/action-button';
import LoadingPage from './loading-page';
import {ReactComponent as CheckmarkIcon} from '../../images/icons/checkmark.svg';
-import {ReactComponent as GiftIcon} from '../../images/icons/gift.svg';
import {getAvailableProducts, getCurrencySymbol, formatNumber, getStripeAmount, isCookiesDisabled, getActiveInterval} from '../../utils/helpers';
-import calculateDiscount from '../../utils/discount';
+import useCardTilt from '../../utils/use-card-tilt';
// TODO: wrap strings with t() once copy is finalised
/* eslint-disable i18next/no-literal-string */
export const GiftPageStyles = `
-.gh-portal-content.gift {
- position: relative;
- padding-top: 0;
+.gh-portal-popup-container.full-size.gift,
+.gh-portal-popup-container.full-size.giftSuccess,
+.gh-portal-popup-container.full-size.giftRedemption {
+ padding: 0;
+ /* The default opacity/translate popup-in animation feels heavy on the
+ full-bleed 50/50 layout — let the page render in place instead. */
+ animation: none;
}
-.gh-portal-gift-bg {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- height: 320px;
- background: linear-gradient(180deg, var(--brandcolor), transparent);
- opacity: 0.08;
- pointer-events: none;
- z-index: 0;
+.gh-portal-content.gift,
+.gh-portal-content.giftSuccess,
+.gh-portal-content.giftRedemption {
+ position: relative;
+ padding: 0;
+ min-height: 100vh;
}
-.gh-portal-popup-wrapper.full-size .gh-portal-gift-bg {
- top: -2vmin;
- left: -6vmin;
- right: -6vmin;
- height: calc(320px + 2vmin);
+.gh-portal-gift-checkout {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ min-height: 100vh;
+ width: 100%;
}
-.gh-portal-gift-sitetitle {
+.gh-portal-gift-checkout-left {
position: relative;
- z-index: 1;
display: flex;
align-items: center;
justify-content: center;
+ background: var(--white);
+ padding: 64px 48px 128px;
+ overflow: hidden;
+}
+
+.gh-portal-gift-checkout-bg {
+ display: none;
+}
+
+.gh-portal-gift-checkout-inner {
+ position: relative;
+ z-index: 1;
+ width: 100%;
+ max-width: 496px;
+ display: flex;
+ flex-direction: column;
+}
+
+.gh-portal-gift-checkout-header {
+ margin-bottom: 8px;
+}
+
+.gh-portal-gift-checkout-header .gh-portal-main-title {
+ text-align: start;
+ margin: 0 0 12px;
+}
+
+.gh-portal-gift-checkout-subtitle {
+ margin: 0;
+ font-size: 1.6rem;
+ line-height: 1.45em;
+ color: var(--grey3);
+}
+
+.gh-portal-gift-checkout-section {
+ margin-top: 24px;
+}
+
+.gh-portal-gift-checkout-label {
+ font-size: 1.2rem;
+ font-weight: 500;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: var(--grey6);
+ margin-bottom: 12px;
+}
+
+.gh-portal-gift-checkout .gh-portal-products-pricetoggle {
+ margin: 0;
+}
+
+.gh-portal-gift-checkout-tiers {
+ display: flex;
+ flex-direction: column;
gap: 8px;
- padding: 15px 32px 0;
}
-.gh-portal-gift-sitetitle-icon {
- display: block;
- width: 24px;
- height: 24px;
- border-radius: 4px;
- background-position: 50%;
- background-size: cover;
- object-fit: cover;
+.gh-portal-gift-checkout-tier {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ width: 100%;
+ background: var(--white);
+ border: 1px solid var(--grey11);
+ border-radius: 10px;
+ padding: 16px 20px;
+ cursor: pointer;
+ text-align: start;
+ transition: border-color 0.2s ease, background-color 0.2s ease;
+ font: inherit;
+ color: inherit;
+}
+
+.gh-portal-gift-checkout-tier:hover {
+ border-color: var(--grey9);
+}
+
+.gh-portal-gift-checkout-tier.selected {
+ border-color: var(--brandcolor);
+ background: color-mix(in srgb, var(--brandcolor) 6%, var(--white));
+ box-shadow: 0 0 0 1px var(--brandcolor) inset;
+}
+
+.gh-portal-gift-checkout-tier-radio {
+ flex-shrink: 0;
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ border: 1.5px solid var(--grey9);
+ background: var(--white);
+ position: relative;
+}
+
+.gh-portal-gift-checkout-tier.selected .gh-portal-gift-checkout-tier-radio {
+ border-color: var(--brandcolor);
+ background: var(--brandcolor);
}
-.gh-portal-gift-sitetitle-name {
+.gh-portal-gift-checkout-tier.selected .gh-portal-gift-checkout-tier-radio::after {
+ content: '';
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--white);
+ transform: translate(-50%, -50%);
+}
+
+.gh-portal-gift-checkout-tier-name {
+ flex: 1;
font-size: 1.5rem;
font-weight: 500;
color: var(--grey0);
- line-height: 1;
}
-.gh-portal-gift-hero {
- position: relative;
- z-index: 1;
+.gh-portal-gift-checkout-tier-price {
+ font-size: 1.5rem;
+ font-weight: 500;
+ color: var(--grey0);
+}
+
+.gh-portal-gift-checkout-benefits {
display: flex;
flex-direction: column;
- align-items: center;
- text-align: center;
- padding: 56px 32px 40px;
+ gap: 10px;
+ padding-left: 4px;
}
-.gh-portal-gift-hero-icon {
- display: inline-flex;
+.gh-portal-gift-checkout-benefit {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ color: var(--grey1);
+ font-size: 1.45rem;
+ line-height: 1.4;
+}
+
+.gh-portal-gift-checkout-benefit svg {
+ width: 14px;
+ height: 14px;
+ margin-top: 4px;
+ color: var(--grey1);
+ flex-shrink: 0;
+}
+
+.gh-portal-gift-checkout .gh-portal-btn-primary {
+ border-radius: 999px;
+}
+
+.gh-portal-gift-checkout-cta {
+ width: 100%;
+ height: 48px;
+ margin-top: 32px;
+ font-size: 1.5rem;
+ font-weight: 600;
+}
+
+.gh-portal-gift-checkout-right {
+ position: sticky;
+ top: 0;
+ align-self: start;
+ height: 100vh;
+ display: flex;
align-items: center;
justify-content: center;
- width: 48px;
- height: 48px;
- color: var(--brandcolor);
- margin-bottom: 20px;
+ background: linear-gradient(to bottom, var(--white), color-mix(in srgb, var(--brandcolor) 5%, var(--white)));
+ padding: 64px 48px 128px;
+ overflow-y: auto;
}
-.gh-portal-gift-hero-icon svg {
- width: 48px;
- height: 48px;
- stroke-width: 1.5;
+.gh-portal-gift-checkout-card-stack {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ width: 100%;
+ max-width: 440px;
}
-.gh-portal-gift-hero .gh-portal-main-title {
- margin: 0 0 14px;
+/* Wraps the card so we can tilt it forward when "Gift details" is expanded,
+ making the benefits feel like they're sliding out from behind the card.
+ Composes cleanly with the cursor-driven tilt applied to the card itself. */
+.gh-portal-gift-checkout-card-frame {
+ width: 100%;
+ transform-style: preserve-3d;
+ perspective: 1200px;
+ transition: transform 0.3s ease;
}
-.gh-portal-gift-hero .gh-portal-main-subtitle {
- margin: 0;
- font-size: 1.7rem;
- line-height: 1.45em;
+.gh-portal-gift-checkout-card-stack[data-revealing="true"] .gh-portal-gift-checkout-card-frame {
+ transform: rotate(3deg);
+}
+
+.gh-portal-gift-checkout-card-benefits {
+ width: 100%;
+ margin-top: 28px;
+ overflow: hidden;
+ transition: height 0.3s ease;
+}
+
+.gh-portal-gift-checkout-details-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ margin-top: 24px;
+ padding: 8px 12px;
+ background: transparent;
+ border: none;
+ color: var(--grey5);
+ font-size: 1.4rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: color 0.15s ease;
+}
+
+.gh-portal-gift-checkout-details-toggle:hover {
color: var(--grey3);
- text-align: center;
}
-.gh-portal-content.gift > section {
+.gh-portal-gift-checkout-details-toggle svg {
+ width: 12px;
+ height: 12px;
+ transition: transform 0.2s ease;
+}
+
+.gh-portal-gift-checkout-details-toggle.is-open svg {
+ transform: rotate(-180deg);
+}
+
+.gh-portal-gift-checkout-details {
+ width: 100%;
+ display: grid;
+ grid-template-rows: 0fr;
+ transition: grid-template-rows 0.3s ease, margin-top 0.3s ease;
+ margin-top: 0;
+}
+
+.gh-portal-gift-checkout-details[data-open="true"] {
+ grid-template-rows: 1fr;
+ margin-top: 32px;
+}
+
+.gh-portal-gift-checkout-details-inner {
+ overflow: hidden;
+}
+
+.gh-portal-gift-checkout-card {
position: relative;
- z-index: 1;
+ width: 100%;
+ max-width: 440px;
+ aspect-ratio: 1.7 / 1;
+ background: linear-gradient(to top right, var(--white), color-mix(in srgb, var(--brandcolor) 8%, var(--white)));
+ border-radius: 32px;
+ padding: 28px;
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9), 0 24px 48px rgba(var(--blackrgb), 0.08), 0 4px 12px rgba(var(--blackrgb), 0.04);
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ overflow: hidden;
+ transform-style: preserve-3d;
+ will-change: transform;
}
-.gh-portal-popup-wrapper.gift .gh-portal-btn-site-title-back {
- background: transparent;
- border-color: transparent;
+.gh-portal-gift-checkout-card-site {
+ position: relative;
+ z-index: 2;
+ display: flex;
+ align-items: center;
+ gap: 8px;
}
-.gh-portal-popup-wrapper.gift .gh-portal-btn-site-title-back:hover {
- border-color: transparent;
+.gh-portal-gift-checkout-card-site-icon {
+ width: 24px;
+ height: 24px;
+ border-radius: 5px;
+ object-fit: cover;
}
-@media (max-width: 480px) {
- .gh-portal-gift-hero {
- padding: 40px 24px 32px;
- }
+.gh-portal-gift-checkout-card-site-name {
+ font-size: 1.4rem;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+ color: var(--grey0);
+}
- .gh-portal-gift-bg {
- height: 240px;
- }
+.gh-portal-gift-checkout-card-meta {
+ position: relative;
+ z-index: 2;
}
-`;
-function GiftProductCardBenefits({product}) {
- if (!product.benefits || !product.benefits.length) {
- return null;
- }
+.gh-portal-gift-checkout-card-duration {
+ font-size: 2.6rem;
+ font-weight: 600;
+ color: var(--grey0);
+ letter-spacing: -0.01em;
+ line-height: 1.1;
+}
- return (
-
- {product.benefits.map((benefit, idx) => {
- const key = benefit?.id || `benefit-${idx}`;
-
- return (
-
- );
- })}
-
- );
+.gh-portal-gift-checkout-card-tier {
+ margin-top: 6px;
+ font-size: 1.4rem;
+ color: var(--grey3);
+ line-height: 1.3;
}
-function GiftProductCardPrice({product, selectedInterval}) {
- const monthlyPrice = product.monthlyPrice;
- const yearlyPrice = product.yearlyPrice;
+/* Wrapped-present cross ribbon: vertical + horizontal straps, bow at intersection */
+.gh-portal-gift-checkout-card-ribbon-v,
+.gh-portal-gift-checkout-card-ribbon-h {
+ position: absolute;
+ background: var(--brandcolor);
+ opacity: 0.3;
+ z-index: 1;
+}
- if (!monthlyPrice || !yearlyPrice) {
- return null;
- }
+.gh-portal-gift-checkout-card-ribbon-v {
+ top: 0;
+ bottom: 0;
+ right: 22%;
+ width: 12px;
+}
- const activePrice = selectedInterval === 'month' ? monthlyPrice : yearlyPrice;
- const currencySymbol = getCurrencySymbol(activePrice.currency);
- const yearlyDiscount = calculateDiscount(monthlyPrice.amount, yearlyPrice.amount);
+.gh-portal-gift-checkout-card-ribbon-h {
+ left: 0;
+ right: 0;
+ top: 32%;
+ height: 12px;
+ transform: translateY(-50%);
+}
- return (
-
-
-
- 1 ? ' long' : '')}>{currencySymbol}
- {formatNumber(getStripeAmount(activePrice.amount))}
-
- {selectedInterval === 'year' && yearlyDiscount > 0 && (
-
{yearlyDiscount}% discount
- )}
-
-
one-time payment
-
- );
+.gh-portal-gift-checkout-card-bow {
+ position: absolute;
+ top: calc(32% - 42px);
+ right: calc(22% - 40px);
+ width: 90px;
+ height: 86px;
+ z-index: 2;
+ color: var(--brandcolor);
+ pointer-events: none;
+ filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.06)) drop-shadow(0 16px 32px rgba(0, 0, 0, 0.06));
}
-function GiftProductCard({brandColor, product, selectedInterval, isDisabled, isPurchasing, onPurchase}) {
- let productDescription = product.description;
- if ((!product.benefits || !product.benefits.length) && !productDescription) {
- productDescription = 'Full access';
+@media (max-width: 880px) {
+ .gh-portal-gift-checkout {
+ grid-template-columns: 1fr;
+ min-height: 0;
}
- return (
-
-
-
{product.name}
-
-
-
-
- {productDescription && (
-
- {productDescription}
-
- )}
-
-
-
-
onPurchase(e, product)}
- disabled={isDisabled}
- isRunning={isPurchasing}
- brandColor={brandColor}
- style={{width: '100%'}}
- />
-
-
-
- );
+ /* Drop sticky/100vh sizing — on mobile the right column should sit naturally
+ above the left content, taking only the height it needs. */
+ .gh-portal-gift-checkout-right {
+ order: -1;
+ position: static;
+ height: auto;
+ padding: 32px 24px;
+ overflow: visible;
+ }
+
+ .gh-portal-gift-checkout-left {
+ padding: 32px 24px 80px;
+ }
+
+ .gh-portal-gift-checkout-card,
+ .gh-portal-gift-checkout-card-stack {
+ max-width: 320px;
+ }
+}
+
+@media (max-width: 480px) {
+ .gh-portal-gift-checkout-header .gh-portal-main-title {
+ font-size: 2.6rem;
+ }
+
+ .gh-portal-gift-checkout-card-duration {
+ font-size: 2rem;
+ }
}
+`;
-function GiftPriceSwitch({selectedInterval, setSelectedInterval, products}) {
+function GiftPriceSwitch({selectedInterval, setSelectedInterval}) {
const {site} = useContext(AppContext);
const {portal_plans: portalPlans} = site;
- const discounts = products.map(product => calculateDiscount(product.monthlyPrice?.amount, product.yearlyPrice?.amount));
- const highestDiscount = Math.max(...discounts);
if (!portalPlans.includes('monthly') || !portalPlans.includes('yearly')) {
return null;
}
return (
-
-
- setSelectedInterval('month')}
- >
- 1 month
-
- setSelectedInterval('year')}
- >
- 1 year
- {highestDiscount > 0 && (save {highestDiscount}%) }
-
-
+
+ setSelectedInterval('month')}
+ >
+ 1 month
+
+ setSelectedInterval('year')}
+ >
+ 1 year
+
);
}
+function getTierPriceLabel(product, selectedInterval) {
+ const activePrice = selectedInterval === 'month' ? product.monthlyPrice : product.yearlyPrice;
+
+ if (!activePrice) {
+ return '';
+ }
+
+ const currencySymbol = getCurrencySymbol(activePrice.currency);
+ return `${currencySymbol}${formatNumber(getStripeAmount(activePrice.amount))}`;
+}
+
+function getDurationLabel(selectedInterval) {
+ return selectedInterval === 'month' ? '1 month' : '1 year';
+}
+
const GiftPage = () => {
const {site, brandColor, action, doAction} = useContext(AppContext);
const [selectedInterval, setSelectedInterval] = useState(null);
- const [selectedProduct, setSelectedProduct] = useState(null);
+ const [selectedProductId, setSelectedProductId] = useState(null);
+ const benefitsInnerRef = useRef(null);
+ const [benefitsHeight, setBenefitsHeight] = useState(undefined);
+ const {cardRef, containerProps: cardTiltProps} = useCardTilt();
+
+ useEffect(() => {
+ const node = benefitsInnerRef.current;
+ if (!node || typeof ResizeObserver === 'undefined') {
+ return;
+ }
+ const observer = new ResizeObserver((entries) => {
+ setBenefitsHeight(entries[0].contentRect.height);
+ });
+ observer.observe(node);
+ return () => observer.disconnect();
+ }, []);
if (!site) {
return
;
}
const {portal_plans: portalPlans, portal_default_plan: portalDefaultPlan} = site;
-
const activeInterval = getActiveInterval({portalPlans, portalDefaultPlan, selectedInterval});
-
const products = getAvailableProducts({site}).filter(p => p.type === 'paid');
const siteIcon = site.icon;
const siteTitle = site.title || '';
- const giftPageHeader = (
- <>
-
-
- {siteIcon && (
-
- )}
-
{siteTitle}
-
-
-
-
-
-
Gift a membership
-
Share a full membership to {siteTitle} with a friend or colleague
-
- >
- );
-
if (products.length === 0) {
return (
<>
-
-
-
-
- {giftPageHeader}
-
-
-
- Gift subscriptions are not available right now.
-
+
>
);
}
+ const activeProduct = products.find(p => p.id === selectedProductId) || products[0];
const isPurchasing = action === 'checkoutGift:running';
const isDisabled = isCookiesDisabled() || isPurchasing;
- const handlePurchase = (e, product) => {
+ const handlePurchase = (e) => {
e.preventDefault();
- setSelectedProduct(product.id);
-
doAction('checkoutGift', {
- tierId: product.id,
+ tierId: activeProduct.id,
cadence: activeInterval
});
};
return (
<>
-
-
-
-
- {giftPageHeader}
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
Tier
+
+ {products.map((product) => {
+ const isSelected = product.id === activeProduct.id;
+ return (
+ setSelectedProductId(product.id)}
+ data-test-tier={product.name}
+ >
+
+ {product.name}
+ {getTierPriceLabel(product, activeInterval)}
+
+ );
+ })}
+
+
+
+
-
- {products.map(product => (
-
- ))}
+
+
+
+
+
+
+
+ {siteIcon && (
+
+ )}
+
{siteTitle}
+
+
+
{getDurationLabel(activeInterval)}
+
{activeProduct.name}
+
+
+
+
+
+
-
+
+ {activeProduct.benefits && activeProduct.benefits.length > 0 && (
+
+
+ {activeProduct.benefits.map((benefit, idx) => {
+ const key = benefit?.id || `benefit-${idx}`;
+ return (
+
+
+ {benefit.name}
+
+ );
+ })}
+
+
+ )}
+
-
+
>
);
diff --git a/apps/portal/src/components/pages/gift-redemption-page.js b/apps/portal/src/components/pages/gift-redemption-page.js
index d958a97c09c..bf5d163f324 100644
--- a/apps/portal/src/components/pages/gift-redemption-page.js
+++ b/apps/portal/src/components/pages/gift-redemption-page.js
@@ -5,189 +5,27 @@ import CloseButton from '../common/close-button';
import InputForm from '../common/input-form';
import {ValidateInputForm} from '../../utils/form';
import {ReactComponent as CheckmarkIcon} from '../../images/icons/checkmark.svg';
-import {ReactComponent as GiftIcon} from '../../images/icons/gift.svg';
import {getGiftDurationLabel, getGiftRedemptionErrorMessage} from '../../utils/gift-redemption-notification';
import {t} from '../../utils/i18n';
import {hasGiftSubscriptions, removePortalLinkFromUrl} from '../../utils/helpers';
+import useCardTilt from '../../utils/use-card-tilt';
export const GiftRedemptionStyles = `
- .gh-portal-popup-container.giftRedemption {
- width: calc(100vw - 24px);
- max-width: 500px;
- padding: 0;
- overflow: hidden;
- flex-shrink: 0;
- }
-
- .gh-portal-popup-container.giftRedemption .gh-portal-closeicon-container {
- position: absolute;
- top: 16px;
- right: 16px;
- z-index: 5;
- }
-
- html[dir="rtl"] .gh-portal-popup-container.giftRedemption .gh-portal-closeicon-container {
- right: unset;
- left: 18px;
- }
-
- .gh-portal-popup-container.giftRedemption .gh-portal-closeicon {
- color: rgba(24, 32, 38, 0.14);
- }
-
- .gh-portal-popup-container.giftRedemption .gh-portal-closeicon:hover {
- color: rgba(24, 32, 38, 0.28);
- }
-
- .gh-portal-gift-redemption {
- position: relative;
- overflow: hidden;
- }
-
- .gh-gift-redemption-bg {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- height: 400px;
- background: linear-gradient(180deg, var(--brandcolor), transparent);
- opacity: 0.08;
- pointer-events: none;
- z-index: 0;
- }
-
- .gh-gift-redemption-panel {
- position: relative;
- z-index: 1;
- padding: 40px 32px 32px;
- }
-
- .gh-gift-redemption-icon {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 48px;
- height: 48px;
- margin: -8px 0 0 -3px;
- color: var(--brandcolor);
- }
-
- html[dir="rtl"] .gh-gift-redemption-icon {
- margin: -8px -3px 0 0;
- }
-
- .gh-gift-redemption-icon svg {
- width: 52px;
- height: 52px;
- }
-
- .gh-gift-redemption-kicker {
- margin-top: 0;
- font-size: 1.3rem;
- font-weight: 600;
- letter-spacing: 0.02em;
- text-transform: uppercase;
- color: var(--brandcolor);
- }
-
- .gh-gift-redemption-title {
- max-width: none;
- margin: 20px 0 0;
- font-size: 2.4rem;
- font-weight: 700;
- line-height: 1.3;
- letter-spacing: -0.015em;
- text-wrap: pretty;
- color: var(--grey0);
- }
-
- .gh-gift-redemption-plan {
- margin-top: 8px;
- font-size: 1.5rem;
- color: var(--grey3);
- }
-
- .gh-gift-redemption-tier {
- font-weight: 700;
- }
-
- .gh-gift-redemption-cadence {
- font-weight: 400;
- }
-
- .gh-gift-redemption-benefits {
- display: flex;
- flex-direction: column;
- gap: 8px;
- margin-top: 20px;
- }
-
- .gh-gift-redemption-form {
- margin-top: 36px;
- }
-
- .gh-gift-redemption-inline-cta {
- margin-top: 36px;
- }
-
- .gh-gift-redemption-benefit {
- display: flex;
- align-items: flex-start;
- gap: 10px;
- color: var(--grey2);
- font-size: 1.45rem;
- line-height: 1.35;
- text-align: left;
- }
-
- .gh-gift-redemption-benefit svg {
- width: 14px;
- height: 14px;
- margin-top: 3px;
- color: var(--grey1);
- flex-shrink: 0;
- }
-
- html[dir="rtl"] .gh-gift-redemption-benefit {
- text-align: right;
- flex-direction: row-reverse;
- }
-
- .gh-gift-redemption-submit {
- width: 100%;
- height: 44px;
- margin-top: 20px;
- font-size: 1.5rem;
- font-weight: 600;
- }
-
- @media (max-width: 480px) {
- .gh-portal-popup-container.giftRedemption {
- padding: 0 !important;
- }
-
- .gh-gift-redemption-panel {
- padding: 32px 24px 24px;
- }
-
- .gh-gift-redemption-title {
- font-size: 2.1rem;
- }
+.gh-portal-gift-redemption-form {
+ margin-top: 24px;
+}
- .gh-gift-redemption-benefit {
- font-size: 1.4rem;
- }
-
- html[dir="rtl"] .gh-gift-redemption-benefit {
- text-align: right;
- }
-
- .gh-gift-redemption-bg {
- height: 300px;
- }
- }
+.gh-portal-gift-redemption-form + .gh-portal-gift-checkout-cta {
+ margin-top: 16px;
+}
`;
+const ChevronIcon = () => (
+
+
+
+);
+
// TODO: Add translation strings once copy has been finalised
const GiftRedemptionPage = () => {
const {action, brandColor, doAction, member, pageData, site} = useContext(AppContext);
@@ -197,6 +35,8 @@ const GiftRedemptionPage = () => {
const [name, setName] = useState(member?.name || '');
const [email, setEmail] = useState(member?.email || '');
const [errors, setErrors] = useState({});
+ const [showDetails, setShowDetails] = useState(false);
+ const {cardRef, containerProps: cardTiltProps} = useCardTilt();
useEffect(() => {
setName(member?.name || '');
@@ -316,75 +156,111 @@ const GiftRedemptionPage = () => {
const buttonLabel = isRedeeming
? 'Redeeming gift...' // TODO: Add translation strings once copy has been finalised
: 'Redeem your membership'; // TODO: Add translation strings once copy has been finalised
- const siteTitle = site?.title;
+ const siteIcon = site?.icon;
+ const siteTitle = site?.title || '';
const headerText = siteTitle
? `You've been gifted a membership to ${siteTitle}`
: 'You\'ve been gifted a membership';
+ const benefits = gift.tier.benefits || [];
return (
-
+ <>
-
-
-
-
-
{'Gift membership'}
-
{headerText}
-
-
- {gift.tier.name}
- ·
- {getGiftDurationLabel(gift)}
-
-
- {gift.tier.benefits.length > 0 && (
-
- {gift.tier.benefits.map((benefit, index) => {
- const benefitName = typeof benefit === 'string' ? benefit : benefit?.name;
- const benefitKey = typeof benefit === 'string' ? benefit : benefit?.id || `gift-benefit-${index}`;
-
- if (!benefitName) {
- return null;
- }
-
- return (
-
-
-
{benefitName}
+
+
+
+
+
+
+ {/* eslint-disable-next-line i18next/no-literal-string -- copy not yet finalised */}
+ A gift, just for you
+ {headerText}
+
+
+ {!isLoggedIn && (
+
+
- );
- })}
+ )}
+
+
+
- )}
-
- {isLoggedIn ? (
-
- ) : (
-
-
-
+
+
+
+
+
+
+ {siteIcon && (
+
+ )}
+
{siteTitle}
+
+
+
{getGiftDurationLabel(gift)}
+
{gift.tier.name}
+
+
+
+
+
+
+
+
+
+ {benefits.length > 0 && (
+ <>
+
+
+
+ {benefits.map((benefit, index) => {
+ const benefitName = typeof benefit === 'string' ? benefit : benefit?.name;
+ const benefitKey = typeof benefit === 'string' ? benefit : benefit?.id || `gift-benefit-${index}`;
+
+ if (!benefitName) {
+ return null;
+ }
+
+ return (
+
+
+ {benefitName}
+
+ );
+ })}
+
+
+
+
setShowDetails(s => !s)}
+ aria-expanded={showDetails}
+ >
+ {/* eslint-disable-next-line i18next/no-literal-string -- copy not yet finalised */}
+ {showDetails ? 'Hide details' : 'Gift details'}
+
+
+ >
+ )}
+
- )}
+
-
+ >
);
};
diff --git a/apps/portal/src/components/pages/gift-success-page.js b/apps/portal/src/components/pages/gift-success-page.js
index e57144a89d0..44b5c283ca9 100644
--- a/apps/portal/src/components/pages/gift-success-page.js
+++ b/apps/portal/src/components/pages/gift-success-page.js
@@ -1,115 +1,112 @@
import {useContext, useState} from 'react';
import AppContext from '../../app-context';
-import {ReactComponent as GiftIcon} from '../../images/icons/gift.svg';
import CloseButton from '../common/close-button';
import copyTextToClipboard from '../../utils/copy-to-clipboard';
+import {getAvailableProducts} from '../../utils/helpers';
+import {ReactComponent as CheckmarkIcon} from '../../images/icons/checkmark.svg';
+import useCardTilt from '../../utils/use-card-tilt';
// TODO: wrap strings with t() once copy is finalised
/* eslint-disable i18next/no-literal-string */
export const GiftSuccessStyle = `
- .gh-portal-gift-success .gh-portal-signup-header {
- margin-bottom: 0;
- padding: 0;
- }
-
- .gh-portal-gift-success .gh-gift-success-icon {
- margin: 12px auto 0;
- text-align: center;
- color: var(--brandcolor);
- width: 56px;
- height: 56px;
- }
-
- .gh-portal-gift-success .gh-gift-success-icon svg {
- width: 56px;
- height: 56px;
- }
-
- .gh-portal-gift-success h1.gh-portal-main-title {
- font-size: 32px;
- margin-top: 16px;
- }
-
- .gh-portal-gift-success .gh-portal-main-subtitle {
- margin-top: 12px;
- }
-
- .gh-portal-gift-success .gh-gift-link-container {
- display: flex;
- align-items: center;
- height: 48px;
- background-color: #f3f3f3;
- border-radius: 8px;
- padding: 6px 6px 6px 12px;
- margin-top: 24px;
- gap: 8px;
- }
-
- .gh-portal-gift-success .gh-gift-link-url {
- flex: 1;
- font-size: 1.5rem;
- color: var(--grey1);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- min-width: 0;
- user-select: all;
- }
-
- .gh-portal-gift-success .gh-gift-copy-btn {
- display: flex;
- align-items: center;
- gap: 4px;
- height: 36px;
- background: var(--brandcolor);
- color: #fff;
- border: none;
- border-radius: 6px;
- padding: 8px 16px 8px 14px;
- font-size: 14px;
- font-weight: 500;
- cursor: pointer;
- white-space: nowrap;
- flex-shrink: 0;
- transition: opacity 0.15s ease;
- will-change: opacity;
- }
-
- .gh-portal-gift-success .gh-gift-copy-btn:hover {
- opacity: 0.9;
- }
-
- .gh-portal-gift-success .gh-gift-footer-text {
- margin: 36px 0 0;
- font-size: 1.3rem;
- color: var(--grey7);
- text-align: center;
- line-height: 1.5;
- }
+.gh-portal-gift-success-link {
+ display: flex;
+ align-items: center;
+ height: 56px;
+ background: color-mix(in srgb, var(--brandcolor) 8%, var(--white));
+ border-radius: 999px;
+ padding: 4px 8px 4px 24px;
+ gap: 8px;
+}
+
+.gh-portal-gift-success-link-url {
+ flex: 1;
+ font-size: 1.6rem;
+ font-weight: 400;
+ color: var(--brandcolor);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ user-select: all;
+}
+
+.gh-portal-gift-success-copy {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ height: 40px;
+ padding: 0 18px;
+ background: var(--brandcolor);
+ color: var(--white);
+ border: none;
+ border-radius: 999px;
+ font-size: 1.4rem;
+ font-weight: 600;
+ cursor: pointer;
+ flex-shrink: 0;
+ transition: opacity 0.15s ease;
+ will-change: opacity;
+}
+
+.gh-portal-gift-success-copy:hover {
+ opacity: 0.9;
+}
+
+.gh-portal-gift-success-copy svg {
+ width: 14px;
+ height: 14px;
+}
+
+.gh-portal-gift-success-footer {
+ margin-top: 24px;
+ margin-bottom: 0;
+ font-size: 1.4rem;
+ color: var(--grey6);
+ line-height: 1.5;
+}
`;
const CopyIcon = () => (
-
+
);
const CheckIcon = () => (
-
+
);
+const ChevronIcon = () => (
+
+
+
+);
+
+function getDurationLabel(cadence) {
+ return cadence === 'month' ? '1 month' : '1 year';
+}
+
const GiftSuccessPage = () => {
const {site, pageData} = useContext(AppContext);
const [copied, setCopied] = useState(false);
+ const [showDetails, setShowDetails] = useState(false);
+ const {cardRef, containerProps: cardTiltProps} = useCardTilt();
const token = pageData?.token;
+ const tierId = pageData?.tierId;
+ const cadence = pageData?.cadence;
const siteUrl = site?.url || '';
+ const siteIcon = site?.icon;
+ const siteTitle = site?.title || '';
const redeemUrl = `${siteUrl.replace(/\/$/, '')}/gift/${token}`;
+ const products = getAvailableProducts({site}).filter(p => p.type === 'paid');
+ const tier = tierId ? products.find(p => p.id === tierId) : null;
+
const handleCopy = () => {
copyTextToClipboard(redeemUrl);
setCopied(true);
@@ -117,29 +114,98 @@ const GiftSuccessPage = () => {
};
return (
-
+ <>
-
-
-
-
Your gift is ready!
-
- Send the link below to share it with whoever you'd like.
-
-
-
-
-
{redeemUrl}
-
- {copied ? : }
- {copied ? 'Copied' : 'Copy'}
-
+
+
+
+
+
+
+
+
+
Shareable link
+
+ {redeemUrl}
+
+ {copied ? : }
+ {copied ? 'Copied' : 'Copy'}
+
+
+
+
+
+ Not ready to share? We've also emailed a copy to your inbox.
+
+
+
+
+
+
+
+
+
+ {siteIcon && (
+
+ )}
+
{siteTitle}
+
+ {tier && cadence && (
+
+
{getDurationLabel(cadence)}
+
{tier.name}
+
+ )}
+
+
+
+
+
+
+
+
+ {tier && tier.benefits && tier.benefits.length > 0 && (
+ <>
+
+
+
+ {tier.benefits.map((benefit, idx) => {
+ const key = benefit?.id || `benefit-${idx}`;
+ return (
+
+
+ {benefit.name}
+
+ );
+ })}
+
+
+
+
setShowDetails(s => !s)}
+ aria-expanded={showDetails}
+ >
+ {showDetails ? 'Hide details' : 'Gift details'}
+
+
+ >
+ )}
+
+
+
-
-
- Not ready to share? We've also emailed a copy to your inbox.
-
-
+ >
);
};
diff --git a/apps/portal/src/components/pages/magic-link-page.js b/apps/portal/src/components/pages/magic-link-page.js
index 4b237d19a43..158a8746853 100644
--- a/apps/portal/src/components/pages/magic-link-page.js
+++ b/apps/portal/src/components/pages/magic-link-page.js
@@ -4,8 +4,16 @@ import CloseButton from '../common/close-button';
import InboxLinkButton from '../common/inbox-link-button';
import AppContext from '../../app-context';
import {ReactComponent as EnvelopeIcon} from '../../images/icons/envelope.svg';
+import {ReactComponent as CheckmarkIcon} from '../../images/icons/checkmark.svg';
import {isIos} from '../../utils/is-ios';
import {t} from '../../utils/i18n';
+import {getGiftDurationLabel} from '../../utils/gift-redemption-notification';
+
+const ChevronIcon = () => (
+
+
+
+);
export const MagicLinkStyles = `
.gh-portal-icon-envelope {
@@ -85,7 +93,8 @@ export default class MagicLinkPage extends React.Component {
this.state = {
[OTC_FIELD_NAME]: '',
errors: {},
- isFocused: false
+ isFocused: false,
+ showDetails: false
};
}
@@ -301,9 +310,114 @@ export default class MagicLinkPage extends React.Component {
);
}
+ renderGiftLayout(showOTCForm) {
+ const {site, pageData, otcRef} = this.context;
+ const gift = pageData?.gift;
+ const siteIcon = site?.icon;
+ const siteTitle = site?.title || '';
+ const submittedEmailOrInbox = pageData?.email ? pageData.email : t('your inbox');
+ const popupTitle = t('Now check your email!');
+ const popupDescription = this.getTranslatedDescription({
+ lastPage: 'gift',
+ otcRef,
+ submittedEmailOrInbox
+ });
+ const benefits = gift.tier?.benefits || [];
+
+ return (
+ <>
+
+
+
+
+
+
+
+ {popupTitle}
+ {popupDescription}
+
+
+ {showOTCForm ? this.renderOTCForm() : this.renderCloseButton()}
+
+
+
+
+
+
+
+
+ {siteIcon && (
+
+ )}
+
{siteTitle}
+
+
+
{getGiftDurationLabel(gift)}
+
{gift.tier?.name}
+
+
+
+
+
+
+
+
+
+ {benefits.length > 0 && (
+ <>
+
+
+
+ {benefits.map((benefit, index) => {
+ const benefitName = typeof benefit === 'string' ? benefit : benefit?.name;
+ const benefitKey = typeof benefit === 'string' ? benefit : benefit?.id || `gift-benefit-${index}`;
+
+ if (!benefitName) {
+ return null;
+ }
+
+ return (
+
+
+ {benefitName}
+
+ );
+ })}
+
+
+
+
this.setState(s => ({showDetails: !s.showDetails}))}
+ aria-expanded={this.state.showDetails}
+ >
+ {/* eslint-disable-next-line i18next/no-literal-string -- copy not yet finalised */}
+ {this.state.showDetails ? 'Hide details' : 'Gift details'}
+
+
+ >
+ )}
+
+
+
+
+ >
+ );
+ }
+
render() {
- const {otcRef} = this.context;
+ const {otcRef, lastPage, pageData} = this.context;
const showOTCForm = !!otcRef;
+ const isGiftMode = lastPage === 'gift' && !!pageData?.gift;
+
+ if (isGiftMode) {
+ return this.renderGiftLayout(showOTCForm);
+ }
return (
diff --git a/apps/portal/src/components/popup-modal.js b/apps/portal/src/components/popup-modal.js
index d8176ea5478..2e16dcdfb1b 100644
--- a/apps/portal/src/components/popup-modal.js
+++ b/apps/portal/src/components/popup-modal.js
@@ -146,7 +146,7 @@ class PopupContent extends React.Component {
}
render() {
- const {page, pageQuery, site, customSiteUrl} = this.context;
+ const {page, pageQuery, site, customSiteUrl, lastPage} = this.context;
const products = getSiteProducts({site, pageQuery});
const noOfProducts = products.length;
@@ -189,11 +189,20 @@ class PopupContent extends React.Component {
}
}
- if (page === 'gift') {
+ if (page === 'gift' || page === 'giftSuccess' || page === 'giftRedemption') {
pageClass += ' full-size';
popupSize = 'full';
}
+ // Magic link page reached via gift redemption: render in the same
+ // 50/50 layout (gift card stays visible on the right) instead of as
+ // a small centered modal. Reuses the giftRedemption class so the
+ // existing full-size CSS rules apply.
+ if (page === 'magiclink' && lastPage === 'gift') {
+ pageClass += ' full-size giftRedemption';
+ popupSize = 'full';
+ }
+
const freeProduct = hasFreeProductPrice({site});
if ((freeProduct && noOfProducts > 2) || (!freeProduct && noOfProducts > 1)) {
if (page === 'accountPlan') {
diff --git a/apps/portal/src/utils/use-card-tilt.js b/apps/portal/src/utils/use-card-tilt.js
new file mode 100644
index 00000000000..9b1f9241be1
--- /dev/null
+++ b/apps/portal/src/utils/use-card-tilt.js
@@ -0,0 +1,76 @@
+import {useCallback, useRef} from 'react';
+
+/**
+ * Subtle 3D tilt effect that follows the cursor.
+ *
+ * Returns a `cardRef` to attach to the card element, and `containerProps`
+ * (mouse handlers) to spread onto the surrounding container that defines
+ * the active tracking area.
+ *
+ * Uses requestAnimationFrame to coalesce mousemove updates into one DOM write
+ * per frame, and toggles the transition duration so the card tracks the cursor
+ * responsively while moving and eases back smoothly on leave. Safari is more
+ * sensitive than Chrome to long transitions overlapping rapid style updates,
+ * so without this it feels like the tilt is debounced.
+ */
+export default function useCardTilt({maxTilt = 3, trackTransition = 'transform 80ms linear', restTransition = 'transform 400ms ease-out'} = {}) {
+ const cardRef = useRef(null);
+ const rafIdRef = useRef(null);
+ const targetRef = useRef({x: 0, y: 0});
+ const isHoveringRef = useRef(false);
+
+ const applyFrame = useCallback(() => {
+ rafIdRef.current = null;
+ const card = cardRef.current;
+ if (!card) {
+ return;
+ }
+ if (isHoveringRef.current) {
+ const {x, y} = targetRef.current;
+ card.style.transition = trackTransition;
+ card.style.transform = `perspective(1200px) rotateX(${-y * maxTilt}deg) rotateY(${x * maxTilt}deg)`;
+ } else {
+ card.style.transition = restTransition;
+ card.style.transform = '';
+ }
+ }, [maxTilt, trackTransition, restTransition]);
+
+ const schedule = useCallback(() => {
+ if (rafIdRef.current === null) {
+ rafIdRef.current = requestAnimationFrame(applyFrame);
+ }
+ }, [applyFrame]);
+
+ const onMouseMove = useCallback((event) => {
+ const card = cardRef.current;
+ if (!card) {
+ return;
+ }
+ const rect = card.getBoundingClientRect();
+ const halfWidth = rect.width / 2;
+ const halfHeight = rect.height / 2;
+ if (halfWidth === 0 || halfHeight === 0) {
+ return;
+ }
+ // Clamp to ±1: the mouse handlers are attached to the whole column,
+ // so the cursor can be well outside the card bounds — without
+ // clamping the rotation would exceed maxTilt near the column edges.
+ const clamp = value => Math.max(-1, Math.min(1, value));
+ targetRef.current = {
+ x: clamp((event.clientX - rect.left - halfWidth) / halfWidth),
+ y: clamp((event.clientY - rect.top - halfHeight) / halfHeight)
+ };
+ isHoveringRef.current = true;
+ schedule();
+ }, [schedule]);
+
+ const onMouseLeave = useCallback(() => {
+ isHoveringRef.current = false;
+ schedule();
+ }, [schedule]);
+
+ return {
+ cardRef,
+ containerProps: {onMouseMove, onMouseLeave}
+ };
+}
diff --git a/apps/shade/.storybook/preview.tsx b/apps/shade/.storybook/preview.tsx
index 2e3ff361296..27be90dfa23 100644
--- a/apps/shade/.storybook/preview.tsx
+++ b/apps/shade/.storybook/preview.tsx
@@ -58,6 +58,7 @@ const preview: Preview = {
storySort: {
method: 'alphabetical',
order: [
+ 'Foundations',
'Primitives',
'Components',
'Layout',
diff --git a/apps/shade/package.json b/apps/shade/package.json
index 66242970853..bd5cc275f8b 100644
--- a/apps/shade/package.json
+++ b/apps/shade/package.json
@@ -51,7 +51,7 @@
"sideEffects": false,
"scripts": {
"dev": "vite build --watch",
- "build": "tsc -p tsconfig.declaration.json && vite build",
+ "build": "tsc -p tsconfig.declaration.json && tsc-alias -p tsconfig.declaration.json && vite build",
"test": "pnpm test:types && vitest run --coverage",
"test:unit": "pnpm test:types && vitest run",
"test:types": "tsc --noEmit",
@@ -100,6 +100,7 @@
"sinon": "18.0.1",
"storybook": "10.3.5",
"tailwindcss": "4.2.1",
+ "tsc-alias": "^1.8.17",
"tw-animate-css": "1.4.0",
"typescript": "5.9.3",
"vite": "5.4.21",
diff --git a/apps/shade/src/components.ts b/apps/shade/src/components.ts
index 3f73e4eb64a..ab46c0000f4 100644
--- a/apps/shade/src/components.ts
+++ b/apps/shade/src/components.ts
@@ -18,7 +18,6 @@ export * from './components/ui/empty-indicator';
export * from './components/ui/field';
export * from './components/ui/flag';
export * from './components/ui/form';
-export * from './components/ui/gh-chart';
export * from './components/ui/hover-card';
export * from './components/ui/indicator';
export * from './components/ui/input';
@@ -26,6 +25,7 @@ export * from './components/ui/input-group';
export * from './components/ui/kbd';
export * from './components/ui/label';
export * from './components/ui/loading-indicator';
+export * from './components/ui/metric-value';
export * from './components/ui/multi-select-combobox';
export * from './components/ui/navbar';
export * from './components/ui/no-value-label';
@@ -39,12 +39,19 @@ export * from './components/ui/sheet';
export * from './components/ui/sidebar';
export * from './components/ui/skeleton';
export * from './components/ui/sonner';
+export * from './components/ui/input-surface';
export * from './components/ui/switch';
export * from './components/ui/table';
export * from './components/ui/tabs';
export * from './components/ui/textarea';
export * from './components/ui/toggle-group';
export * from './components/ui/tooltip';
+export * from './components/ui/trend-badge';
+
+// Feature components — product-shaped compositions of UI primitives
+export * from './components/features/charts/gh-chart';
+export * from './components/features/kpi/kpi-card';
+export * from './components/features/kpi/kpi-tabs';
export type {DropdownMenuCheckboxItemProps as DropdownMenuCheckboxItemProps} from '@radix-ui/react-dropdown-menu';
diff --git a/apps/shade/src/components/ui/gh-chart.stories.tsx b/apps/shade/src/components/features/charts/gh-chart.stories.tsx
similarity index 100%
rename from apps/shade/src/components/ui/gh-chart.stories.tsx
rename to apps/shade/src/components/features/charts/gh-chart.stories.tsx
diff --git a/apps/shade/src/components/ui/gh-chart.tsx b/apps/shade/src/components/features/charts/gh-chart.tsx
similarity index 99%
rename from apps/shade/src/components/ui/gh-chart.tsx
rename to apps/shade/src/components/features/charts/gh-chart.tsx
index f489c85702c..467ddd9d4eb 100644
--- a/apps/shade/src/components/ui/gh-chart.tsx
+++ b/apps/shade/src/components/features/charts/gh-chart.tsx
@@ -1,6 +1,6 @@
import {calculateYAxisWidth, cn, formatDisplayDateWithRange, formatNumber, getYRange} from '@/lib/utils';
import React from 'react';
-import {AlignedAxisTick, ChartConfig, ChartContainer, ChartTooltip} from './chart';
+import {AlignedAxisTick, ChartConfig, ChartContainer, ChartTooltip} from '@/components/ui/chart';
import {Area, AreaChart, CartesianGrid, XAxis, YAxis} from 'recharts';
import {TrendingDown, TrendingUp} from 'lucide-react';
diff --git a/apps/shade/src/components/ui/filters.stories.tsx b/apps/shade/src/components/features/filters/filters.stories.tsx
similarity index 100%
rename from apps/shade/src/components/ui/filters.stories.tsx
rename to apps/shade/src/components/features/filters/filters.stories.tsx
diff --git a/apps/shade/src/components/ui/filters.tsx b/apps/shade/src/components/features/filters/filters.tsx
similarity index 100%
rename from apps/shade/src/components/ui/filters.tsx
rename to apps/shade/src/components/features/filters/filters.tsx
diff --git a/apps/shade/src/components/features/kpi/kpi-card.stories.tsx b/apps/shade/src/components/features/kpi/kpi-card.stories.tsx
new file mode 100644
index 00000000000..90ac2298adf
--- /dev/null
+++ b/apps/shade/src/components/features/kpi/kpi-card.stories.tsx
@@ -0,0 +1,177 @@
+import type {Meta, StoryObj} from '@storybook/react-vite';
+import {Coins, Eye, User} from 'lucide-react';
+
+import {Button} from '@/components/ui/button';
+import {Card, CardContent} from '@/components/ui/card';
+import {KpiCardHeader, KpiCardHeaderLabel, KpiCardHeaderValue} from './kpi-card';
+
+const meta = {
+ title: 'Features / KPI / Card',
+ component: KpiCardHeaderValue,
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component: 'Card header layout for displaying a KPI metric with optional trend badge. Composes `MetricValue` and `TrendBadge` from the Components layer. Use inside a `Card` (from Components / Card).'
+ }
+ }
+ }
+} satisfies Meta
;
+
+export default meta;
+type Story = StoryObj;
+
+export const WithUpTrend: Story = {
+ render: () => (
+
+
+
+
+
+ Unique visitors
+
+
+
+
+ Chart placeholder content
+
+ )
+};
+
+export const WithTrendTooltip: Story = {
+ render: () => (
+
+
+
+
+
+ Unique visitors
+
+
+
+
+ Chart placeholder content
+
+ )
+};
+
+export const WithDownTrend: Story = {
+ render: () => (
+
+
+
+
+
+ Members
+
+
+
+
+ Chart placeholder content
+
+ )
+};
+
+export const WithColorIndicator: Story = {
+ render: () => (
+
+
+
+
+
+
+ MRR
+
+
+
+
+ Chart placeholder content
+
+ )
+};
+
+export const NoTrend: Story = {
+ render: () => (
+
+
+
+
+
+ Page views
+
+
+
+
+ Chart placeholder content
+
+ )
+};
+
+export const WithHoverButton: Story = {
+ render: () => (
+
+
+
+
+
+
+ Members
+
+
+
+
+ View more
+
+
+ Chart placeholder content
+
+ )
+};
+
+export const HiddenTrend: Story = {
+ render: () => (
+
+
+
+
+
+ All-time revenue
+
+
+
+
+ Chart placeholder content
+
+ )
+};
diff --git a/apps/shade/src/components/features/kpi/kpi-card.tsx b/apps/shade/src/components/features/kpi/kpi-card.tsx
new file mode 100644
index 00000000000..1e06da199cc
--- /dev/null
+++ b/apps/shade/src/components/features/kpi/kpi-card.tsx
@@ -0,0 +1,70 @@
+import * as React from 'react';
+import {cn} from '@/lib/utils';
+import {MetricValue} from '@/components/ui/metric-value';
+import {TrendBadge} from '@/components/ui/trend-badge';
+
+const KpiCardHeader: React.FC> = ({children, className, ...props}) => {
+ return (
+
+ {children}
+
+ );
+};
+
+const KpiCardHeaderLabel: React.FC> = ({children, className, color, ...props}) => {
+ return (
+
+ {color &&
}
+ {children}
+
+ );
+};
+
+interface KpiCardValueProps {
+ value: string | number;
+ diffDirection?: 'up' | 'down' | 'same' | 'empty' | 'hidden';
+ diffValue?: string | number;
+ diffTooltip?: React.ReactNode;
+}
+
+const KpiCardHeaderValue: React.FC = ({value, diffDirection, diffValue, diffTooltip}) => {
+ let trailing: React.ReactNode = null;
+ if (diffDirection && diffDirection !== 'hidden') {
+ if (diffDirection === 'empty') {
+ // Reserves the same vertical space as a real trend badge without showing one.
+ trailing = (
+
+ {diffValue}
+
+ );
+ } else {
+ trailing = (
+
+ );
+ }
+ }
+ return (
+
+ );
+};
+
+export {KpiCardHeader, KpiCardHeaderLabel, KpiCardHeaderValue};
diff --git a/apps/shade/src/components/features/kpi/kpi-tabs.stories.tsx b/apps/shade/src/components/features/kpi/kpi-tabs.stories.tsx
new file mode 100644
index 00000000000..e7692aef1a5
--- /dev/null
+++ b/apps/shade/src/components/features/kpi/kpi-tabs.stories.tsx
@@ -0,0 +1,253 @@
+import type {Meta, StoryObj} from '@storybook/react-vite';
+
+import {Tabs, TabsContent, TabsList} from '@/components/ui/tabs';
+import {KpiTabTrigger, KpiTabValue} from './kpi-tabs';
+
+const meta = {
+ title: 'Features / KPI / Tabs',
+ component: KpiTabValue,
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component: 'KPI tabs for analytics dashboards. Each trigger renders a metric label, value, and optional trend badge. Wraps the generic Tabs primitive with `variant=\'kpis\'` styling.'
+ }
+ }
+ },
+ decorators: [
+ Story => (
+
+
+
+ )
+ ]
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Basic: Story = {
+ render: () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Signups analytics and charts would go here.
+
+
+ Revenue analytics and charts would go here.
+
+
+ Engagement analytics and charts would go here.
+
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story: 'KPI tabs displaying metrics with trend indicators - perfect for dashboard analytics.'
+ }
+ }
+ }
+};
+
+export const WithIcons: Story = {
+ render: () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Active users analytics would go here.
+
+
+ Page views analytics would go here.
+
+
+ Bounce rate analytics would go here.
+
+
+ Conversion analytics would go here.
+
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story: 'KPI tabs enhanced with icons for better visual recognition of different metrics.'
+ }
+ }
+ }
+};
+
+export const WithColors: Story = {
+ render: () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Organic traffic breakdown would go here.
+
+
+ Paid traffic breakdown would go here.
+
+
+ Social traffic breakdown would go here.
+
+
+ Direct traffic breakdown would go here.
+
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story: 'KPI tabs with color-coded indicators to categorize different data sources or types.'
+ }
+ }
+ }
+};
+
+export const WithoutTrends: Story = {
+ render: () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Subscriber details would go here.
+
+
+ Posts overview would go here.
+
+
+ Comments overview would go here.
+
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story: 'KPI tabs showing absolute values without trend indicators - useful for cumulative or static metrics.'
+ }
+ }
+ }
+};
diff --git a/apps/shade/src/components/features/kpi/kpi-tabs.tsx b/apps/shade/src/components/features/kpi/kpi-tabs.tsx
new file mode 100644
index 00000000000..a95508b02d9
--- /dev/null
+++ b/apps/shade/src/components/features/kpi/kpi-tabs.tsx
@@ -0,0 +1,136 @@
+import * as React from 'react';
+import * as LucideIcons from 'lucide-react';
+import {type LucideIcon} from 'lucide-react';
+
+import {cn} from '@/lib/utils';
+import {Button, ButtonProps} from '@/components/ui/button';
+import {MetricValue} from '@/components/ui/metric-value';
+import {Tabs, TabsContent, TabsList, TabsProps, TabsTrigger} from '@/components/ui/tabs';
+import {TrendBadge} from '@/components/ui/trend-badge';
+
+/**
+ * KPI variant of the Tabs primitives. Wraps the generic Tabs / TabsList /
+ * TabsTrigger / TabsContent and pins the `kpis` cva variant so consumers don't
+ * have to pass `variant='kpis'` themselves. The `kpis` cva variant lives in
+ * `ui/tabs.tsx` as a private implementation detail used only here.
+ */
+const KpiTabs = React.forwardRef<
+ React.ElementRef,
+ Omit
+>(({...props}, ref) => (
+
+));
+KpiTabs.displayName = 'KpiTabs';
+
+const KpiTabsList = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>((props, ref) => (
+
+));
+KpiTabsList.displayName = 'KpiTabsList';
+
+const KpiTabsContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>((props, ref) => (
+
+));
+KpiTabsContent.displayName = 'KpiTabsContent';
+
+interface KpiTabTriggerProps extends React.ComponentProps {
+ children: React.ReactNode;
+}
+
+const KpiTabTrigger: React.FC = ({children, ...props}) => {
+ return (
+
+ {children}
+
+ );
+};
+
+interface KpiTabValueProps {
+ color?: string;
+ icon?: keyof typeof LucideIcons;
+ label: string;
+ value: string | number;
+ diffDirection?: 'up' | 'down' | 'same' | 'hidden';
+ diffValue?: string | number;
+ className?: string;
+ 'data-testid'?: string;
+}
+
+const KpiTabValue: React.FC = ({
+ color,
+ icon: iconName,
+ label,
+ value,
+ diffDirection,
+ diffValue,
+ className,
+ 'data-testid': testId
+}) => {
+ const IconComponent = iconName ? LucideIcons[iconName] as LucideIcon : null;
+
+ const labelNode = (
+
+ {color &&
}
+ {IconComponent && }
+ {label}
+
+ );
+
+ const trailing = diffDirection && diffDirection !== 'hidden' ? (
+
+ ) : null;
+
+ return (
+
+ );
+};
+
+interface KpiDropdownButtonProps extends ButtonProps {
+ className?: string;
+ children: React.ReactNode;
+}
+
+const KpiDropdownButton = React.forwardRef(
+ ({variant = 'dropdown', className, ...props}, ref) => {
+ return (
+
+ );
+ }
+);
+KpiDropdownButton.displayName = 'KpiDropdownButton';
+
+export {
+ KpiTabs,
+ KpiTabsList,
+ KpiTabsContent,
+ KpiTabTrigger,
+ KpiTabValue,
+ KpiDropdownButton
+};
diff --git a/apps/shade/src/components/ui/card.stories.tsx b/apps/shade/src/components/ui/card.stories.tsx
index e92607aaffb..36547bf0709 100644
--- a/apps/shade/src/components/ui/card.stories.tsx
+++ b/apps/shade/src/components/ui/card.stories.tsx
@@ -1,7 +1,6 @@
import type {Meta, StoryObj} from '@storybook/react-vite';
-import {Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, KpiCardHeader, KpiCardHeaderLabel, KpiCardHeaderValue} from './card';
+import {Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle} from './card';
import {Button} from './button';
-import {Eye, User, Coins} from 'lucide-react';
const meta = {
title: 'Components / Card',
@@ -10,7 +9,7 @@ const meta = {
parameters: {
docs: {
description: {
- component: 'Flexible containers for displaying content with consistent styling. Includes standard cards for general content and specialized KPI cards for displaying metrics and trends.'
+ component: 'Flexible container for grouped content with consistent styling, padding, and optional border. Compose with `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, and `CardFooter`. KPI variants live under `Features / KPI / Card`.'
}
}
}
@@ -39,179 +38,3 @@ export const Default: Story = {
]
}
};
-
-export const KpiCardWithUpTrend: Story = {
- args: {
- className: 'w-[350px]',
- children: [
-
-
-
-
- Unique visitors
-
-
-
- ,
-
- Chart placeholder content
-
- ]
- }
-};
-
-export const KpiCardWithTrendTooltip: Story = {
- args: {
- className: 'w-[350px] mt-20',
- children: [
-
-
-
-
- Unique visitors
-
-
-
- ,
-
- Chart placeholder content
-
- ]
- }
-};
-
-export const KpiCardWithDownTrend: Story = {
- args: {
- className: 'w-[350px]',
- children: [
-
-
-
-
- Members
-
-
-
- ,
-
- Chart placeholder content
-
- ]
- }
-};
-
-export const KpiCardWithColorIndicator: Story = {
- args: {
- className: 'w-[350px]',
- children: [
-
-
-
-
-
- MRR
-
-
-
- ,
-
- Chart placeholder content
-
- ]
- }
-};
-
-export const KpiCardNoTrend: Story = {
- args: {
- className: 'w-[350px]',
- children: [
-
-
-
-
- Page views
-
-
-
- ,
-
- Chart placeholder content
-
- ]
- }
-};
-
-export const KpiCardWithHoverButton: Story = {
- args: {
- className: 'w-[350px] group',
- children: [
-
-
-
-
-
- Members
-
-
-
-
- View more
-
- ,
-
- Chart placeholder content
-
- ]
- }
-};
-
-export const KpiCardHiddenTrend: Story = {
- args: {
- className: 'w-[350px]',
- children: [
-
-
-
-
- All-time revenue
-
-
-
- ,
-
- Chart placeholder content
-
- ]
- }
-};
diff --git a/apps/shade/src/components/ui/card.tsx b/apps/shade/src/components/ui/card.tsx
index 5a3540343ce..a0f0e81dca3 100644
--- a/apps/shade/src/components/ui/card.tsx
+++ b/apps/shade/src/components/ui/card.tsx
@@ -1,7 +1,6 @@
import * as React from 'react';
import {cn} from '@/lib/utils';
import {cva} from 'class-variance-authority';
-import {TrendingDown, TrendingUp} from 'lucide-react';
type CardsVariant = 'outline' | 'plain';
const CardsVariantContext = React.createContext('outline');
@@ -151,71 +150,6 @@ const CardFooter = React.forwardRef<
});
CardFooter.displayName = 'CardFooter';
-const KpiCardHeader: React.FC> = ({children, className, ...props}) => {
- return (
-
- {children}
-
- );
-};
-
-const KpiCardHeaderLabel: React.FC> = ({children, className, color, ...props}) => {
- return (
-
- {color &&
}
- {children}
-
- );
-};
-
-interface KpiCardValueProps {
- value: string | number;
- diffDirection?: 'up' | 'down' | 'same' | 'empty' | 'hidden';
- diffValue?: string | number;
- diffTooltip?: React.ReactNode;
-}
-
-const KpiCardHeaderValue: React.FC = ({value, diffDirection, diffValue, diffTooltip}) => {
- const diffContainerClassName = cn(
- 'flex items-center gap-1 text-xs h-[22px] px-1.5 rounded-xs group/diff cursor-default',
- diffDirection === 'up' && `text-state-success bg-state-success/10 ${diffTooltip && 'hover:bg-state-success/20'}`,
- diffDirection === 'down' && `text-state-danger bg-state-danger/10 ${diffTooltip && 'hover:bg-state-danger/20'}`,
- diffDirection === 'same' && 'text-text-secondary bg-muted'
- );
- return (
-
-
- {value}
-
- {diffDirection && diffDirection !== 'hidden' &&
- <>
-
-
{diffValue}
- {diffDirection === 'up' &&
-
- }
- {diffDirection === 'down' &&
-
- }
- {diffTooltip &&
-
- {diffTooltip}
-
- }
-
- >
- }
-
- );
-};
-
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface EmptyCardProps extends React.ComponentPropsWithoutRef<'div'> {}
@@ -238,9 +172,6 @@ export {
CardTitle,
CardDescription,
CardContent,
- KpiCardHeader,
- KpiCardHeaderLabel,
- KpiCardHeaderValue,
EmptyCard,
cardVariants
};
diff --git a/apps/shade/src/components/ui/input-group.tsx b/apps/shade/src/components/ui/input-group.tsx
index b08d86d8dbf..9d55c05875a 100644
--- a/apps/shade/src/components/ui/input-group.tsx
+++ b/apps/shade/src/components/ui/input-group.tsx
@@ -4,13 +4,19 @@ import {cva, type VariantProps} from 'class-variance-authority';
import {cn} from '@/lib/utils';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
+import {inputSurfaceClasses} from '@/components/ui/input-surface';
import {Textarea} from '@/components/ui/textarea';
function InputGroup({className, ...props}: React.ComponentProps<'div'>) {
return (
textarea]:h-auto',
// Variants based on alignment.
@@ -19,12 +25,11 @@ function InputGroup({className, ...props}: React.ComponentProps<'div'>) {
'has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3',
'has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3',
- // Focus state.
+ // Focus state — scoped to the input-group control specifically so that
+ // focusing an InputGroupButton inside the group does NOT trigger the surface
+ // focus ring. This is why we don't use inputSurface('within') here.
'has-[[data-slot=input-group-control]:focus-visible]:outline-hidden has-[[data-slot=input-group-control]:focus-visible]:bg-transparent has-[[data-slot=input-group-control]:focus-visible]:border-focus-ring has-[[data-slot=input-group-control]:focus-visible]:ring-2 has-[[data-slot=input-group-control]:focus-visible]:ring-focus-ring/25',
- // Error state.
- 'has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40',
-
className
)}
data-slot="input-group"
diff --git a/apps/shade/src/components/ui/input-surface.stories.tsx b/apps/shade/src/components/ui/input-surface.stories.tsx
new file mode 100644
index 00000000000..9dd2acca304
--- /dev/null
+++ b/apps/shade/src/components/ui/input-surface.stories.tsx
@@ -0,0 +1,200 @@
+import type {Meta, StoryObj} from '@storybook/react-vite';
+import {cn} from '@/lib/utils';
+import {inputSurface, inputSurfaceClasses} from './input-surface';
+
+/**
+ * Docs-only Storybook entry for the `inputSurface` recipe.
+ * Lives under "Foundations" rather than "Components" because there's no component to render —
+ * just a shared visual recipe that powers Input, Textarea, InputGroup and the Select trigger.
+ */
+const meta: Meta = {
+ title: 'Foundations / Input Surface',
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Shared visual recipe for input-like surfaces. The same border, background, radius, focus ring, and invalid state used by every form control in Shade. Use `inputSurface(\'self\')` when applying directly to a focusable element (`
`, `
diff --git a/apps/shade/src/docs/introduction.mdx b/apps/shade/src/docs/introduction.mdx
index 9701b68e2b8..52d192001a9 100644
--- a/apps/shade/src/docs/introduction.mdx
+++ b/apps/shade/src/docs/introduction.mdx
@@ -4,112 +4,83 @@ import { Meta } from '@storybook/addon-docs/blocks';
-# Shade Design System
+# Welcome to Shade
-
Shade is Ghost's design system for product design. Built on ShadCN/UI and TailwindCSS, it provides a complete set of components and patterns for building consistent user experiences.
+
Shade is the design system that powers Ghost's product surfaces. It exists so that engineers, designers, and AI agents can build admin UI quickly without reinventing the same components, spacing decisions, or color choices each time.
-## Overview
+## What Shade gives you
-Shade is built on three powerful foundations:
-- [ShadCN/UI](https://ui.shadcn.com/) - Reusable components built with Radix UI
-- [TailwindCSS](https://tailwindcss.com/) - Utility-first CSS framework
-- [Radix UI](https://www.radix-ui.com/) - Accessible component primitives
+If you're shipping admin features, Shade gives you four things:
-### Scope & Reuse
+A **set of visual values** (color, type, spacing, radius, motion) that automatically work in light and dark mode.
-Shade contains reusable, system-wide components and patterns. Keep one-off, app-specific components in the consuming application rather than adding them to Shade.
+A **set of layout primitives** (`Stack`, `Inline`, `Box`, `Grid`, `Container`, `Text`) that replace the endless `flex flex-col gap-4` strings with something readable.
-### Target Audience
+A **library of generic UI controls** (Button, Input, Dialog, Tabs, and so on) built on Radix and ShadCN, accessible by default.
-- **Designers**: Understand our design language and component capabilities
-- **Engineers**: Find implementation details and integration examples
-- **AI Agents**: Access machine-readable metadata for automation
+A **smaller set of product-shaped compositions** for things Ghost does over and over — KPI cards, filter builders, the area chart on Stats — where the generic controls aren't enough.
-### Core Surfaces
+The line between those four layers is the most important thing to understand about Shade. We cover it in detail on the [Architecture](?path=/docs/architecture--docs) page, including a glossary of terms.
-Shade components are used across:
-- Admin interfaces
-- Settings panels
-- Portal experiences
-- Email templates
-- Documentation sites
+## Who it's for
-## Getting Started
+**Designers** working in code use Shade to compose screens fast. The primitives and components do the heavy lifting; you focus on the structure and content of the surface you're designing.
-### Installation
+**Engineers** use it as a reliable substrate. If a control already exists in Shade, use it — it's accessible, themed, and tested. If it doesn't, the design system tells you which layer to build into.
-```bash
-# Install Shade
-yarn add @tryghost/shade
+**AI agents** generating UI code use Shade as a constraint. The layer rules and the typed component APIs let the agent pick the right tool without inventing components or making up tokens.
-# Install peer dependencies
-yarn add react react-dom tailwindcss
-```
-
-> Note: The package is currently private and primarily consumed internally in the monorepo. The snippet above reflects usage once published.
+## Getting set up
-### Basic Usage
+Shade lives in the Ghost monorepo (`apps/shade`) and is consumed by the React admin apps via `@tryghost/shade/*` entrypoints. There's nothing to install separately — just import:
```tsx
import {Button} from '@tryghost/shade/components';
function MyComponent() {
- return (
-
- Continue
-
- );
+ return
Continue ;
}
```
-### Configure Styles (CSS-first)
-
-Import Shade's styles in your app entry CSS:
+To pick up Shade's CSS in your app, import its stylesheet once at your entry point:
```css
@import "@tryghost/shade/styles.css";
```
-For Tailwind v4 utility generation, scan both your app files and Shade component usage paths with `@source`.
-
-### Scope Styles & Dark Mode
+If you're using Tailwind v4, add Shade's source paths to your `@source` directives so utility generation picks up classes used inside Shade components.
-Shade scopes all styles to a `.shade` container and toggles dark mode with a `.dark` class inside that scope.
+## Light and dark mode
-Use `ShadeApp` to handle scoping and provide context:
+Every Shade component is themed. To toggle modes, wrap your tree in `ShadeApp`:
```tsx
import {ShadeApp} from '@tryghost/shade/app';
import {Button} from '@tryghost/shade/components';
export default function App() {
- // Toggle dark mode by switching the `darkMode` prop
return (
-
- Continue
-
+ Continue
);
}
```
-Alternatively, wrap your UI in a div with `className="shade"` and toggle a nested `.dark` class when needed.
+`ShadeApp` adds a `.shade` scope to your tree and toggles a `.dark` class inside it. If you'd rather not use the wrapper, you can apply those two classes manually — the components will respond.
-## Documentation Structure
+## Where to go next
-Our documentation is organized into:
+If you're new here, read the [Architecture](?path=/docs/architecture--docs) page next. It explains the layers (tokens, primitives, components, recipes, features) with a glossary so the vocabulary makes sense, and a short decision rule for choosing where new code belongs.
-1. **Components** - Individual UI elements with variants and usage
-2. **Patterns** - Common UI patterns and compositions
-3. **Tokens** - Design tokens via TailwindCSS
-4. **Guidelines** - Best practices and standards
+If you already know your way around and just want to ship, browse:
-## Resources
+- **Foundations** — visual recipes like `inputSurface` that shape every form control's chrome.
+- **Primitives** — `Stack`, `Inline`, `Box`, `Grid`, `Container`, `Text`. The structural vocabulary.
+- **Components** — generic controls (Button, Input, Dialog, etc.).
+- **Layout** — page shells (Page, ListHeader, ViewHeader).
+- **Features** — product compositions (KPI cards, charts, filters).
-- [GitHub Repository](https://github.com/TryGhost/Ghost)
-- [ShadCN/UI Documentation](https://ui.shadcn.com)
-- [TailwindCSS Documentation](https://tailwindcss.com/docs)
-- [Radix UI Documentation](https://www.radix-ui.com/docs/primitives)
+Each section's pages have a description, props, and live examples.
diff --git a/apps/shade/src/docs/primitives-guide.mdx b/apps/shade/src/docs/primitives-guide.mdx
index e5e427f846f..fe35e9bb4eb 100644
--- a/apps/shade/src/docs/primitives-guide.mdx
+++ b/apps/shade/src/docs/primitives-guide.mdx
@@ -4,39 +4,48 @@ import { Meta } from '@storybook/addon-docs/blocks';
-# Primitives Guide
+# Working with primitives
-
Use primitives to make layout intent explicit. This page explains when to use each primitive, which props matter, and how to migrate from raw layout classes.
+
Primitives are how Shade replaces the endless string of `flex flex-col gap-4 items-start` utilities with something a human can read and an AI agent can generate consistently. This page is a practical guide to the six primitives, their props, and how to migrate existing layouts.
-## Why Primitives
+## Why primitives exist
-Primitives solve a simple problem: repeated `flex/grid/gap` class strings hide intent.
+Most layout code in any React codebase looks the same: a `
` with five Tailwind layout utilities. After a while it becomes invisible. Reviewers stop reading those classes; agents glue together their own variants of "vertical stack with medium gap"; small spacing inconsistencies accumulate across the product.
-Use primitives so that:
+Primitives solve that by giving the structural concepts names. Instead of inferring "this is a vertical stack" from `flex flex-col gap-4`, you write `
` and the intent is right there.
-- humans can read structure quickly
-- AI agents can generate consistent layout trees
-- spacing/alignment decisions stay explicit
+The benefits are practical:
-## Primitive Selection
+- A reviewer skimming a screen can tell in one second what its structure is.
+- An AI agent generating UI doesn't have to re-derive flex syntax every time.
+- Spacing decisions become semantic (`gap="lg"`) rather than numeric (`gap-6`), so we can tune the spacing scale without touching every component.
-- `Stack`: vertical grouping and vertical spacing
-- `Inline`: horizontal grouping, action rows, wrapped inline controls
-- `Box`: padding and radius framing without layout semantics
-- `Container`: width constraints and horizontal page padding
-- `Grid`: two-dimensional column layout
-- `Text`: semantic text element + typography rules
+## The six primitives
-Fast rule:
+You only need to remember six. They cover essentially every layout situation that doesn't involve writing a custom component.
-- vertical composition -> `Stack`
-- horizontal composition -> `Inline`
-- framed region -> `Box`
-- max width shell -> `Container`
-- columns/cards -> `Grid`
-- copy/headings/labels -> `Text`
+**`Stack`** is for vertical groupings. Use it whenever you have a column of children that need consistent spacing between them.
-## Prop Reference
+**`Inline`** is for horizontal groupings — toolbars, action rows, sequences of inline controls.
+
+**`Box`** is a framing primitive. Use it when you want padding or radius without imposing a layout direction.
+
+**`Container`** constrains width — for page shells where content shouldn't span the full viewport.
+
+**`Grid`** is for actual two-dimensional grids: card grids, multi-column lists.
+
+**`Text`** is the typographic primitive. Use it for paragraphs, headings, labels, captions — anything that's "rendered text".
+
+The fast version of when to reach for each:
+
+- Vertical composition? `Stack`.
+- Horizontal composition? `Inline`.
+- A framed region? `Box`.
+- A page shell with a max width? `Container`.
+- Cards or columns? `Grid`.
+- Words on the screen? `Text`.
+
+## Props at a glance
### Stack
@@ -51,10 +60,10 @@ Fast rule:
| Prop | Type | Default | Purpose |
| --- | --- | --- | --- |
| `as` | `div|header|section|footer|nav|span` | `div` | Render element |
-| `gap` | `none|xs|sm|md|lg|xl|2xl` | `md` | Horizontal spacing |
+| `gap` | `none|xs|sm|md|lg|xl|2xl` | `md` | Horizontal spacing between children |
| `align` | `start|center|end|stretch|baseline` | `center` | Cross-axis alignment |
| `justify` | `start|center|end|between|around|evenly` | `start` | Main-axis distribution |
-| `wrap` | `boolean` | `false` | Wrap items to multiple rows |
+| `wrap` | `boolean` | `false` | Wrap children to multiple rows |
### Box
@@ -91,11 +100,11 @@ Fast rule:
| `weight` | `regular|medium|semibold|bold` | `regular` | Font weight |
| `tone` | `primary|secondary|tertiary|inverse` | `primary` | Text tone token |
| `leading` | `none|snug|normal|relaxed|tight|tighter|supertight|body|heading` | `body` | Line-height token |
-| `truncate` | `boolean` | `false` | Single-line truncation |
+| `truncate` | `boolean` | `false` | Single-line truncation with ellipsis |
-## Composition Examples
+## Putting them together
-### Page Shell
+A typical page shell, all primitives:
```tsx
import {Button} from '@tryghost/shade/components';
@@ -109,18 +118,17 @@ import {Container, Inline, Stack, Text} from '@tryghost/shade/primitives';
- Core content goes here.
+ Manage your audience.
```
-### Header Shell
+Read that out loud and it tells you what's there: a page-width container; vertically stacked content; the top row is a horizontal inline split with the title on the left and the action button on the right; the body below is another vertical stack of text.
-```tsx
-import {Button} from '@tryghost/shade/components';
-import {Inline, Stack, Text} from '@tryghost/shade/primitives';
+A header with actions and a count:
+```tsx
Posts
@@ -133,11 +141,9 @@ import {Inline, Stack, Text} from '@tryghost/shade/primitives';
```
-### List Shell
+A list of framed rows:
```tsx
-import {Box, Grid, Stack, Text} from '@tryghost/shade/primitives';
-
Drafts
@@ -151,9 +157,9 @@ import {Box, Grid, Stack, Text} from '@tryghost/shade/primitives';
```
-## Migration Examples
+## Migrating existing layouts
-### Before -> After: Vertical Layout
+The migration is mechanical, and almost always shorter:
```tsx
// Before
@@ -175,7 +181,7 @@ import {Box, Grid, Stack, Text} from '@tryghost/shade/primitives';
```
-### Before -> After: Framed Grid
+Or for a card grid:
```tsx
// Before
@@ -193,10 +199,14 @@ import {Box, Grid, Stack, Text} from '@tryghost/shade/primitives';
```
-## Practical Guardrails
+## Things to avoid
+
+Don't add anonymous `
` wrappers that only carry `flex/grid/gap` utilities. If you need that structure, reach for a primitive instead.
+
+Don't use raw spacing numbers (`gap-6`, `p-3`) in primitive APIs. Use the semantic scale (`gap="lg"`, `padding="sm"`) — it's what the spacing tokens are for.
+
+Don't put product workflow logic inside a primitive. Primitives are layout vocabulary; if a component is starting to know about Ghost-specific data, it belongs in a Feature, not a primitive.
-- Do not add anonymous wrappers that only carry `flex/grid/gap` utilities.
-- Prefer semantic spacing (`sm`, `md`, `lg`) over ad-hoc spacing choices.
-- Keep primitives layout-focused; do not put product workflow logic in primitives.
+Don't reach for `Box` when `Stack` or `Inline` would do. `Box` is the right tool when you genuinely don't want a layout direction — just framing.
diff --git a/apps/shade/src/docs/tokens.mdx b/apps/shade/src/docs/tokens.mdx
index 0b21a28f0c6..26e84a6680b 100644
--- a/apps/shade/src/docs/tokens.mdx
+++ b/apps/shade/src/docs/tokens.mdx
@@ -4,222 +4,138 @@ import { Meta } from '@storybook/addon-docs/blocks';
-# Design Tokens
+# Design tokens
-
Shade's design tokens are implemented through CSS-first Tailwind v4 tokens (`@theme` + CSS variables). This document outlines our token system and how to use it effectively.
+
Tokens are the smallest unit of the design system — a color, a size, a duration. Every other layer (primitives, components, features) ultimately resolves down to tokens. This page explains how they're defined, which families exist, and how to consume them.
-## Color System
+## What a token is
-### Base Colors
+In Shade, a token is a CSS custom property exposed through Tailwind v4's `@theme` block. So `bg-background`, `text-foreground`, and `rounded-md` are all backed by tokens that flip automatically when dark mode is on.
-Our color system includes semantic colors and a comprehensive neutral palette:
+There are two kinds of token in practice:
-```css
-@theme {
- // Base colors
- --color-transparent: transparent;
- --color-current: currentColor;
- --color-ghostaccent: var(--accent-color, #ff0095);
- --color-white: #FFF;
- --color-black: #15171A;
-
- // Gray scale (note: we use 'gray', not 'grey')
- --color-gray-50: #FAFAFB;
- --color-gray-100: #F4F5F6;
- // ... more shades
- --color-gray-900: #394047;
- --color-gray: #ABB4BE;
-
- // Brand colors
- --color-green-100: #E1F9E4;
- --color-green-400: #58DA67;
- --color-green-500: #30CF43;
- --color-green-600: #2AB23A;
- --color-green: #30CF43;
- // ... more colors
-}
-```
+**Raw tokens** are concrete values — `--color-gray-500`, `--text-base`, `--spacing`. They never change between modes; they just exist.
-> ⚠️ Note: While both `grey` and `gray` token aliases exist for legacy compatibility, use `gray` for new components following TailwindCSS conventions.
+**Semantic tokens** are named by purpose, not value. `--background`, `--foreground`, `--border-default`, `--surface-elevated`. They reference raw tokens (or specific values) and they're allowed to flip between light and dark mode. **You should almost always reach for the semantic token, not the raw one.** That way the same component renders correctly in both modes without any work from you.
-### Semantic Tokens
+## Where they're defined
-Theme-aware colors using CSS variables:
+Two files do the heavy lifting:
-```css
-@theme {
- --color-background: var(--background);
- --color-foreground: var(--foreground);
- --color-primary: var(--primary);
- --color-primary-foreground: var(--primary-foreground);
- // ... more semantic colors
-}
-```
+**`theme-variables.css`** holds the runtime values for every semantic token, including the dark-mode overrides. This is where `--background` becomes `hsl(0 0% 100%)` in light and `hsl(216 11% 9%)` in dark.
-### Semantic Contract
+**`tailwind.theme.css`** is the Tailwind `@theme` block. It exposes both the raw token catalogue (the gray scale, the brand colors, the type ramp, the spacing unit) and the bindings that make `bg-background` resolve to `var(--background)`.
-The semantic visual rules and guarantees are defined in:
+If you want to know what a Tailwind class is actually doing, search `tailwind.theme.css` first.
-- `theme-variables.css` (runtime values + dark mode overrides)
-- `tailwind.theme.css` (Tailwind aliases)
+## Color
-Current semantic families:
+Shade uses a small semantic palette layered on top of a larger raw palette.
-- Surface: `surface-page`, `surface-panel`, `surface-elevated`, `surface-overlay`, `surface-inverse`
-- Text: `text-primary`, `text-secondary`, `text-tertiary`, `text-inverse`
-- Border/focus: `border-subtle`, `border-default`, `border-strong`, `focus-ring`
-- State: `state-info`, `state-success`, `state-warning`, `state-danger`
+### Semantic color families
-Semantic typography/radius/motion families:
+Color tokens are grouped by what they're for, not by their RGB value. The current families are:
-- Typography: `font-body`, `font-heading`, `font-code`, `text-body-{sm|md|lg}`, `leading-{body|heading}`
-- Sizing: `--control-height` (shared medium control height, currently `34px`)
-- Radius: `radius-control`, `radius-surface`, `radius-badge`, `radius-pill`
-- Motion: `duration-{fast|base|slow}`, `ease-{standard|emphasized}`
+- **Surface** — `surface-page`, `surface-panel`, `surface-elevated`, `surface-overlay`, `surface-inverse`. Backgrounds with intent: "the page itself", "a card sitting on the page", "a popover floating above everything".
+- **Text** — `text-primary`, `text-secondary`, `text-tertiary`, `text-inverse`. Use these instead of the foreground variants when you want explicit hierarchy.
+- **Border / focus** — `border-subtle`, `border-default`, `border-strong`, `focus-ring`. Three weights of border plus the focus ring color.
+- **State** — `state-info`, `state-success`, `state-warning`, `state-danger`. Plus matching `-foreground` variants for text on those backgrounds.
-## Typography
+Every one of these flips between light and dark mode. You don't have to do anything for that to work.
-### Font Families
+### Raw color palette
-```css
-@theme {
- --font-sans: Inter, -apple-system, ...; /* Default UI font */
- --font-serif: Georgia, serif; /* Editorial content */
- --font-mono: Consolas, ...; /* Code */
- // Additional web fonts available
-}
-```
+If you genuinely need a specific shade rather than a semantic token, the raw palette is also available — `gray-50` through `gray-900`, the brand greens, plus chart colors (`--chart-1` through `--chart-5` and named accents like `--chart-purple`, `--chart-rose`).
-### Font Sizes
+A note on spelling: both `grey` and `gray` work as token aliases for legacy reasons, but `gray` is the convention going forward — it matches Tailwind.
-A comprehensive scale from 2xs to 9xl:
+### Picking the right color
-```css
-@theme {
- --text-2xs: 1.0rem;
- --text-base: 1.4rem;
- --text-xs: 1.2rem;
- // ... more sizes
- --text-9xl: 12.8rem;
- --text-9xl--line-height: 1;
-}
-```
+The default move is the semantic token: `bg-background`, `text-foreground`, `border-border-default`. Reach for raw colors when the semantic vocabulary doesn't fit (chart series, brand logos, illustrations).
-### Line Heights
+What you should not do is hardcode hex or `hsl()` values, even temporarily. They don't theme, they don't dark-mode, and they don't show up in design audits.
-```css
-@theme {
- --leading-base: 1.5em;
- --leading-tight: 1.35em;
- --leading-tighter: 1.25em;
- --leading-supertight: 1.1em;
-}
-```
+## Typography
-## Spacing
+### Font families
-Our spacing system uses a 0.4rem (4px) base unit:
+Three faces are available via `--font-sans` (Inter, the default UI face), `--font-serif` (Georgia, for editorial content), and `--font-mono` (Consolas, for code).
-```css
-@theme {
- --spacing: 0.4rem; /* Base unit */
- // Utilities are derived as calc(var(--spacing) * n)
-}
-```
+### Font sizes
-## Breakpoints
+The type ramp runs from `--text-2xs` (1.0rem) up to `--text-9xl` (12.8rem). Use the semantic `text-base` for body copy. Larger sizes have line-height variables baked in (`--text-9xl--line-height: 1`) so headlines don't get awkward leading.
-```css
-@theme {
- --breakpoint-sm: 480px;
- --breakpoint-md: 640px;
- --breakpoint-sidebar: 800px;
- --breakpoint-lg: 1024px;
- --breakpoint-sidebarlg: 1240px;
- --breakpoint-xl: 1320px;
- --breakpoint-xxl: 1440px;
- --breakpoint-xxxl: 1600px;
- --breakpoint-tablet: 860px;
-}
-```
+### Line height
-## Shadows
+`--leading-base` (1.5em) is the default. `--leading-tight`, `--leading-tighter`, `--leading-supertight` exist for headings and dense type.
-```css
-@theme {
- --shadow: 0 0 1px rgba(0,0,0,.05), 0 5px 18px rgba(0,0,0,.08);
- --shadow-xs: 0 0 1px rgba(0,0,0,0.04), ...;
- // ... more shadow values
-}
-```
+## Spacing
-## Border Radius
+Shade's spacing scale is built on a 4px base (`--spacing: 0.4rem`). Tailwind utilities like `p-4`, `gap-2`, `mt-3` derive from that base — `p-4` is `4 × 0.4rem = 1.6rem`.
-```css
-@theme {
- --radius: 0.4rem;
- --radius-sm: 0.4rem;
- --radius-md: 0.6rem;
- --radius-lg: 0.8rem;
- // ... more radius values
-}
-```
+For primitives (`Stack`, `Inline`, `Box`), use the semantic spacing scale instead of the raw Tailwind numbers: `gap="md"` rather than `gap-4`. The named values map onto the same underlying base unit but make intent readable: "a medium gap" instead of "the number four".
-## Animations
+The semantic scale: `none | xs | sm | md | lg | xl | 2xl`.
-Pre-defined animations for common interactions:
+## Sizing
-```css
-@theme {
- --animate-toaster-in: toasterIn 0.8s cubic-bezier(...);
- --animate-fade-in: fadeIn 0.15s ease forwards;
- // ... more animations
-}
-```
+A few size tokens exist for shared dimensions:
-## Usage Guidelines
+- `--control-height` (currently `34px`) — the height of a medium form control. Used by Input, Select trigger, the "medium" Button size, etc.
-### Best Practices
+If you need a control to match the others vertically, reach for this token rather than re-deriving it.
-1. **Use Semantic Tokens**
- ```js
- // ✅ Good
- className="bg-background text-foreground"
+## Radius
- // ❌ Avoid
- className="bg-white text-black"
- ```
+Border radius has both a numeric scale (`--radius`, `--radius-sm`, `--radius-md`, `--radius-lg`) and semantic aliases (`radius-control` for form controls, `radius-surface` for cards, `radius-badge` for pills, `radius-pill` for fully rounded).
-2. **Spacing Consistency**
- ```js
- // ✅ Good
- className="p-4 gap-2"
+Prefer the semantic version when it fits — it'll keep visual rhythm consistent if we ever shift the radius vocabulary.
- // ❌ Avoid
- style={{padding: '16px', gap: '8px'}}
- ```
+## Motion
-3. **Typography Scale**
- ```js
- // ✅ Good
- className="text-2xl font-sans"
+`--duration-fast`, `--duration-base`, `--duration-slow` for timing. `--ease-standard` and `--ease-emphasized` for curves. There's also a set of pre-built named animations (`--animate-fade-in`, `--animate-toaster-in`, etc.) for common interactions.
- // ❌ Avoid
- style={{fontSize: '22px'}}
- ```
+## Shadows and breakpoints
-### Dark Mode
+Standard shadows live on `--shadow`, `--shadow-xs`, `--shadow-sm`, `--shadow-md`, `--shadow-lg`. Breakpoints are defined as `--breakpoint-sm` through `--breakpoint-xxxl`, plus a few specials (`--breakpoint-sidebar`, `--breakpoint-sidebarlg`, `--breakpoint-tablet`).
-Dark mode uses a CSS custom variant:
-```css
-@custom-variant dark (&:is(.dark *):not(.light *));
+## How to consume tokens
+
+Most of the time, you consume tokens through Tailwind utility classes:
+
+```tsx
+// Good — semantic, themed
+
+ Hello
+
+
+// Bad — hardcoded, breaks in dark mode
+
+ Hello
+
```
-### CSS Reset
+Inside a stylesheet (rare in Shade, but it happens), use the CSS variables directly:
-Shade uses a scoped CSS reset:
```css
-@import "./preflight.css"; /* Custom scoped reset provided */
+.something {
+ background: var(--surface-elevated);
+ color: var(--surface-elevated-foreground);
+}
```
+Don't double-wrap variables in `hsl()` — they already contain `hsl(...)`. Writing `hsl(var(--background))` produces nonsense.
+
+## Dark mode
+
+Dark mode is handled by toggling a `.dark` class on a Shade-scoped ancestor (`ShadeApp` does this for you). The CSS custom variant in `theme-variables.css` flips every semantic token under that class, so `bg-background`, `text-foreground`, etc. all switch automatically.
+
+You don't write `dark:` variants in component code. The tokens do that work.
+
+The exception is when a component genuinely needs a different layout or asset between modes (a logo, a custom illustration). For those rare cases, `dark:` Tailwind variants are fine. But for color and surface decisions, stick to semantic tokens and let them flip.
+
+## Reset
+
+Shade ships its own scoped CSS reset (`preflight.css`) so it doesn't fight whatever reset the rest of the app might use. You don't need to do anything to get it — it's imported by `styles.css`.
+
diff --git a/apps/shade/src/patterns.ts b/apps/shade/src/patterns.ts
index 9781c117bcc..db148e08146 100644
--- a/apps/shade/src/patterns.ts
+++ b/apps/shade/src/patterns.ts
@@ -1,5 +1,5 @@
// Feature-level compositions and pattern contracts
-export * from './components/ui/filters';
+export * from './components/features/filters/filters';
export {default as ColorPicker} from './components/features/color-picker/color-picker';
export type {ColorPickerProps} from './components/features/color-picker/color-picker';
export {default as PostShareModal} from './components/features/post-share-modal';
diff --git a/apps/shade/test/unit/components/ui/filters.test.tsx b/apps/shade/test/unit/components/ui/filters.test.tsx
index 70b29bd7823..e4bc1ab33af 100644
--- a/apps/shade/test/unit/components/ui/filters.test.tsx
+++ b/apps/shade/test/unit/components/ui/filters.test.tsx
@@ -1,7 +1,7 @@
import {useMemo, useState} from 'react';
import {act, fireEvent, render, screen, waitFor} from '../../utils/test-utils';
import {afterEach, beforeAll, describe, expect, it, vi} from 'vitest';
-import {createFilter, FilterFieldConfig, Filters, ValueSource} from '../../../../src/components/ui/filters';
+import {createFilter, FilterFieldConfig, Filters, ValueSource} from '../../../../src/components/features/filters/filters';
type TestOption = {
value: string;
diff --git a/apps/shade/tsconfig.declaration.json b/apps/shade/tsconfig.declaration.json
index d26eefa4fff..c74c0a30418 100644
--- a/apps/shade/tsconfig.declaration.json
+++ b/apps/shade/tsconfig.declaration.json
@@ -6,6 +6,7 @@
"declaration": true,
"declarationMap": true,
"declarationDir": "./types",
+ "outDir": "./types",
"emitDeclarationOnly": true,
"tsBuildInfoFile": "./types/tsconfig.tsbuildinfo",
"rootDir": "./src"
diff --git a/apps/stats/test/unit/hooks/use-top-sources-growth.test.tsx b/apps/stats/test/unit/hooks/use-top-sources-growth.test.tsx
index 9307883bb05..a3d95fd4bed 100644
--- a/apps/stats/test/unit/hooks/use-top-sources-growth.test.tsx
+++ b/apps/stats/test/unit/hooks/use-top-sources-growth.test.tsx
@@ -81,7 +81,9 @@ describe('useTopSourcesGrowth', () => {
mockGetRangeDates.mockReturnValue({
startDate: mockStartDate,
endDate: mockEndDate,
- timezone: null
+ // The function's actual return type is `string`, but this test exercises
+ // the falsy-timezone code path in the hook. Cast to satisfy the type.
+ timezone: null as unknown as string
});
renderHook(() => useTopSourcesGrowth(30));
diff --git a/ghost/core/core/server/data/migrations/versions/6.36/2026-04-29-12-13-44-add-batch-id-to-members-status-events.js b/ghost/core/core/server/data/migrations/versions/6.36/2026-04-29-12-13-44-add-batch-id-to-members-status-events.js
index 7b3a2c539f0..3c3c45c580f 100644
--- a/ghost/core/core/server/data/migrations/versions/6.36/2026-04-29-12-13-44-add-batch-id-to-members-status-events.js
+++ b/ghost/core/core/server/data/migrations/versions/6.36/2026-04-29-12-13-44-add-batch-id-to-members-status-events.js
@@ -4,4 +4,4 @@ module.exports = createAddColumnMigration('members_status_events', 'batch_id', {
type: 'string',
maxlength: 24,
nullable: true
-});
+}, {algorithm: 'auto'});
diff --git a/ghost/core/core/server/services/members/members-api/services/payments-service.js b/ghost/core/core/server/services/members/members-api/services/payments-service.js
index 591da0925d6..c0feac954fe 100644
--- a/ghost/core/core/server/services/members/members-api/services/payments-service.js
+++ b/ghost/core/core/server/services/members/members-api/services/payments-service.js
@@ -189,6 +189,8 @@ class PaymentsService {
const successUrlObj = new URL(successUrl);
successUrlObj.searchParams.set('stripe', 'gift-purchase-success');
successUrlObj.searchParams.set('gift_token', token);
+ successUrlObj.searchParams.set('gift_tier', tier.id.toHexString());
+ successUrlObj.searchParams.set('gift_cadence', cadence);
const data = {
amount,
diff --git a/ghost/core/test/unit/server/services/members/members-api/services/payments-service.test.js b/ghost/core/test/unit/server/services/members/members-api/services/payments-service.test.js
index 3be1a75bfbd..2561a311450 100644
--- a/ghost/core/test/unit/server/services/members/members-api/services/payments-service.test.js
+++ b/ghost/core/test/unit/server/services/members/members-api/services/payments-service.test.js
@@ -368,7 +368,7 @@ describe('PaymentsService', function () {
assert.equal(args.metadata.gift_token, 'AbCdEfGhIjKl');
});
- it('appends gift token to success URL', async function () {
+ it('appends gift token, tier and cadence to success URL', async function () {
const tier = await createTier({monthlyPrice: 5000, yearlyPrice: 50000});
await service.getGiftPaymentLink({...defaultGiftOptions, tier, cadence: 'year'});
@@ -378,6 +378,8 @@ describe('PaymentsService', function () {
assert.equal(successUrl.searchParams.get('stripe'), 'gift-purchase-success');
assert.equal(successUrl.searchParams.get('gift_token'), args.metadata.gift_token);
+ assert.equal(successUrl.searchParams.get('gift_tier'), tier.id.toHexString());
+ assert.equal(successUrl.searchParams.get('gift_cadence'), 'year');
});
it('prevents caller metadata from overwriting gift-specific keys', async function () {
diff --git a/package.json b/package.json
index e12c34348ac..089220050eb 100644
--- a/package.json
+++ b/package.json
@@ -76,6 +76,7 @@
"juice>cheerio": "0.22.0",
"lodash.template": "4.5.0",
"@tootallnate/once@<3.0.1": "^3.0.1",
+ "axios@<1.15.2": "^1.15.2",
"clean-css@<4.1.11": "^4.1.11",
"debug@>=4.0.0 <4.3.1": "^4.3.1",
"debug@<2.6.9": "^2.6.9",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 368af77280b..1c3099c0629 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -37,6 +37,7 @@ overrides:
juice>cheerio: 0.22.0
lodash.template: 4.5.0
'@tootallnate/once@<3.0.1': ^3.0.1
+ axios@<1.15.2: ^1.15.2
clean-css@<4.1.11: ^4.1.11
debug@>=4.0.0 <4.3.1: ^4.3.1
debug@<2.6.9: ^2.6.9
@@ -246,7 +247,7 @@ importers:
version: 9.37.0
'@tailwindcss/vite':
specifier: 4.2.1
- version: 4.2.1(vite@7.1.12(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
+ version: 4.2.1(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@tanstack/react-query':
specifier: 4.36.1
version: 4.36.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -267,7 +268,7 @@ importers:
version: 18.3.7(@types/react@18.3.28)
'@vitejs/plugin-react-swc':
specifier: 4.1.0
- version: 4.1.0(@swc/helpers@0.5.21)(vite@7.1.12(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
+ version: 4.1.0(@swc/helpers@0.5.21)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
eslint:
specifier: catalog:eslint9
version: 9.37.0(jiti@2.6.1)
@@ -308,14 +309,14 @@ importers:
specifier: 8.58.0
version: 8.58.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)
vite:
- specifier: 7.1.12
- version: 7.1.12(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
+ specifier: 7.3.2
+ version: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-tsconfig-paths:
specifier: 5.1.4
- version: 5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
+ version: 5.1.4(typescript@5.9.3)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vitest:
specifier: 4.1.2
- version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(jsdom@28.1.0(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.1.12(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
+ version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(jsdom@28.1.0(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
apps/admin-x-design-system:
dependencies:
@@ -1272,6 +1273,9 @@ importers:
tailwindcss:
specifier: 4.2.1
version: 4.2.1
+ tsc-alias:
+ specifier: ^1.8.17
+ version: 1.8.17
tw-animate-css:
specifier: 1.4.0
version: 1.4.0
@@ -4125,12 +4129,6 @@ packages:
cpu: [ppc64]
os: [aix]
- '@esbuild/aix-ppc64@0.25.12':
- resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
- engines: {node: '>=18'}
- cpu: [ppc64]
- os: [aix]
-
'@esbuild/aix-ppc64@0.27.4':
resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==}
engines: {node: '>=18'}
@@ -4149,12 +4147,6 @@ packages:
cpu: [arm64]
os: [android]
- '@esbuild/android-arm64@0.25.12':
- resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
- engines: {node: '>=18'}
- cpu: [arm64]
- os: [android]
-
'@esbuild/android-arm64@0.27.4':
resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==}
engines: {node: '>=18'}
@@ -4173,12 +4165,6 @@ packages:
cpu: [arm]
os: [android]
- '@esbuild/android-arm@0.25.12':
- resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
- engines: {node: '>=18'}
- cpu: [arm]
- os: [android]
-
'@esbuild/android-arm@0.27.4':
resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==}
engines: {node: '>=18'}
@@ -4197,12 +4183,6 @@ packages:
cpu: [x64]
os: [android]
- '@esbuild/android-x64@0.25.12':
- resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
- engines: {node: '>=18'}
- cpu: [x64]
- os: [android]
-
'@esbuild/android-x64@0.27.4':
resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==}
engines: {node: '>=18'}
@@ -4221,12 +4201,6 @@ packages:
cpu: [arm64]
os: [darwin]
- '@esbuild/darwin-arm64@0.25.12':
- resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
- engines: {node: '>=18'}
- cpu: [arm64]
- os: [darwin]
-
'@esbuild/darwin-arm64@0.27.4':
resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==}
engines: {node: '>=18'}
@@ -4245,12 +4219,6 @@ packages:
cpu: [x64]
os: [darwin]
- '@esbuild/darwin-x64@0.25.12':
- resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
- engines: {node: '>=18'}
- cpu: [x64]
- os: [darwin]
-
'@esbuild/darwin-x64@0.27.4':
resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==}
engines: {node: '>=18'}
@@ -4269,12 +4237,6 @@ packages:
cpu: [arm64]
os: [freebsd]
- '@esbuild/freebsd-arm64@0.25.12':
- resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
- engines: {node: '>=18'}
- cpu: [arm64]
- os: [freebsd]
-
'@esbuild/freebsd-arm64@0.27.4':
resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==}
engines: {node: '>=18'}
@@ -4293,12 +4255,6 @@ packages:
cpu: [x64]
os: [freebsd]
- '@esbuild/freebsd-x64@0.25.12':
- resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
- engines: {node: '>=18'}
- cpu: [x64]
- os: [freebsd]
-
'@esbuild/freebsd-x64@0.27.4':
resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==}
engines: {node: '>=18'}
@@ -4317,12 +4273,6 @@ packages:
cpu: [arm64]
os: [linux]
- '@esbuild/linux-arm64@0.25.12':
- resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
- engines: {node: '>=18'}
- cpu: [arm64]
- os: [linux]
-
'@esbuild/linux-arm64@0.27.4':
resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==}
engines: {node: '>=18'}
@@ -4341,12 +4291,6 @@ packages:
cpu: [arm]
os: [linux]
- '@esbuild/linux-arm@0.25.12':
- resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
- engines: {node: '>=18'}
- cpu: [arm]
- os: [linux]
-
'@esbuild/linux-arm@0.27.4':
resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==}
engines: {node: '>=18'}
@@ -4365,12 +4309,6 @@ packages:
cpu: [ia32]
os: [linux]
- '@esbuild/linux-ia32@0.25.12':
- resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
- engines: {node: '>=18'}
- cpu: [ia32]
- os: [linux]
-
'@esbuild/linux-ia32@0.27.4':
resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==}
engines: {node: '>=18'}
@@ -4389,12 +4327,6 @@ packages:
cpu: [loong64]
os: [linux]
- '@esbuild/linux-loong64@0.25.12':
- resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
- engines: {node: '>=18'}
- cpu: [loong64]
- os: [linux]
-
'@esbuild/linux-loong64@0.27.4':
resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==}
engines: {node: '>=18'}
@@ -4413,12 +4345,6 @@ packages:
cpu: [mips64el]
os: [linux]
- '@esbuild/linux-mips64el@0.25.12':
- resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
- engines: {node: '>=18'}
- cpu: [mips64el]
- os: [linux]
-
'@esbuild/linux-mips64el@0.27.4':
resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==}
engines: {node: '>=18'}
@@ -4437,12 +4363,6 @@ packages:
cpu: [ppc64]
os: [linux]
- '@esbuild/linux-ppc64@0.25.12':
- resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
- engines: {node: '>=18'}
- cpu: [ppc64]
- os: [linux]
-
'@esbuild/linux-ppc64@0.27.4':
resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==}
engines: {node: '>=18'}
@@ -4461,12 +4381,6 @@ packages:
cpu: [riscv64]
os: [linux]
- '@esbuild/linux-riscv64@0.25.12':
- resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
- engines: {node: '>=18'}
- cpu: [riscv64]
- os: [linux]
-
'@esbuild/linux-riscv64@0.27.4':
resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==}
engines: {node: '>=18'}
@@ -4485,12 +4399,6 @@ packages:
cpu: [s390x]
os: [linux]
- '@esbuild/linux-s390x@0.25.12':
- resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
- engines: {node: '>=18'}
- cpu: [s390x]
- os: [linux]
-
'@esbuild/linux-s390x@0.27.4':
resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==}
engines: {node: '>=18'}
@@ -4509,24 +4417,12 @@ packages:
cpu: [x64]
os: [linux]
- '@esbuild/linux-x64@0.25.12':
- resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
- engines: {node: '>=18'}
- cpu: [x64]
- os: [linux]
-
'@esbuild/linux-x64@0.27.4':
resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
- '@esbuild/netbsd-arm64@0.25.12':
- resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}
- engines: {node: '>=18'}
- cpu: [arm64]
- os: [netbsd]
-
'@esbuild/netbsd-arm64@0.27.4':
resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==}
engines: {node: '>=18'}
@@ -4545,24 +4441,12 @@ packages:
cpu: [x64]
os: [netbsd]
- '@esbuild/netbsd-x64@0.25.12':
- resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
- engines: {node: '>=18'}
- cpu: [x64]
- os: [netbsd]
-
'@esbuild/netbsd-x64@0.27.4':
resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
- '@esbuild/openbsd-arm64@0.25.12':
- resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}
- engines: {node: '>=18'}
- cpu: [arm64]
- os: [openbsd]
-
'@esbuild/openbsd-arm64@0.27.4':
resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==}
engines: {node: '>=18'}
@@ -4581,24 +4465,12 @@ packages:
cpu: [x64]
os: [openbsd]
- '@esbuild/openbsd-x64@0.25.12':
- resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
- engines: {node: '>=18'}
- cpu: [x64]
- os: [openbsd]
-
'@esbuild/openbsd-x64@0.27.4':
resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
- '@esbuild/openharmony-arm64@0.25.12':
- resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}
- engines: {node: '>=18'}
- cpu: [arm64]
- os: [openharmony]
-
'@esbuild/openharmony-arm64@0.27.4':
resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==}
engines: {node: '>=18'}
@@ -4617,12 +4489,6 @@ packages:
cpu: [x64]
os: [sunos]
- '@esbuild/sunos-x64@0.25.12':
- resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
- engines: {node: '>=18'}
- cpu: [x64]
- os: [sunos]
-
'@esbuild/sunos-x64@0.27.4':
resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==}
engines: {node: '>=18'}
@@ -4641,12 +4507,6 @@ packages:
cpu: [arm64]
os: [win32]
- '@esbuild/win32-arm64@0.25.12':
- resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
- engines: {node: '>=18'}
- cpu: [arm64]
- os: [win32]
-
'@esbuild/win32-arm64@0.27.4':
resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==}
engines: {node: '>=18'}
@@ -4665,12 +4525,6 @@ packages:
cpu: [ia32]
os: [win32]
- '@esbuild/win32-ia32@0.25.12':
- resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
- engines: {node: '>=18'}
- cpu: [ia32]
- os: [win32]
-
'@esbuild/win32-ia32@0.27.4':
resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==}
engines: {node: '>=18'}
@@ -4689,12 +4543,6 @@ packages:
cpu: [x64]
os: [win32]
- '@esbuild/win32-x64@0.25.12':
- resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
- engines: {node: '>=18'}
- cpu: [x64]
- os: [win32]
-
'@esbuild/win32-x64@0.27.4':
resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==}
engines: {node: '>=18'}
@@ -10070,11 +9918,8 @@ packages:
aws4@1.13.2:
resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==}
- axios@1.13.6:
- resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==}
-
- axios@1.15.0:
- resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==}
+ axios@1.16.0:
+ resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==}
b4a@1.8.0:
resolution: {integrity: sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==}
@@ -13507,11 +13352,6 @@ packages:
engines: {node: '>=12'}
hasBin: true
- esbuild@0.25.12:
- resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
- engines: {node: '>=18'}
- hasBin: true
-
esbuild@0.27.4:
resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==}
engines: {node: '>=18'}
@@ -17453,6 +17293,10 @@ packages:
resolution: {integrity: sha512-at/ZndSy3xEGJ8i0ygALh8ru9qy7gWW1cmkaqBN29JmMlIvM//MEO9y1sk/avxuwnPcfhkejkLsuPxH81BrkSg==}
engines: {node: '>=0.8.0'}
+ mylas@2.1.14:
+ resolution: {integrity: sha512-BzQguy9W9NJgoVn2mRWzbFrFWWztGCcng2QI9+41frfk+Athwgx3qhqhvStz7ExeUUu7Kzw427sNzHpEZNINog==}
+ engines: {node: '>=16.0.0'}
+
mysql2@3.14.1:
resolution: {integrity: sha512-7ytuPQJjQB8TNAYX/H2yhL+iQOnIBjAMam361R7UAL0lOVXWjtdrmoL9HYKqKoLp/8UUTRcvo1QPvK9KL7wA8w==}
engines: {node: '>= 8.0'}
@@ -18394,6 +18238,10 @@ packages:
engines: {node: '>=18'}
hasBin: true
+ plimit-lit@1.6.1:
+ resolution: {integrity: sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==}
+ engines: {node: '>=12'}
+
pluralize@2.0.0:
resolution: {integrity: sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw==}
@@ -19231,9 +19079,6 @@ packages:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
- proxy-from-env@1.1.0:
- resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
-
proxy-from-env@2.1.0:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'}
@@ -19313,6 +19158,10 @@ packages:
querystringify@2.2.0:
resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
+ queue-lit@1.5.2:
+ resolution: {integrity: sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw==}
+ engines: {node: '>=12'}
+
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -21320,6 +21169,11 @@ packages:
'@swc/wasm':
optional: true
+ tsc-alias@1.8.17:
+ resolution: {integrity: sha512-EIduCZHqbNwPm8BZYfq1aD7BQ697A4h6uSGMOFQfYGoQwfrYFTKwYfy9Bv42YxHkduVBcn9Zx0DkX111DKskyg==}
+ engines: {node: '>=16.20.2'}
+ hasBin: true
+
tsconfck@3.1.6:
resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==}
engines: {node: ^18 || >=20}
@@ -21929,8 +21783,8 @@ packages:
terser:
optional: true
- vite@7.1.12:
- resolution: {integrity: sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==}
+ vite@7.3.2:
+ resolution: {integrity: sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
@@ -24930,9 +24784,6 @@ snapshots:
'@esbuild/aix-ppc64@0.21.5':
optional: true
- '@esbuild/aix-ppc64@0.25.12':
- optional: true
-
'@esbuild/aix-ppc64@0.27.4':
optional: true
@@ -24942,9 +24793,6 @@ snapshots:
'@esbuild/android-arm64@0.21.5':
optional: true
- '@esbuild/android-arm64@0.25.12':
- optional: true
-
'@esbuild/android-arm64@0.27.4':
optional: true
@@ -24954,9 +24802,6 @@ snapshots:
'@esbuild/android-arm@0.21.5':
optional: true
- '@esbuild/android-arm@0.25.12':
- optional: true
-
'@esbuild/android-arm@0.27.4':
optional: true
@@ -24966,9 +24811,6 @@ snapshots:
'@esbuild/android-x64@0.21.5':
optional: true
- '@esbuild/android-x64@0.25.12':
- optional: true
-
'@esbuild/android-x64@0.27.4':
optional: true
@@ -24978,9 +24820,6 @@ snapshots:
'@esbuild/darwin-arm64@0.21.5':
optional: true
- '@esbuild/darwin-arm64@0.25.12':
- optional: true
-
'@esbuild/darwin-arm64@0.27.4':
optional: true
@@ -24990,9 +24829,6 @@ snapshots:
'@esbuild/darwin-x64@0.21.5':
optional: true
- '@esbuild/darwin-x64@0.25.12':
- optional: true
-
'@esbuild/darwin-x64@0.27.4':
optional: true
@@ -25002,9 +24838,6 @@ snapshots:
'@esbuild/freebsd-arm64@0.21.5':
optional: true
- '@esbuild/freebsd-arm64@0.25.12':
- optional: true
-
'@esbuild/freebsd-arm64@0.27.4':
optional: true
@@ -25014,9 +24847,6 @@ snapshots:
'@esbuild/freebsd-x64@0.21.5':
optional: true
- '@esbuild/freebsd-x64@0.25.12':
- optional: true
-
'@esbuild/freebsd-x64@0.27.4':
optional: true
@@ -25026,9 +24856,6 @@ snapshots:
'@esbuild/linux-arm64@0.21.5':
optional: true
- '@esbuild/linux-arm64@0.25.12':
- optional: true
-
'@esbuild/linux-arm64@0.27.4':
optional: true
@@ -25038,9 +24865,6 @@ snapshots:
'@esbuild/linux-arm@0.21.5':
optional: true
- '@esbuild/linux-arm@0.25.12':
- optional: true
-
'@esbuild/linux-arm@0.27.4':
optional: true
@@ -25050,9 +24874,6 @@ snapshots:
'@esbuild/linux-ia32@0.21.5':
optional: true
- '@esbuild/linux-ia32@0.25.12':
- optional: true
-
'@esbuild/linux-ia32@0.27.4':
optional: true
@@ -25062,9 +24883,6 @@ snapshots:
'@esbuild/linux-loong64@0.21.5':
optional: true
- '@esbuild/linux-loong64@0.25.12':
- optional: true
-
'@esbuild/linux-loong64@0.27.4':
optional: true
@@ -25074,9 +24892,6 @@ snapshots:
'@esbuild/linux-mips64el@0.21.5':
optional: true
- '@esbuild/linux-mips64el@0.25.12':
- optional: true
-
'@esbuild/linux-mips64el@0.27.4':
optional: true
@@ -25086,9 +24901,6 @@ snapshots:
'@esbuild/linux-ppc64@0.21.5':
optional: true
- '@esbuild/linux-ppc64@0.25.12':
- optional: true
-
'@esbuild/linux-ppc64@0.27.4':
optional: true
@@ -25098,9 +24910,6 @@ snapshots:
'@esbuild/linux-riscv64@0.21.5':
optional: true
- '@esbuild/linux-riscv64@0.25.12':
- optional: true
-
'@esbuild/linux-riscv64@0.27.4':
optional: true
@@ -25110,9 +24919,6 @@ snapshots:
'@esbuild/linux-s390x@0.21.5':
optional: true
- '@esbuild/linux-s390x@0.25.12':
- optional: true
-
'@esbuild/linux-s390x@0.27.4':
optional: true
@@ -25122,15 +24928,9 @@ snapshots:
'@esbuild/linux-x64@0.21.5':
optional: true
- '@esbuild/linux-x64@0.25.12':
- optional: true
-
'@esbuild/linux-x64@0.27.4':
optional: true
- '@esbuild/netbsd-arm64@0.25.12':
- optional: true
-
'@esbuild/netbsd-arm64@0.27.4':
optional: true
@@ -25140,15 +24940,9 @@ snapshots:
'@esbuild/netbsd-x64@0.21.5':
optional: true
- '@esbuild/netbsd-x64@0.25.12':
- optional: true
-
'@esbuild/netbsd-x64@0.27.4':
optional: true
- '@esbuild/openbsd-arm64@0.25.12':
- optional: true
-
'@esbuild/openbsd-arm64@0.27.4':
optional: true
@@ -25158,15 +24952,9 @@ snapshots:
'@esbuild/openbsd-x64@0.21.5':
optional: true
- '@esbuild/openbsd-x64@0.25.12':
- optional: true
-
'@esbuild/openbsd-x64@0.27.4':
optional: true
- '@esbuild/openharmony-arm64@0.25.12':
- optional: true
-
'@esbuild/openharmony-arm64@0.27.4':
optional: true
@@ -25176,9 +24964,6 @@ snapshots:
'@esbuild/sunos-x64@0.21.5':
optional: true
- '@esbuild/sunos-x64@0.25.12':
- optional: true
-
'@esbuild/sunos-x64@0.27.4':
optional: true
@@ -25188,9 +24973,6 @@ snapshots:
'@esbuild/win32-arm64@0.21.5':
optional: true
- '@esbuild/win32-arm64@0.25.12':
- optional: true
-
'@esbuild/win32-arm64@0.27.4':
optional: true
@@ -25200,9 +24982,6 @@ snapshots:
'@esbuild/win32-ia32@0.21.5':
optional: true
- '@esbuild/win32-ia32@0.25.12':
- optional: true
-
'@esbuild/win32-ia32@0.27.4':
optional: true
@@ -25212,9 +24991,6 @@ snapshots:
'@esbuild/win32-x64@0.21.5':
optional: true
- '@esbuild/win32-x64@0.25.12':
- optional: true
-
'@esbuild/win32-x64@0.27.4':
optional: true
@@ -27764,7 +27540,7 @@ snapshots:
dependencies:
'@slack/types': 2.20.1
'@types/node': 25.6.0
- axios: 1.15.0
+ axios: 1.16.0
transitivePeerDependencies:
- debug
@@ -29441,12 +29217,12 @@ snapshots:
tailwindcss: 4.2.1
vite: 5.4.21(@types/node@22.19.17)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)
- '@tailwindcss/vite@4.2.1(vite@7.1.12(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
+ '@tailwindcss/vite@4.2.1(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
'@tailwindcss/node': 4.2.1
'@tailwindcss/oxide': 4.2.1
tailwindcss: 4.2.1
- vite: 7.1.12(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
+ vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
'@tanstack/query-core@4.36.1': {}
@@ -29800,7 +29576,7 @@ snapshots:
'@tryghost/content-api@1.12.6':
dependencies:
- axios: 1.13.6
+ axios: 1.16.0
transitivePeerDependencies:
- debug
@@ -31176,11 +30952,11 @@ snapshots:
'@ungap/structured-clone@1.3.0': {}
- '@vitejs/plugin-react-swc@4.1.0(@swc/helpers@0.5.21)(vite@7.1.12(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
+ '@vitejs/plugin-react-swc@4.1.0(@swc/helpers@0.5.21)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-beta.35
'@swc/core': 1.15.21(@swc/helpers@0.5.21)
- vite: 7.1.12(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
+ vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- '@swc/helpers'
@@ -31324,23 +31100,23 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.0
- '@vitest/mocker@3.2.4(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.1.12(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
+ '@vitest/mocker@3.2.4(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
msw: 2.12.14(@types/node@25.6.0)(typescript@5.9.3)
- vite: 7.1.12(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
+ vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
- '@vitest/mocker@4.1.2(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.1.12(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
+ '@vitest/mocker@4.1.2(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
'@vitest/spy': 4.1.2
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
msw: 2.12.14(@types/node@25.6.0)(typescript@5.9.3)
- vite: 7.1.12(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
+ vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
'@vitest/pretty-format@3.2.4':
dependencies:
@@ -32111,15 +31887,7 @@ snapshots:
aws4@1.13.2: {}
- axios@1.13.6:
- dependencies:
- follow-redirects: 1.16.0
- form-data: 4.0.5
- proxy-from-env: 1.1.0
- transitivePeerDependencies:
- - debug
-
- axios@1.15.0:
+ axios@1.16.0:
dependencies:
follow-redirects: 1.16.0
form-data: 4.0.5
@@ -37238,35 +37006,6 @@ snapshots:
'@esbuild/win32-ia32': 0.21.5
'@esbuild/win32-x64': 0.21.5
- esbuild@0.25.12:
- optionalDependencies:
- '@esbuild/aix-ppc64': 0.25.12
- '@esbuild/android-arm': 0.25.12
- '@esbuild/android-arm64': 0.25.12
- '@esbuild/android-x64': 0.25.12
- '@esbuild/darwin-arm64': 0.25.12
- '@esbuild/darwin-x64': 0.25.12
- '@esbuild/freebsd-arm64': 0.25.12
- '@esbuild/freebsd-x64': 0.25.12
- '@esbuild/linux-arm': 0.25.12
- '@esbuild/linux-arm64': 0.25.12
- '@esbuild/linux-ia32': 0.25.12
- '@esbuild/linux-loong64': 0.25.12
- '@esbuild/linux-mips64el': 0.25.12
- '@esbuild/linux-ppc64': 0.25.12
- '@esbuild/linux-riscv64': 0.25.12
- '@esbuild/linux-s390x': 0.25.12
- '@esbuild/linux-x64': 0.25.12
- '@esbuild/netbsd-arm64': 0.25.12
- '@esbuild/netbsd-x64': 0.25.12
- '@esbuild/openbsd-arm64': 0.25.12
- '@esbuild/openbsd-x64': 0.25.12
- '@esbuild/openharmony-arm64': 0.25.12
- '@esbuild/sunos-x64': 0.25.12
- '@esbuild/win32-arm64': 0.25.12
- '@esbuild/win32-ia32': 0.25.12
- '@esbuild/win32-x64': 0.25.12
-
esbuild@0.27.4:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.4
@@ -41725,7 +41464,7 @@ snapshots:
mailgun.js@10.4.0:
dependencies:
- axios: 1.13.6
+ axios: 1.16.0
base-64: 1.0.0
url-join: 4.0.1
transitivePeerDependencies:
@@ -41733,7 +41472,7 @@ snapshots:
mailgun.js@8.2.2:
dependencies:
- axios: 1.13.6
+ axios: 1.16.0
base-64: 1.0.0
url-join: 4.0.1
transitivePeerDependencies:
@@ -42457,6 +42196,8 @@ snapshots:
rimraf: 2.4.5
optional: true
+ mylas@2.1.14: {}
+
mysql2@3.14.1:
dependencies:
aws-ssl-profiles: 1.1.2
@@ -42970,7 +42711,7 @@ snapshots:
'@yarnpkg/lockfile': 1.1.0
'@yarnpkg/parsers': 3.0.2
'@zkochan/js-yaml': 0.0.7
- axios: 1.13.6
+ axios: 1.16.0
chalk: 4.1.2
cli-cursor: 3.1.0
cli-spinners: 2.6.1
@@ -43602,6 +43343,10 @@ snapshots:
optionalDependencies:
fsevents: 2.3.2
+ plimit-lit@1.6.1:
+ dependencies:
+ queue-lit: 1.5.2
+
pluralize@2.0.0: {}
pluralize@8.0.0: {}
@@ -44555,8 +44300,6 @@ snapshots:
forwarded: 0.2.0
ipaddr.js: 1.9.1
- proxy-from-env@1.1.0: {}
-
proxy-from-env@2.1.0: {}
prr@0.0.0: {}
@@ -44627,6 +44370,8 @@ snapshots:
querystringify@2.2.0: {}
+ queue-lit@1.5.2: {}
+
queue-microtask@1.2.3: {}
queue@6.0.2:
@@ -47301,6 +47046,16 @@ snapshots:
optionalDependencies:
'@swc/core': 1.15.21(@swc/helpers@0.5.21)
+ tsc-alias@1.8.17:
+ dependencies:
+ chokidar: 3.6.0
+ commander: 9.5.0
+ get-tsconfig: 4.13.7
+ globby: 11.1.0
+ mylas: 2.1.14
+ normalize-path: 3.0.0
+ plimit-lit: 1.6.1
+
tsconfck@3.1.6(typescript@5.9.3):
optionalDependencies:
typescript: 5.9.3
@@ -47894,7 +47649,7 @@ snapshots:
debug: 4.4.3(supports-color@5.5.0)
es-module-lexer: 1.7.0
pathe: 2.0.3
- vite: 7.1.12(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
+ vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- '@types/node'
- jiti
@@ -47950,13 +47705,13 @@ snapshots:
- supports-color
- typescript
- vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
+ vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
debug: 4.4.3(supports-color@5.5.0)
globrex: 0.1.2
tsconfck: 3.1.6(typescript@5.9.3)
optionalDependencies:
- vite: 7.1.12(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
+ vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- supports-color
- typescript
@@ -47985,9 +47740,9 @@ snapshots:
lightningcss: 1.31.1
terser: 5.46.1
- vite@7.1.12(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
+ vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
- esbuild: 0.25.12
+ esbuild: 0.27.4
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
postcss: 8.5.6
@@ -48147,7 +47902,7 @@ snapshots:
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
- '@vitest/mocker': 3.2.4(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.1.12(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
+ '@vitest/mocker': 3.2.4(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
@@ -48165,7 +47920,7 @@ snapshots:
tinyglobby: 0.2.15
tinypool: 1.1.1
tinyrainbow: 2.0.0
- vite: 7.1.12(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
+ vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-node: 3.2.4(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
why-is-node-running: 2.3.0
optionalDependencies:
@@ -48186,10 +47941,10 @@ snapshots:
- tsx
- yaml
- vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(jsdom@28.1.0(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.1.12(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
+ vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(jsdom@28.1.0(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@vitest/expect': 4.1.2
- '@vitest/mocker': 4.1.2(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.1.12(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
+ '@vitest/mocker': 4.1.2(msw@2.12.14(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/pretty-format': 4.1.2
'@vitest/runner': 4.1.2
'@vitest/snapshot': 4.1.2
@@ -48206,7 +47961,7 @@ snapshots:
tinyexec: 1.1.1
tinyglobby: 0.2.15
tinyrainbow: 3.1.0
- vite: 7.1.12(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
+ vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.31.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
why-is-node-running: 2.3.0
optionalDependencies:
'@opentelemetry/api': 1.9.1