UX/UI Design Audit — Operator Surface (/admin + act-as /dashboard)
- Author: PRODUCT_DESIGN (L1, external design-audit lane)
- Date: 2026-11-02 · Target: https://staging.yetdapay.com · Local ref:
8e2f0a6(main) - Method: Playwright chromium, viewports 1440×900 / 375×812 (+ header-break probes at 480/768/992/1024/1280), DOM geometry + computed-style measurement, live staging data
- Fixture:
qa.admin@staging.dev(admin); act-as targetseller_nougat(Nougat Labs) - Evidence:
/tmp/ux-audit/admin/shots/*.png,/tmp/ux-audit/admin/metrics.json - Scope note: Design review only — no product code changed. Security/functional defects (e.g. act-as scoping bugs) are INDEPENDENT_QA's lane and are not re-litigated here.
Screen inventory
| # | Screen | Viewports | Evidence |
|---|---|---|---|
| S1 | /admin header (post-D29 wrap) | 375→1440 | d-admin-top.png, m-admin-top.png, m-dropdown-open.png |
| S2 | /admin KPI row | both | d-admin-top.png, m-admin-top.png |
| S3 | /admin 판매자 심사 (verification cards) | both | d-admin-top.png, m-admin-top.png |
| S4 | /admin 사용자 계정 (users table) | both | d-admin-top.png |
| S5 | /admin 거래 · 정산 모니터링 | both | d-admin-with-context.png |
| S6 | /admin 운영 로그 · 감사 | both | d-admin-with-context.png |
| S7 | 판매자 컨텍스트 dropdown | both | d-dropdown-open.png, d-dropdown-filtered.png, m-dropdown-open.png |
| S8 | /dashboard under act-as (banner + context UX) | both | d-dashboard-actas.png, d-actas-banner.png, d-dashboard-actas-scrolled.png, m-dashboard-actas.png, probe2-dashboard-actas.png |
| S9 | /admin dark theme spot check | both | d-admin-dark.png, m-admin-dark.png |
Verified OK (keep)
- D29 fix holds:
scrollWidth == innerWidthat every probed width (375–1440). Zero horizontal overflow. (fe/layouts/admin.vue:57) - D31/D33 fixes hold: mobile dropdown fits viewport (right edge 206px < 375); theme toggle is 44×44px on mobile.
- KPI grid collapses 4-up → 2×2 correctly; both data tables scroll inside their cards (wrap 349px < table 620/712px).
- Context dropdown: searchable, 37 items scroll within
max-h-320,전체 보기 (admin)correctly disabled with no context, selection persists, clear-X works. - Verification badge tone map is semantically correct: 승인됨=good, 반려됨=bad, 보완 요청=warn, 심사 대기=info, 미등록=neutral. (
fe/pages/admin/index.vue:24-30) - Impersonation banner: sticky
top-0survives scroll (top=0 at scrollY 2000), shows seller name once loaded, offers both운영자 콘솔로 돌아가기and컨텍스트 해제; sidebar also exposes운영자 콘솔— redundant escape paths are good. - Impersonated view keeps the
SANDBOXbadge — operator sees impersonation and mock-env warnings simultaneously.Mock 거래액/실제 admin JWTcopy is appropriately truthful. - Review action buttons are 44px tall on mobile and wrap cleanly; action order (승인 primary → 보완 secondary → 반려 destructive) is sane.
Findings
Severity: HIGH = blocks/misleads operator workflows · MEDIUM = real friction or misuse risk · LOW = polish/consistency.
S1 — /admin header (post-D29)
| ID | Sev | Finding | Evidence | Recommendation | Files |
|---|---|---|---|---|---|
| A1 | HIGH | hidden/sm:hidden/sm:inline-flex utilities are inert on UiButton/UiBadge — component .ng-btn{display:inline-flex}/.ng-badge{display:inline-flex} scoped styles win the cascade over Tailwind display utilities. Result: two 로그아웃 buttons render at every breakpoint (32px icon + 98px labeled side-by-side at 1440); the labeled one wraps to its own row at 375 → 3-row, 169px header (~21% of viewport) instead of 2 rows (~117px); hidden sm:inline-flex ADMIN badge leaks onto mobile. D29 fixed the overflow but the collapse quality is degraded by a component-level bug that likely affects every hidden sm:* on Ui* components app-wide. | DOM: ng-btn hidden sm:inline-flex measured display:flex @375; ng-btn sm:hidden measured display:flex @1440; header heights 65/105/117/169 | Don't rely on display utilities on component roots: conditionally render (breakpoint-aware v-if/wrapper), or ship !hidden-style important utilities, or add a hidden boolean prop to UiButton/UiBadge. Re-audit other hidden sm:*/sm:hidden on Ui* components (e.g. dashboard.vue:276 SANDBOX badge is same pattern). | fe/layouts/admin.vue:64,119-125; fe/components/ui/UiButton.vue:66; fe/components/ui/UiBadge.vue:16 |
| A2 | LOW | Even correctly collapsed, mobile header is 2 rows ≈117px with a sparse brand row (wordmark + 2 icons). Acceptable, but a single-row header with the context group inside a compact popover would reclaim ~6% viewport. | header@375 row analysis | Optional consolidation; revisit only if operators live on mobile. | fe/layouts/admin.vue:57-126 |
S2 — KPI row
| ID | Sev | Finding | Evidence | Recommendation | Files |
|---|---|---|---|---|---|
| A3 | LOW | 심사 대기 KPI shows a warning-triangle icon permanently — icon implies an alert even at 0건. Value-bearing semantics should be state-driven. | d-admin-top.png | Show the icon/warn tone only when pendingCount > 0. | fe/pages/admin/index.vue:112 |
S3 — 판매자 심사 (verification cards)
| ID | Sev | Finding | Evidence | Recommendation | Files |
|---|---|---|---|---|---|
| A4 | HIGH | All 38 verifications render unbounded, each with a memo input + 3 action buttons — including 미등록 (never applied) and already 승인됨/반려됨 sellers. No pending-first ordering, no filter/tabs, no search. The list alone pushes S5/S6 to y≈16,548px (~18 viewports down) — monitoring and audit are effectively unreachable. | card list y-offsets; 38 lis; every li has 승인/보완/반려 | Default view = PENDING_REVIEW only (KPI already counts it); per-status action sets (terminal states get none or 재심사); collapse memo behind the chosen action; cap the list with pagination/"전체 보기". | fe/pages/admin/index.vue:117-136 |
| A5 | MEDIUM | 반려 has no confirm step and the memo is optional for 보완/반려 — an operator can reject a seller with no recorded reason, one mis-tap away. Same pattern for 비활성 in S4. | code: review() fires immediately | UiModal exists — confirm destructive/status-revoking actions; require memo for NEEDS_CORRECTION/REJECTED (seller sees this in their console). | fe/pages/admin/index.vue:61-73,130-133 |
S4 — 사용자 계정 (users table)
| ID | Sev | Finding | Evidence | Recommendation | Files |
|---|---|---|---|---|---|
| A6 | HIGH | All 123 users render in one unbounded table — no pagination, search, sort, or role/status filter. Primary cause of the 16k px page. | tbody tr count = 123, 0 filter controls | Paginate or fixed-height scroll; email search + role/status filter are the minimum viable operator controls. | fe/pages/admin/index.vue:138-156 |
| A7 | MEDIUM | Self-lockout affordance: the signed-in admin's own row (qa.admin@staging.dev, role admin, 활성) offers 비활성 — no guard, no confirm, no "this is you" marker. Even if the API 400s, the UI shouldn't present it. | DOM: self row has 비활성 button | Disable/hide the action on u.id === me.id with a tooltip, and/or confirm-modal all status flips. | fe/pages/admin/index.vue:149-150 |
| A8 | LOW | role column uses tone="accent" mono for every role — admin is visually identical to user/seller; roles are the highest-risk column on the page. | 123 accent badges | Map roles to tones (admin=info/warn, seller=accent, user=neutral). | fe/pages/admin/index.vue:144 |
S5 — 거래 · 정산 모니터링
| ID | Sev | Finding | Evidence | Recommendation | Files |
|---|---|---|---|---|---|
| A9 | MEDIUM | Scope mixing under act-as: the card's mini-KPIs (주문, 상품, 지급 실패) come from global admin.overview(), while the orders table + settlement count are act-as-scoped (X-Act-As-Seller). The 판매자 컨텍스트 적용 중 badge warns, but global counts sit directly above scoped rows — numbers cannot reconcile and will mislead. | code: Promise.all([admin.overview(), …, orders.list(), settlements.list()]) mixes scopes | Either scope the mini-KPIs to the context too, or explicitly label them 전체 and separate visually. | fe/pages/admin/index.vue:44-47,160-179 |
| A10 | LOW | UiKpi renders .card (1px border, r16, bg-surface) — nesting three bordered cards inside a UiCard produces card-in-card noise absent from the design language elsewhere. | 3 nested .cards measured | Add a borderless/plain UiKpi variant for in-card stats. | fe/components/ui/UiKpi.vue:17; fe/pages/admin/index.vue:164-168 |
| A11 | LOW | Order 상태 column prints raw lowercase enum (paid) in an otherwise Korean, badge-driven UI — inconsistent status language. | order rows: "paid" plain text | Same pattern as user status: UiBadge + status→tone/label map. | fe/pages/admin/index.vue:173 |
S6 — 운영 로그 · 감사
| ID | Sev | Finding | Evidence | Recommendation | Files |
|---|---|---|---|---|---|
| A12 | MEDIUM | Audit rows are raw machine strings (checkout · order + ord_… entityId); detailJson is frequently empty, leaving a dangling ·; no actor, no severity, no expand/copy, capped at 10 with no "전체 보기". An audit surface you can't read or page is decorative — and it sits at the bottom of a 16k px page. | rows: ord_bdc56c623674 · (detail empty); 10 rows, no more-link | Human-readable action labels + actor + entity link; expandable detail (UiJson exists); pagination or route to a full audit view. | fe/pages/admin/index.vue:181-192 |
S7 — 판매자 컨텍스트 dropdown
| ID | Sev | Finding | Evidence | Recommendation | Files |
|---|---|---|---|---|---|
| A13 | MEDIUM | No selected-state: the currently-impersonated seller item carries no check/aria-selected/active style — operator can't confirm context from inside the menu. | probe: sel:null on active seller item | Add active/selected to DropItem, render check icon + aria-selected. | fe/components/ui/UiDropdown.vue:5-11,63-75; fe/layouts/admin.vue:17-26 |
| A14 | MEDIUM | ARIA menu semantics incomplete: role="menu"/menuitem declared, but Escape does not close (verified — menu still open after Escape), no arrow-key navigation, no focus return to trigger. Either honor the menu pattern or downgrade to a plain list. | menu open after Escape: 1 | Implement Escape-close + arrow navigation + focus management in UiDropdown (used app-wide — fix once, helps user menus too). | fe/components/ui/UiDropdown.vue:36-40,47-75 |
| A15 | LOW | Duplicate seller display names (Admin Matrix Seller ×2, Commerce Seller ×4) make impersonation targets ambiguous — choosing the wrong one is a support/audit hazard. | dd item list | Show name + muted slug/id as a second line. | fe/layouts/admin.vue:21-25 |
S8 — /dashboard under act-as (impersonation UX)
| ID | Sev | Finding | Evidence | Recommendation | Files |
|---|---|---|---|---|---|
| A16 | MEDIUM | Impersonation marker arrives late and can show a raw id: the banner mounts only after fetchMe resolves (userRole==='admin' gate) — early probe shows no banner while the dashboard already renders (flash of unmarked impersonation); and actAsLabel falls back to the stored id, so seller_nougat (not Nougat Labs) can persist in the banner. | early "(no banner yet)"; late text — seller_nougat | Render the banner synchronously from stored context + token (assume admin until proven otherwise — fail-open for a warning is acceptable since it's chrome, not authz), and resolve id→name without layout shift (reserved space/skeleton). | fe/layouts/dashboard.vue:49-53,228-237 |
| A17 | LOW | Banner action links are 12px text, ~18px hit area at 375 — below the ≥44px tap-target rule already applied to .ng-nav (D36). | measured 124×18 / 74×18 | min-h-11 touch padding on banner actions (mobile only is fine). | fe/layouts/dashboard.vue:230-236 |
| A18 | LOW | Sticky-offset stack (top-[66px]/top-[33px]/top-0) is hard-coded to banner heights — brittle if banner text wraps (i18n, long seller names) or a third banner appears. | headerTop computed | Measure banners or drive offset via CSS var/ResizeObserver. | fe/layouts/dashboard.vue:56-60,249 |
S9 — dark theme
- Spot-check captured (
d-admin-dark.png); tokens are theme-complete (--ng-warn,--ng-info, etc. all have dark variants). Visual sign-off NOT_VERIFIED — recommend a human pass on badge bg/line contrast in dark.
Cross-cutting
- Systemic A1 root cause is the highest-leverage fix: any
hidden sm:*/sm:hiddenclass placed on aUiButton/UiBadge(and likely other.ng-*display roots) silently fails. Sweep usages before assuming the admin header is the only casualty (layouts/dashboard.vueuses the samehidden md:inline-flex/hidden sm:blockpatterns on native elements — those are fine; the risk is specifically on component roots whose own CSS setsdisplay). - Toast visibility on mobile (D32) was a symptom of page overflow; overflow is gone, so presumably resolved — not re-verified in this audit.
/adminhas no confirm dialogs at all (A5/A7) — introduces the firstUiModaluse on this surface; align with product tone (short, consequence-stating copy).
Top-5 priorities
- Bound the unbounded lists (A4+A6). 123 users + 38 review cards bury the entire monitoring/audit row ~16.5k px down. Filter
PENDING_REVIEW, paginate users, cap with "전체 보기". Highest workflow impact. - Fix the
hidden-utility suppression on Ui roots (A1).* One component-level fix removes the duplicate logout everywhere, un-leaks the ADMIN badge, and shrinks the mobile header by a full row (~52px). Systemic. - Guard destructive operator actions (A5+A7). Confirm modal for
반려/비활성, required memo on reject/correction, and self-lockout protection on the admin's own row. Misuse/lockout risk. - De-mix scopes in the monitoring card (A9). Global KPIs above act-as-scoped rows misreport state; either scope the KPIs or label
전체. Data-integrity perception. - Complete the context dropdown (A13+A14+A15). Escape/arrow keys, selected-seller marker, slug disambiguation — the impersonation picker is the highest-consequence control on the surface. A11y + targeting safety.
Limitations
- Review based on DOM geometry/computed styles + code inspection against the design tokens in
fe/assets/css/main.css; screenshots are evidence artifacts, not pixel-diffed. - Dark theme not visually verified (S9).
- Functional/security correctness of act-as scoping, review API, and audit coverage is INDEPENDENT_QA's lane — see
fe/e2e/qa-sweep/*findings.