tour-guide
Thêm một in-app product tour (spotlight onboarding overlay) tự viết, KHÔNG cần thư viện (không joyride/intro.js/driver.js). Vẽ lớp tối + khoét spotlight quanh từng phần tử theo CSS selector, gắn tooltip nhãn, và 1 pill "tap để đóng" đặt vào khoảng trống lớn nhất. Responsive mobile/desktop, lock scroll, đo bằng getBoundingClientRect, click bất kỳ đâu để đóng. Dùng khi user muốn: "thêm tour guide", "onboarding tour", "hướng dẫn trong app", "spotlight tour", "product tour", "walkthrough", "tour kiểu bonbon", hoặc invoke /tour-guide. Hợp với React/Next.js + Tailwind. Mỗi route tự định nghĩa danh sách điểm dừng (spots); phần tử được đánh dấu bằng thuộc tính data-tour ổn định.
How do I install this agent skill?
npx skills add https://github.com/rheinmir/setup --skill tour-guideIs this agent skill safe to install?
- Gen Agent Trust Hubpass
The skill is a self-contained UI utility for creating an in-app product tour using React and SVG. It operates entirely within the browser's document context to highlight UI elements and displays informational labels without external dependencies or network activity.
- Socketpass
No alerts
- Snykpass
Risk: LOW · No issues
What does this agent skill do?
tour-guide — In-app spotlight tour (no library)
Pattern đã chạy thật trong bonbon DMS. Một component duy nhất + quy ước data-tour + mảng spots mỗi route.
WHAT
Purpose và context
- Purpose: thêm một in-app product tour (spotlight onboarding overlay) tự viết, 0 thư viện: lớp tối + khoét spotlight quanh từng phần tử theo CSS selector, tooltip nhãn, pill "tap để đóng" ở khoảng trống lớn nhất, responsive mobile/desktop.
- Trigger (when to use): user muốn "thêm tour guide", "onboarding tour", "hướng dẫn trong app", "spotlight tour", "product tour", "walkthrough", "tour kiểu bonbon", hoặc invoke
/tour-guide. Hợp với React/Next.js + Tailwind. - Non-goals: không dùng thư viện tour (joyride/intro.js/driver.js); không phải tour nhiều bước next/prev — một overlay hiện mọi điểm dừng của route, click đâu cũng đóng.
Mental model
- Đánh dấu phần tử cần highlight bằng
data-tour="..."(ổn định hơn class). - Một state
tourActive+ nút trigger (vd tap logo) bật/tắt. - Mỗi route khai báo
spots: TourSpot[](selector + label) truyền vào<TourGuide>. - Component đo DOM, vẽ SVG dim + spotlight cutout + tooltip, click đâu cũng đóng.
Input và output contract
| Field | Required? | Ý nghĩa | |
|---|---|---|---|
| In | app React/Next.js (+ Tailwind) | có | nơi đặt component |
| In | danh sách điểm dừng mỗi route (selector + label, tuỳ labelSide/spotlightOnly/tooltipOnly) | có | mảng spots: TourSpot[] |
| In | 6 token màu brand + dismissText | không | mặc định trong TOUR_THEME / prop |
| Out | components/ui/tour-guide.tsx (hoặc nơi tương đương) | có | copy nguyên file, 0 dependency ngoài React |
| Out | thuộc tính data-tour trên phần tử + state tourActive + nút trigger + <TourGuide> mỗi route | có | tour bật/tắt được |
Rules và capabilities
Bản gốc không có mục Rules riêng — các luật dưới gom từ "Cách hoạt động", Bước 1–2 và Gotchas (giữ ý nguyên văn):
- RULE-01 (MUST): Không thư viện tour — copy nguyên file component, 0 dependency ngoài React; chỉ chỉnh 6 token màu trong
TOUR_THEMEvàdismissTextqua prop. - RULE-02 (MUST): Dùng
data-tourthay vì class — class hay đổi do refactor/Tailwind;data-tourbền. - RULE-03 (MUST): Lock scroll: set
document.body.style.overflow='hidden'khi active, nhớ khôi phục khi đóng (đã handle trong cleanup). - RULE-04 (MUST): Click-to-close: toàn overlay
onClick={onClose}; tooltip/hint làpointer-events-nonenên không chặn click đóng. - RULE-05 (SHOULD): Đo trễ 300ms: chờ layout/animation ổn định trước khi đo. Nếu list load async, mở tour sau khi data có (hoặc gọi remeasure).
- Capabilities: sửa code frontend của app (thêm component, thuộc tính, state); đo DOM runtime.
Failure boundaries
- Selector không khớp / phần tử khuất hoặc off-screen → spot tự bị bỏ qua (không lỗi) — nếu mọi spot bị bỏ thì tour trống: failed, kiểm lại
data-tourhoặc thời điểm mở tour. - App có lớp z-index cao hơn
z-[500]→ overlay bị che: nâng z-index. - App không phải React → clarify: component là TSX/React, cần port.
HOW
Main workflow
| Step | Type | Inputs | Action | Outputs/exit | Failure/next |
|---|---|---|---|---|---|
| W01 | effect | app | Bước 1: tạo components/ui/tour-guide.tsx (copy nguyên file), chỉnh TOUR_THEME | component | — |
| W02 | effect | phần tử cần highlight | Bước 2: thêm data-tour="..." | selector ổn định | — |
| W03 | effect | route | Bước 3: state tourActive + nút trigger + mảng SPOTS + <TourGuide> | tour chạy trên route | layout 2 dạng → B01 |
| W04 | judgment | trang chạy | Mở tour, kiểm spotlight/tooltip/pill đúng chỗ trên mobile + desktop, click đâu cũng đóng, scroll khôi phục | tour đạt | spot mất → kiểm Gotchas |
Chi tiết từng bước (nguồn chân lý cho W01–W04):
Bước 1 — Component (copy nguyên file)
Tạo components/ui/tour-guide.tsx (hoặc nơi tương đương). 0 dependency ngoài React.
Chỉnh 6 token màu trong TOUR_THEME cho khớp brand; dismissText đổi qua prop.
"use client";
import { useEffect, useState, useCallback } from "react";
/** Sửa 6 token này cho khớp brand của bạn. */
const TOUR_THEME = {
scrim: "rgba(0,0,0,0.72)", // lớp tối
ring: "rgba(255,255,255,0.2)", // viền spotlight
tipDesktop: "border border-sky-400 bg-white text-slate-900 shadow-[0_2px_8px_rgba(56,189,248,0.2)]",
tipMobile: "bg-sky-100 text-slate-900",
hintDesktop: "bg-slate-900 text-white",
hintMobile: "bg-white/25 text-white backdrop-blur-sm",
};
export interface TourSpot {
selector: string; // CSS selector phần tử (ưu tiên [data-tour='...'])
label: string; // chữ tooltip ("" nếu chỉ muốn spotlight)
labelSide?: "top" | "bottom" | "right"; // hướng đặt tooltip
spotlightOnly?: boolean; // chỉ khoét spotlight, không tooltip
tooltipOnly?: boolean; // chỉ tooltip, không khoét spotlight
}
interface MeasuredSpot {
top: number; left: number; width: number; height: number;
label: string; labelSide: "top" | "bottom" | "right";
spotlightOnly?: boolean; tooltipOnly?: boolean;
}
function measure(spots: TourSpot[]): MeasuredSpot[] {
return spots.flatMap(spot => {
const el = document.querySelector(spot.selector);
if (!el) return [];
const r = el.getBoundingClientRect();
if (r.width === 0 && r.height === 0) return [];
if (r.top < -20 || r.top > window.innerHeight) return []; // off-screen → bỏ
const vh = window.innerHeight;
return [{
top: r.top, left: r.left, width: r.width, height: r.height,
label: spot.label,
labelSide: spot.labelSide ?? (r.top > vh * 0.6 ? "top" : "bottom"),
spotlightOnly: spot.spotlightOnly,
tooltipOnly: spot.tooltipOnly,
}];
});
}
export function TourGuide({
active, onClose, spots, dismissText = "Tap anywhere to close",
}: {
active: boolean; onClose: () => void; spots: TourSpot[]; dismissText?: string;
}) {
const [maskId] = useState(() => `tm-${Math.random().toString(36).slice(2, 8)}`);
const [rects, setRects] = useState<MeasuredSpot[]>([]);
const remeasure = useCallback(() => { if (active) setRects(measure(spots)); }, [active, spots]);
useEffect(() => {
if (!active) { setRects([]); document.body.style.overflow = ""; return; }
window.scrollTo({ top: 0, behavior: "instant" });
document.body.style.overflow = "hidden";
const id = setTimeout(() => setRects(measure(spots)), 300) as unknown as number;
window.addEventListener("resize", remeasure);
return () => {
clearTimeout(id);
window.removeEventListener("resize", remeasure);
document.body.style.overflow = "";
};
}, [active, remeasure]);
if (!active) return null;
const PAD = 8;
const vw = window.innerWidth;
const vh = window.innerHeight;
const isDesktop = vw >= 768;
return (
<div className="fixed inset-0 z-[500]" onClick={onClose}>
<svg className="pointer-events-none absolute inset-0" width={vw} height={vh}>
<defs>
<mask id={maskId}>
<rect width="100%" height="100%" fill="white" />
{rects.filter(r => !r.tooltipOnly).map((r, i) => (
<rect key={i} x={r.left - PAD} y={r.top - PAD}
width={r.width + PAD * 2} height={r.height + PAD * 2} rx={12} fill="black" />
))}
</mask>
</defs>
<rect width="100%" height="100%" fill={TOUR_THEME.scrim} mask={`url(#${maskId})`} />
{rects.filter(r => !r.tooltipOnly).map((r, i) => (
<rect key={i} x={r.left - PAD} y={r.top - PAD}
width={r.width + PAD * 2} height={r.height + PAD * 2} rx={12}
fill="none" stroke={TOUR_THEME.ring} strokeWidth={1.5} />
))}
</svg>
{rects.filter(r => !r.spotlightOnly).map((r, i) => {
const isTop = r.labelSide === "top";
const isRight = r.labelSide === "right";
const isTall = r.height > 50 && r.height <= 200;
const tip = `whitespace-nowrap rounded-lg px-2.5 py-1 text-[10px] font-semibold shadow-md ${isDesktop ? TOUR_THEME.tipDesktop : TOUR_THEME.tipMobile}`;
let tooltipTop: number;
if (isRight) {
const estWR = Math.min(r.label.length * 7 + 24, 220);
tooltipTop = r.top + r.height / 2 - 14;
const rightLeft = r.left + r.width + PAD + 6;
return (
<div key={i} className="pointer-events-none fixed z-[501]"
style={{ top: tooltipTop, left: Math.min(rightLeft, vw - estWR - 8) }}>
<div className={tip}>{r.label}</div>
</div>
);
} else if (isTop) {
tooltipTop = Math.max(8, r.top - 30);
} else if (isTall) {
tooltipTop = Math.min(r.top + r.height * 0.82, vh - 40);
} else if (r.height > 200 && (!isDesktop || r.tooltipOnly)) {
return null; // card mở rộng (mobile luôn ẩn; desktop chỉ ẩn khi tooltipOnly)
} else {
tooltipTop = Math.min(r.top + r.height + 6, vh - 40);
}
const estW = Math.min(r.label.length * 7 + 24, 220);
const centerX = r.left + r.width / 2 - estW / 2;
const tooltipLeft = Math.max(8, Math.min(centerX, vw - estW - 8));
return (
<div key={i} className="pointer-events-none fixed z-[501]"
style={{ top: tooltipTop, left: tooltipLeft }}>
<div className={tip}>{r.label}</div>
</div>
);
})}
{(() => {
const PILL_H = 40, MARGIN = 16;
const zones: [number, number][] = rects.map(r => [r.top - PAD - 36, r.top + r.height + PAD + 36]);
zones.sort((a, b) => a[0] - b[0]);
const merged: [number, number][] = [];
for (const z of zones) {
if (merged.length && z[0] <= merged[merged.length - 1][1]) {
merged[merged.length - 1][1] = Math.max(merged[merged.length - 1][1], z[1]);
} else { merged.push([...z] as [number, number]); }
}
const gaps: { top: number; bot: number; size: number }[] = [];
let prev = 80;
for (const [zTop, zBot] of merged) {
if (zTop - prev >= PILL_H + MARGIN * 2) gaps.push({ top: prev, bot: zTop, size: zTop - prev });
prev = zBot;
}
if (vh - prev >= PILL_H + MARGIN * 2) gaps.push({ top: prev, bot: vh, size: vh - prev });
const best = gaps.sort((a, b) => b.size - a.size)[0];
if (!best || best.size < PILL_H + MARGIN * 2) return null;
const pillTop = best.top + (best.size - PILL_H) / 2;
return (
<div className={`pointer-events-none fixed left-1/2 z-[501] -translate-x-1/2 rounded-full px-5 py-2.5 text-xs font-semibold shadow-lg ${isDesktop ? TOUR_THEME.hintDesktop : TOUR_THEME.hintMobile}`}
style={{ top: pillTop }}>
{dismissText}
</div>
);
})()}
</div>
);
}
Bước 2 — Đánh dấu phần tử bằng data-tour
Trên các phần tử muốn highlight, thêm attribute ổn định:
<button data-tour="logo" onClick={() => setTourActive(true)}>…</button>
<input data-tour="search-input" … />
<div data-tour="list-item" … />
Dùng
data-tourthay vì class — class hay đổi do refactor/Tailwind;data-tourbền.
Bước 3 — Wire vào route (state + trigger + spots)
import { TourGuide, type TourSpot } from "@/components/ui/tour-guide";
import { useMemo, useState } from "react";
const [tourActive, setTourActive] = useState(false);
const SPOTS = useMemo<TourSpot[]>(() => [
{ selector: "[data-tour='logo']", label: "Mở hướng dẫn", labelSide: "bottom" },
{ selector: "[data-tour='search-input']", label: "Tìm kiếm ở đây", labelSide: "bottom" },
{ selector: "[data-tour='list-item']", label: "Nhấn để xem chi tiết", labelSide: "bottom" },
{ selector: "[data-tour='action-approve']", label: "Duyệt", labelSide: "top" },
], []);
// …trong JSX:
<button data-tour="logo" onClick={() => setTourActive(true)}>Hướng dẫn</button>
<TourGuide active={tourActive} onClose={() => setTourActive(false)} spots={SPOTS} dismissText="Nhấn bất kỳ đâu để đóng" />
Branches
| ID | Kind | Guard | Hành vi | Skip / failure | Rejoin |
|---|---|---|---|---|---|
| B01 | conditional_required | layout mobile và desktop khác nhau | khai 2 mảng spots (selector data-tour='desktop-*' riêng), chọn theo window.innerWidth >= 768 (hoặc useMediaQuery) | trộn 1 mảng vẫn chạy vì measure() bỏ phần tử ẩn | W04 |
| B02 | conditional_required | list load async | mở tour sau khi data có (hoặc gọi remeasure) | — | W04 |
| B03 | user_optional | phần tử quá to / chỉ cần một kiểu | tooltipOnly (không khoét) hoặc spotlightOnly + label: "" (không tooltip) | — | W04 |
Validation và stopping
Kiểm bằng mắt trên trang chạy thật ở cả hai bề rộng (< 768 và ≥ 768): mỗi spot có cutout/tooltip đúng phần tử, pill nằm ở khoảng trống, click bất kỳ đâu đóng, body.style.overflow khôi phục sau khi đóng. Dừng khi mọi route đã khai spots hiện đúng.
Examples
- Positive: màn danh sách đơn có logo, ô tìm kiếm, dòng đơn, nút duyệt → gắn
data-tour4 chỗ,SPOTSnhư Bước 3 → tap logo mở tour: 4 spotlight kèm nhãn, pill "Nhấn bất kỳ đâu để đóng" ở vùng trống, tap đóng và trang cuộn lại được. - Boundary/failure: mở tour ngay khi list còn đang fetch → spot
[data-tour='list-item']chưa có trong DOM nên bịmeasure()bỏ, tour thiếu điểm → mở tour sau khi data về (B02).
Reference — TourSpot
| field | ý nghĩa |
|---|---|
selector | CSS selector (ưu tiên [data-tour='x']); element không tồn tại/khuất → tự bỏ qua |
label | chữ tooltip; "" + spotlightOnly = chỉ khoét sáng |
labelSide | top / bottom / right (mặc định: tự chọn theo vị trí) |
spotlightOnly | khoét spotlight, KHÔNG tooltip |
tooltipOnly | tooltip nổi, KHÔNG khoét (cho phần tử quá to / desktop) |
Reference — Mobile vs Desktop
Vì layout 2 dạng khác nhau, khai báo 2 mảng spots (selector data-tour='desktop-*' riêng) rồi chọn theo window.innerWidth >= 768 (hoặc useMediaQuery). Phần tử desktop ẩn trên mobile sẽ tự bị measure() bỏ qua, nên trộn chung 1 mảng cũng chạy — nhưng tách rõ ràng dễ chỉnh nhãn hơn.
Reference — Gotchas
- z-index: overlay
z-[500], tooltip/hintz-[501]. Nâng nếu app có thứ cao hơn. - Đo trễ 300ms: chờ layout/animation ổn định trước khi đo. Nếu list load async, mở tour sau khi data có (hoặc gọi remeasure).
- Lock scroll: set
document.body.style.overflow='hidden'khi active, nhớ khôi phục khi đóng (đã handle trong cleanup). - Click-to-close: toàn overlay
onClick={onClose}; tooltip/hint làpointer-events-nonenên không chặn click đóng. - Element khuất: spot off-screen tự loại — nên cuộn lên đầu (
scrollTo top) trước khi đo (đã làm).
Origin
Trích & generalize từ bonbon DMS components/dms/tour-guide.tsx (đã chạy production, mobile+desktop).
How can the creator link this skill?
Add the canonical catalog link to the repository README so users can inspect current installs and available audits. The publishing guide covers the complete discovery path.
<a href="https://skillzs.dev/skills/rheinmir/setup/tour-guide">View tour-guide on skillZs</a>