{
  "config": {
    "argv": [
      "/opt/hostedtoolcache/node/20.20.2/x64/bin/node",
      "/home/runner/work/dr-card-web/dr-card-web/node_modules/.bin/playwright",
      "test",
      "tests/e2e/test-gate.spec.ts"
    ],
    "configFile": "/home/runner/work/dr-card-web/dr-card-web/playwright.config.ts",
    "rootDir": "/home/runner/work/dr-card-web/dr-card-web/tests/e2e",
    "failOnFlakyTests": false,
    "forbidOnly": true,
    "fullyParallel": true,
    "globalSetup": null,
    "globalTeardown": null,
    "globalTimeout": 0,
    "grep": {},
    "grepInvert": null,
    "maxFailures": 0,
    "metadata": {
      "ci": {
        "commitHref": "https://github.com/sanchuang-dev/dr-card-web/commit/e62411226a8fad34600843b40c84484781506ae3",
        "commitHash": "e62411226a8fad34600843b40c84484781506ae3",
        "prHref": "https://github.com/sanchuang-dev/dr-card-web/pull/284",
        "prTitle": "release(test): promote dev newcomer auto-popup fix",
        "prBaseHash": "02cd0bcb5942fe841775c7f808ea8995d5ec8a4e",
        "buildHref": "https://github.com/sanchuang-dev/dr-card-web/actions/runs/42046279096"
      },
      "gitCommit": {
        "shortHash": "e624112",
        "hash": "e62411226a8fad34600843b40c84484781506ae3",
        "subject": "Merge e42f805ddbe8d4d030e756e24e65993492970601 into 02cd0bcb5942fe841775c7f808ea8995d5ec8a4e",
        "body": "Merge e42f805ddbe8d4d030e756e24e65993492970601 into 02cd0bcb5942fe841775c7f808ea8995d5ec8a4e\n",
        "author": {
          "name": "SINGING-AKIBA",
          "email": "980325684@qq.com",
          "time": 1793889254000
        },
        "committer": {
          "name": "GitHub",
          "email": "noreply@github.com",
          "time": 1793889254000
        },
        "branch": "HEAD"
      },
      "gitDiff": "diff --git a/src/App.tsx b/src/App.tsx\nindex 661efab..ff63317 100644\n--- a/src/App.tsx\n+++ b/src/App.tsx\n@@ -3,10 +3,10 @@ import { RouterProvider } from 'react-router-dom'\n import { router } from './app/router'\n import { initialEntryRedirect } from './app/router/entryRedirect'\n import NewcomerDialog from './components/mobile/NewcomerDialog'\n-import { getAuthSession, type NewUserPopup } from './services/auth/session'\n+import { getAuthSession, getNewUserPopup } from './services/auth/session'\n \n export default function App() {\n-  const [newUserPopup, setNewUserPopup] = useState<NewUserPopup | null>(null)\n+  const [newUserPopup, setNewUserPopup] = useState<ReturnType<typeof getNewUserPopup>>(null)\n   const popupHandledThisDocument = useRef(false)\n \n   // Hide the new-user dialog only while React Router resolves the initial\n@@ -19,8 +19,8 @@ export default function App() {\n \n   useEffect(() => {\n     const handleSessionChanged = () => {\n-      const session = getAuthSession()\n-      if (session?.is_new_user !== true || !session.popup) {\n+      const popup = getNewUserPopup(getAuthSession())\n+      if (!popup) {\n         setNewUserPopup(null)\n         return\n       }\n@@ -28,7 +28,7 @@ export default function App() {\n       // during this page lifetime; do not add unconfirmed persistent storage keys.\n       if (popupHandledThisDocument.current) return\n       popupHandledThisDocument.current = true\n-      setNewUserPopup(session.popup)\n+      setNewUserPopup(popup)\n     }\n \n     window.addEventListener('dr-card-ui:auth-session-changed', handleSessionChanged)\n@@ -40,9 +40,9 @@ export default function App() {\n   return (\n     <>\n       <RouterProvider router={router} />\n-      <NewcomerDialog open={newUserPopup !== null && !entryRedirectPending} title={newUserPopup?.title ?? ''}\n-        description={newUserPopup?.description ?? ''} points={newUserPopup?.points ?? ''}\n-        onComplete={dismissPopup} onDismiss={dismissPopup} />\n+      <NewcomerDialog open={newUserPopup !== null && !entryRedirectPending}\n+        title={newUserPopup?.title ?? ''} description={newUserPopup?.description ?? ''}\n+        points={newUserPopup?.points} onComplete={dismissPopup} onDismiss={dismissPopup} />\n     </>\n   )\n }\ndiff --git a/src/components/mobile/NewcomerDialog.tsx b/src/components/mobile/NewcomerDialog.tsx\nindex 7a3b06a..c371f6f 100644\n--- a/src/components/mobile/NewcomerDialog.tsx\n+++ b/src/components/mobile/NewcomerDialog.tsx\n@@ -5,12 +5,12 @@ export interface NewcomerDialogProps {\n   open: boolean\n   title: string\n   description: string\n-  points: number | string\n+  points?: number | string\n   onComplete: () => void\n   onDismiss: () => void\n }\n \n-/** Displays the new-user bubble-points reward returned by the login API. */\n+/** Displays the new-user notice immediately after the login API identifies a new user. */\n export default function NewcomerDialog({ open, title, description, points, onComplete, onDismiss }: NewcomerDialogProps) {\n   return (\n     <PromptOverlay open={open} label=\"新人泡泡值赠送提示\" onDismiss={onDismiss}>\n@@ -18,10 +18,12 @@ export default function NewcomerDialog({ open, title, description, points, onCom\n         <span className=\"mx-auto inline-flex rounded-pill bg-reward-subtle px-3 py-1 text-[11px] font-semibold tracking-[0.12em] text-reward-text\">新人福利</span>\n         <h2 className=\"mt-3 text-xl font-bold leading-7 text-text-primary\">{title}</h2>\n         <p className=\"mt-3 text-sm leading-6 text-text-secondary\">{description}</p>\n-        <div className=\"mx-auto mt-5 flex min-h-24 w-full flex-col items-center justify-center rounded-container border border-reward/30 bg-reward-subtle px-4 py-3\">\n-          <span className=\"text-xs font-medium text-reward-text\">赠送泡泡值</span>\n-          <span className=\"mt-1 text-3xl font-extrabold tabular-nums text-reward-text\">{points}</span>\n-        </div>\n+        {points !== undefined && (\n+          <div className=\"mx-auto mt-5 flex min-h-24 w-full flex-col items-center justify-center rounded-container border border-reward/30 bg-reward-subtle px-4 py-3\">\n+            <span className=\"text-xs font-medium text-reward-text\">赠送泡泡值</span>\n+            <span className=\"mt-1 text-3xl font-extrabold tabular-nums text-reward-text\">{points}</span>\n+          </div>\n+        )}\n       </div>\n       <div className=\"bg-surface px-5 pb-5 pt-4\">\n         <Button size=\"large\" className=\"w-full rounded-pill\" onClick={onComplete}>我知道了</Button>\ndiff --git a/src/services/auth/session.test.ts b/src/services/auth/session.test.ts\nindex 7add2db..f451cf7 100644\n--- a/src/services/auth/session.test.ts\n+++ b/src/services/auth/session.test.ts\n@@ -1,6 +1,6 @@\n import { describe, expect, it } from 'vitest'\n \n-import { parseLoginResponse } from './session'\n+import { getNewUserPopup, parseLoginResponse } from './session'\n \n describe('login response contract', () => {\n   it('accepts the original top-level login response', () => {\n@@ -17,19 +17,65 @@ describe('login response contract', () => {\n     })).toEqual({ userInfo: { id: 'user-1' }, accessToken: 'token-1', is_new_user: false })\n   })\n \n-  it('preserves new-user popup content from the backend envelope', () => {\n+  it('preserves new-user metadata returned beside the data envelope', () => {\n+    expect(parseLoginResponse({\n+      code: 0,\n+      is_new_user: true,\n+      popup: { title: '注册成功', description: '已获得泡泡值奖励', points: 120 },\n+      data: { userInfo: { id: 'user-2' }, accessToken: 'token-2' },\n+    })).toEqual({\n+      userInfo: { id: 'user-2' },\n+      accessToken: 'token-2',\n+      is_new_user: true,\n+      popup: { title: '注册成功', description: '已获得泡泡值奖励', points: 120 },\n+    })\n+  })\n+\n+  it('preserves new-user metadata nested inside the data envelope', () => {\n     expect(parseLoginResponse({ code: 0, data: {\n-      userInfo: { id: 'user-2' }, accessToken: 'token-2', is_new_user: true,\n+      userInfo: { id: 'user-3' }, accessToken: 'token-3', is_new_user: true,\n+      popup: { title: '注册成功', description: '已获得泡泡值奖励', points: 120 },\n+    } })).toEqual({\n+      userInfo: { id: 'user-3' },\n+      accessToken: 'token-3',\n+      is_new_user: true,\n       popup: { title: '注册成功', description: '已获得泡泡值奖励', points: 120 },\n-    } })).toEqual({ userInfo: { id: 'user-2' }, accessToken: 'token-2', is_new_user: true,\n-      popup: { title: '注册成功', description: '已获得泡泡值奖励', points: 120 } })\n+    })\n   })\n \n-  it('ignores malformed popup content and preserves ordinary login', () => {\n-    expect(parseLoginResponse({ userInfo: {}, accessToken: 'token-3', is_new_user: true,\n-      popup: { title: 'Hi', description: '', points: 5 } })).toEqual({\n-      userInfo: {}, accessToken: 'token-3', is_new_user: true,\n+  it('shows a default new-user notice when the backend omits optional popup content', () => {\n+    const session = parseLoginResponse({\n+      code: 0,\n+      data: { userInfo: { id: 'user-4' }, accessToken: 'token-4', is_new_user: true },\n+    })\n+    expect(getNewUserPopup(session)).toEqual({\n+      title: '欢迎加入卡博士',\n+      description: '恭喜成为卡博士新用户，欢迎开启卡博士体验。',\n+    })\n+  })\n+\n+  it('does not show a notice for an existing user', () => {\n+    const session = parseLoginResponse({\n+      code: 0,\n+      data: { userInfo: { id: 'user-5' }, accessToken: 'token-5', is_new_user: false },\n+    })\n+    expect(getNewUserPopup(session)).toBeNull()\n+  })\n+\n+  it('ignores malformed popup content but keeps the new-user flag', () => {\n+    const session = parseLoginResponse({\n+      userInfo: {}, accessToken: 'token-6', is_new_user: true,\n+      popup: { title: 'Hi', description: '', points: 5 },\n+    })\n+    expect(session).toEqual({\n+      userInfo: {}, accessToken: 'token-6', is_new_user: true,\n+      popup: {\n+        title: 'Hi',\n+        description: '恭喜成为卡博士新用户，欢迎开启卡博士体验。',\n+        points: 5,\n+      },\n     })\n+    expect(getNewUserPopup(session)).toEqual(session.popup)\n   })\n \n   it('does not treat a non-success envelope as a valid session', () => {\ndiff --git a/src/services/auth/session.ts b/src/services/auth/session.ts\nindex 5e2f9c2..2155f9b 100644\n--- a/src/services/auth/session.ts\n+++ b/src/services/auth/session.ts\n@@ -18,7 +18,7 @@ const LOGIN_PATH = '/api/oauth/login'\n export interface NewUserPopup {\n   title: string\n   description: string\n-  points: number | string\n+  points?: number | string\n }\n \n export interface AuthSession {\n@@ -33,6 +33,19 @@ interface LoginResponse extends AuthSession {}\n interface LoginEnvelope {\n   code?: unknown\n   data?: unknown\n+  is_new_user?: unknown\n+  popup?: unknown\n+}\n+\n+const DEFAULT_NEW_USER_POPUP: NewUserPopup = {\n+  title: '欢迎加入卡博士',\n+  description: '恭喜成为卡博士新用户，欢迎开启卡博士体验。',\n+}\n+\n+/** Build a visible first-entry notice from the new-user flag, even without optional popup copy. */\n+export function getNewUserPopup(session: AuthSession | undefined): NewUserPopup | null {\n+  if (session?.is_new_user !== true) return null\n+  return session.popup ?? DEFAULT_NEW_USER_POPUP\n }\n \n const loginClient = createHttpClient({\n@@ -78,7 +91,7 @@ export function setAuthFlowEnabled(enabled: boolean) {\n   authFlowEnabled = enabled && runtimePolicy.dataMode === 'api'\n }\n \n-/** Accept the confirmed backend envelope without weakening the login contract. */\n+/** Accept the confirmed backend envelope and preserve new-user metadata at either envelope level. */\n export function parseLoginResponse(result: unknown): LoginResponse {\n   if (!result || typeof result !== 'object') {\n     throw createContractError('登录接口返回格式无效', { code: 'AUTH_LOGIN_RESPONSE_INVALID' })\n@@ -99,18 +112,35 @@ export function parseLoginResponse(result: unknown): LoginResponse {\n     })\n   }\n \n-  const popupValue = payload.popup\n+  // Some API gateways wrap userInfo/accessToken in data but leave extension fields\n+  // (is_new_user/popup) on the outer response. Prefer data when both are present.\n+  const isNewUser = typeof payload.is_new_user === 'boolean'\n+    ? payload.is_new_user\n+    : response.is_new_user\n+  const popupValue = payload.popup ?? response.popup\n   const popup = popupValue && typeof popupValue === 'object' ? popupValue as Partial<NewUserPopup> : undefined\n-  const validPopup = popup && typeof popup.title === 'string' && popup.title.trim()\n-    && typeof popup.description === 'string' && popup.description.trim()\n-    && (typeof popup.points === 'number' || typeof popup.points === 'string')\n-    ? { title: popup.title, description: popup.description, points: popup.points }\n+  const hasPopupContent = Boolean(\n+    (typeof popup?.title === 'string' && popup.title.trim()) ||\n+    (typeof popup?.description === 'string' && popup.description.trim()) ||\n+    typeof popup?.points === 'number' ||\n+    typeof popup?.points === 'string',\n+  )\n+  const validPopup = hasPopupContent\n+    ? {\n+        title: typeof popup?.title === 'string' && popup.title.trim() ? popup.title : DEFAULT_NEW_USER_POPUP.title,\n+        description: typeof popup?.description === 'string' && popup.description.trim()\n+          ? popup.description\n+          : DEFAULT_NEW_USER_POPUP.description,\n+        ...(typeof popup?.points === 'number' || typeof popup?.points === 'string'\n+          ? { points: popup.points }\n+          : {}),\n+      }\n     : undefined\n \n   return {\n     userInfo: payload.userInfo,\n     accessToken: payload.accessToken,\n-    ...(typeof payload.is_new_user === 'boolean' ? { is_new_user: payload.is_new_user } : {}),\n+    ...(typeof isNewUser === 'boolean' ? { is_new_user: isNewUser } : {}),\n     ...(validPopup ? { popup: validPopup } : {}),\n   }\n }",
      "actualWorkers": 2
    },
    "preserveOutput": "always",
    "projects": [
      {
        "outputDir": "/home/runner/work/dr-card-web/dr-card-web/test-results/artifacts",
        "repeatEach": 1,
        "retries": 1,
        "metadata": {
          "ci": {
            "commitHref": "https://github.com/sanchuang-dev/dr-card-web/commit/e62411226a8fad34600843b40c84484781506ae3",
            "commitHash": "e62411226a8fad34600843b40c84484781506ae3",
            "prHref": "https://github.com/sanchuang-dev/dr-card-web/pull/284",
            "prTitle": "release(test): promote dev newcomer auto-popup fix",
            "prBaseHash": "02cd0bcb5942fe841775c7f808ea8995d5ec8a4e",
            "buildHref": "https://github.com/sanchuang-dev/dr-card-web/actions/runs/42046279096"
          },
          "gitCommit": {
            "shortHash": "e624112",
            "hash": "e62411226a8fad34600843b40c84484781506ae3",
            "subject": "Merge e42f805ddbe8d4d030e756e24e65993492970601 into 02cd0bcb5942fe841775c7f808ea8995d5ec8a4e",
            "body": "Merge e42f805ddbe8d4d030e756e24e65993492970601 into 02cd0bcb5942fe841775c7f808ea8995d5ec8a4e\n",
            "author": {
              "name": "SINGING-AKIBA",
              "email": "980325684@qq.com",
              "time": 1793889254000
            },
            "committer": {
              "name": "GitHub",
              "email": "noreply@github.com",
              "time": 1793889254000
            },
            "branch": "HEAD"
          },
          "gitDiff": "diff --git a/src/App.tsx b/src/App.tsx\nindex 661efab..ff63317 100644\n--- a/src/App.tsx\n+++ b/src/App.tsx\n@@ -3,10 +3,10 @@ import { RouterProvider } from 'react-router-dom'\n import { router } from './app/router'\n import { initialEntryRedirect } from './app/router/entryRedirect'\n import NewcomerDialog from './components/mobile/NewcomerDialog'\n-import { getAuthSession, type NewUserPopup } from './services/auth/session'\n+import { getAuthSession, getNewUserPopup } from './services/auth/session'\n \n export default function App() {\n-  const [newUserPopup, setNewUserPopup] = useState<NewUserPopup | null>(null)\n+  const [newUserPopup, setNewUserPopup] = useState<ReturnType<typeof getNewUserPopup>>(null)\n   const popupHandledThisDocument = useRef(false)\n \n   // Hide the new-user dialog only while React Router resolves the initial\n@@ -19,8 +19,8 @@ export default function App() {\n \n   useEffect(() => {\n     const handleSessionChanged = () => {\n-      const session = getAuthSession()\n-      if (session?.is_new_user !== true || !session.popup) {\n+      const popup = getNewUserPopup(getAuthSession())\n+      if (!popup) {\n         setNewUserPopup(null)\n         return\n       }\n@@ -28,7 +28,7 @@ export default function App() {\n       // during this page lifetime; do not add unconfirmed persistent storage keys.\n       if (popupHandledThisDocument.current) return\n       popupHandledThisDocument.current = true\n-      setNewUserPopup(session.popup)\n+      setNewUserPopup(popup)\n     }\n \n     window.addEventListener('dr-card-ui:auth-session-changed', handleSessionChanged)\n@@ -40,9 +40,9 @@ export default function App() {\n   return (\n     <>\n       <RouterProvider router={router} />\n-      <NewcomerDialog open={newUserPopup !== null && !entryRedirectPending} title={newUserPopup?.title ?? ''}\n-        description={newUserPopup?.description ?? ''} points={newUserPopup?.points ?? ''}\n-        onComplete={dismissPopup} onDismiss={dismissPopup} />\n+      <NewcomerDialog open={newUserPopup !== null && !entryRedirectPending}\n+        title={newUserPopup?.title ?? ''} description={newUserPopup?.description ?? ''}\n+        points={newUserPopup?.points} onComplete={dismissPopup} onDismiss={dismissPopup} />\n     </>\n   )\n }\ndiff --git a/src/components/mobile/NewcomerDialog.tsx b/src/components/mobile/NewcomerDialog.tsx\nindex 7a3b06a..c371f6f 100644\n--- a/src/components/mobile/NewcomerDialog.tsx\n+++ b/src/components/mobile/NewcomerDialog.tsx\n@@ -5,12 +5,12 @@ export interface NewcomerDialogProps {\n   open: boolean\n   title: string\n   description: string\n-  points: number | string\n+  points?: number | string\n   onComplete: () => void\n   onDismiss: () => void\n }\n \n-/** Displays the new-user bubble-points reward returned by the login API. */\n+/** Displays the new-user notice immediately after the login API identifies a new user. */\n export default function NewcomerDialog({ open, title, description, points, onComplete, onDismiss }: NewcomerDialogProps) {\n   return (\n     <PromptOverlay open={open} label=\"新人泡泡值赠送提示\" onDismiss={onDismiss}>\n@@ -18,10 +18,12 @@ export default function NewcomerDialog({ open, title, description, points, onCom\n         <span className=\"mx-auto inline-flex rounded-pill bg-reward-subtle px-3 py-1 text-[11px] font-semibold tracking-[0.12em] text-reward-text\">新人福利</span>\n         <h2 className=\"mt-3 text-xl font-bold leading-7 text-text-primary\">{title}</h2>\n         <p className=\"mt-3 text-sm leading-6 text-text-secondary\">{description}</p>\n-        <div className=\"mx-auto mt-5 flex min-h-24 w-full flex-col items-center justify-center rounded-container border border-reward/30 bg-reward-subtle px-4 py-3\">\n-          <span className=\"text-xs font-medium text-reward-text\">赠送泡泡值</span>\n-          <span className=\"mt-1 text-3xl font-extrabold tabular-nums text-reward-text\">{points}</span>\n-        </div>\n+        {points !== undefined && (\n+          <div className=\"mx-auto mt-5 flex min-h-24 w-full flex-col items-center justify-center rounded-container border border-reward/30 bg-reward-subtle px-4 py-3\">\n+            <span className=\"text-xs font-medium text-reward-text\">赠送泡泡值</span>\n+            <span className=\"mt-1 text-3xl font-extrabold tabular-nums text-reward-text\">{points}</span>\n+          </div>\n+        )}\n       </div>\n       <div className=\"bg-surface px-5 pb-5 pt-4\">\n         <Button size=\"large\" className=\"w-full rounded-pill\" onClick={onComplete}>我知道了</Button>\ndiff --git a/src/services/auth/session.test.ts b/src/services/auth/session.test.ts\nindex 7add2db..f451cf7 100644\n--- a/src/services/auth/session.test.ts\n+++ b/src/services/auth/session.test.ts\n@@ -1,6 +1,6 @@\n import { describe, expect, it } from 'vitest'\n \n-import { parseLoginResponse } from './session'\n+import { getNewUserPopup, parseLoginResponse } from './session'\n \n describe('login response contract', () => {\n   it('accepts the original top-level login response', () => {\n@@ -17,19 +17,65 @@ describe('login response contract', () => {\n     })).toEqual({ userInfo: { id: 'user-1' }, accessToken: 'token-1', is_new_user: false })\n   })\n \n-  it('preserves new-user popup content from the backend envelope', () => {\n+  it('preserves new-user metadata returned beside the data envelope', () => {\n+    expect(parseLoginResponse({\n+      code: 0,\n+      is_new_user: true,\n+      popup: { title: '注册成功', description: '已获得泡泡值奖励', points: 120 },\n+      data: { userInfo: { id: 'user-2' }, accessToken: 'token-2' },\n+    })).toEqual({\n+      userInfo: { id: 'user-2' },\n+      accessToken: 'token-2',\n+      is_new_user: true,\n+      popup: { title: '注册成功', description: '已获得泡泡值奖励', points: 120 },\n+    })\n+  })\n+\n+  it('preserves new-user metadata nested inside the data envelope', () => {\n     expect(parseLoginResponse({ code: 0, data: {\n-      userInfo: { id: 'user-2' }, accessToken: 'token-2', is_new_user: true,\n+      userInfo: { id: 'user-3' }, accessToken: 'token-3', is_new_user: true,\n+      popup: { title: '注册成功', description: '已获得泡泡值奖励', points: 120 },\n+    } })).toEqual({\n+      userInfo: { id: 'user-3' },\n+      accessToken: 'token-3',\n+      is_new_user: true,\n       popup: { title: '注册成功', description: '已获得泡泡值奖励', points: 120 },\n-    } })).toEqual({ userInfo: { id: 'user-2' }, accessToken: 'token-2', is_new_user: true,\n-      popup: { title: '注册成功', description: '已获得泡泡值奖励', points: 120 } })\n+    })\n   })\n \n-  it('ignores malformed popup content and preserves ordinary login', () => {\n-    expect(parseLoginResponse({ userInfo: {}, accessToken: 'token-3', is_new_user: true,\n-      popup: { title: 'Hi', description: '', points: 5 } })).toEqual({\n-      userInfo: {}, accessToken: 'token-3', is_new_user: true,\n+  it('shows a default new-user notice when the backend omits optional popup content', () => {\n+    const session = parseLoginResponse({\n+      code: 0,\n+      data: { userInfo: { id: 'user-4' }, accessToken: 'token-4', is_new_user: true },\n+    })\n+    expect(getNewUserPopup(session)).toEqual({\n+      title: '欢迎加入卡博士',\n+      description: '恭喜成为卡博士新用户，欢迎开启卡博士体验。',\n+    })\n+  })\n+\n+  it('does not show a notice for an existing user', () => {\n+    const session = parseLoginResponse({\n+      code: 0,\n+      data: { userInfo: { id: 'user-5' }, accessToken: 'token-5', is_new_user: false },\n+    })\n+    expect(getNewUserPopup(session)).toBeNull()\n+  })\n+\n+  it('ignores malformed popup content but keeps the new-user flag', () => {\n+    const session = parseLoginResponse({\n+      userInfo: {}, accessToken: 'token-6', is_new_user: true,\n+      popup: { title: 'Hi', description: '', points: 5 },\n+    })\n+    expect(session).toEqual({\n+      userInfo: {}, accessToken: 'token-6', is_new_user: true,\n+      popup: {\n+        title: 'Hi',\n+        description: '恭喜成为卡博士新用户，欢迎开启卡博士体验。',\n+        points: 5,\n+      },\n     })\n+    expect(getNewUserPopup(session)).toEqual(session.popup)\n   })\n \n   it('does not treat a non-success envelope as a valid session', () => {\ndiff --git a/src/services/auth/session.ts b/src/services/auth/session.ts\nindex 5e2f9c2..2155f9b 100644\n--- a/src/services/auth/session.ts\n+++ b/src/services/auth/session.ts\n@@ -18,7 +18,7 @@ const LOGIN_PATH = '/api/oauth/login'\n export interface NewUserPopup {\n   title: string\n   description: string\n-  points: number | string\n+  points?: number | string\n }\n \n export interface AuthSession {\n@@ -33,6 +33,19 @@ interface LoginResponse extends AuthSession {}\n interface LoginEnvelope {\n   code?: unknown\n   data?: unknown\n+  is_new_user?: unknown\n+  popup?: unknown\n+}\n+\n+const DEFAULT_NEW_USER_POPUP: NewUserPopup = {\n+  title: '欢迎加入卡博士',\n+  description: '恭喜成为卡博士新用户，欢迎开启卡博士体验。',\n+}\n+\n+/** Build a visible first-entry notice from the new-user flag, even without optional popup copy. */\n+export function getNewUserPopup(session: AuthSession | undefined): NewUserPopup | null {\n+  if (session?.is_new_user !== true) return null\n+  return session.popup ?? DEFAULT_NEW_USER_POPUP\n }\n \n const loginClient = createHttpClient({\n@@ -78,7 +91,7 @@ export function setAuthFlowEnabled(enabled: boolean) {\n   authFlowEnabled = enabled && runtimePolicy.dataMode === 'api'\n }\n \n-/** Accept the confirmed backend envelope without weakening the login contract. */\n+/** Accept the confirmed backend envelope and preserve new-user metadata at either envelope level. */\n export function parseLoginResponse(result: unknown): LoginResponse {\n   if (!result || typeof result !== 'object') {\n     throw createContractError('登录接口返回格式无效', { code: 'AUTH_LOGIN_RESPONSE_INVALID' })\n@@ -99,18 +112,35 @@ export function parseLoginResponse(result: unknown): LoginResponse {\n     })\n   }\n \n-  const popupValue = payload.popup\n+  // Some API gateways wrap userInfo/accessToken in data but leave extension fields\n+  // (is_new_user/popup) on the outer response. Prefer data when both are present.\n+  const isNewUser = typeof payload.is_new_user === 'boolean'\n+    ? payload.is_new_user\n+    : response.is_new_user\n+  const popupValue = payload.popup ?? response.popup\n   const popup = popupValue && typeof popupValue === 'object' ? popupValue as Partial<NewUserPopup> : undefined\n-  const validPopup = popup && typeof popup.title === 'string' && popup.title.trim()\n-    && typeof popup.description === 'string' && popup.description.trim()\n-    && (typeof popup.points === 'number' || typeof popup.points === 'string')\n-    ? { title: popup.title, description: popup.description, points: popup.points }\n+  const hasPopupContent = Boolean(\n+    (typeof popup?.title === 'string' && popup.title.trim()) ||\n+    (typeof popup?.description === 'string' && popup.description.trim()) ||\n+    typeof popup?.points === 'number' ||\n+    typeof popup?.points === 'string',\n+  )\n+  const validPopup = hasPopupContent\n+    ? {\n+        title: typeof popup?.title === 'string' && popup.title.trim() ? popup.title : DEFAULT_NEW_USER_POPUP.title,\n+        description: typeof popup?.description === 'string' && popup.description.trim()\n+          ? popup.description\n+          : DEFAULT_NEW_USER_POPUP.description,\n+        ...(typeof popup?.points === 'number' || typeof popup?.points === 'string'\n+          ? { points: popup.points }\n+          : {}),\n+      }\n     : undefined\n \n   return {\n     userInfo: payload.userInfo,\n     accessToken: payload.accessToken,\n-    ...(typeof payload.is_new_user === 'boolean' ? { is_new_user: payload.is_new_user } : {}),\n+    ...(typeof isNewUser === 'boolean' ? { is_new_user: isNewUser } : {}),\n     ...(validPopup ? { popup: validPopup } : {}),\n   }\n }",
          "actualWorkers": 2
        },
        "id": "chromium-mobile",
        "name": "chromium-mobile",
        "testDir": "/home/runner/work/dr-card-web/dr-card-web/tests/e2e",
        "testIgnore": [],
        "testMatch": [
          "**/*.@(spec|test).?(c|m)[jt]s?(x)"
        ],
        "timeout": 30000
      }
    ],
    "quiet": false,
    "reporter": [
      [
        "line",
        null
      ],
      [
        "html",
        {
          "outputFolder": "playwright-report",
          "open": "never"
        }
      ],
      [
        "json",
        {
          "outputFile": "test-results/playwright-results.json"
        }
      ]
    ],
    "reportSlowTests": {
      "max": 5,
      "threshold": 300000
    },
    "shard": null,
    "tags": [],
    "updateSnapshots": "missing",
    "updateSourceMethod": "patch",
    "version": "1.63.0",
    "workers": 2,
    "webServer": null
  },
  "suites": [
    {
      "title": "test-gate.spec.ts",
      "file": "test-gate.spec.ts",
      "column": 0,
      "line": 0,
      "specs": [
        {
          "title": "@test-gate build identity matches the production-like test policy",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 52,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-11-05T14:35:23.889Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-c3b6e0ce5d8c5d8734e5",
          "file": "test-gate.spec.ts",
          "line": 31,
          "column": 1
        },
        {
          "title": "@test-gate browser entry does not start the app router",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 1,
                  "parallelIndex": 1,
                  "status": "passed",
                  "duration": 1331,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-11-05T14:35:23.923Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-8ed37c8041402a2a6b34",
          "file": "test-gate.spec.ts",
          "line": 40,
          "column": 1
        },
        {
          "title": "@test-gate / shows the unsupported-host notice in a browser",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 1259,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-11-05T14:35:24.014Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-b030d57053896c93a550",
          "file": "test-gate.spec.ts",
          "line": 51,
          "column": 3
        },
        {
          "title": "@test-gate /profile shows the unsupported-host notice in a browser",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 1048,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-11-05T14:35:25.505Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-8605535a2807e17fcccc",
          "file": "test-gate.spec.ts",
          "line": 51,
          "column": 3
        },
        {
          "title": "@test-gate /settings shows the unsupported-host notice in a browser",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 1,
                  "parallelIndex": 1,
                  "status": "passed",
                  "duration": 1030,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-11-05T14:35:25.586Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-4a2fdc9884c1725de4f8",
          "file": "test-gate.spec.ts",
          "line": 51,
          "column": 3
        },
        {
          "title": "@test-gate gated browser entry issues no business API request",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 1003,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-11-05T14:35:26.567Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-09f38a2b8ed735e192d8",
          "file": "test-gate.spec.ts",
          "line": 65,
          "column": 1
        },
        {
          "title": "@test-gate injected native host bypasses the notice and starts the app",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 1,
                  "parallelIndex": 1,
                  "status": "passed",
                  "duration": 1060,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-11-05T14:35:26.635Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-8caf237c4d42513fff2f",
          "file": "test-gate.spec.ts",
          "line": 80,
          "column": 1
        }
      ]
    }
  ],
  "errors": [],
  "stats": {
    "startTime": "2026-11-05T14:35:23.004Z",
    "duration": 4738.83,
    "expected": 7,
    "skipped": 0,
    "unexpected": 0,
    "flaky": 0
  }
}