Refines 3c2f650 — that commit broke the loop by suppressing the single-tariff
redirect on idx=0, but left the BackButton visible. The user still saw Back
on the detail page and needed two taps (Back → Close) to exit. Серёжа's
report was specifically that Back should not appear at all when you land on
your only subscription via a bot button.
Move the decision up to TelegramBackButton:
- Subscribe to the existing ['subscriptions-list'] query (deduped by
React Query, so no extra fetch).
- On /subscriptions/:id, when multi_tariff_enabled is false and there is
no in-app history (idx=0 deep-link entry), hide the BackButton. Telegram
then surfaces its native Close (X) in the header.
- Multi-tariff users still see BackButton on /subscriptions/:id deep-links
because their /subscriptions list is meaningful (they have many subs).
The 2bcba3b fallback-to-parent behaviour remains intact for unrelated
deep-links like /balance/top-up, /admin/*, etc.
Revert the defensive guard in Subscriptions.tsx — the loop can no longer be
triggered because no BackButton is shown to trigger it.
Behaviour summary:
flow BackButton exit cost
-------------------------------------- ---------- ---------
deep-link → /subscriptions/:id (1T) hidden Close, 1 tap
/ → /subscriptions → /subscriptions/:id shown Back → /, 1 tap
deep-link → /balance/top-up shown Back → /balance ✓
deep-link → /subscriptions/:id (multi) shown Back → /subscriptions list
Reported by Серёжа, bugs topic #599679.
User flow that hung:
1. Bot deep-link opens MiniApp directly on /subscriptions/:id (idx=0).
2. Telegram shows BackButton.
3. Tap → TelegramBackButton handler sees no in-app history and falls
back to getFallbackParentPath('/subscriptions/:id') = '/subscriptions'.
4. Subscriptions page renders, sees single-tariff + 1 subscription,
<Navigate replace> redirects right back to /subscriptions/:id.
5. BackButton looks dead — taps do nothing visible.
Fix: only redirect when there is in-app history. When idx=0 (deep-link or
just-arrived-via-back), render the list page instead. The user then sees
their single subscription as a card and can tap into it, and the next
BackButton tap exits the MiniApp as expected.
Reported by Серёжа in bugs topic #599679.
Surface: users (and especially Серёжа в bugs topic) were seeing raw i18n keys
like `subscription.promoGroup.title` rendered as literal text on the renewal
screen. Audit found 118 keys referenced in code that were absent from all 4
locale files, plus 140 keys missing only in zh/fa (silently falling back to
Russian for those users).
Root causes:
- cf52999 (extract TariffPickerGrid) introduced `subscription.promoGroup.title`
that never existed — should have been `subscription.promoGroup.yourGroup`.
- Subscription.tsx used `subscription.autopay.title` (object path) while
SubscriptionListCard.tsx used `subscription.autopay` (leaf) — i18next can't
hold both shapes for the same key.
- Many user-flow keys (subscription.notFound, subscriptions.empty, common.ok,
successNotification.*, etc.) were never added when their components landed.
- Historical drift had ~140 keys in ru/en that never got zh/fa translations.
Fix:
- Rename TariffPickerGrid usage `promoGroup.title` → `promoGroup.yourGroup`.
- Rename Subscription.tsx usage `autopay.title` → `autopay`.
- Add 116 brand-new keys across ru/en/zh/fa (464 entries).
- Add 8 partially-missing keys (en+ru gaps) for landing periods, dashboard
campaign/referrer stats, and promo offer failure counts.
- Translate 280 entries to close the zh/fa drift.
Final state: 0 keys missing in any of the 4 locale files for any code-referenced
translation key. Build + tsc green.
Pair with the bot's #558449 follow-up. Pass getYandexCid() through
the four cabinet API methods that previously omitted it:
- switchTraffic (PUT /traffic)
- activateTrial (POST /trial — body added, was previously empty POST)
- updateCountries (POST /countries)
- switchTariff (POST /tariff/switch)
Without these the backend now-typed yandex_cid field would silently
stay None for these paid flows even when the user had a CID cached.
Pair-commit with the bot's #558449 fix. On the user's first cabinet
visit, the separate POST /yandex-cid that Metrika init fires may not
finish before the user clicks Buy. Pass the locally-cached CID
directly in every purchase request body — renewSubscription,
purchaseTraffic, purchaseDevices, submitPurchase, purchaseTariff —
so the backend can persist it synchronously and the offline-conversion
event fires reliably.
.prose ul/ol used list-inside, but TipTap wraps each <li>'s content
in <p>. Combining position:inside with a block-level <p> dropped the
content onto the next line under the marker — visible both inside
the editor and on the rendered article page.
Drop list-inside (default to outside) and add `.prose li > p` rules:
first paragraph renders inline with the marker, subsequent paragraphs
stay block to preserve multi-paragraph list items.
Closes Bedolaga bug-topic #597116.
Third and final step in the AdminTrafficUsage decomp. Move the
filter dropdowns + PeriodSelector into
src/components/admin/trafficUsage/filters/:
- PeriodSelector.tsx (103 lines): fixed period tabs (1/3/7/14/30
days) + custom date-range mode. Exports the PERIODS constant
so the parent's prefetch effect iterates the same canonical
list.
- TariffFilter.tsx (133 lines): pop-over multi-select for tariff
names.
- StatusFilter.tsx (147 lines): pop-over multi-select for
subscription statuses. Exports STATUS_COLORS alongside.
- NodeFilter.tsx (135 lines): pop-over multi-select for traffic
nodes (with flag emoji + traffic-share numbers).
- CountryFilter.tsx (132 lines): pop-over multi-select for
source countries.
All five are pure controlled components — parent owns every
Set<string> filter state and feeds them via {available, selected,
onChange}. The parent imports them and drops 9 now-unused icon
imports (FilterIcon, ChevronDownIcon, ServerIcon, CalendarIcon,
StatusIcon, GlobeIcon — they live inside the filter files now).
AdminTrafficUsage.tsx: 1593 → 977 lines (-616).
Cumulative across the 3-step admin-traffic decomp:
1904 → 977 (-927, -49%).
Second step in the AdminTrafficUsage decomp. Move the 15 inline SVG
icons (Search/Chevron/Refresh/Download/Sort/Filter/Server/Calendar/X/
Status/Globe/Shield/ServerSmall) into a single barrel at
src/components/admin/trafficUsage/TrafficIcons.tsx. Pure SVG, no
behavioural change.
AdminTrafficUsage.tsx: 1734 → 1593 lines (-141).
First step in the AdminTrafficUsage decomp. Two new files under
src/components/admin/trafficUsage/:
- trafficUsageHelpers.ts (166 lines): the TanStack Table module
augmentation (sticky/align/bold meta), formatters (formatBytes,
formatCurrency, formatShortDate, formatGbPerDay,
toBackendSortField, getFlagEmoji wrapper), risk-assessment helpers
(bytesToGbPerDay, getRatio, getRowBgColor, getNodeTextColor,
getRiskLevel, getCompositeRisk), and the RISK_STYLES constant.
- RiskBadge.tsx (36 lines): the small per-row risk indicator
(dot + GB/d value + mini progress bar). Pure presentational.
Parent imports everything from the new modules; the inline
'TanStack Table module augmentation' lives in the helpers file
so the declaration merge still picks up across the codebase
once the helpers are imported.
AdminTrafficUsage.tsx: 1904 → 1734 lines (-170).
Fourth and final step in the AdminBulkActions decomp.
SubscriptionSubRow: the expanded per-user secondary <tr> shown when
the operator drills into multi-subscription users (tariff, status,
days remaining, traffic bar, devices). Pure presentational.
StatusBadge: the pill used both inside the sub-row and the primary
user-table column (subscription_status). Co-located here because
both consumers want the same visual contract.
AdminBulkActions.tsx: 1367 → 1182 lines (-185).
Cumulative across the 4-step admin decomp: 2539 → 1182 (-1357, -53%).
After MultiSelectDropdown extraction the parent no longer references
ChevronDownIcon directly — the icon now lives only inside the moved
sub-component. Trailing import cleanup from the previous commit.
Third step in the AdminBulkActions decomp. Move the 154-line
pop-over multi-select used by the tariff / status / node filters
into src/components/admin/bulkActions/MultiSelectDropdown.tsx.
Pure controlled primitive: closes on outside click, provides
select-all / deselect-all helpers, uses the shared ChevronDownIcon
from DropdownSelect.tsx. Exports MultiSelectOption type alongside.
Parent drops its inline ChevronDownIcon import (now only referenced
by the moved sibling) and imports MultiSelectDropdown + the option
type from the new module.
AdminBulkActions.tsx: 1517 → 1367 lines (-150).
Second step in the AdminBulkActions decomp. Two new files:
- actionTargets.ts (27 lines): SUBSCRIPTION_LEVEL_ACTIONS set +
isSubscriptionLevelAction(). The classification is the source of
truth shared between FloatingActionBar (renders two grouped menus
when multi-tariff is on) and the parent page (gates selection-count
semantics + the active-paid count in delete-subscription).
- FloatingActionBar.tsx (304 lines): the bottom-dock selection
toolbar. Pure presentational — parent owns selection state and
supplies onAction (opens the modal with the picked action type)
+ onToggleAllSubscriptions. The full 11-item action config (icons
+ label keys + colour classes) lives inside the file as a private
ActionConfig array.
Parent now imports FloatingActionBar + isSubscriptionLevelAction
from the new modules; the inline ActionConfig interface is gone.
AdminBulkActions.tsx: 1819 → 1517 lines (-302).
Cumulative across the 2-step admin decomp: 2539 → 1517 (-1022, -40%).
First and largest step in the AdminBulkActions decomp. Two new
files under src/components/admin/bulkActions/:
- DropdownSelect.tsx (47 lines): the small native-<select> wrapper
used both by the filter row and the ActionModal's tariff/promo
picker. Exports DropdownSelect + the shared ChevronDownIcon +
DropdownOption type.
- ActionModal.tsx (712 lines): the per-action configuration +
execution modal. Wraps three internal views — ProgressView (live
SSE log + counters while a job streams), ErrorDetails (collapsible
per-row error list after completion), and the per-action form
(days / tariff / traffic / balance / promo-group / grant /
set-devices / delete-* confirmations). Exports the ModalState
and ProgressState types alongside.
Self-contained: parent feeds modal state + reference data (tariffs,
promo groups, users for active-paid counting) and receives
onExecute(params) when the operator confirms. No queries or
mutations here — execution flow stays in the parent's
handleExecuteAction.
Parent keeps a local CheckIcon (still used by MultiSelectDropdown
checkboxes + the user-table column defs) and re-imports
ChevronDownIcon / DropdownOption from the new file.
AdminBulkActions.tsx: 2539 → 1796 lines (-743).
The largest and last structural extraction in the SubscriptionPurchase
decomp. Moves the classic-mode purchase flow (period → traffic →
servers → devices → confirm) into a self-contained step wizard at
src/components/subscription/purchase/ClassicPurchaseWizard.tsx.
The wizard self-owns:
- the preview query (gated on confirm step + form open)
- the submitPurchase mutation
- all six pieces of wizard state (currentStep, selectedPeriod,
selectedTraffic, selectedServers, selectedDevices, showPurchaseForm)
- the steps memo + currentSelection memo + currentStepIndex/isFirst/isLast
- the init useEffect (seeds from classicOptions.selection)
- toggleServer / goToNextStep / goToPrevStep / resetPurchase / getStepLabel
- its own useCloseOnSuccessNotification handler (closes form + resets
step on global success notification) — replaces the parent's
handleCloseAllModals dependency for the classic flow
Parent shrinks to a pure orchestrator: route params, three top-level
queries (subscription, purchase-options, subscriptions-list), mode
detection, two pieces of tariffs-mode state, the success-notification
handler for the remaining tariff modals, and JSX that delegates to
the four extracted sub-components.
SubscriptionPurchase.tsx: 908 → 370 lines (-538).
Cumulative across the 5-step decomp: 2054 → 370 (-1684, -82%).
Drops 11 parent imports (useEffect, useMemo, useCallback, useMutation,
useQueryClient, useNavigate, PurchaseSelection, PeriodOption,
InsufficientBalancePrompt, CheckIcon, Twemoji, useCurrency,
usePromoDiscount, getErrorMessage, PurchaseStep).
Move the tariff selection surface (promo-group banner, all-purchased
empty state, and the 1/2-col tariff cards grid with per-tariff CTAs)
into src/components/subscription/purchase/TariffPickerGrid.tsx.
The grid is pure presentation — it owns no queries or mutations.
Two callbacks back into the parent:
- onSelectTariff(tariff) → parent sets selectedTariff + showForm
- onSwitchTariff(tariffId) → parent sets switchTariffId
Internal helpers (formatPrice via useCurrency, applyPromoDiscount
via usePromoDiscount, glass tokens via useTheme + getGlassColors,
navigate for the 'all purchased' back-to-list button) all derive
inside the component — nothing threaded as props beyond data.
SubscriptionPurchase.tsx: 1202 → 908 lines (-294).
The single biggest extraction in the SubscriptionPurchase decomp.
Moves the per-tariff purchase form into a self-contained component
at src/components/subscription/purchase/TariffPurchaseForm.tsx.
The form self-owns:
- the purchaseTariff mutation (mode-aware: daily-tariff activate vs
period-based + custom-days / custom-traffic toggles + summary)
- the auto-scroll-into-view ref + effect on mount
- selectedTariffPeriod / customDays / customTrafficGb / useCustomDays /
useCustomTraffic — five pieces of state that were parent-level but
only ever read inside this form. Form remounts with fresh state
when the parent passes a new tariff (key={selectedTariff.id}).
Parent only owns selectedTariff + showTariffPurchase (the two pieces
the orchestrator needs to decide what to render) and threads them
into <TariffPurchaseForm>. Tariff card click handlers drop their
period-seeding line (the form seeds itself from tariff.periods[0]
on mount). The subscription_expired fallback callback simplifies
for the same reason.
SubscriptionPurchase.tsx: 1838 → 1222 lines (-616). Drops useRef,
TariffPeriod, and getInsufficientBalanceError from parent imports.
Move the tariff-switch preview modal (137 lines of inline JSX
+ its previewTariffSwitch query + switchTariff mutation + the
auto-scroll-into-view useEffect + the ref) into a self-contained
sheet at src/components/subscription/sheets/SwitchTariffSheet.tsx.
Parent keeps the trigger (switchTariffId selection state lives on
the TariffPickerGrid card click) but no longer holds the query, the
mutation, the ref, or the side-effect. The subscription_expired
backend signal — which used to call three setters at parent level
(setSelectedTariff, setSelectedTariffPeriod, setShowTariffPurchase)
— now flows through a single onExpiredFallback(tariff) callback.
SubscriptionPurchase.tsx: 2010 → 1841 lines (-169).
Prerequisite for the SubscriptionPurchase god-page decomposition.
The applyPromoDiscount helper + its active-discount useQuery were
inlined in SubscriptionPurchase.tsx and called from 9 sites across
3 purchase flows (classic wizard, tariff picker, tariff form,
switch-tariff sheet). Lifting both into src/hooks/usePromoDiscount
gives every future extracted sub-component a single seam to call,
without threading the function through props or re-fetching the
discount in each child.
SubscriptionPurchase.tsx: 2054 → 2009 lines (-45).
PRODUCT.md anti-references call out the SaaS-template / emoji-in-chrome
register: the reference products (Linear, Stripe, Things 3) don't put
platform emoji in product chrome. Three small drift spots:
- Balance saved-cards link used a 💳 emoji as the leading icon.
Replaced with a new CreditCardIcon added to the barrel (Heroicons-
style stroke, matches the chevron / chat / wallet siblings).
- Subscription paused-info banner used a ⏸️ emoji. Replaced with a
new PauseIcon added to the barrel. Same 24×24 stroke, currentColor,
preserves the urgent-400 tint via inline style.
- Balance payment-method min/max range used a literal en-dash ('–')
in source. PRODUCT.md's anti-em-dash rule covers the en-dash by
the same logic. Switched to a translated 'to' connector
(t('common.rangeTo', 'to')) so other locales can translate freely.
Previously the bottom nav swapped Support out for Wheel whenever the
operator enabled the wheel feature flag. This left a frustrated
paying customer (a key persona — already mid-ticket, opening the
app to follow up) unable to find help from the primary nav: they had
to know to dig into the hamburger drawer.
Wheel is a brand-moment surface, Support is a critical-path surface.
Replacing the second with the first is the wrong IA trade.
New rule: Support is the floor of the bottom nav and never moves.
Slot 4 still carries an optional item, with Wheel winning over
Referral when both are enabled (operator opted into Wheel as a
deliberate brand moment; Referral users can use the hamburger).
Critique P3 issue.
DESIGN.md 'Tunable-but-Scarce Rule' caps accent at <=10% of any
screen and reserves the accent + status hues for action / status,
never decoration. Four sites were spending those tokens on pure
chrome:
- Subscription.tsx purchased-traffic bar: zone.mainHex linear-gradient
on a non-status progress bar; the per-purchase bar inherited the
user's GLOBAL traffic-zone color (a fresh purchase could read
'critical' just because the user's overall usage was). Replaced
with solid accent-500 fill — same affordance, honest semantics.
- SubscriptionCardActive tariff badge: zone-colored linear-gradient
background + zone-colored label. The tariff name has no traffic-zone
semantics, so tinting it by the global traffic zone was a
Status-Hue Lockout violation. Switched to glass innerBg/innerBorder.
- Balance hero card: bg-gradient-to-br from-accent-500/10 + glow prop
on Card. Removed; flat surface, the giant numeric is the affordance.
Eliminates the SaaS hero-metric template tell.
- Login background: two stacked fixed inset gradients (linear +
accent radial halo) read as the airdrop / crypto aesthetic
PRODUCT.md explicitly anti-references. Replaced with the plain
body bg-dark-950.
Three issues from the post-PRODUCT.md /impeccable critique pass:
P0 — backdrop-blur-xl shipped to mobile on the two hero cards
(SubscriptionCardActive + Subscription detail). DESIGN.md
'Mobile-Without-Blur Rule' restricts backdrop-blur to ≥1024px.
Gate with lg: prefix so phones (half the audience) get the opaque
surface and skip the scroll-jank-inducing GPU blur layer.
P1 — Two destructive device-deletion sites still called the bare
browser confirm(). The codebase already imports useDestructiveConfirm
and uses it correctly for revoke (line 484). Bring the all-devices
and per-device delete paths onto the same platform-aware path, so
inside Telegram the user gets the native destructive popup with
haptic + theme, and on web they get the inline destructive panel.
P2 (partial) — Removed the decorative ambient radial-gradient halo
behind the Subscription detail hero card and the trial-shimmer border
overlay. Same chrome was distilled out of SubscriptionCardActive
earlier in this branch; the rationale is identical (zone/accent hue
leaking into pure decoration violates DESIGN.md Tunable-but-Scarce
+ Status-Hue Lockout rules). Trial state is conveyed by the header
badge.
The five extracted sheet components each had a hand-inlined chevron
SVG copy-pasted from the original god page. Swap them for the
existing ChevronRightIcon from src/components/icons — same visual,
one source of truth (Heroicons-aligned path), and pre-baked sizing
through the cn() className merge.
Note on the broader 'icon unification' audit recommendation: this
project does not depend on lucide-react. It already ships a 703-line
hand-rolled icon barrel at src/components/icons/index.tsx. The real
cleanup is migrating the ~139 inline SVGs across the app to that
barrel, not adding a new icon library. This commit consolidates the
five duplicated chevrons introduced by the recent sheet extraction;
broader inline-SVG migration is a separate, larger refactor.
Move the delete-subscription expand/confirm block into
src/components/subscription/sheets/DeleteSubscriptionSheet.tsx.
The sheet itself decides between the Telegram destructive native dialog
and the inline web confirm panel, so the parent no longer needs the
platform discriminator. It owns deleteLoading and the API call; the
parent passes onDeleted (navigate + invalidate) and the textSecondary
glass color token used in the warning copy.
Subscription.tsx: 1828 → 1750 lines.
Drops now-unused imports (usePlatform; deleteLoading state).
Move the classic-mode manage-servers sheet (~280 lines of inline
JSX, the countries useQuery, its seeding useEffect, and the update
mutation) into src/components/subscription/sheets/ServerManagementSheet.tsx.
Parent keeps selectedServersToUpdate so the global 'close all modals'
callback can still clear it; the sheet owns the rest. The query stays
gated on trial state inside the sheet.
Subscription.tsx: 2117 → 1828 lines (−289).
Move the buy-traffic sheet (~170 lines of inline JSX + its
packages useQuery + purchase useMutation) into
src/components/subscription/sheets/TrafficTopupSheet.tsx.
Parent keeps selectedTrafficPackage in its state so the global
'close all modals' callback can still reset it; the sheet owns the
network calls and only fires while open.
Subscription.tsx: 2288 → ~2120 lines.
Move the reduce-devices sheet (~170 lines of inline JSX + its
useQuery + useMutation + seeding useEffect) into
src/components/subscription/sheets/DeviceReductionSheet.tsx.
Parent passes the shared targetDeviceLimit state (also used by the
revoke flow earlier) plus open/close + ids; the sheet owns the
reduction-info query and the reduce mutation, so neither fires until
the sheet is open.
Subscription.tsx: 2477 → 2288 lines (−189).
Move the buy-devices sheet (190 lines of inline JSX + its useQuery +
useMutation) into src/components/subscription/sheets/DeviceTopupSheet.tsx.
Parent passes shared state (purchaseOptions, devicesToAdd, ids); the
sheet self-owns the device-price query and purchase mutation, so the
query only fires when the sheet is actually open.
Subscription.tsx: 2671 → 2477 lines (−194).
Critique flagged the card as carrying '12 chrome elements for 2 facts'.
Removing 8 of 12 without browser QA risks gutting functional info.
Apply the conservative 3-element strip: only kill chromes with zero
information value.
• Background radial glow (200x200 circle, top-right, decorative tint
based on traffic zone) — pure ornament, no signal.
• Trial shimmer border (animate-trial-glow on inset wrapper) — trial
state is already signalled by the badge in the header; the second
signal was visual noise.
• Trial badge own animate-trial-glow + 2-stop gradient bg — flatten
to single bg-accent-400/10 + border-accent-400/25 + solid text.
The badge still says TRIAL with brand color; it no longer pulses.
• Zone dot 8px-radius color-tinted boxShadow + 'transition: all' —
drop the glow, transition only background. Reduces filter+composite
work, dot still signals zone via color.
Kept (functional):
• Big percentage + traffic-used metadata (primary info, not chrome)
• Progress bar (primary signal)
• Connect Device CTA with HoverBorderGradient (primary action;
rotating border is its own UX debate, deferred)
• Sparkline + connected devices count (functional metadata)
Full 12→4 collapse the critique recommended needs browser QA and a
design-direction call (move days/tariff/refresh to the detail page
header). Bigger restructure as separate PR.
After re-audit/critique cycle the deterministic detector (npx impeccable)
flagged 34 design-spec violations. Knock out 33 of them.
• 25 × pure-black-white: sed-sweep bg-black/X → bg-dark-950/X across
18+ files (modal scrims, photo viewer backdrop, code blocks). The
base resolves to rgba(10,15,26,X) — visually identical to true
black, satisfies the 'no #000' impeccable rule.
• 3 × bounce-easing: SuccessNotificationModal celebration icon and
SyncTab loading arrows used animate-bounce; replaced with
animate-pulse. Bounce easing reads dated; pulse conveys 'in
progress' without the cartoon feel.
• 3 × border-accent-on-rounded: TelegramCallback + VerifyEmail
spinners used 'border-b-2 border-accent-500' on rounded-full —
detector reads it as a side-stripe even though it's a ring loader.
Switch to canonical 'border-2 border-accent-500 border-t-transparent'
(3/4 ring colored). Same visual, no spec violation.
• 1 × ai-color-palette: AdminLandingStats had text-purple-400 on a
gift-stats heading; purple is not in the brand palette. Swap to
text-accent-400.
• 1 × layout-transition: TrafficProgressBar.tsx fill bar still used
transition: width 1.2s (slipped past the earlier optimize pass).
Convert to transform: scaleX with origin-left. Same gradient, same
duration, runs on the compositor.
Remaining: 1 finding in third-party Aceternity background-beams-collision
component (indigo-500 gradient on decorative WebGL background) — left
as-is, it's lifted decorative third-party code.
Detector: 34 → 1.
Created with future-API intent during the optimize pass but no .tsx
consumer materialised — the three Subscription progress bars inline
their classes directly. Dead utility classes accumulate, confuse the
next reader ("this looks like our pattern, am I doing it wrong?"),
and add bytes to the CSS. Drop both the dark version and the
.light .progress-bar override.
If a future progress bar needs them back: inline first, extract only
when 3+ call sites share intent (per /impeccable extract guidance).
Audit P2: admin pages prioritise density but cabinet also runs in the
Telegram Mini App on phones. Small button targets (~24-32px) work for
mouse precision but fail mobile thumbs. Apply targeted bumps that keep
desktop density unchanged.
src/styles/globals.css
• .btn — add min-h-[44px] on mobile, sm:min-h-0 on desktop.
Cascades to every .btn-primary / .btn-secondary / .btn-ghost /
.btn-danger consumer (76 + 48 + N usages app-wide). One change,
everywhere.
• .btn-icon — same logic (min-h/min-w 44px mobile). Drops the
duplicate definition that appeared further down the file.
components/admin/userDetail/TicketsTab.tsx — mobile-critical chat
• Back button h-8 w-8 → h-10 w-10 sm:h-8 sm:w-8 (40px / 32px).
Adds aria-label since it had none.
• Status pills px-2.5 py-1 → +min-h-[36px] py-1.5 sm:min-h-0
sm:py-1 (36px / 26px). Mis-tap penalty on mobile is high (it
flips ticket status).
• Reply send button — add min-h/min-w 44px mobile + aria-label.
Why not blanket bump py-1.5 → py-2.5 across all 40+ admin pages:
visual density patterns were iteratively designed and a global sweep
would regress them. Per-page review can target specific buttons that
deserve mobile touch floor without breaking density elsewhere.
icon-only buttons not using .btn-icon (raw <button><svg/></button>)
left as-is — they're typically in compact toolbars where visual
density is the point. Per-page review under /impeccable adapt
target=<page> if mobile flow analysis flags one.
Audit: only 3 selectors respected prefers-reduced-motion (.hover-
border-gradient, .bento-card, .admin-orb). Codebase has ~358 animation
declarations and ~970 transition declarations — without a global rule
the rest ignored the OS-level preference.
Add a universal rule that collapses every animation and transition to
0.01ms when the user has requested reduced motion. State-conveying
motion (toast slide-in, modal fade, accordion expand) still completes
to its final frame — only the journey is skipped. Pure decorative
motion (background gradients, etc.) effectively disappears.
The 3 specific rules above stay as documentation of intent — this
catch-all subsumes them via !important. Affects every animation in
the app without per-call-site touchups. WCAG 2.3.3.
Note: dangerouslySetInnerHTML across all 17 hit sites was already
sanitized via DOMPurify (getSvgHtml uses SVG profile, renderMarkdown
uses strict ALLOWED_TAGS, linkifyText / sanitizeHtml / formatContent
all wire through DOMPurify). The earlier audit P2 flag was a false
positive — no source change needed for that item.
Audit P1: 5 of 7 layout-property animations were transition-[width]
or transition-[left] — these trigger reflow + repaint every frame.
Convert to transform-based equivalents that run on the compositor.
Subscription.tsx — 3 progress bars + 1 toggle:
• Device usage bar (track w-16) — width % → scaleX with a 0.0625
floor (= 4px on 64px track) preserving the minWidth: 4px guard
when connectedDevices > 0.
• Traffic-purchase progress — absolute inset-0 + width % →
transform: scaleX with origin-left, gradient stretches with the
fill same as before.
• Subscription period progress — same conversion.
• Autopay toggle thumb — left: 3↔26px → translateX(0↔23px) with
fixed left: 3px. role='switch' + aria-checked unchanged.
globals.css — .progress-fill utility class:
• transition-property: width, background-color → transform,
background-color. Width set to w-full + origin-left so consumers
just set transform: scaleX(0..1). Currently has no .tsx consumers
(grep clean) but the API stays valid for future use.
Skipped (bounded one-shot, transform alternative has tradeoffs):
• Info.tsx + InfoPageView.tsx accordion height. Animating from 0
to content-driven scrollHeight needs layout one way or another —
grid-template-rows 0fr/1fr and max-height also layout-class
properties; transform: scaleY squishes text contents. Cost is
bounded: one animation per user click, not continuous, not on
critical path. Separate work if priority changes.
Visual parity: scaleX renders identical geometry to width % on the
inner fill. box-shadow on the device bar (0 0 8px 40)
scales proportionally — tiny visual difference at full width, none
visible at partial widths.
The #FFB800 (23 hits) and #FF3B5C (11 hits) raw hex literals across
Subscription / SubscriptionPurchase / TrialOfferCard / useTrafficZone
all represent the same semantic: 'expiring soon' and 'expired'.
Distinct from warning-500 (#f59e0b) and error-500 (#ef4444) — the
subscription timeline needs a sharper, hotter signal than the
system-wide warning/error roles.
Add two semantic tokens:
--color-urgent-400: 255, 184, 0 (#FFB800)
--color-critical-500: 255, 59, 92 (#FF3B5C)
Exposed via Tailwind as urgent.400 / critical.500. Sweep all
single-and double-quoted hex literals to rgb(var(--color-...))
form so style attrs, SVG stroke=, and ternary string values all
resolve through CSS vars (auto-respects future light-theme
variants).
Left as-is: 4 hex appearances inside gradient pair strings —
linear-gradient(135deg, #FFB800, #FF8C00) — where the partner
color (#FF8C00 / #FF6B35) isn't in the token set. Partial-replace
inside gradient strings would just add noise; full extraction of
the orange-ramp companion is out of scope for this pass.
Per /impeccable audit: Toast.tsx had a 4px border-l accent on top of
a 1px full border — the explicit 'side-stripe' absolute ban. Toast is
on screen after every notify.* call, so the violation repeated
constantly.
Polish pass:
• Replace 'border border-l-4 border-dark-700 ${style.border}' with
a single tinted full border (border-success-500/40, etc). Semantic
still carries through the colored icon box + matching border tint.
• Drop bg-tint flood — the icon box at 40px is enough at this scale.
• Reduce shadow weight (shadow-2xl shadow-black/50 → shadow-xl
shadow-black/30). Floating layer needs elevation, not drama.
• Soften interaction scale (1.02 hover → 1.01, 0.98 active → 0.99)
to match a calmer overall feel.
• Thinner progress bar (h-1 → h-0.5) — the countdown is ambient, not
primary content.
A11y:
• Container: role='region' + aria-label + aria-live='polite' so SR
announces incoming toasts without stealing focus.
• Toast: role='alert' for type=error (interrupts SR — appropriate
for failures), role='status' for everything else.
• Toast: tabIndex=0 + Enter/Space to activate, Escape to dismiss —
was click-only before, useless on keyboard.
• focus-visible: 2px ring at accent-500/60 with offset for visibility
on the dark backdrop.
• Icon box aria-hidden — it's decorative reinforcement, the role +
text content is what SR reads.
• Progress bar aria-hidden — visual countdown only.
shrink keyframe already used transform: scaleX (no layout thrash)
and slide-in-right keyframe already used ease-out-expo — both
align with motion laws as-is, kept unchanged.
Per /impeccable audit findings: admin overview pages concentrated
AI-aesthetic tells. Apply the 'quieter' refinement — strip the loudness,
keep the structure, preserve state-conveying motion.
AdminPanel.tsx
• Hero h1 — drop bg-clip-text + 3-stop gradient. Solid text-dark-50
(the absolute ban applied; type weight + size carry hierarchy now).
• 'Online' dot — drop the 10px-radius glow shadow; the dot + pulse
animation alone signal liveness.
• GlassCard — drop the 3D perspective-tilt mouse-tracking (decorative
motion that didn't convey state), drop the staggered enter
animation (no orchestrated page-load), drop the top hairline-glow
gradient, drop the inner shine overlay + drop-shadow-sm on the
count badge. Keep backdrop-blur on the card body itself — the
component is literally named GlassCard, the glass identity is the
point. Hover state stays.
• Stats-bar pills (2 places) — drop backdrop-blur. Tiny data cells
don't need glass.
• Search empty-state icon container — drop backdrop-blur. Decorative
on a non-interactive surface.
AdminDashboard.tsx
• Strip backdrop-blur from all 9 stat / node / panel cards. They
were using it as ambient noise; solid bg-dark-800/{30,50} reads
better and renders faster.
• Daily-sales progress bar — drop the from-accent-600 to-accent-400
gradient (and hover gradient swap). Solid bg-accent-500, hover
bg-accent-400. Width still animates (functional state change).
• Animate-pulse on the connected-node status dot kept — it conveys
'live' state, which is exactly what motion is for in product UI.
Last and biggest tab — extracted via view-facade pattern (like Info).
JSX moves into components/admin/userDetail/SubscriptionTab.tsx;
state, queries, mutation handlers stay in parent because most are
shared with other tabs (panelInfo + devicesQuery used by Info,
inline-confirm armer used by destructive actions, etc.).
The new file is 1223 lines — over the soft cap, but cohesive: one
tab with multi-sub list, sub-detail view, traffic packages,
devices CRUD with inline rename, panel info/config, live traffic,
node usage, and request history table. Sub-extracting (Devices /
RequestHistory / PanelLinks) is the next step but not free —
each adds prop-passing boilerplate.
Wide prop interface (~50 props) but every prop is a single pass-
through line at the call site. No state moves; just JSX reroute.
Parent imports trimmed: DEVICE_ALIAS_MAX_LENGTH, getFlagEmoji,
createNumberInputHandler, getCountryFlag, PlusIcon, MinusIcon,
StatusBadge — all gone, subscription tab was their last consumer.
RefreshIcon kept (page header reload button still uses it).
Net to AdminUserDetail.tsx: down to 970 lines (-1022). Total
across the 7 tab extractions: 3820 → 970 (-2850, -75%).
The info tab needed a different shape than the prior 5 — its
panelInfo, promoGroups, editingPromoGroup, action handlers
(reset-trial, disable, full-delete) are SHARED with the subscription
tab and with the inline-confirm armer up in the parent. Moving any
of that into the tab would either duplicate the query/state or
fork the confirm state. So the new InfoTab is a 'view facade':
state and handlers stay parent-owned, the JSX moves out.
Prop surface is wide (~25 props) but every prop is a single
'pass through' line at the call site — no logic shifted. Each
handler / state slot still lives in exactly one place; the tab
only renders.
Bonus: the parent's formatWithCurrency / useCurrency import is
dropped — info tab was the last consumer in the page body.
Net to AdminUserDetail.tsx: down to 1992 lines (-403). Total
journey across the 6 tab extractions: 3820 → 1992 (-1828, -48%).
Only the subscription tab JSX (~1043 lines) remains inline.