{
  "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/dc10b10bde3c218888822aac068ef857f3c3ddc6",
        "commitHash": "dc10b10bde3c218888822aac068ef857f3c3ddc6",
        "prHref": "https://github.com/sanchuang-dev/dr-card-web/pull/236",
        "prTitle": "chore(release): promote card transfer buddy picker and Chromium 88 fix dev → test",
        "prBaseHash": "4cb41c1c4e69048eae91cc6db11c78fba0bbeb79",
        "buildHref": "https://github.com/sanchuang-dev/dr-card-web/actions/runs/38706898858"
      },
      "gitCommit": {
        "shortHash": "dc10b10",
        "hash": "dc10b10bde3c218888822aac068ef857f3c3ddc6",
        "subject": "Merge 9c591ce29b6ca29f532cd9aa15ab90da7c7477d2 into 4cb41c1c4e69048eae91cc6db11c78fba0bbeb79",
        "body": "Merge 9c591ce29b6ca29f532cd9aa15ab90da7c7477d2 into 4cb41c1c4e69048eae91cc6db11c78fba0bbeb79\n",
        "author": {
          "name": "Tomz Dang",
          "email": "dangjingtao@163.com",
          "time": 1792053398000
        },
        "committer": {
          "name": "GitHub",
          "email": "noreply@github.com",
          "time": 1792053398000
        },
        "branch": "HEAD"
      },
      "gitDiff": "diff --git a/package.json b/package.json\nindex 9dff200..0f3c070 100644\n--- a/package.json\n+++ b/package.json\n@@ -26,7 +26,7 @@\n     \"build:cf\": \"npm run typecheck && npm run verify:images && node scripts/build-h5.mjs cf\",\n     \"preview\": \"vite preview --host 0.0.0.0\",\n     \"test:e2e\": \"npm run test:e2e:formal\",\n-    \"test:e2e:formal\": \"playwright test tests/e2e/formal-h5.spec.ts tests/e2e/uxe-exchange.spec.ts tests/e2e/redeem-code-project-selector.spec.ts\",\n+    \"test:e2e:formal\": \"playwright test tests/e2e/formal-h5.spec.ts tests/e2e/uxe-exchange.spec.ts tests/e2e/redeem-code-project-selector.spec.ts tests/e2e/card-ticket-legacy-layout.spec.ts\",\n     \"test:e2e:bridge-lab\": \"playwright test tests/e2e/bridge-lab.spec.ts\",\n     \"test:e2e:test-gate\": \"playwright test tests/e2e/test-gate.spec.ts\",\n     \"test:e2e:ui\": \"playwright test tests/e2e/formal-h5.spec.ts --ui\",\ndiff --git a/scripts/verify-h022-route-state-convergence.mjs b/scripts/verify-h022-route-state-convergence.mjs\nindex 9da4619..8847d61 100644\n--- a/scripts/verify-h022-route-state-convergence.mjs\n+++ b/scripts/verify-h022-route-state-convergence.mjs\n@@ -28,7 +28,6 @@ try {\n   assert.equal(route('/onboarding/success').implementationVariant, 'onboarding')\n \n   const stateFamilies = {\n-    '/card/share': ['success'],\n     '/card/verify/confirm': ['done', 'repeat'],\n     '/orders': ['completed', 'ongoing', 'aftersale', 'empty'],\n     '/address': ['empty'],\n@@ -36,6 +35,10 @@ try {\n     '/service/chat/human': ['queuing', 'connected'],\n   }\n \n+  // Coupon transfer has no backend write API yet: the former fixture-only\n+  // \"success\" state must never be advertised as a business success.\n+  assert.ok(!(route('/card/share').states ?? []).some((state) => state.key === 'success'))\n+\n   for (const [path, expectedStates] of Object.entries(stateFamilies)) {\n     const keys = new Set((route(path).states ?? []).map((state) => state.key))\n     for (const state of expectedStates) {\ndiff --git a/src/app/router/routes.ts b/src/app/router/routes.ts\nindex 05bc2fb..a2f7cfd 100644\n--- a/src/app/router/routes.ts\n+++ b/src/app/router/routes.ts\n@@ -980,13 +980,12 @@ export const ROUTES: RouteMeta[] = [\n   },\n   {\n     path: '/card/share',\n-    title: '分享',\n-    nodes: [65, 66],\n+    title: '选择转赠搭子',\n+    nodes: [65],\n     task: 'T009',\n-    entry: '卡包-可用券「转赠」按钮（reference/卡包.html 原型按钮文案；本页标题按 reference/分享.html 为「分享」）',\n+    entry: '卡包-第三方可用券「转赠」按钮，须携带当前券 id',\n     returnTo: '卡包',\n-    states: [{ key: 'success', node: 66, label: '分享成功' }],\n-    owner: '分享对象选择与分享成功（T009；B-015 已关闭，按原型做搭子列表夹具，不做接受/次数/时效/持久化）',\n+    owner: '只读加载当前登录用户的真实搭子并单选；转赠接口尚未接入，不提供成功状态或写操作',\n   },\n   {\n     path: '/card/verify',\ndiff --git a/src/pages/Card.tsx b/src/pages/Card.tsx\nindex 80032af..45ffb4b 100644\n--- a/src/pages/Card.tsx\n+++ b/src/pages/Card.tsx\n@@ -153,14 +153,17 @@ function MovieTicket({ coupon, expired, used, onUse, onShare }: MovieTicketProps\n         />\n \n         {/* 主体：flex 左右两栏 */}\n-        <div className=\"flex\">\n+        <div className=\"flex w-full min-w-0\">\n           {/* 左半：色块区 */}\n           <div\n             className=\"flex flex-col items-center justify-center px-4 py-4 text-center\"\n             style={{\n               background: leftBg,\n-              minWidth: 120,\n-              flex: '0 0 auto',\n+              // The left piece stays bounded so Chromium 88 has space to shrink the right pane.\n+              width: '36%',\n+              minWidth: 108,\n+              maxWidth: 128,\n+              flex: '0 0 36%',\n             }}\n             aria-hidden\n           >\n@@ -183,9 +186,20 @@ function MovieTicket({ coupon, expired, used, onUse, onShare }: MovieTicketProps\n           />\n \n           {/* 右半：内容区 */}\n-          <div className=\"flex-1 px-4 py-3\">\n-            <div className=\"flex items-start justify-between gap-2\">\n-              <h3 className={`min-w-0 truncate text-[15px] font-semibold ${isInactive ? 'text-coupon-used' : 'text-text-primary'}`}>\n+          <div className=\"min-w-0 flex-1 px-3 py-3\" style={{ minWidth: 0, flex: '1 1 0%' }}>\n+            <div className=\"flex min-w-0 items-start justify-between gap-2\">\n+              <h3\n+                className={`min-w-0 flex-1 text-[15px] font-semibold leading-5 ${isInactive ? 'text-coupon-used' : 'text-text-primary'}`}\n+                style={{\n+                  minWidth: 0,\n+                  // -webkit-line-clamp is supported by legacy Chromium 88; avoid unsupported CSS line-clamp.\n+                  display: '-webkit-box',\n+                  WebkitLineClamp: 2,\n+                  WebkitBoxOrient: 'vertical',\n+                  overflow: 'hidden',\n+                  overflowWrap: 'anywhere',\n+                }}\n+              >\n                 {coupon.name}\n               </h3>\n               <span\n@@ -203,14 +217,14 @@ function MovieTicket({ coupon, expired, used, onUse, onShare }: MovieTicketProps\n             </div>\n \n             {coupon.validityLabel && (\n-              <div className={`mt-1.5 flex items-center gap-1 text-[11px] ${isInactive ? 'text-text-inactive-muted' : 'text-text-tertiary'}`}>\n-                <CalendarDays className=\"h-3 w-3\" />\n+              <div className={`mt-1.5 flex min-w-0 items-start gap-1 break-all text-[11px] ${isInactive ? 'text-text-inactive-muted' : 'text-text-tertiary'}`}>\n+                <CalendarDays className=\"mt-0.5 h-3 w-3 flex-none\" />\n                 {coupon.validityLabel}\n               </div>\n             )}\n \n             {coupon.limitNote && (\n-              <div className={`mt-1 text-[11px] ${isInactive ? 'text-text-inactive-muted' : 'text-text-tertiary'}`}>\n+              <div className={`mt-1 min-w-0 break-all text-[11px] ${isInactive ? 'text-text-inactive-muted' : 'text-text-tertiary'}`}>\n                 {coupon.limitNote}\n               </div>\n             )}\n@@ -219,13 +233,13 @@ function MovieTicket({ coupon, expired, used, onUse, onShare }: MovieTicketProps\n              *  使用：橙色实心 pill；转赠：白底描边 pill。\n              *  两个按钮 flex-1 平分宽度，视觉权重一致。\n              *  已过期：两按钮都禁用；已使用：使用按钮隐藏（演示态）。 */}\n-            <div className=\"mt-3 flex items-center gap-2\">\n+            <div className=\"mt-3 flex min-w-0 items-center gap-2\">\n               {!used && !expired && (\n                 <button\n                   type=\"button\"\n                   onClick={onUse}\n                   disabled={!canAction}\n-                  className=\"h-8 flex-1 rounded-full bg-primary text-[12px] font-semibold text-text-inverse shadow-primary-button active:bg-primary-pressed disabled:opacity-50\"\n+                  className=\"h-8 min-w-0 flex-1 rounded-full bg-primary px-1 text-[12px] font-semibold text-text-inverse shadow-primary-button active:bg-primary-pressed disabled:opacity-50\"\n                 >\n                   使用\n                 </button>\n@@ -234,7 +248,7 @@ function MovieTicket({ coupon, expired, used, onUse, onShare }: MovieTicketProps\n                 type=\"button\"\n                 onClick={onShare}\n                 disabled={!canAction}\n-                className={`h-8 flex-1 rounded-full border text-[12px] font-semibold ${\n+                className={`h-8 min-w-0 flex-1 rounded-full border px-1 text-[12px] font-semibold ${\n                   canAction\n                     ? 'border-border-strong bg-transparent text-text-secondary active:bg-surface-subtle'\n                     : 'border-border bg-transparent text-text-inactive-muted'\n@@ -257,7 +271,7 @@ function CouponTicketSkeleton() {\n       <span className=\"sr-only\">正在加载体验券</span>\n       {[0, 1].map((index) => (\n         <div key={index} aria-hidden=\"true\" className=\"flex min-h-[166px] overflow-hidden rounded-[16px] bg-surface shadow-sm\">\n-          <div className=\"flex w-[120px] shrink-0 flex-col items-center justify-center gap-3 bg-reward-subtle px-4\">\n+          <div className=\"flex w-[36%] min-w-[108px] max-w-[128px] shrink-0 flex-col items-center justify-center gap-3 bg-reward-subtle px-4\">\n             <Skeleton className=\"h-9 w-14\" />\n             <Skeleton className=\"h-3 w-12\" />\n           </div>\ndiff --git a/src/pages/CardShare.test.tsx b/src/pages/CardShare.test.tsx\nnew file mode 100644\nindex 0000000..0f8c61d\n--- /dev/null\n+++ b/src/pages/CardShare.test.tsx\n@@ -0,0 +1,82 @@\n+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'\n+import { MemoryRouter } from 'react-router-dom'\n+import { afterEach, describe, expect, it, vi } from 'vitest'\n+import type { ReactNode } from 'react'\n+\n+const mocks = vi.hoisted(() => ({ mode: 'api', relations: vi.fn() }))\n+vi.mock('../app/config/runtime', () => ({ runtimePolicy: { get dataMode() { return mocks.mode } } }))\n+vi.mock('../services/buddyRelations', () => ({ loadBuddyRelations: mocks.relations }))\n+vi.mock('../components/mobile/DebugPanel', () => ({ default: () => null }))\n+vi.mock('../components/mobile/PageContainer', () => ({\n+  default: ({ children }: { children: ReactNode }) => <main>{children}</main>,\n+}))\n+\n+import CardShare from './CardShare'\n+\n+const people = [\n+  { id: '19', nickname: '南沙搭子阿明', avatarUrl: null },\n+  { id: '22', nickname: '天河搭子小芳', avatarUrl: 'https://example.com/avatar.jpg' },\n+]\n+\n+const mount = (url = '/card/share?coupon=91001') => render(\n+  <MemoryRouter initialEntries={[url]}><CardShare /></MemoryRouter>,\n+)\n+afterEach(() => { mocks.mode = 'api'; mocks.relations.mockReset() })\n+\n+describe('card transfer only prepares a real buddy recipient', () => {\n+  it('reads current authenticated buddies, never the old fixture names, and never claims transfer succeeded', async () => {\n+    mocks.relations.mockResolvedValue(people)\n+    mount()\n+    expect(screen.getByText('正在加载我的搭子…')).toBeTruthy()\n+    expect(await screen.findByRole('radio', { name: '南沙搭子阿明' })).toBeTruthy()\n+    expect(mocks.relations).toHaveBeenCalledTimes(1)\n+    expect(screen.queryByText('小美')).toBeNull()\n+    expect(screen.getByRole('button', { name: '确认选择' }).hasAttribute('disabled')).toBe(true)\n+\n+    fireEvent.click(screen.getByRole('radio', { name: '南沙搭子阿明' }))\n+    fireEvent.change(screen.getByPlaceholderText('搜索我的搭子'), { target: { value: '天河' } })\n+    expect(within(screen.getByLabelText('已选搭子')).getByRole('radio', { name: '南沙搭子阿明' })).toBeTruthy()\n+    expect(screen.getByRole('radio', { name: '天河搭子小芳' })).toBeTruthy()\n+    fireEvent.click(screen.getByRole('button', { name: '确认选择' }))\n+    expect(screen.getByText('已选择「南沙搭子阿明」。转赠接口尚未接入，卡券没有转出。')).toBeTruthy()\n+    expect(screen.queryByText('分享成功')).toBeNull()\n+    expect(mocks.relations).toHaveBeenCalledTimes(1)\n+  })\n+\n+  it('keeps selection when keyword finds nobody, then changes it only on a real click', async () => {\n+    mocks.relations.mockResolvedValue(people)\n+    mount()\n+    fireEvent.click(await screen.findByRole('radio', { name: '南沙搭子阿明' }))\n+    fireEvent.change(screen.getByPlaceholderText('搜索我的搭子'), { target: { value: '不存在' } })\n+    expect(screen.getByText('没有匹配的搭子，已选搭子仍保留')).toBeTruthy()\n+    fireEvent.change(screen.getByPlaceholderText('搜索我的搭子'), { target: { value: '' } })\n+    fireEvent.click(screen.getByRole('radio', { name: '天河搭子小芳' }))\n+    expect(within(screen.getByLabelText('已选搭子')).getByRole('radio', { name: '天河搭子小芳' })).toBeTruthy()\n+    expect(screen.queryByText('分享成功')).toBeNull()\n+  })\n+\n+  it('shows empty and failure states without falling back to fixture relations', async () => {\n+    mocks.relations.mockRejectedValueOnce(new Error('登录搭子服务暂不可用')).mockResolvedValueOnce([])\n+    mount()\n+    expect(await screen.findByText('登录搭子���务暂不可用')).toBeTruthy()\n+    expect(screen.queryByText('小美')).toBeNull()\n+    fireEvent.click(screen.getByRole('button', { name: '重新加载' }))\n+    await waitFor(() => expect(screen.getByText('暂无搭子，可先去「洗头搭子」添加好友')).toBeTruthy())\n+    expect(mocks.relations).toHaveBeenCalledTimes(2)\n+  })\n+\n+  it('does not request buddies or invent a coupon when link has no coupon ID', () => {\n+    mount('/card/share')\n+    expect(screen.getByText('未选择待转赠卡券，请从卡包点击「转赠」进入')).toBeTruthy()\n+    expect(mocks.relations).not.toHaveBeenCalled()\n+    expect(screen.queryByRole('button', { name: '确认选择' })).toBeNull()\n+  })\n+\n+  it('clearly labels mock-only preview relations and never calls real list service', async () => {\n+    mocks.mode = 'mock'\n+    mount()\n+    expect(await screen.findByText('演示搭子，仅用于 UI 预览；非真实账号')).toBeTruthy()\n+    expect(screen.getByRole('radio', { name: '小美' })).toBeTruthy()\n+    expect(mocks.relations).not.toHaveBeenCalled()\n+  })\n+})\ndiff --git a/src/pages/CardShare.tsx b/src/pages/CardShare.tsx\nindex cd34b53..acb0b8b 100644\n--- a/src/pages/CardShare.tsx\n+++ b/src/pages/CardShare.tsx\n@@ -1,147 +1,191 @@\n-import { useState } from 'react'\n-import { useNavigate, useSearchParams } from 'react-router-dom'\n-import { Check, ShoppingBag } from 'lucide-react'\n+import { useEffect, useMemo, useState } from 'react'\n+import { useSearchParams } from 'react-router-dom'\n+import { Check, RefreshCw, UserRound } from 'lucide-react'\n import PageContainer from '../components/mobile/PageContainer'\n import DebugPanel from '../components/mobile/DebugPanel'\n import { Button, SearchField } from '../components/ui'\n-import { useFixtureQueryControls, useFixtureState } from '../app/fixtures/useFixture'\n import { findRouteByPathname } from '../app/router/routes'\n-import {\n-  resolveCardCoupon,\n-  SHARE_PRODUCT_FIXTURE,\n-  SHARE_TARGET_FIXTURES,\n-  type ShareTargetFixture,\n-} from '../app/fixtures'\n+import { runtimePolicy } from '../app/config/runtime'\n+import { SHARE_TARGET_FIXTURES } from '../app/fixtures'\n+import { loadBuddyRelations, type BuddyMember } from '../services/buddyRelations'\n+\n+type BuddyRemote =\n+  | { state: 'loading' }\n+  | { state: 'ready'; items: BuddyMember[] }\n+  | { state: 'error'; message: string }\n \n /**\n- * 分享（#65 选择接收的人 / #66 分享成功）\n- * -------------------------------------------------------------\n- * 视觉、结构、文案与单选交互照 reference/分享.html、reference/分享成功.html 还原：\n- * - #65：搜索框 + 分组标题「搭子列表」+ 单选列表 + 底部主操作「下一步」，默认选中第一位；\n- * - #66：成功环 hero + 商品信息卡（缩略图 / 名称 / 点分隔 meta / 标签）+「查看我的卡包」+ ghost「返回」。\n- * 两个节点在原稿里是同一条链路的前后页，故合并为一页，用 fixture state 切换，\n- * 便于 preview/dev 按 URL 直达截图，同时 test/prod 外部 query 不再可信。\n- *\n- * B-015 已关闭：按原型做「搭子列表」夹具，单选对象 → 下一步 → 分享成功；\n- *   不做对方接受、次数限制、时效限制和持久化，分享后原卡包状态不变。\n- * B-017 已关闭：#66 商品卡的「洗发试用装 / 已发货 / 单次使用」仅为原型展示夹具\n- *   （SHARE_PRODUCT_FIXTURE），「已发货」不代表分享操作触发真实发货，不实现物流规则。\n+ * Transfer preparation only: pick an actual buddy from the authenticated account.\n+ * No transfer API has been signed, so this screen MUST NOT mutate a coupon, navigate\n+ * to the legacy fixture success page, or claim that anything was gifted.\n  */\n export default function CardShare() {\n-  const navigate = useNavigate()\n   const route = findRouteByPathname('/card/share')\n-  const { state } = useFixtureState(route)\n-  const { patch: patchFixtureState } = useFixtureQueryControls()\n   const [searchParams] = useSearchParams()\n-\n-  const coupon = resolveCardCoupon(searchParams.get('coupon'))\n+  const couponId = searchParams.get('coupon')?.trim() || null\n+  const mockMode = runtimePolicy.dataMode === 'mock'\n   const [keyword, setKeyword] = useState('')\n-  const [selected, setSelected] = useState<string>(SHARE_TARGET_FIXTURES[0].id)\n-\n-  const targets = SHARE_TARGET_FIXTURES.filter((item) => item.name.includes(keyword.trim()))\n-  const receiver: ShareTargetFixture =\n-    SHARE_TARGET_FIXTURES.find((item) => item.id === selected) ?? SHARE_TARGET_FIXTURES[0]\n-\n-  const submit = () => {\n-    patchFixtureState(\n-      { state: 'success' },\n-      { coupon: coupon.id, target: receiver.id },\n-    )\n-  }\n-\n-  if (state?.key === 'success') {\n-    return (\n-      <PageContainer className=\"flex min-h-full flex-col pb-6\" inset>\n-        <div className=\"flex flex-col items-center gap-3 px-5 pb-4 pt-14 text-center\">\n-          <span className=\"relative flex h-16 w-16 items-center justify-center rounded-full bg-success-bg\" aria-hidden>\n-            <span className=\"absolute -inset-1.5 rounded-full border border-dashed border-success opacity-25\" />\n-            <Check className=\"h-8 w-8 text-success\" strokeWidth={3} />\n-          </span>\n-          <h1 className=\"text-lg font-semibold text-text-primary\">分享成功</h1>\n-          <p className=\"max-w-[260px] text-sm leading-6 text-text-secondary\">已成功分享给好友，邀请他也来一起玩吧</p>\n-        </div>\n+  const [selectedId, setSelectedId] = useState<string | null>(null)\n+  const [confirmedId, setConfirmedId] = useState<string | null>(null)\n+  const [retry, setRetry] = useState(0)\n+  const [remote, setRemote] = useState<BuddyRemote>({ state: 'loading' })\n \n-        <section className=\"mx-4 mt-5 flex items-center gap-3 rounded-container bg-surface p-3 shadow-sm\" aria-label=\"分享的商品\">\n-          <span\n-            className=\"flex h-14 w-14 shrink-0 items-center justify-center rounded-[12px] bg-surface-selected text-text-brand\"\n-            aria-hidden\n-          >\n-            <ShoppingBag className=\"h-7 w-7\" strokeWidth={1.8} />\n-          </span>\n-          <div className=\"min-w-0 flex-1\">\n-            <h2 className=\"truncate text-base font-semibold text-text-primary\">{SHARE_PRODUCT_FIXTURE.name}</h2>\n-            <p className=\"mt-1 flex items-center gap-1.5 text-xs text-text-tertiary\">\n-              <span>{SHARE_PRODUCT_FIXTURE.date}</span>\n-              <span className=\"h-[3px] w-[3px] rounded-full bg-current opacity-60\" aria-hidden />\n-              <span>{SHARE_PRODUCT_FIXTURE.shipping}</span>\n-            </p>\n-            <span className=\"mt-1.5 inline-flex rounded-full bg-surface-selected px-2 py-0.5 text-[11px] font-medium text-text-brand\">\n-              {SHARE_PRODUCT_FIXTURE.tag}\n-            </span>\n-          </div>\n-        </section>\n+  useEffect(() => {\n+    if (!couponId) return\n+    let active = true\n+    setRemote({ state: 'loading' })\n+    setSelectedId(null)\n+    setConfirmedId(null)\n+    if (mockMode) {\n+      setRemote({\n+        state: 'ready',\n+        items: SHARE_TARGET_FIXTURES.map(item => ({\n+          id: item.id, nickname: item.name, avatarUrl: null,\n+        })),\n+      })\n+    } else {\n+      // Reuse the exact backend list service as /buddy; it pages through the\n+      // authenticated GET /api/friends/index records and never takes a user_id.\n+      void loadBuddyRelations().then(items => {\n+        if (active) setRemote({ state: 'ready', items })\n+      }).catch(error => {\n+        if (active) setRemote({\n+          state: 'error',\n+          message: error instanceof Error ? error.message : '搭子列表加载失败，请重试',\n+        })\n+      })\n+    }\n+    return () => { active = false }\n+  }, [couponId, mockMode, retry])\n \n-        <div className=\"mt-auto flex flex-col items-center gap-2 px-4 pb-[calc(1rem+env(safe-area-inset-bottom))] pt-5\">\n-          <Button size=\"large\" className=\"h-12 w-full\" onClick={() => navigate('/card', { replace: true })}>\n-            查看我的卡包\n-          </Button>\n-          <Button variant=\"ghost\" className=\"h-9 text-sm font-medium\" onClick={() => navigate(-1)}>\n-            返回\n-          </Button>\n-        </div>\n+  const items = remote.state === 'ready' ? remote.items : []\n+  const selected = items.find(item => item.id === selectedId) ?? null\n+  const filtered = useMemo(() => {\n+    const term = keyword.trim()\n+    return items.filter(item => item.id !== selectedId && item.nickname.includes(term))\n+  }, [items, keyword, selectedId])\n \n-        <DebugPanel route={route} />\n-      </PageContainer>\n-    )\n+  const choose = (id: string) => {\n+    setSelectedId(id)\n+    setConfirmedId(null)\n   }\n \n+  const renderBuddy = (buddy: BuddyMember, checked: boolean) => (\n+    <button\n+      key={buddy.id}\n+      type=\"button\"\n+      role=\"radio\"\n+      aria-checked={checked}\n+      onClick={() => choose(buddy.id)}\n+      className={`flex min-h-[68px] w-full min-w-0 items-center gap-3 rounded-xl border px-3 py-3 text-left ${\n+        checked ? 'border-primary bg-surface-selected' : 'border-border-subtle bg-surface'\n+      }`}\n+    >\n+      {buddy.avatarUrl ? (\n+        <img src={buddy.avatarUrl} alt=\"\" className=\"h-11 w-11 flex-none rounded-full object-cover\" />\n+      ) : (\n+        <span className=\"flex h-11 w-11 flex-none items-center justify-center rounded-full bg-reward-subtle text-reward-strong\" aria-hidden>\n+          <UserRound className=\"h-5 w-5\" />\n+        </span>\n+      )}\n+      <span className=\"min-w-0 flex-1 break-words text-[15px] font-medium text-text-primary\">{buddy.nickname}</span>\n+      <span aria-hidden className={`flex h-5 w-5 flex-none items-center justify-center rounded-full border ${\n+        checked ? 'border-primary bg-primary text-text-inverse' : 'border-border bg-surface'\n+      }`}>\n+        {checked && <Check className=\"h-3.5 w-3.5\" strokeWidth={3} />}\n+      </span>\n+    </button>\n+  )\n+\n   return (\n     <PageContainer className=\"flex min-h-full flex-col pb-6\" inset>\n-      <div className=\"px-4 pt-3\">\n-        <SearchField\n-          value={keyword}\n-          onChange={(event) => setKeyword(event.target.value)}\n-          onClear={() => setKeyword('')}\n-          placeholder=\"搜索搭子\"\n-          inputClassName=\"placeholder:text-text-tertiary\"\n-        />\n-      </div>\n+      <header className=\"px-1 pt-3\">\n+        <h1 className=\"text-[16px] font-semibold text-text-primary\">选择转赠搭子</h1>\n+        <p className=\"mt-1 text-[12px] text-text-tertiary\">\n+          转赠接口暂未接入，当前仅支持选择接收人，不会实际转出卡券\n+        </p>\n+      </header>\n+\n+      {!couponId ? (\n+        <section role=\"alert\" className=\"mt-5 rounded-xl bg-surface p-5 text-center text-sm text-text-secondary\">\n+          未选择待转赠卡券，请从卡包点击「转赠」进入\n+        </section>\n+      ) : (\n+        <>\n+          {mockMode && (\n+            <p role=\"status\" className=\"mt-3 rounded-lg bg-reward-subtle px-3 py-2 text-[12px] text-reward-text\">\n+              演示搭子，仅用于 UI 预览；非真实账号\n+            </p>\n+          )}\n+          <div className=\"pt-4\">\n+            <SearchField\n+              value={keyword}\n+              onChange={(event) => setKeyword(event.target.value)}\n+              onClear={() => setKeyword('')}\n+              placeholder=\"搜索我的搭子\"\n+              inputClassName=\"placeholder:text-text-tertiary\"\n+            />\n+          </div>\n \n-      <section className=\"mt-5 px-4\" aria-label=\"搭子列表\">\n-        <h2 className=\"text-sm font-medium text-text-primary\">搭子列表</h2>\n-        <div className=\"mt-2 overflow-hidden rounded-container bg-surface shadow-sm\">\n-          {targets.map((target, index) => {\n-            const checked = selected === target.id\n-            return (\n-              <button\n-                key={target.id}\n-                type=\"button\"\n-                onClick={() => setSelected(target.id)}\n-                className={`flex w-full items-center gap-3 px-4 py-3.5 text-left ${index > 0 ? 'border-t border-border-subtle' : ''}`}\n-              >\n-                <span className=\"flex h-11 w-11 flex-none items-center justify-center rounded-full bg-surface-selected text-sm font-semibold text-text-brand\">\n-                  {target.name.slice(0, 1)}\n-                </span>\n-                <span className=\"min-w-0 flex-1 truncate text-[15px] font-medium text-text-primary\">{target.name}</span>\n-                <span\n-                  className={`flex h-5 w-5 flex-none items-center justify-center rounded-full border ${\n-                    checked ? 'border-primary bg-primary text-text-inverse' : 'border-border bg-surface'\n-                  }`}\n-                  aria-hidden\n-                >\n-                  {checked && <Check className=\"h-3.5 w-3.5\" strokeWidth={3} />}\n-                </span>\n-              </button>\n-            )\n-          })}\n-        </div>\n-      </section>\n+          <section className=\"mt-5 min-w-0\" aria-label=\"搭子列表\">\n+            <div className=\"flex items-center justify-between\">\n+              <h2 className=\"text-sm font-medium text-text-primary\">我的搭子</h2>\n+              {remote.state === 'ready' && (\n+                <span className=\"text-[12px] text-text-tertiary\">{items.length} 位</span>\n+              )}\n+            </div>\n+            <div className=\"mt-3\" role=\"radiogroup\" aria-label=\"选择接收搭子\">\n+              {remote.state === 'loading' ? (\n+                <p role=\"status\" className=\"rounded-xl bg-surface px-4 py-8 text-center text-sm text-text-secondary\">\n+                  正在加载我的搭子…\n+                </p>\n+              ) : remote.state === 'error' ? (\n+                <div role=\"alert\" className=\"rounded-xl bg-surface px-4 py-7 text-center\">\n+                  <p className=\"text-sm text-text-secondary\">{remote.message}</p>\n+                  <Button variant=\"outline\" className=\"mt-3\" leadingIcon={RefreshCw}\n+                    onClick={() => setRetry(x => x + 1)}>重新加载</Button>\n+                </div>\n+              ) : items.length === 0 ? (\n+                <p role=\"status\" className=\"rounded-xl bg-surface px-4 py-8 text-center text-sm text-text-secondary\">\n+                  暂无搭子，可先去「洗头搭子」添加好友\n+                </p>\n+              ) : (\n+                <div className=\"space-y-2.5\">\n+                  {selected && (\n+                    <div className=\"space-y-2\" aria-label=\"已选搭子\">\n+                      <p className=\"text-[12px] font-medium text-reward-text\">已选搭子</p>\n+                      {renderBuddy(selected, true)}\n+                    </div>\n+                  )}\n+                  <p className=\"pt-1 text-[12px] text-text-tertiary\">搜索结果</p>\n+                  {filtered.map(item => renderBuddy(item, false))}\n+                  {filtered.length === 0 && (\n+                    <p className=\"rounded-xl bg-surface px-4 py-5 text-center text-[13px] text-text-tertiary\">\n+                      没有匹配的搭子{selected ? '，已选搭子仍保留' : ''}\n+                    </p>\n+                  )}\n+                </div>\n+              )}\n+            </div>\n+          </section>\n \n-      <div className=\"mt-auto px-4 pb-[env(safe-area-inset-bottom)] pt-5\">\n-        <Button size=\"large\" className=\"h-12 w-full rounded-pill\" onClick={submit} disabled={!selected}>\n-          下一步\n-        </Button>\n-      </div>\n+          <div className=\"mt-auto pt-6 pb-[env(safe-area-inset-bottom)]\">\n+            {confirmedId && selected?.id === confirmedId && (\n+              <p role=\"status\" className=\"mb-3 rounded-xl bg-reward-subtle px-3 py-3 text-center text-[12px] leading-5 text-text-secondary\">\n+                已选择「{selected.nickname}」。转赠接口尚未接入，卡券没有转出。\n+              </p>\n+            )}\n+            <Button\n+              size=\"large\"\n+              className=\"h-12 w-full rounded-pill\"\n+              disabled={!selected}\n+              onClick={() => { if (selected) setConfirmedId(selected.id) }}\n+            >\n+              确认选择\n+            </Button>\n+          </div>\n+        </>\n+      )}\n \n       <DebugPanel route={route} />\n     </PageContainer>\ndiff --git a/tests/e2e/card-ticket-legacy-layout.spec.ts b/tests/e2e/card-ticket-legacy-layout.spec.ts\nnew file mode 100644\nindex 0000000..f485c6d\n--- /dev/null\n+++ b/tests/e2e/card-ticket-legacy-layout.spec.ts\n@@ -0,0 +1,40 @@\n+import { expect, test } from '@playwright/test'\n+\n+test('@formal-h5 card ticket fits Chromium 88-compatible narrow flex sizing', async ({ page }) => {\n+  for (const width of [320, 375]) {\n+    await page.setViewportSize({ width, height: 700 })\n+    await page.goto('/card', { waitUntil: 'domcontentloaded' })\n+    const title = page.getByText('Mock 新人体验活动')\n+    await expect(title).toBeVisible()\n+    const ticket = title.locator('xpath=ancestor::article')\n+    // Change the heading and inspect its geometry in ONE locator evaluation.\n+    // Playwright locators are lazy; querying by the old text after replacing it would time out.\n+    const layout = await ticket.evaluate(node => {\n+      const article = node as HTMLElement\n+      const pane = article.querySelector('h3')?.parentElement?.parentElement as HTMLElement | null\n+      const titleElement = article.querySelector('h3') as HTMLElement | null\n+      const buttons = Array.from(article.querySelectorAll('button')) as HTMLButtonElement[]\n+      if (!pane || !titleElement || buttons.length !== 2) throw new Error('ticket layout nodes missing')\n+      titleElement.textContent = '天津生物工程职业技术学院洗护体验与公益合作活动'.repeat(2)\n+      const bounds = article.getBoundingClientRect()\n+      const right = pane.getBoundingClientRect()\n+      const titleBounds = titleElement.getBoundingClientRect()\n+      return {\n+        articleRight: bounds.right,\n+        contentRight: right.right,\n+        articleWidth: bounds.width,\n+        titleHeight: titleBounds.height,\n+        contentScrollWidth: pane.scrollWidth,\n+        contentClientWidth: pane.clientWidth,\n+        buttonRights: buttons.map(button => button.getBoundingClientRect().right),\n+      }\n+    })\n+    expect(layout.articleWidth).toBeLessThanOrEqual(width)\n+    expect(layout.contentRight).toBeLessThanOrEqual(layout.articleRight + 1)\n+    expect(layout.contentScrollWidth).toBeLessThanOrEqual(layout.contentClientWidth + 1)\n+    for (const right of layout.buttonRights) {\n+      expect(right).toBeLessThanOrEqual(layout.articleRight + 1)\n+    }\n+    expect(layout.titleHeight).toBeLessThanOrEqual(45)\n+  }\n+})\ndiff --git a/tests/e2e/formal-h5.spec.ts b/tests/e2e/formal-h5.spec.ts\nindex 4d0b309..b22f4cb 100644\n--- a/tests/e2e/formal-h5.spec.ts\n+++ b/tests/e2e/formal-h5.spec.ts\n@@ -329,14 +329,16 @@ test('@formal-h5 H022 shared claim-success implementation keeps route variants d\n test('@formal-h5 H022 prototype states stay on one route implementation', async ({ page }) => {\n   const runtimeErrors = collectRuntimeErrors(page)\n \n-  await page.goto('/card/share', { waitUntil: 'domcontentloaded' })\n+  await page.goto('/card/share?coupon=91001', { waitUntil: 'domcontentloaded' })\n   await expectHealthyFormalRoute(page)\n-  await expect(page).toHaveURL(/\\/card\\/share$/)\n+  expect(new URL(page.url()).pathname + new URL(page.url()).search).toBe('/card/share?coupon=91001')\n+  await expect(page.getByRole('button', { name: '确认选择' })).toBeDisabled()\n \n+  // Historic success URL must not imply a transfer without coupon ID or API.\n   await page.goto('/card/share?state=success', { waitUntil: 'domcontentloaded' })\n   await expectHealthyFormalRoute(page)\n-  await expect(page).toHaveURL(/\\/card\\/share\\?state=success$/)\n-  await expect(page.getByText('分享成功', { exact: true })).toBeVisible()\n+  await expect(page.getByText('未选择待转赠卡券，请从卡包点击「转赠」进入')).toBeVisible()\n+  await expect(page.getByText('分享成功', { exact: true })).toHaveCount(0)\n \n   await page.goto('/service/chat/human?state=queuing', { waitUntil: 'domcontentloaded' })\n   await expectHealthyFormalRoute(page)\n@@ -352,7 +354,7 @@ test('@formal-h5 H022 prototype states stay on one route implementation', async\n test('@formal-h5 H023 shared SearchField stays consistent across formal H5 consumers', async ({ page }) => {\n   const runtimeErrors = collectRuntimeErrors(page)\n \n-  await page.goto('/card/share', { waitUntil: 'domcontentloaded' })\n+  await page.goto('/card/share?coupon=91001', { waitUntil: 'domcontentloaded' })\n   await expectHealthyFormalRoute(page)\n   const cardShareField = page.locator('[data-search-field=\"subtle\"][data-search-field-size=\"regular\"] input')\n   await expect(cardShareField).toHaveCount(1)",
      "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/dc10b10bde3c218888822aac068ef857f3c3ddc6",
            "commitHash": "dc10b10bde3c218888822aac068ef857f3c3ddc6",
            "prHref": "https://github.com/sanchuang-dev/dr-card-web/pull/236",
            "prTitle": "chore(release): promote card transfer buddy picker and Chromium 88 fix dev → test",
            "prBaseHash": "4cb41c1c4e69048eae91cc6db11c78fba0bbeb79",
            "buildHref": "https://github.com/sanchuang-dev/dr-card-web/actions/runs/38706898858"
          },
          "gitCommit": {
            "shortHash": "dc10b10",
            "hash": "dc10b10bde3c218888822aac068ef857f3c3ddc6",
            "subject": "Merge 9c591ce29b6ca29f532cd9aa15ab90da7c7477d2 into 4cb41c1c4e69048eae91cc6db11c78fba0bbeb79",
            "body": "Merge 9c591ce29b6ca29f532cd9aa15ab90da7c7477d2 into 4cb41c1c4e69048eae91cc6db11c78fba0bbeb79\n",
            "author": {
              "name": "Tomz Dang",
              "email": "dangjingtao@163.com",
              "time": 1792053398000
            },
            "committer": {
              "name": "GitHub",
              "email": "noreply@github.com",
              "time": 1792053398000
            },
            "branch": "HEAD"
          },
          "gitDiff": "diff --git a/package.json b/package.json\nindex 9dff200..0f3c070 100644\n--- a/package.json\n+++ b/package.json\n@@ -26,7 +26,7 @@\n     \"build:cf\": \"npm run typecheck && npm run verify:images && node scripts/build-h5.mjs cf\",\n     \"preview\": \"vite preview --host 0.0.0.0\",\n     \"test:e2e\": \"npm run test:e2e:formal\",\n-    \"test:e2e:formal\": \"playwright test tests/e2e/formal-h5.spec.ts tests/e2e/uxe-exchange.spec.ts tests/e2e/redeem-code-project-selector.spec.ts\",\n+    \"test:e2e:formal\": \"playwright test tests/e2e/formal-h5.spec.ts tests/e2e/uxe-exchange.spec.ts tests/e2e/redeem-code-project-selector.spec.ts tests/e2e/card-ticket-legacy-layout.spec.ts\",\n     \"test:e2e:bridge-lab\": \"playwright test tests/e2e/bridge-lab.spec.ts\",\n     \"test:e2e:test-gate\": \"playwright test tests/e2e/test-gate.spec.ts\",\n     \"test:e2e:ui\": \"playwright test tests/e2e/formal-h5.spec.ts --ui\",\ndiff --git a/scripts/verify-h022-route-state-convergence.mjs b/scripts/verify-h022-route-state-convergence.mjs\nindex 9da4619..8847d61 100644\n--- a/scripts/verify-h022-route-state-convergence.mjs\n+++ b/scripts/verify-h022-route-state-convergence.mjs\n@@ -28,7 +28,6 @@ try {\n   assert.equal(route('/onboarding/success').implementationVariant, 'onboarding')\n \n   const stateFamilies = {\n-    '/card/share': ['success'],\n     '/card/verify/confirm': ['done', 'repeat'],\n     '/orders': ['completed', 'ongoing', 'aftersale', 'empty'],\n     '/address': ['empty'],\n@@ -36,6 +35,10 @@ try {\n     '/service/chat/human': ['queuing', 'connected'],\n   }\n \n+  // Coupon transfer has no backend write API yet: the former fixture-only\n+  // \"success\" state must never be advertised as a business success.\n+  assert.ok(!(route('/card/share').states ?? []).some((state) => state.key === 'success'))\n+\n   for (const [path, expectedStates] of Object.entries(stateFamilies)) {\n     const keys = new Set((route(path).states ?? []).map((state) => state.key))\n     for (const state of expectedStates) {\ndiff --git a/src/app/router/routes.ts b/src/app/router/routes.ts\nindex 05bc2fb..a2f7cfd 100644\n--- a/src/app/router/routes.ts\n+++ b/src/app/router/routes.ts\n@@ -980,13 +980,12 @@ export const ROUTES: RouteMeta[] = [\n   },\n   {\n     path: '/card/share',\n-    title: '分享',\n-    nodes: [65, 66],\n+    title: '选择转赠搭子',\n+    nodes: [65],\n     task: 'T009',\n-    entry: '卡包-可用券「转赠」按钮（reference/卡包.html 原型按钮文案；本页标题按 reference/分享.html 为「分享」）',\n+    entry: '卡包-第三方可用券「转赠」按钮，须携带当前券 id',\n     returnTo: '卡包',\n-    states: [{ key: 'success', node: 66, label: '分享成功' }],\n-    owner: '分享对象选择与分享成功（T009；B-015 已关闭，按原型做搭子列表夹具，不做接受/次数/时效/持久化）',\n+    owner: '只读加载当前登录用户的真实搭子并单选；转赠接口尚未接入，不提供成功状态或写操作',\n   },\n   {\n     path: '/card/verify',\ndiff --git a/src/pages/Card.tsx b/src/pages/Card.tsx\nindex 80032af..45ffb4b 100644\n--- a/src/pages/Card.tsx\n+++ b/src/pages/Card.tsx\n@@ -153,14 +153,17 @@ function MovieTicket({ coupon, expired, used, onUse, onShare }: MovieTicketProps\n         />\n \n         {/* 主体：flex 左右两栏 */}\n-        <div className=\"flex\">\n+        <div className=\"flex w-full min-w-0\">\n           {/* 左半：色块区 */}\n           <div\n             className=\"flex flex-col items-center justify-center px-4 py-4 text-center\"\n             style={{\n               background: leftBg,\n-              minWidth: 120,\n-              flex: '0 0 auto',\n+              // The left piece stays bounded so Chromium 88 has space to shrink the right pane.\n+              width: '36%',\n+              minWidth: 108,\n+              maxWidth: 128,\n+              flex: '0 0 36%',\n             }}\n             aria-hidden\n           >\n@@ -183,9 +186,20 @@ function MovieTicket({ coupon, expired, used, onUse, onShare }: MovieTicketProps\n           />\n \n           {/* 右半：内容区 */}\n-          <div className=\"flex-1 px-4 py-3\">\n-            <div className=\"flex items-start justify-between gap-2\">\n-              <h3 className={`min-w-0 truncate text-[15px] font-semibold ${isInactive ? 'text-coupon-used' : 'text-text-primary'}`}>\n+          <div className=\"min-w-0 flex-1 px-3 py-3\" style={{ minWidth: 0, flex: '1 1 0%' }}>\n+            <div className=\"flex min-w-0 items-start justify-between gap-2\">\n+              <h3\n+                className={`min-w-0 flex-1 text-[15px] font-semibold leading-5 ${isInactive ? 'text-coupon-used' : 'text-text-primary'}`}\n+                style={{\n+                  minWidth: 0,\n+                  // -webkit-line-clamp is supported by legacy Chromium 88; avoid unsupported CSS line-clamp.\n+                  display: '-webkit-box',\n+                  WebkitLineClamp: 2,\n+                  WebkitBoxOrient: 'vertical',\n+                  overflow: 'hidden',\n+                  overflowWrap: 'anywhere',\n+                }}\n+              >\n                 {coupon.name}\n               </h3>\n               <span\n@@ -203,14 +217,14 @@ function MovieTicket({ coupon, expired, used, onUse, onShare }: MovieTicketProps\n             </div>\n \n             {coupon.validityLabel && (\n-              <div className={`mt-1.5 flex items-center gap-1 text-[11px] ${isInactive ? 'text-text-inactive-muted' : 'text-text-tertiary'}`}>\n-                <CalendarDays className=\"h-3 w-3\" />\n+              <div className={`mt-1.5 flex min-w-0 items-start gap-1 break-all text-[11px] ${isInactive ? 'text-text-inactive-muted' : 'text-text-tertiary'}`}>\n+                <CalendarDays className=\"mt-0.5 h-3 w-3 flex-none\" />\n                 {coupon.validityLabel}\n               </div>\n             )}\n \n             {coupon.limitNote && (\n-              <div className={`mt-1 text-[11px] ${isInactive ? 'text-text-inactive-muted' : 'text-text-tertiary'}`}>\n+              <div className={`mt-1 min-w-0 break-all text-[11px] ${isInactive ? 'text-text-inactive-muted' : 'text-text-tertiary'}`}>\n                 {coupon.limitNote}\n               </div>\n             )}\n@@ -219,13 +233,13 @@ function MovieTicket({ coupon, expired, used, onUse, onShare }: MovieTicketProps\n              *  使用：橙色实心 pill；转赠：白底描边 pill。\n              *  两个按钮 flex-1 平分宽度，视觉权重一致。\n              *  已过期：两按钮都禁用；已使用：使用按钮隐藏（演示态）。 */}\n-            <div className=\"mt-3 flex items-center gap-2\">\n+            <div className=\"mt-3 flex min-w-0 items-center gap-2\">\n               {!used && !expired && (\n                 <button\n                   type=\"button\"\n                   onClick={onUse}\n                   disabled={!canAction}\n-                  className=\"h-8 flex-1 rounded-full bg-primary text-[12px] font-semibold text-text-inverse shadow-primary-button active:bg-primary-pressed disabled:opacity-50\"\n+                  className=\"h-8 min-w-0 flex-1 rounded-full bg-primary px-1 text-[12px] font-semibold text-text-inverse shadow-primary-button active:bg-primary-pressed disabled:opacity-50\"\n                 >\n                   使用\n                 </button>\n@@ -234,7 +248,7 @@ function MovieTicket({ coupon, expired, used, onUse, onShare }: MovieTicketProps\n                 type=\"button\"\n                 onClick={onShare}\n                 disabled={!canAction}\n-                className={`h-8 flex-1 rounded-full border text-[12px] font-semibold ${\n+                className={`h-8 min-w-0 flex-1 rounded-full border px-1 text-[12px] font-semibold ${\n                   canAction\n                     ? 'border-border-strong bg-transparent text-text-secondary active:bg-surface-subtle'\n                     : 'border-border bg-transparent text-text-inactive-muted'\n@@ -257,7 +271,7 @@ function CouponTicketSkeleton() {\n       <span className=\"sr-only\">正在加载体验券</span>\n       {[0, 1].map((index) => (\n         <div key={index} aria-hidden=\"true\" className=\"flex min-h-[166px] overflow-hidden rounded-[16px] bg-surface shadow-sm\">\n-          <div className=\"flex w-[120px] shrink-0 flex-col items-center justify-center gap-3 bg-reward-subtle px-4\">\n+          <div className=\"flex w-[36%] min-w-[108px] max-w-[128px] shrink-0 flex-col items-center justify-center gap-3 bg-reward-subtle px-4\">\n             <Skeleton className=\"h-9 w-14\" />\n             <Skeleton className=\"h-3 w-12\" />\n           </div>\ndiff --git a/src/pages/CardShare.test.tsx b/src/pages/CardShare.test.tsx\nnew file mode 100644\nindex 0000000..0f8c61d\n--- /dev/null\n+++ b/src/pages/CardShare.test.tsx\n@@ -0,0 +1,82 @@\n+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'\n+import { MemoryRouter } from 'react-router-dom'\n+import { afterEach, describe, expect, it, vi } from 'vitest'\n+import type { ReactNode } from 'react'\n+\n+const mocks = vi.hoisted(() => ({ mode: 'api', relations: vi.fn() }))\n+vi.mock('../app/config/runtime', () => ({ runtimePolicy: { get dataMode() { return mocks.mode } } }))\n+vi.mock('../services/buddyRelations', () => ({ loadBuddyRelations: mocks.relations }))\n+vi.mock('../components/mobile/DebugPanel', () => ({ default: () => null }))\n+vi.mock('../components/mobile/PageContainer', () => ({\n+  default: ({ children }: { children: ReactNode }) => <main>{children}</main>,\n+}))\n+\n+import CardShare from './CardShare'\n+\n+const people = [\n+  { id: '19', nickname: '南沙搭子阿明', avatarUrl: null },\n+  { id: '22', nickname: '天河搭子小芳', avatarUrl: 'https://example.com/avatar.jpg' },\n+]\n+\n+const mount = (url = '/card/share?coupon=91001') => render(\n+  <MemoryRouter initialEntries={[url]}><CardShare /></MemoryRouter>,\n+)\n+afterEach(() => { mocks.mode = 'api'; mocks.relations.mockReset() })\n+\n+describe('card transfer only prepares a real buddy recipient', () => {\n+  it('reads current authenticated buddies, never the old fixture names, and never claims transfer succeeded', async () => {\n+    mocks.relations.mockResolvedValue(people)\n+    mount()\n+    expect(screen.getByText('正在加载我的搭子…')).toBeTruthy()\n+    expect(await screen.findByRole('radio', { name: '南沙搭子阿明' })).toBeTruthy()\n+    expect(mocks.relations).toHaveBeenCalledTimes(1)\n+    expect(screen.queryByText('小美')).toBeNull()\n+    expect(screen.getByRole('button', { name: '确认选择' }).hasAttribute('disabled')).toBe(true)\n+\n+    fireEvent.click(screen.getByRole('radio', { name: '南沙搭子阿明' }))\n+    fireEvent.change(screen.getByPlaceholderText('搜索我的搭子'), { target: { value: '天河' } })\n+    expect(within(screen.getByLabelText('已选搭子')).getByRole('radio', { name: '南沙搭子阿明' })).toBeTruthy()\n+    expect(screen.getByRole('radio', { name: '天河搭子小芳' })).toBeTruthy()\n+    fireEvent.click(screen.getByRole('button', { name: '确认选择' }))\n+    expect(screen.getByText('已选择「南沙搭子阿明」。转赠接口尚未接入，卡券没有转出。')).toBeTruthy()\n+    expect(screen.queryByText('分享成功')).toBeNull()\n+    expect(mocks.relations).toHaveBeenCalledTimes(1)\n+  })\n+\n+  it('keeps selection when keyword finds nobody, then changes it only on a real click', async () => {\n+    mocks.relations.mockResolvedValue(people)\n+    mount()\n+    fireEvent.click(await screen.findByRole('radio', { name: '南沙搭子阿明' }))\n+    fireEvent.change(screen.getByPlaceholderText('搜索我的搭子'), { target: { value: '不存在' } })\n+    expect(screen.getByText('没有匹配的搭子，已选搭子仍保留')).toBeTruthy()\n+    fireEvent.change(screen.getByPlaceholderText('搜索我的搭子'), { target: { value: '' } })\n+    fireEvent.click(screen.getByRole('radio', { name: '天河搭子小芳' }))\n+    expect(within(screen.getByLabelText('已选搭子')).getByRole('radio', { name: '天河搭子小芳' })).toBeTruthy()\n+    expect(screen.queryByText('分享成功')).toBeNull()\n+  })\n+\n+  it('shows empty and failure states without falling back to fixture relations', async () => {\n+    mocks.relations.mockRejectedValueOnce(new Error('登录搭子服务暂不可用')).mockResolvedValueOnce([])\n+    mount()\n+    expect(await screen.findByText('登录搭子���务暂不可用')).toBeTruthy()\n+    expect(screen.queryByText('小美')).toBeNull()\n+    fireEvent.click(screen.getByRole('button', { name: '重新加载' }))\n+    await waitFor(() => expect(screen.getByText('暂无搭子，可先去「洗头搭子」添加好友')).toBeTruthy())\n+    expect(mocks.relations).toHaveBeenCalledTimes(2)\n+  })\n+\n+  it('does not request buddies or invent a coupon when link has no coupon ID', () => {\n+    mount('/card/share')\n+    expect(screen.getByText('未选择待转赠卡券，请从卡包点击「转赠」进入')).toBeTruthy()\n+    expect(mocks.relations).not.toHaveBeenCalled()\n+    expect(screen.queryByRole('button', { name: '确认选择' })).toBeNull()\n+  })\n+\n+  it('clearly labels mock-only preview relations and never calls real list service', async () => {\n+    mocks.mode = 'mock'\n+    mount()\n+    expect(await screen.findByText('演示搭子，仅用于 UI 预览；非真实账号')).toBeTruthy()\n+    expect(screen.getByRole('radio', { name: '小美' })).toBeTruthy()\n+    expect(mocks.relations).not.toHaveBeenCalled()\n+  })\n+})\ndiff --git a/src/pages/CardShare.tsx b/src/pages/CardShare.tsx\nindex cd34b53..acb0b8b 100644\n--- a/src/pages/CardShare.tsx\n+++ b/src/pages/CardShare.tsx\n@@ -1,147 +1,191 @@\n-import { useState } from 'react'\n-import { useNavigate, useSearchParams } from 'react-router-dom'\n-import { Check, ShoppingBag } from 'lucide-react'\n+import { useEffect, useMemo, useState } from 'react'\n+import { useSearchParams } from 'react-router-dom'\n+import { Check, RefreshCw, UserRound } from 'lucide-react'\n import PageContainer from '../components/mobile/PageContainer'\n import DebugPanel from '../components/mobile/DebugPanel'\n import { Button, SearchField } from '../components/ui'\n-import { useFixtureQueryControls, useFixtureState } from '../app/fixtures/useFixture'\n import { findRouteByPathname } from '../app/router/routes'\n-import {\n-  resolveCardCoupon,\n-  SHARE_PRODUCT_FIXTURE,\n-  SHARE_TARGET_FIXTURES,\n-  type ShareTargetFixture,\n-} from '../app/fixtures'\n+import { runtimePolicy } from '../app/config/runtime'\n+import { SHARE_TARGET_FIXTURES } from '../app/fixtures'\n+import { loadBuddyRelations, type BuddyMember } from '../services/buddyRelations'\n+\n+type BuddyRemote =\n+  | { state: 'loading' }\n+  | { state: 'ready'; items: BuddyMember[] }\n+  | { state: 'error'; message: string }\n \n /**\n- * 分享（#65 选择接收的人 / #66 分享成功）\n- * -------------------------------------------------------------\n- * 视觉、结构、文案与单选交互照 reference/分享.html、reference/分享成功.html 还原：\n- * - #65：搜索框 + 分组标题「搭子列表」+ 单选列表 + 底部主操作「下一步」，默认选中第一位；\n- * - #66：成功环 hero + 商品信息卡（缩略图 / 名称 / 点分隔 meta / 标签）+「查看我的卡包」+ ghost「返回」。\n- * 两个节点在原稿里是同一条链路的前后页，故合并为一页，用 fixture state 切换，\n- * 便于 preview/dev 按 URL 直达截图，同时 test/prod 外部 query 不再可信。\n- *\n- * B-015 已关闭：按原型做「搭子列表」夹具，单选对象 → 下一步 → 分享成功；\n- *   不做对方接受、次数限制、时效限制和持久化，分享后原卡包状态不变。\n- * B-017 已关闭：#66 商品卡的「洗发试用装 / 已发货 / 单次使用」仅为原型展示夹具\n- *   （SHARE_PRODUCT_FIXTURE），「已发货」不代表分享操作触发真实发货，不实现物流规则。\n+ * Transfer preparation only: pick an actual buddy from the authenticated account.\n+ * No transfer API has been signed, so this screen MUST NOT mutate a coupon, navigate\n+ * to the legacy fixture success page, or claim that anything was gifted.\n  */\n export default function CardShare() {\n-  const navigate = useNavigate()\n   const route = findRouteByPathname('/card/share')\n-  const { state } = useFixtureState(route)\n-  const { patch: patchFixtureState } = useFixtureQueryControls()\n   const [searchParams] = useSearchParams()\n-\n-  const coupon = resolveCardCoupon(searchParams.get('coupon'))\n+  const couponId = searchParams.get('coupon')?.trim() || null\n+  const mockMode = runtimePolicy.dataMode === 'mock'\n   const [keyword, setKeyword] = useState('')\n-  const [selected, setSelected] = useState<string>(SHARE_TARGET_FIXTURES[0].id)\n-\n-  const targets = SHARE_TARGET_FIXTURES.filter((item) => item.name.includes(keyword.trim()))\n-  const receiver: ShareTargetFixture =\n-    SHARE_TARGET_FIXTURES.find((item) => item.id === selected) ?? SHARE_TARGET_FIXTURES[0]\n-\n-  const submit = () => {\n-    patchFixtureState(\n-      { state: 'success' },\n-      { coupon: coupon.id, target: receiver.id },\n-    )\n-  }\n-\n-  if (state?.key === 'success') {\n-    return (\n-      <PageContainer className=\"flex min-h-full flex-col pb-6\" inset>\n-        <div className=\"flex flex-col items-center gap-3 px-5 pb-4 pt-14 text-center\">\n-          <span className=\"relative flex h-16 w-16 items-center justify-center rounded-full bg-success-bg\" aria-hidden>\n-            <span className=\"absolute -inset-1.5 rounded-full border border-dashed border-success opacity-25\" />\n-            <Check className=\"h-8 w-8 text-success\" strokeWidth={3} />\n-          </span>\n-          <h1 className=\"text-lg font-semibold text-text-primary\">分享成功</h1>\n-          <p className=\"max-w-[260px] text-sm leading-6 text-text-secondary\">已成功分享给好友，邀请他也来一起玩吧</p>\n-        </div>\n+  const [selectedId, setSelectedId] = useState<string | null>(null)\n+  const [confirmedId, setConfirmedId] = useState<string | null>(null)\n+  const [retry, setRetry] = useState(0)\n+  const [remote, setRemote] = useState<BuddyRemote>({ state: 'loading' })\n \n-        <section className=\"mx-4 mt-5 flex items-center gap-3 rounded-container bg-surface p-3 shadow-sm\" aria-label=\"分享的商品\">\n-          <span\n-            className=\"flex h-14 w-14 shrink-0 items-center justify-center rounded-[12px] bg-surface-selected text-text-brand\"\n-            aria-hidden\n-          >\n-            <ShoppingBag className=\"h-7 w-7\" strokeWidth={1.8} />\n-          </span>\n-          <div className=\"min-w-0 flex-1\">\n-            <h2 className=\"truncate text-base font-semibold text-text-primary\">{SHARE_PRODUCT_FIXTURE.name}</h2>\n-            <p className=\"mt-1 flex items-center gap-1.5 text-xs text-text-tertiary\">\n-              <span>{SHARE_PRODUCT_FIXTURE.date}</span>\n-              <span className=\"h-[3px] w-[3px] rounded-full bg-current opacity-60\" aria-hidden />\n-              <span>{SHARE_PRODUCT_FIXTURE.shipping}</span>\n-            </p>\n-            <span className=\"mt-1.5 inline-flex rounded-full bg-surface-selected px-2 py-0.5 text-[11px] font-medium text-text-brand\">\n-              {SHARE_PRODUCT_FIXTURE.tag}\n-            </span>\n-          </div>\n-        </section>\n+  useEffect(() => {\n+    if (!couponId) return\n+    let active = true\n+    setRemote({ state: 'loading' })\n+    setSelectedId(null)\n+    setConfirmedId(null)\n+    if (mockMode) {\n+      setRemote({\n+        state: 'ready',\n+        items: SHARE_TARGET_FIXTURES.map(item => ({\n+          id: item.id, nickname: item.name, avatarUrl: null,\n+        })),\n+      })\n+    } else {\n+      // Reuse the exact backend list service as /buddy; it pages through the\n+      // authenticated GET /api/friends/index records and never takes a user_id.\n+      void loadBuddyRelations().then(items => {\n+        if (active) setRemote({ state: 'ready', items })\n+      }).catch(error => {\n+        if (active) setRemote({\n+          state: 'error',\n+          message: error instanceof Error ? error.message : '搭子列表加载失败，请重试',\n+        })\n+      })\n+    }\n+    return () => { active = false }\n+  }, [couponId, mockMode, retry])\n \n-        <div className=\"mt-auto flex flex-col items-center gap-2 px-4 pb-[calc(1rem+env(safe-area-inset-bottom))] pt-5\">\n-          <Button size=\"large\" className=\"h-12 w-full\" onClick={() => navigate('/card', { replace: true })}>\n-            查看我的卡包\n-          </Button>\n-          <Button variant=\"ghost\" className=\"h-9 text-sm font-medium\" onClick={() => navigate(-1)}>\n-            返回\n-          </Button>\n-        </div>\n+  const items = remote.state === 'ready' ? remote.items : []\n+  const selected = items.find(item => item.id === selectedId) ?? null\n+  const filtered = useMemo(() => {\n+    const term = keyword.trim()\n+    return items.filter(item => item.id !== selectedId && item.nickname.includes(term))\n+  }, [items, keyword, selectedId])\n \n-        <DebugPanel route={route} />\n-      </PageContainer>\n-    )\n+  const choose = (id: string) => {\n+    setSelectedId(id)\n+    setConfirmedId(null)\n   }\n \n+  const renderBuddy = (buddy: BuddyMember, checked: boolean) => (\n+    <button\n+      key={buddy.id}\n+      type=\"button\"\n+      role=\"radio\"\n+      aria-checked={checked}\n+      onClick={() => choose(buddy.id)}\n+      className={`flex min-h-[68px] w-full min-w-0 items-center gap-3 rounded-xl border px-3 py-3 text-left ${\n+        checked ? 'border-primary bg-surface-selected' : 'border-border-subtle bg-surface'\n+      }`}\n+    >\n+      {buddy.avatarUrl ? (\n+        <img src={buddy.avatarUrl} alt=\"\" className=\"h-11 w-11 flex-none rounded-full object-cover\" />\n+      ) : (\n+        <span className=\"flex h-11 w-11 flex-none items-center justify-center rounded-full bg-reward-subtle text-reward-strong\" aria-hidden>\n+          <UserRound className=\"h-5 w-5\" />\n+        </span>\n+      )}\n+      <span className=\"min-w-0 flex-1 break-words text-[15px] font-medium text-text-primary\">{buddy.nickname}</span>\n+      <span aria-hidden className={`flex h-5 w-5 flex-none items-center justify-center rounded-full border ${\n+        checked ? 'border-primary bg-primary text-text-inverse' : 'border-border bg-surface'\n+      }`}>\n+        {checked && <Check className=\"h-3.5 w-3.5\" strokeWidth={3} />}\n+      </span>\n+    </button>\n+  )\n+\n   return (\n     <PageContainer className=\"flex min-h-full flex-col pb-6\" inset>\n-      <div className=\"px-4 pt-3\">\n-        <SearchField\n-          value={keyword}\n-          onChange={(event) => setKeyword(event.target.value)}\n-          onClear={() => setKeyword('')}\n-          placeholder=\"搜索搭子\"\n-          inputClassName=\"placeholder:text-text-tertiary\"\n-        />\n-      </div>\n+      <header className=\"px-1 pt-3\">\n+        <h1 className=\"text-[16px] font-semibold text-text-primary\">选择转赠搭子</h1>\n+        <p className=\"mt-1 text-[12px] text-text-tertiary\">\n+          转赠接口暂未接入，当前仅支持选择接收人，不会实际转出卡券\n+        </p>\n+      </header>\n+\n+      {!couponId ? (\n+        <section role=\"alert\" className=\"mt-5 rounded-xl bg-surface p-5 text-center text-sm text-text-secondary\">\n+          未选择待转赠卡券，请从卡包点击「转赠」进入\n+        </section>\n+      ) : (\n+        <>\n+          {mockMode && (\n+            <p role=\"status\" className=\"mt-3 rounded-lg bg-reward-subtle px-3 py-2 text-[12px] text-reward-text\">\n+              演示搭子，仅用于 UI 预览；非真实账号\n+            </p>\n+          )}\n+          <div className=\"pt-4\">\n+            <SearchField\n+              value={keyword}\n+              onChange={(event) => setKeyword(event.target.value)}\n+              onClear={() => setKeyword('')}\n+              placeholder=\"搜索我的搭子\"\n+              inputClassName=\"placeholder:text-text-tertiary\"\n+            />\n+          </div>\n \n-      <section className=\"mt-5 px-4\" aria-label=\"搭子列表\">\n-        <h2 className=\"text-sm font-medium text-text-primary\">搭子列表</h2>\n-        <div className=\"mt-2 overflow-hidden rounded-container bg-surface shadow-sm\">\n-          {targets.map((target, index) => {\n-            const checked = selected === target.id\n-            return (\n-              <button\n-                key={target.id}\n-                type=\"button\"\n-                onClick={() => setSelected(target.id)}\n-                className={`flex w-full items-center gap-3 px-4 py-3.5 text-left ${index > 0 ? 'border-t border-border-subtle' : ''}`}\n-              >\n-                <span className=\"flex h-11 w-11 flex-none items-center justify-center rounded-full bg-surface-selected text-sm font-semibold text-text-brand\">\n-                  {target.name.slice(0, 1)}\n-                </span>\n-                <span className=\"min-w-0 flex-1 truncate text-[15px] font-medium text-text-primary\">{target.name}</span>\n-                <span\n-                  className={`flex h-5 w-5 flex-none items-center justify-center rounded-full border ${\n-                    checked ? 'border-primary bg-primary text-text-inverse' : 'border-border bg-surface'\n-                  }`}\n-                  aria-hidden\n-                >\n-                  {checked && <Check className=\"h-3.5 w-3.5\" strokeWidth={3} />}\n-                </span>\n-              </button>\n-            )\n-          })}\n-        </div>\n-      </section>\n+          <section className=\"mt-5 min-w-0\" aria-label=\"搭子列表\">\n+            <div className=\"flex items-center justify-between\">\n+              <h2 className=\"text-sm font-medium text-text-primary\">我的搭子</h2>\n+              {remote.state === 'ready' && (\n+                <span className=\"text-[12px] text-text-tertiary\">{items.length} 位</span>\n+              )}\n+            </div>\n+            <div className=\"mt-3\" role=\"radiogroup\" aria-label=\"选择接收搭子\">\n+              {remote.state === 'loading' ? (\n+                <p role=\"status\" className=\"rounded-xl bg-surface px-4 py-8 text-center text-sm text-text-secondary\">\n+                  正在加载我的搭子…\n+                </p>\n+              ) : remote.state === 'error' ? (\n+                <div role=\"alert\" className=\"rounded-xl bg-surface px-4 py-7 text-center\">\n+                  <p className=\"text-sm text-text-secondary\">{remote.message}</p>\n+                  <Button variant=\"outline\" className=\"mt-3\" leadingIcon={RefreshCw}\n+                    onClick={() => setRetry(x => x + 1)}>重新加载</Button>\n+                </div>\n+              ) : items.length === 0 ? (\n+                <p role=\"status\" className=\"rounded-xl bg-surface px-4 py-8 text-center text-sm text-text-secondary\">\n+                  暂无搭子，可先去「洗头搭子」添加好友\n+                </p>\n+              ) : (\n+                <div className=\"space-y-2.5\">\n+                  {selected && (\n+                    <div className=\"space-y-2\" aria-label=\"已选搭子\">\n+                      <p className=\"text-[12px] font-medium text-reward-text\">已选搭子</p>\n+                      {renderBuddy(selected, true)}\n+                    </div>\n+                  )}\n+                  <p className=\"pt-1 text-[12px] text-text-tertiary\">搜索结果</p>\n+                  {filtered.map(item => renderBuddy(item, false))}\n+                  {filtered.length === 0 && (\n+                    <p className=\"rounded-xl bg-surface px-4 py-5 text-center text-[13px] text-text-tertiary\">\n+                      没有匹配的搭子{selected ? '，已选搭子仍保留' : ''}\n+                    </p>\n+                  )}\n+                </div>\n+              )}\n+            </div>\n+          </section>\n \n-      <div className=\"mt-auto px-4 pb-[env(safe-area-inset-bottom)] pt-5\">\n-        <Button size=\"large\" className=\"h-12 w-full rounded-pill\" onClick={submit} disabled={!selected}>\n-          下一步\n-        </Button>\n-      </div>\n+          <div className=\"mt-auto pt-6 pb-[env(safe-area-inset-bottom)]\">\n+            {confirmedId && selected?.id === confirmedId && (\n+              <p role=\"status\" className=\"mb-3 rounded-xl bg-reward-subtle px-3 py-3 text-center text-[12px] leading-5 text-text-secondary\">\n+                已选择「{selected.nickname}」。转赠接口尚未接入，卡券没有转出。\n+              </p>\n+            )}\n+            <Button\n+              size=\"large\"\n+              className=\"h-12 w-full rounded-pill\"\n+              disabled={!selected}\n+              onClick={() => { if (selected) setConfirmedId(selected.id) }}\n+            >\n+              确认选择\n+            </Button>\n+          </div>\n+        </>\n+      )}\n \n       <DebugPanel route={route} />\n     </PageContainer>\ndiff --git a/tests/e2e/card-ticket-legacy-layout.spec.ts b/tests/e2e/card-ticket-legacy-layout.spec.ts\nnew file mode 100644\nindex 0000000..f485c6d\n--- /dev/null\n+++ b/tests/e2e/card-ticket-legacy-layout.spec.ts\n@@ -0,0 +1,40 @@\n+import { expect, test } from '@playwright/test'\n+\n+test('@formal-h5 card ticket fits Chromium 88-compatible narrow flex sizing', async ({ page }) => {\n+  for (const width of [320, 375]) {\n+    await page.setViewportSize({ width, height: 700 })\n+    await page.goto('/card', { waitUntil: 'domcontentloaded' })\n+    const title = page.getByText('Mock 新人体验活动')\n+    await expect(title).toBeVisible()\n+    const ticket = title.locator('xpath=ancestor::article')\n+    // Change the heading and inspect its geometry in ONE locator evaluation.\n+    // Playwright locators are lazy; querying by the old text after replacing it would time out.\n+    const layout = await ticket.evaluate(node => {\n+      const article = node as HTMLElement\n+      const pane = article.querySelector('h3')?.parentElement?.parentElement as HTMLElement | null\n+      const titleElement = article.querySelector('h3') as HTMLElement | null\n+      const buttons = Array.from(article.querySelectorAll('button')) as HTMLButtonElement[]\n+      if (!pane || !titleElement || buttons.length !== 2) throw new Error('ticket layout nodes missing')\n+      titleElement.textContent = '天津生物工程职业技术学院洗护体验与公益合作活动'.repeat(2)\n+      const bounds = article.getBoundingClientRect()\n+      const right = pane.getBoundingClientRect()\n+      const titleBounds = titleElement.getBoundingClientRect()\n+      return {\n+        articleRight: bounds.right,\n+        contentRight: right.right,\n+        articleWidth: bounds.width,\n+        titleHeight: titleBounds.height,\n+        contentScrollWidth: pane.scrollWidth,\n+        contentClientWidth: pane.clientWidth,\n+        buttonRights: buttons.map(button => button.getBoundingClientRect().right),\n+      }\n+    })\n+    expect(layout.articleWidth).toBeLessThanOrEqual(width)\n+    expect(layout.contentRight).toBeLessThanOrEqual(layout.articleRight + 1)\n+    expect(layout.contentScrollWidth).toBeLessThanOrEqual(layout.contentClientWidth + 1)\n+    for (const right of layout.buttonRights) {\n+      expect(right).toBeLessThanOrEqual(layout.articleRight + 1)\n+    }\n+    expect(layout.titleHeight).toBeLessThanOrEqual(45)\n+  }\n+})\ndiff --git a/tests/e2e/formal-h5.spec.ts b/tests/e2e/formal-h5.spec.ts\nindex 4d0b309..b22f4cb 100644\n--- a/tests/e2e/formal-h5.spec.ts\n+++ b/tests/e2e/formal-h5.spec.ts\n@@ -329,14 +329,16 @@ test('@formal-h5 H022 shared claim-success implementation keeps route variants d\n test('@formal-h5 H022 prototype states stay on one route implementation', async ({ page }) => {\n   const runtimeErrors = collectRuntimeErrors(page)\n \n-  await page.goto('/card/share', { waitUntil: 'domcontentloaded' })\n+  await page.goto('/card/share?coupon=91001', { waitUntil: 'domcontentloaded' })\n   await expectHealthyFormalRoute(page)\n-  await expect(page).toHaveURL(/\\/card\\/share$/)\n+  expect(new URL(page.url()).pathname + new URL(page.url()).search).toBe('/card/share?coupon=91001')\n+  await expect(page.getByRole('button', { name: '确认选择' })).toBeDisabled()\n \n+  // Historic success URL must not imply a transfer without coupon ID or API.\n   await page.goto('/card/share?state=success', { waitUntil: 'domcontentloaded' })\n   await expectHealthyFormalRoute(page)\n-  await expect(page).toHaveURL(/\\/card\\/share\\?state=success$/)\n-  await expect(page.getByText('分享成功', { exact: true })).toBeVisible()\n+  await expect(page.getByText('未选择待转赠卡券，请从卡包点击「转赠」进入')).toBeVisible()\n+  await expect(page.getByText('分享成功', { exact: true })).toHaveCount(0)\n \n   await page.goto('/service/chat/human?state=queuing', { waitUntil: 'domcontentloaded' })\n   await expectHealthyFormalRoute(page)\n@@ -352,7 +354,7 @@ test('@formal-h5 H022 prototype states stay on one route implementation', async\n test('@formal-h5 H023 shared SearchField stays consistent across formal H5 consumers', async ({ page }) => {\n   const runtimeErrors = collectRuntimeErrors(page)\n \n-  await page.goto('/card/share', { waitUntil: 'domcontentloaded' })\n+  await page.goto('/card/share?coupon=91001', { waitUntil: 'domcontentloaded' })\n   await expectHealthyFormalRoute(page)\n   const cardShareField = page.locator('[data-search-field=\"subtle\"][data-search-field-size=\"regular\"] input')\n   await expect(cardShareField).toHaveCount(1)",
          "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": 68,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-15T08:37:47.210Z",
                  "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": 1131,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-15T08:37:47.197Z",
                  "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": 1038,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-15T08:37:47.345Z",
                  "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": 969,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-15T08:37:48.804Z",
                  "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": 991,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-15T08:37:48.881Z",
                  "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": 916,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-15T08:37:49.787Z",
                  "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": 907,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-15T08:37:49.943Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-8caf237c4d42513fff2f",
          "file": "test-gate.spec.ts",
          "line": 80,
          "column": 1
        }
      ]
    }
  ],
  "errors": [],
  "stats": {
    "startTime": "2026-10-15T08:37:46.278Z",
    "duration": 4612.683999999999,
    "expected": 7,
    "skipped": 0,
    "unexpected": 0,
    "flaky": 0
  }
}