Walk a newcomer through the AI workbench once, and say a line under what shows up later

Step 5 of the lower-barrier plan: notes, never paragraphs on the page. The first time the workbench
shows a task, once its window has settled, the spotlight tour walks through New task, Connect an AI,
the gates, the list, a task's menu, the timeline and the message box (ui_ai_tour.js); the home
section's "How it works" runs it again. What only shows up after the AI has done something gets
one line with Got it the first time it appears: under a run's verdict, beside the undo of the AI's
canvas changes, under a private task's message box with Hide. Seen ones are remembered in this
browser only.

The tour engine now skips a target folded to nothing (a tab not shown) and lights only the visible
part of a tall one (the timeline), cut to its scrolling box and the window. The task menu button
has a class of its own to be found by.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DemonGatanjieu
2026-11-13 02:57:47 +08:00
co-authored by Claude Opus 5.5
parent 22cc06495f
commit 47ca8a764a
15 changed files with 247 additions and 9 deletions
+5 -1
View File
@@ -606,7 +606,11 @@ covered by the `styles.css` manifest.
(the app that came last, a warning when its list is a short one, six starter cards); `ui_ai_starter_sheet.js`
holds the cards and the sheet each opens (fields, what happens, what stays, what the AI does not get), which starts a
prepared task with its switches set (a new tab; privacy for the private card) through `ui_ai_handoff.js` startTask;
a card's tags name only what Anomalous makes sure of. `ui_ai_ready.js` is that
a card's tags name only what Anomalous makes sure of. `ui_ai_tour.js` holds a newcomer's notes: the workbench's
spotlight tour (new task, connecting, gates, the list, a task's ⋯, the timeline, the message box), by itself the
first time a task shows and from the home section's "How it works", and `firstHint`, one line with Got it under a
control that only shows up later (a run's verdict, the undo of the AI's canvas changes, a private task's Hide).
`ui_ai_ready.js` is that
waiting task's one page: the sentence to send the AI, and what the user sent so far; what to say
first and pictures go in the message box. The pictures (`web/assets/ai_*.webp`)
were made with ChatGPT. `ai_task_view.js` holds
+2
View File
@@ -86,6 +86,8 @@
- On a graphics card of 8 GB or less, Anomalous's upscale-and-redraw ways (the light upscale, the Tile upscale, enlarging only the character) decode the last step with ComfyUI's own VAE Decode (Tiled). A 2x upscale on a 6 GB laptop spent 20 minutes decoding alone and froze the desktop; the tiled decode needs no extra pack, and bigger cards stay as they were.
- 主页多了「让 AI 帮你做」:顶上一行写最近连接的 AI 软件(连的是精简工具清单时会提醒它接不了 AMB 里的任务),下面六张卡:先试一次、跑我存的预设、照我说的出图、改一张图、隐私模式出图、看懂我的画布。点开先看说明页:点「开始」会发生什么、什么不会动、AI 拿不到什么、要注意什么;再点「开始」,AMB 把任务建好、开关设好(在新标签页里做,隐私卡直接是隐私任务),给你一句复制给 AI 的话。卡上的小标签只写 AMB 自己能保证的事。
- The home page has "Let the AI do it": a line on the AI app that came last (with a warning when it holds a short tool list that cannot take a task handed over here), and six cards: Try it once, Run my preset, Draw what I describe, Change a picture, Draw in privacy mode, Explain my canvas. A card opens a sheet first: what happens on Start, what stays as it is, what the AI does not get, what to mind; Start makes the task with its switches set (a new tab; the privacy card makes a private task) and gives the sentence to copy to the AI. A card's tags name only what Anomalous itself makes sure of.
- AI 工作台有了新手引导:第一次打开、里面有任务时,自动带你看一遍新任务、连接 AI、关卡、任务列表、任务的 ⋯、时间线和留言框;以后在主页「让 AI 帮你做」点「怎么用」可以再看。要等 AI 做过事才出现的东西(跑完图的「不太行」、AI 改画布的「撤销这一串」、隐私任务里的「藏」),第一次出现时下面有一行说明,点「知道了」就不再出现。引导遇到看不见的按钮会跳过,很高的一块只框住看得见的部分。
- The AI workbench has a newcomer's tour: the first time it shows a task it walks you through New task, Connect an AI, the gates, the list, a task's ⋯, the timeline and the message box; the home page's How it works (under Let the AI do it) runs it again. What shows up only after the AI has done something (Not quite under a run, the AI's canvas undo, Hide in a private task) gets one line the first time, gone after Got it. A tour skips a control that cannot be seen and lights only the visible part of a tall one.
## v1.63.0 (AI Ways and Skills, Saves, Official Example Workflows, Privacy Mode) — 2026-11-10
+14
View File
@@ -166,10 +166,24 @@ AI 想用几块现成做法一步步画一张图时,先在展台上放一张
| 「隐私模式出图」 | 写「画面大意(会发给 AI)」,建一个隐私任务 | 不发图和原文、新标签页 |
| 「看懂我的画布」 | 就是画布右键的「交给 AI…」小窗 | 每步可撤销 |
顶上一行在有 AI 来过以后多一个「怎么用」:转到 AI 工作台,等它打开后走一遍工作台的引导。
说明页都分几行:点「开始」后、不会动(或 AI 拿不到、管不到)、要注意;必填的没填,按「开始」时下面红字说缺什么。「开始」以后和别处「交给 AI」一样,弹出要发给 AI 的那句话。后端:`GET /anomalous/ai_starters`(只读),建任务还是 `POST /anomalous/ai_tasks`,再按卡片设好新标签页、隐私。
为什么:新手不敢上手,怕弄坏东西。卡片把常用的几件事准备成任务,说明页先讲清楚会发生什么;标签只写 AMB 自己做得到的保证,不写要靠 AI 自觉的事。
## AI 工作台的新手引导(`ui_ai_tour.js`)
第一次打开 AI 工作台、里面有任务时,等窗口打开稳了,自动走一遍聚光灯引导(和主页的界面导览同一种),七步:「新任务」、「连接 AI」、「关卡」、任务列表的筛选、任务标题栏右边的 ⋯、时间线、留言框。看不见的那一步(比如窄窗口里时间线收在别的标签里)跳过。走过一次就不再自己出现(记在这个浏览器里),主页「让 AI 帮你做」的「怎么用」可以再看。
有些东西要等 AI 做过事才出现,引导走的时候还看不到,所以它们第一次出现时,下面多一行小字,旁边「知道了」,点了就不再出现:
- 跑完的图下面「效果好」「不太行」:觉得不行就点「不太行」,再点哪里不行,AI 就知道该改哪儿。
- AI 改画布的一串步骤旁有「撤销这一串」时:点它就能退回去。
- 隐私任务的留言框里有「藏」时:选中几个字再点「藏」,这几个字 AI 只拿到代号。
为什么:不在页面上写大段说明。界面一直在的东西用引导讲一次;后来才出现的东西在它旁边说一句。
## 任务单(`ui_job_sheet.js`)
右键节点或画布空白处「任务单(批量换提示词)」打开,在画布右下角,ComfyUI 的画布按钮上面。用同一张画布连着跑很多次:观众要的“一次贴几十条提示词,每条跑几次”和“运行间隔”都是它。
+32
View File
@@ -0,0 +1,32 @@
"""A newcomer's notes on the AI workbench (web/modules/ui_ai_tour.js): every tour step and every first-time
line has its words in both languages, and each first-time line is used where its control is drawn."""
import re
import unittest
from pathlib import Path
PLUGIN = Path(__file__).resolve().parents[1]
MODULES = PLUGIN / "web" / "modules"
class TourWordsTests(unittest.TestCase):
def test_every_step_and_line_has_both_languages(self):
tour = (MODULES / "ui_ai_tour.js").read_text(encoding="utf-8")
words = (MODULES / "locales.js").read_text(encoding="utf-8")
steps = re.findall(r"step\('(\w+)'", tour)
self.assertEqual(steps, ["new", "connect", "gates", "list", "menu", "timeline", "message"])
keys = [f"aiTour_{name}_{part}" for name in steps for part in ("title", "body")] + ["aiFirstHintDone"]
for key in keys:
self.assertEqual(words.count(f" {key}: "), 2, key)
def test_each_first_time_line_sits_under_its_control(self):
uses = {"verdict": "ui_ai_verdict.js", "undo": "ui_ai_chain.js", "hide": "ui_ai_message.js"}
words = (MODULES / "locales.js").read_text(encoding="utf-8")
for hint, module in uses.items():
code = (MODULES / module).read_text(encoding="utf-8")
key = re.search(rf"firstHint\('{hint}', t\('(\w+)'\)\)", code)
self.assertIsNotNone(key, module)
self.assertEqual(words.count(f" {key.group(1)}: "), 2, key.group(1))
if __name__ == "__main__":
unittest.main()
+38
View File
@@ -1849,6 +1849,25 @@ export const i18n = {
aiConnectCopyRules: "复制这段规则",
aiConnectRules: "在 Anomalous Model Browser(AMB)的任务里:\n1. 新开对话或拿不准现在到哪一步时,先调用 get_situation:任务、画布还缺哪几根线、下一步做什么,一次就知道。\n2. 先用 AMB 的 MCP 工具:模型、图片、节点、画布、出图和进度都有对应的工具(search_models、get_image_info、zoom_image、find_nodes、run_workflow、run_status 等)。不要自己去读 ComfyUI 的文件夹、源码或接口。日常出图用 run_workflow,带上 workflow(存好的工作流名)和要改的提示词、种子或尺寸,一步完成。\n3. 只通过运行 ComfyUI 工作流来改图片;绝不用自己的代码去改、裁、涂或覆盖图片文件。\n4. 要问我的事用 ask_user,说明和结果用 report,都在 AMB 里。\n5. 如果还是用了别的工具(命令、脚本、读写文件),紧接着调用 log_outside,写明做了什么、为什么、动了哪些文件。",
aiConnectLocal: "只有运行 ComfyUI 的这台电脑能连接。加好以后,在 AI 软件里直接说要做什么就行;连过一次以后不用再加。",
aiFirstHintDone: "知道了",
aiFirstVerdict: "觉得不行就点「不太行」,再点哪里不行,AI 就知道该改哪儿。",
aiFirstUndo: "AI 改画布的这几步,点「撤销这一串」就能退回去。",
aiFirstHide: "选中几个字再点「藏」,这几个字 AI 只拿到代号。",
aiStartHowItWorks: "怎么用",
aiTour_new_title: "新任务",
aiTour_new_body: "要交给 AI 一件事,先点这里开一个任务,再把那句话发给你的 AI 软件,它就会接着这个任务做。首页的「让 AI 帮你做」也能直接开。",
aiTour_connect_title: "连接 AI",
aiTour_connect_body: "AI 软件第一次用 AMB 时,从这里拿地址和要发给它的那句话。连过一次就不用再连。",
aiTour_gates_title: "关卡",
aiTour_gates_body: "AI 做哪些事要先问你:改画布、用显卡、下载模型、挪文件……刚开始用,可以都设成先问。",
aiTour_list_title: "任务列表",
aiTour_list_body: "每件交给 AI 的事是一条,最新的在上面,可以按进行中、等你回答、出错筛一下。不要的任务在它右边的 ⋯ 里移除,只删记录。",
aiTour_menu_title: "这个任务的 ⋯",
aiTour_menu_body: "改名、隐私模式(AI 看不到这个任务的图和提示词原文)、发给 AI 了什么,都在这里。",
aiTour_timeline_title: "时间线",
aiTour_timeline_body: "AI 的每一步按时间排在这里,点开能看细节。它改画布的步骤都能撤销,不怕改坏。",
aiTour_message_title: "留言框",
aiTour_message_body: "想跟 AI 说话就写在这里,它下一步就会收到;也能附上图库里的图。",
aiStartTitle: "让 AI 帮你做",
aiStartShortList: "{client} 连的是精简工具清单,接不了 AMB 里的任务:在「连接 AI」里选「全部工具」,再让它重新连接。",
aiStartGo: "开始",
@@ -5826,6 +5845,25 @@ export const i18n = {
aiConnectCopyRules: "Copy the rules",
aiConnectRules: "In Anomalous Model Browser (AMB) tasks:\n1. In a new chat, or unsure where things stand, call get_situation first: the task, what the canvas still lacks and the next step, in one answer.\n2. Use AMB's MCP tools first: models, pictures, nodes, the canvas, runs and their progress all have tools (search_models, get_image_info, zoom_image, find_nodes, run_workflow, run_status...). Do not read ComfyUI's folders, source or routes yourself. For a daily run use run_workflow with workflow (a saved one's name) and the prompt, seed or size to change: one call.\n3. Change pictures only by running ComfyUI workflows; never edit, crop, paint over or overwrite picture files with code of your own.\n4. Ask me with ask_user and explain with report, in AMB.\n5. If you still use another tool (a command, a script, files), call log_outside right after: what you ran, why, and which files.",
aiConnectLocal: "Only this computer, where ComfyUI runs, can connect. Once added, just tell the AI app what to do; it stays added.",
aiFirstHintDone: "Got it",
aiFirstVerdict: "Not right? Press Not quite and then what is off: the AI knows what to fix.",
aiFirstUndo: "Press the undo beside these steps to take the AI's canvas changes back.",
aiFirstHide: "Select a few words and press Hide: the AI gets a token for them.",
aiStartHowItWorks: "How it works",
aiTour_new_title: "New task",
aiTour_new_body: "To hand the AI something, start a task here, then send the sentence to your AI app: it goes on with this task. The home page's Let the AI do it starts one too.",
aiTour_connect_title: "Connect an AI",
aiTour_connect_body: "The first time an AI app uses Anomalous, take the address and the sentence for it from here. Once is enough.",
aiTour_gates_title: "Gates",
aiTour_gates_body: "What the AI asks you before doing: changing the canvas, using the graphics card, downloading models, moving files… At first you may set them all to ask.",
aiTour_list_title: "The tasks",
aiTour_list_body: "Each thing handed to the AI is a row, the newest on top; filter by running, waiting for you or failed. Remove one you do not need from its ⋯: only the record goes.",
aiTour_menu_title: "This task's ⋯",
aiTour_menu_body: "Rename, privacy mode (the AI sees none of this task's pictures and prompt words), what was sent to the AI: all here.",
aiTour_timeline_title: "The timeline",
aiTour_timeline_body: "Every step of the AI in order, each opening to its details. Its canvas changes all undo, so nothing is lost.",
aiTour_message_title: "The message box",
aiTour_message_body: "Write to the AI here: it gets the message with its next step. Pictures from the gallery can go along.",
aiStartTitle: "Let the AI do it",
aiStartShortList: "{client} holds a short tool list and cannot take a task handed over in Anomalous: pick Every tool in Connect an AI, then reconnect it.",
aiStartGo: "Start",
+4 -1
View File
@@ -9,6 +9,7 @@
import { app } from '../../../scripts/app.js';
import { translate as t } from './locales.js';
import { firstHint } from './ui_ai_tour.js';
import { timeLabel } from './activity_log.js';
import { canUndo, undoEntry } from './canvas_undo.js';
import { anomalousConfirm } from './ui_dialog.js';
@@ -89,10 +90,12 @@ export function chainRow(steps, number, ctx) {
open.append(el('span', 'anomalous-ai-chain-title', t('aiChain', { count: steps.length })));
if (ctx.live) open.append(el('span', 'anomalous-ai-chain-last', t('aiChainLast', { what: t(toolKey(steps.at(-1).tool)) })));
head.append(open);
let undoHint = null;
if (changesOf(steps, ctx.entries).some(entry => canUndo(app, entry))) {
head.append(iconButton('anomalous-ai-chain-undo', 'undo', t('aiChainUndo'), async () => {
if (await undoChanges(steps, ctx.entries, t('aiChainUndo'), 'aiChainUndoConfirm')) ctx.changed();
}));
undoHint = firstHint('undo', t('aiFirstUndo'));
}
const chips = el('div', 'anomalous-ai-chain-chips');
for (const { tool, kind, count } of chainTools(steps)) {
@@ -118,7 +121,7 @@ export function chainRow(steps, number, ctx) {
show(true);
};
show(opened.has(key));
body.append(head, chips, inside);
body.append(head, ...(undoHint ? [undoHint] : []), chips, inside);
const [first, last] = [timeLabel(steps[0].at), timeLabel(steps.at(-1).at)];
row.append(body, el('div', 'anomalous-ai-step-when', first === last ? first : `${first}–${last}`));
return row;
+8
View File
@@ -12,6 +12,7 @@
*/
import { translate as t } from './locales.js';
import { firstHint } from './ui_ai_tour.js';
import { mentions } from './ui_ai_mention.js';
import { galleryPicker } from './ui_ai_gallery_pick.js';
import { takePictures } from './ui_ai_drop.js';
@@ -111,6 +112,13 @@ export function messageBox(onSend, source) {
field.addEventListener('focus', shown);
shown();
line.insertBefore(hide, send);
const hideHint = firstHint('hide', t('aiFirstHide'));
if (hideHint) {
box.insertBefore(hideHint, note);
const sync = () => { hideHint.hidden = hide.hidden; };
field.addEventListener('focus', sync);
sync();
}
takePictures(box, field, { add: item => at.add(item), room, say: (text) => {
note.textContent = text;
note.classList.add('is-error');
+4 -2
View File
@@ -12,6 +12,7 @@ import { openConnectGuide } from './ui_ai_connect.js';
import { handCanvasToAi } from './ui_ai_canvas_hand.js';
import { icon } from './ui_ai_icons.js';
import { STARTERS, cardTags, openStarter } from './ui_ai_starter_sheet.js';
import { tourWhenShown } from './ui_ai_tour.js';
function el(tag, className, text) {
const node = document.createElement(tag);
@@ -28,11 +29,12 @@ function button(className, label, onClick) {
}
/** The line about the AI app: who came last, or none yet; a short tool list said apart. */
function appLine(app) {
function appLine(owner, app) {
const line = el('div', `anomalous-ai-start-app${app ? ' is-known' : ''}`);
line.append(el('span', 'anomalous-ai-visit-dot'),
el('span', '', app ? t('aiLastVisit', { client: app.client, when: `${dayLabel(app.when)} ${timeLabel(app.when)}` }) : t('aiNoVisit')),
button('anomalous-home-link', t('aiConnect'), () => openConnectGuide()));
if (app) line.append(button('anomalous-home-link', t('aiStartHowItWorks'), () => { owner.goTo('ai'); setTimeout(tourWhenShown, 700); }));
const lines = [line];
if (app && (app.tools === 'core' || app.tools === 'mine')) lines.push(el('div', 'anomalous-ai-start-warn', t('aiStartShortList', { client: app.client })));
return lines;
@@ -61,7 +63,7 @@ export function renderAiStarters(owner, after) {
const head = el('div', 'anomalous-ai-start-head');
const title = el('span', 'anomalous-ai-start-title');
title.append(icon('sparkle', 18), el('span', '', t('aiStartTitle')));
head.append(title, ...appLine(data.app));
head.append(title, ...appLine(owner, data.app));
const grid = el('div', 'anomalous-ai-start-cards');
grid.append(...STARTERS.map(starter => card(owner, starter, data)));
section.append(head, grid);
+1 -1
View File
@@ -51,7 +51,7 @@ export function openTaskMenu(anchor, task, { tasks, rename, remove, changed }) {
export function taskMenu(task, actions) {
const more = document.createElement('button');
more.type = 'button';
more.className = 'anomalous-ai-tool is-icon';
more.className = 'anomalous-ai-tool is-icon anomalous-ai-task-menu';
more.textContent = '⋯';
more.title = t('notebookMore');
more.onclick = () => openTaskMenu(more, task, actions);
+90
View File
@@ -0,0 +1,90 @@
/**
* Notes for a newcomer to the AI workbench, in two kinds and never as paragraphs on the page:
* - the workbench's tour (the spotlight tour of ui_spotlight_tour.js with its own steps): a new task,
* connecting, the gates, the list, a task's ⋯, the timeline and the message box. It runs by itself the
* first time the workbench shows a task, and again from the home page's "How it works" (startAiTour).
* - firstHint(): one line under a control that only shows up later (a run's verdict, undo for the AI's
* canvas changes, the Hide button of a private task), shown until the user says Got it.
* What was seen is remembered in this browser only; without storage the notes simply come again.
*/
import { translate as t } from './locales.js';
import { isSpotlightTourActive, startSpotlightTour } from './ui_spotlight_tour.js';
const TOUR_KEY = 'anomalous_ai_tour_seen';
const HINTS_KEY = 'anomalous_ai_hints_seen';
const step = (id, targetSelector, icon, position) => ({ id, targetSelector, icon, position, titleKey: `aiTour_${id}_title`, bodyKey: `aiTour_${id}_body` });
export const AI_TOUR = Object.freeze([
step('new', '.anomalous-ai-tasks .anomalous-ai-new-task', '➕', 'right'),
step('connect', '.anomalous-ai-tasks-ways .anomalous-ai-way:nth-child(1)', '🔌', 'right'),
step('gates', '.anomalous-ai-tasks-ways .anomalous-ai-way:nth-child(2)', '🛡️', 'right'),
step('list', '.anomalous-ai-tasks .anomalous-ai-task-filters', '📋', 'right'),
step('menu', '.anomalous-ai-task-head .anomalous-ai-task-menu', '⋯', 'bottom'),
step('timeline', '.anomalous-ai-timeline', '🕘', 'left'),
step('message', '.anomalous-ai-task .anomalous-ai-message', '💬', 'top'),
]);
function seen(key) {
try {
const ids = JSON.parse(localStorage.getItem(key) || '[]');
return new Set(Array.isArray(ids) ? ids : []);
} catch (_) {
return new Set();
}
}
function remember(key, id) {
try {
localStorage.setItem(key, JSON.stringify([...seen(key).add(id)]));
} catch (_) {
// a convenience only: the note comes again next time
}
}
/** Whether the workbench shows a task at its full size (not mid-animation, not folded into tabs). */
const settled = page => (page.querySelector('.anomalous-ai-timeline')?.getBoundingClientRect().width || 0) > 200;
export function startAiTour() {
return startSpotlightTour(null, { steps: AI_TOUR });
}
/** The tour as soon as the workbench shows a task (after going there from the home page); gives up after a few seconds. */
export function tourWhenShown(tries = 20) {
const page = document.querySelector('.anomalous-ai');
if (page && settled(page)) {
remember(TOUR_KEY, 'workbench');
startAiTour();
} else if (tries > 0) {
setTimeout(() => tourWhenShown(tries - 1), 200);
}
}
/** The tour by itself, once, when `page` (the workbench) shows a task and nothing else is in front; after the
* window has finished opening, since a tour started in its animation points at a squeezed layout. */
export function aiTourOnce(page) {
if (seen(TOUR_KEY).has('workbench') || isSpotlightTourActive() || !page?.isConnected) return;
setTimeout(() => {
if (!page.isConnected || isSpotlightTourActive() || !settled(page)) return;
if (startAiTour()) remember(TOUR_KEY, 'workbench');
}, 900);
}
/** The line for `id` (words `text`) until the user said Got it, or null; one of each on the page at a time. */
export function firstHint(id, text) {
if (seen(HINTS_KEY).has(id) || document.querySelector(`.anomalous-ai-first-hint[data-hint="${id}"]`)) return null;
const line = document.createElement('div');
line.className = 'anomalous-ai-first-hint';
line.dataset.hint = id;
const words = document.createElement('span');
words.textContent = text;
const done = document.createElement('button');
done.type = 'button';
done.className = 'anomalous-ai-first-hint-done';
done.textContent = t('aiFirstHintDone');
done.onclick = () => {
remember(HINTS_KEY, id);
line.remove();
};
line.append(words, done);
return line;
}
+3
View File
@@ -8,6 +8,7 @@
*/
import { translate as t } from './locales.js';
import { firstHint } from './ui_ai_tour.js';
import { anomalousAlert } from './ui_dialog.js';
import { icon } from './ui_ai_icons.js';
@@ -64,5 +65,7 @@ export function verdictRow(step, taskId) {
}
row.append(issues);
}
const hint = !step.verdict && firstHint('verdict', t('aiFirstVerdict'));
if (hint) row.append(hint);
return row;
}
+2 -1
View File
@@ -63,6 +63,7 @@ import {
activityIds, duration, groupSteps, stepDetail, taskCounts, taskResults, taskState, toolKey,
} from './ai_task_view.js';
import { taskTitle } from './ai_task_title.js';
import { aiTourOnce } from './ui_ai_tour.js';
const NARROW_PX = 720;
const SHORT_PX = 880; // a window this low (not maximised) keeps less room around the task's head
@@ -543,7 +544,7 @@ export function renderAiWorkbench(owner, panel) {
owner.headerLeadTools?.replaceChildren(listToggle(state.page, toggleTasks));
owner.railNews?.('ai', false);
hidePanelToast();
void refresh();
void refresh().then(() => aiTourOnce(state.page)); // a newcomer's tour, once (ui_ai_tour.js)
pollVisible();
}
+18 -2
View File
@@ -297,7 +297,10 @@ export function ensureTourStyles() {
function resolveStepTarget(step) {
if (typeof document === 'undefined') return null;
const shown = el => Boolean(el?.isConnected && el.getClientRects().length); // hidden targets are skipped
const shown = (el) => { // hidden targets are skipped, and so is one folded to nothing (a tab not shown)
const box = el?.isConnected && el.getClientRects().length ? el.getBoundingClientRect() : null;
return Boolean(box && box.width > 0 && box.height > 0);
};
let el = document.querySelector(step.targetSelector);
if (!shown(el) && step.fallbackSelector) {
el = document.querySelector(step.fallbackSelector);
@@ -305,6 +308,19 @@ function resolveStepTarget(step) {
return shown(el) ? el : null;
}
/** The part of `rect` (target's) that can be seen: a tall target (a timeline) is cut to its scrolling box and the window. */
function visiblePart(target, rect) {
let top = Math.max(rect.top, 0);
let bottom = Math.min(rect.bottom, window.innerHeight);
for (let node = target.parentElement; node && node !== document.body; node = node.parentElement) {
if (!/(auto|scroll)/.test(getComputedStyle(node).overflowY)) continue;
const box = node.getBoundingClientRect();
[top, bottom] = [Math.max(top, box.top), Math.min(bottom, box.bottom)];
break;
}
return bottom > top ? { left: rect.left, right: rect.right, width: rect.width, top, bottom, height: bottom - top } : rect;
}
function computeCardPosition(rect, position, cardWidth = 330, cardHeight = 220) {
const margin = 12;
const padding = 16;
@@ -399,7 +415,7 @@ export function startSpotlightTour(owner, { steps = TOUR_STEPS, onClose = null }
// Scroll target into view if out of sight
target.scrollIntoView?.({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
const rect = target.getBoundingClientRect?.() || { left: 0, top: 0, width: 100, height: 40, right: 100, bottom: 40 };
const rect = target.getBoundingClientRect ? visiblePart(target, target.getBoundingClientRect()) : { left: 0, top: 0, width: 100, height: 40, right: 100, bottom: 40 };
const buffer = 5;
// Position spotlight box smoothly
+1 -1
View File
@@ -53,4 +53,4 @@
@import url("./styles/52-model-guide.css?v=20261108-guide");
@import url("./styles/53-ai-plan.css?v=20261109-plan");
@import url("./styles/54-job-sheet.css?v=20261109-sheet");
@import url("./styles/55-ai-starters.css?v=20261113-start");
@import url("./styles/55-ai-starters.css?v=20261113-tour");
+25
View File
@@ -79,3 +79,28 @@
font-weight: 600;
cursor: pointer;
}
/* A newcomer's line under a control that shows up later (ui_ai_tour.js firstHint) */
.anomalous-ai-first-hint {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 10px;
margin-top: 6px;
padding: 5px 10px;
border-radius: 8px;
background: color-mix(in srgb, var(--amb-link) 10%, transparent);
color: var(--amb-text-soft);
font-size: 12px;
line-height: 1.5;
}
.anomalous-ai-first-hint[hidden] { display: none; }
.anomalous-ai-first-hint-done {
padding: 0;
border: 0;
background: none;
color: var(--amb-link);
font: inherit;
cursor: pointer;
}
.anomalous-ai-first-hint-done:hover { text-decoration: underline; }