feat(guide): replace automatic popup with restrained top-right amber notice badge
This commit is contained in:
@@ -79,6 +79,7 @@ tests/*
|
||||
!tests/studio_contracts.mjs
|
||||
!tests/material_workspace_flow.mjs
|
||||
!tests/export_availability.mjs
|
||||
!tests/update_guide.mjs
|
||||
!tests/test_export_availability.py
|
||||
!tests/model_sources_hub.mjs
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ list summaries do not contain prompt bodies. See the Material Library contract b
|
||||
| --- | --- |
|
||||
| Python routes, storage, paths, metadata, covers, or scan state | [`docs/architecture/backend.md`](docs/architecture/backend.md) |
|
||||
| Browser lifecycle, UI state, localization, media, or graph edits | [`docs/architecture/frontend.md`](docs/architecture/frontend.md) |
|
||||
| Update-guide content/versioning or sidebar hover labels | [`docs/architecture/update-guide.md`](docs/architecture/update-guide.md) |
|
||||
| Workflow Recipes, packages, galleries, Parameter Notebooks, or prompt roles | [`docs/architecture/recipes.md`](docs/architecture/recipes.md) |
|
||||
| Material Library snapshots, image parameter details, or reusable node blocks | [`docs/architecture/material-library.md`](docs/architecture/material-library.md) |
|
||||
| Model Doctor, provenance hashes, missing-model recovery, or deep scanning | [`docs/architecture/model-resolution.md`](docs/architecture/model-resolution.md) |
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Navigation and update guide
|
||||
- Sidebar icons reveal compact text labels on hover or keyboard focus while keeping their existing button size and actions. Detailed hints explain each tool; scan-state and language changes retain labels.
|
||||
- Added a non-intrusive update guide system: instead of an automatic popup on startup, an amber breathing badge (`!` + '新指引' / 'Guide') appears in the top-right header when a new guide version is unacknowledged. Clicking opens the three-step guide modal, and dismissing smoothly hides the badge and stores persistent acknowledgment; see `docs/architecture/update-guide.md`.
|
||||
|
||||
### Export availability
|
||||
- Removed Prompt Studio's JSON export action and closed the Material Library file-import entry; copying prompts and local saves remain available.
|
||||
- Recipe package import/export is disabled in the UI and API, including both upload inspection and import commit. The verified AMB0/AMB1 workflow Import / Export Center is restored in Toolbox as the only active import/export center. Ordinary downloads and image/workflow hash metadata are unchanged.
|
||||
|
||||
@@ -25,6 +25,12 @@ Major UI panels live in `web/modules/ui_*.js`. Shared browser state remains on
|
||||
the `AnomalousBrowser` instance. Pure parsing, normalization, comparison, and
|
||||
transaction helpers remain in focused modules rather than acquiring DOM state.
|
||||
|
||||
Update-guide content and UI lifecycle are separate modules; the browser show/close
|
||||
methods only wire them in. Help provides an explicit replay entry. Read
|
||||
[update-guide.md](update-guide.md) before changing guide IDs, steps, persistence,
|
||||
or sidebar icon/label transitions. Do not embed guide logic in the main/sidebar
|
||||
bootstrap or reuse its content version as a feature flag.
|
||||
|
||||
The main surfaces are:
|
||||
|
||||
- Sidebar and folder manager: navigation, folder visibility, scan controls.
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# 更新引导维护契约
|
||||
|
||||
更新引导只解释明显变化,不承担功能开关、数据迁移或执行用户操作。
|
||||
修改前阅读根目录 [AGENTS.md](../../AGENTS.md)。
|
||||
|
||||
## 内容与实现分工
|
||||
|
||||
| 文件 | 责任 |
|
||||
| --- | --- |
|
||||
| `web/modules/update_guide_data.js` | 当前引导的稳定 ID、步骤顺序、图标和文案键;也提供配置校验 |
|
||||
| `web/modules/locales.js` | 每个步骤及按钮的中英文文案,均按纯文本展示 |
|
||||
| `web/modules/ui_update_guide.js` | 显示、翻页、关闭、焦点、版本记录;一般内容更新不要修改 |
|
||||
| `web/modules/sidebar_actions.js` | 六个底部按钮的短标签、完整名称和用途提示,不改变点击动作 |
|
||||
| `web/styles.css` | 引导布局和底部图标/文字切换的现有规则 |
|
||||
| `web/main.js` / `ui_sidebar.js` | 仅在浏览器打开/关闭及帮助页重看时接线 |
|
||||
|
||||
## 后续 AI 更新步骤
|
||||
|
||||
1. 先确认功能已实现及真实入口。不要在引导里承诺未上线、暂停或验证失败的功能。
|
||||
2. 在 `CURRENT_UPDATE_GUIDE.steps` 编写 1–5 个步骤,每项包含唯一 `id`、短 `icon`、`titleKey`、`bodyKey`。
|
||||
3. 在 `locales.js` 补齐这些键的中文和英文。标题说明变化,正文说明入口和用户该怎么用。禁止 HTML、可执行回调、请求及文件写入。
|
||||
4. **只有需要所有用户重新看到时才换引导 `id`**,例如 `2026-10-library-navigation`。修改错字、翻译、样式时保持 ID。不使用时间戳、随机数、Git 提交号或普通版本号自动生成 ID。
|
||||
5. 配置校验会拒绝重复步骤、空步骤及缺失双语文案。配置错误只跳过引导,不应阻止插件打开;必须修正错误后再交付。
|
||||
6. 运行下方测试,再在浏览器验证首次打开、跳过后重开、帮助页重看、Esc 和中英文长文案。
|
||||
7. 修改现有样式规则,不在文件末尾追加覆盖包。没有明确产品决策,不增加强制阅读、自动跳转、网络请求或额外追踪。
|
||||
|
||||
## 生命周期与记录
|
||||
|
||||
- 未确认的更新引导不再强制自动弹窗打扰用户,而是在面板右上角显示克制的琥珀金感叹号徽标(`#anomalous-update-notice-btn`);用户主动点击时才唤起引导。
|
||||
- 使用原生 `dialog` 的顶层显示和焦点约束,关闭时尽可能恢复原焦点。
|
||||
- 完成、跳过、×、Esc、点击空白关闭会记录已读,并触发感叹号徽标平滑淡出消除;关闭父浏览器仅清理,不将未读引导标记为已读。
|
||||
- 持久化键为 `anomalous_update_guide_seen:<id>`,值为 `1`。只保存本地已读状态,不发送到服务端。
|
||||
- 存储不可用时,当前页面会话仍记住关闭状态;刷新页面后可能再次显示。
|
||||
- 同一时刻只有一个引导。重复调用不叠加弹窗;所有关闭路径通过 `closeUpdateGuide` 释放资源。
|
||||
- 帮助页的“查看更新引导”使用 `force: true`,允许重看已确认版本。
|
||||
- 引导不自动点击对应功能,也不修改画布、模型、素材、导入导出限制。
|
||||
|
||||
## 底部按钮约束
|
||||
|
||||
原按钮保留点击处理器、尺寸和图标。短标签是独立且 `aria-hidden` 的 `span`,
|
||||
不能占用已用于详细提示的 `::before`/`::after`。扫描状态替换 SVG 后重新配置标签。
|
||||
语言切换须更新六个标签、完整 accessible name 和用途提示。
|
||||
悬停与键盘焦点使用相同文字切换;无悬停设备直接显示短文字;减少动态效果偏好禁用位移动画。
|
||||
|
||||
## 验证
|
||||
|
||||
在插件根目录运行:
|
||||
|
||||
```powershell
|
||||
node --experimental-vm-modules tests/update_guide.mjs
|
||||
node --experimental-vm-modules tests/prompt_ui_lifecycle.mjs
|
||||
node --experimental-vm-modules tests/export_availability.mjs
|
||||
```
|
||||
|
||||
`update_guide.mjs` 加载真实模块,检查双语配置、版本记忆、重看、翻页、关闭清理、
|
||||
父窗口关闭、存储失败及扫描图标替换后的标签。它不能代替实际浏览器的布局与焦点验证。
|
||||
@@ -48,7 +48,7 @@ export class Element extends Events {
|
||||
setAttribute(name, value) { this.attrs[name] = value; }
|
||||
removeAttribute(name) { delete this.attrs[name]; }
|
||||
getAttribute(name) { return this.attrs[name] ?? null; }
|
||||
matches(selector) { return selector.startsWith('.') ? this.classList.contains(selector.slice(1)) : this.tagName === selector; }
|
||||
matches(selector) { return selector.startsWith('#') ? this.id === selector.slice(1) : selector.startsWith('.') ? this.classList.contains(selector.slice(1)) : this.tagName === selector; }
|
||||
querySelectorAll(selector) { return all(this).slice(1).filter(child => selector.split(',').some(part => child.matches(part.trim()))); }
|
||||
querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
|
||||
closest(selector) { return this.matches(selector) ? this : this.parentNode?.closest(selector) || null; }
|
||||
@@ -62,7 +62,7 @@ export class Element extends Events {
|
||||
scrollIntoView() {}
|
||||
}
|
||||
|
||||
export function fixture() {
|
||||
export function fixture({ storage = new Map(), storageOverride } = {}) {
|
||||
const document = new Events();
|
||||
document.body = new Element('body');
|
||||
document.createElement = tag => new Element(tag);
|
||||
@@ -71,7 +71,6 @@ export function fixture() {
|
||||
document.querySelectorAll = selector => document.body.querySelectorAll(selector);
|
||||
const window = new Events();
|
||||
Object.assign(window, { innerWidth: 1440, innerHeight: 900, anomalous_browser_lang: 'en' });
|
||||
const storage = new Map();
|
||||
const timers = new Map();
|
||||
let timerId = 0;
|
||||
const errors = [], requests = [], clipboard = [];
|
||||
@@ -85,7 +84,7 @@ export function fixture() {
|
||||
document, window, app, errors, anomalous_browser_lang: 'en',
|
||||
setTimeout: callback => { timers.set(++timerId, callback); return timerId; }, clearTimeout: id => timers.delete(id),
|
||||
requestAnimationFrame: callback => callback(), queueMicrotask,
|
||||
localStorage: { getItem: key => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, String(value)), removeItem: key => storage.delete(key) },
|
||||
localStorage: storageOverride || { getItem: key => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, String(value)), removeItem: key => storage.delete(key) },
|
||||
navigator: { clipboard: { async writeText(text) { clipboard.push(text); } } }, confirm: () => true,
|
||||
fetch: async (url, options = {}) => { requests.push([url, options]); return state.fetch(url, options); },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { fixture, Element } from './ui_fixture.mjs';
|
||||
|
||||
const storage = new Map();
|
||||
const f = fixture({ storage });
|
||||
const data = await f.module('update_guide_data.js');
|
||||
const { i18n } = await f.module('locales.js');
|
||||
assert.equal(data.validateUpdateGuide(data.CURRENT_UPDATE_GUIDE, i18n), true);
|
||||
assert.equal(data.validateUpdateGuide({ ...data.CURRENT_UPDATE_GUIDE, steps: [] }, i18n), false);
|
||||
assert.equal(data.validateUpdateGuide({ ...data.CURRENT_UPDATE_GUIDE, steps: [data.CURRENT_UPDATE_GUIDE.steps[0], data.CURRENT_UPDATE_GUIDE.steps[0]] }, i18n), false);
|
||||
assert.equal(data.validateUpdateGuide(data.CURRENT_UPDATE_GUIDE, { zh: i18n.zh, en: {} }), false);
|
||||
const guide = await f.module('ui_update_guide.js');
|
||||
const owner = { modal: new Element('div') };
|
||||
assert.equal(guide.showUpdateGuide(owner), false, 'never show over a closed browser');
|
||||
owner.modal.classList.add('visible');
|
||||
assert.equal(guide.showUpdateGuide(owner), true);
|
||||
assert.equal(guide.showUpdateGuide(owner), false, 'no duplicate dialog');
|
||||
let dialog = f.document.querySelector('dialog');
|
||||
assert.equal(f.button(dialog, 'Back').disabled, true);
|
||||
await f.button(dialog, 'Next').click();
|
||||
assert.match(dialog.textContent, /Workflow sharing/);
|
||||
await f.button(dialog, 'Back').click();
|
||||
assert.match(dialog.textContent, /Hover to identify/);
|
||||
await f.button(dialog, 'Next').click();
|
||||
await f.button(dialog, 'Next').click();
|
||||
await f.button(dialog, 'Got it').click();
|
||||
assert.equal(dialog.isConnected, false);
|
||||
assert.equal(dialog.listenerCount(), 0);
|
||||
assert.equal(guide.showUpdateGuide(owner), false);
|
||||
assert.equal(guide.hasAcknowledged(data.CURRENT_UPDATE_GUIDE.id), true);
|
||||
const fresh = fixture({ storage });
|
||||
assert.equal((await fresh.module('ui_update_guide.js')).showUpdateGuide(owner), false, 'dismissal persists across page sessions');
|
||||
|
||||
assert.equal(guide.showUpdateGuide(owner, { force: true }), true);
|
||||
dialog = f.document.querySelector('dialog');
|
||||
let stopped = false, prevented = false;
|
||||
dialog.dispatch('keydown', { key: 'Escape', stopPropagation() { stopped = true; }, preventDefault() { prevented = true; } });
|
||||
assert.ok(stopped && prevented);
|
||||
assert.equal(dialog.listenerCount(), 0);
|
||||
assert.ok(owner.modal.classList.contains('visible'), 'Escape belongs only to the guide');
|
||||
|
||||
const nextGuide = { ...data.CURRENT_UPDATE_GUIDE, id: 'next-release' };
|
||||
assert.equal(guide.showUpdateGuide(owner, { guide: nextGuide }), true);
|
||||
guide.closeUpdateGuide(owner);
|
||||
guide.closeUpdateGuide(owner);
|
||||
assert.equal(guide.showUpdateGuide(owner, { guide: nextGuide }), true, 'parent close must not acknowledge unread content');
|
||||
await f.button(f.document.querySelector('dialog'), 'Skip').click();
|
||||
assert.equal(guide.showUpdateGuide(owner, { guide: nextGuide }), false);
|
||||
assert.equal(guide.showUpdateGuide(owner, { guide: { ...nextGuide, steps: [] } }), false);
|
||||
|
||||
const unavailable = fixture({ storageOverride: { getItem() { throw new Error('Storage blocked'); }, setItem() { throw new Error('Storage blocked'); } } });
|
||||
const unavailableGuide = await unavailable.module('ui_update_guide.js');
|
||||
assert.equal(unavailableGuide.showUpdateGuide(owner), true);
|
||||
unavailable.document.querySelector('dialog').dispatch('cancel', { preventDefault() {} });
|
||||
assert.equal(unavailableGuide.showUpdateGuide(owner), false, 'blocked storage still prevents repeated prompts in this session');
|
||||
console.log('Update guide: configuration, steps, persistence, replay, Escape, parent close and blocked storage passed.');
|
||||
|
||||
const actions = await f.module('sidebar_actions.js');
|
||||
const root = new Element('div');
|
||||
const scan = new Element('button'); scan.id = 'anomalous-scan-btn';
|
||||
let clicks = 0; scan.onclick = () => clicks++;
|
||||
root.append(scan);
|
||||
actions.configureSidebarActions(root);
|
||||
actions.configureSidebarActions(root);
|
||||
assert.equal(scan.querySelectorAll('.anomalous-action-label').length, 1);
|
||||
assert.equal(scan.querySelector('.anomalous-action-label').textContent, 'Scan');
|
||||
await scan.click(); assert.equal(clicks, 1);
|
||||
scan.innerHTML = '<svg></svg>'; // Scanner status refresh replaces its icon.
|
||||
scan.classList.add('anomalous-radar-spinning');
|
||||
actions.configureSidebarAction(scan);
|
||||
assert.ok(scan.querySelector('.anomalous-action-label'));
|
||||
assert.ok(scan.classList.contains('anomalous-radar-spinning'));
|
||||
assert.match(scan.getAttribute('data-tooltip'), /metadata and previews/);
|
||||
console.log('Sidebar labels: repeated setup, scanner icon replacement and click behavior passed.');
|
||||
+10
-7
@@ -7,6 +7,7 @@ import { showRecipes, refreshRecipes, renderRecipeList, handleSaveRecipe } from
|
||||
import { showMaterials, refreshMaterials, openSavedMaterial, openMaterialLibrary } from './modules/ui_materials.js';
|
||||
import { openPromptStudio } from './modules/ui_prompt_composer.js';
|
||||
import { openPromptTranslator } from './modules/ui_prompt_translator.js';
|
||||
import { showUpdateGuide, closeUpdateGuide } from './modules/ui_update_guide.js';
|
||||
import { showImageWorkbench } from './modules/ui_gallery_detail.js';
|
||||
import { initDoctorPanel, diagnoseNode, renderGlobalDashboard, initAssistantPanel, renderAssistantModelCard, _loadAssistantHistory, _openGalleryReplacer, openLoraInsertionPicker, runGlobalDoctorScan } from './modules/ui_doctor.js';
|
||||
import { app } from "../../scripts/app.js";
|
||||
@@ -277,14 +278,16 @@ class AnomalousBrowser {
|
||||
}
|
||||
this.setTriggerVisible(false);
|
||||
this.modal.classList.add('visible');
|
||||
if (!this.foldersData) {
|
||||
this.loadFolders();
|
||||
} else {
|
||||
this.loadModels();
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.foldersData) {
|
||||
this.loadFolders();
|
||||
} else {
|
||||
this.loadModels();
|
||||
}
|
||||
this.refreshUpdateNotice?.();
|
||||
}
|
||||
|
||||
close() {
|
||||
closeUpdateGuide(this);
|
||||
this.modal.classList.remove('visible');
|
||||
this.setTriggerVisible(true);
|
||||
const canvas = document.getElementById('graph-canvas');
|
||||
|
||||
@@ -1,5 +1,32 @@
|
||||
export const i18n = {
|
||||
zh: {
|
||||
actionTools: "工具",
|
||||
actionScan: "扫描",
|
||||
actionDoctor: "医生",
|
||||
actionAssistant: "助手",
|
||||
actionMaterials: "素材",
|
||||
actionSettings: "设置",
|
||||
actionToolsHint: "打开提示词工具、模型来源和导入导出中心",
|
||||
actionScanHint: "扫描本地模型,补充模型信息和预览",
|
||||
actionDoctorHint: "检查工作流中缺失的模型并修复路径",
|
||||
actionAssistantHint: "操作选中的节点,应用模型或参数方案",
|
||||
actionMaterialsHint: "浏览和使用保存的提示词、参数与工作流素材",
|
||||
actionSettingsHint: "调整语言、界面和插件选项",
|
||||
updateGuideTitle: "本次更新",
|
||||
updateGuideReplay: "查看更新引导",
|
||||
updateGuideSkip: "跳过",
|
||||
updateGuideBack: "上一步",
|
||||
updateGuideNext: "下一步",
|
||||
updateGuideDone: "知道了",
|
||||
updateGuideProgress: "第 {current} / {total} 步",
|
||||
updateGuideNoticeLabel: "新指引",
|
||||
updateGuideNoticeTooltip: "💡 新版本功能与界面改版指引 (点击查看)",
|
||||
updateGuideNavigationTitle: "悬停,看懂每个入口",
|
||||
updateGuideNavigationBody: "左下角的按钮保持原来的大小。鼠标悬停或用键盘选中时,图标会切换成文字;稍作停留可以查看完整用途。依次为工具箱、扫描、模型医生、节点助手、素材库和设置。",
|
||||
updateGuideTransferTitle: "导入导出,统一到工具箱",
|
||||
updateGuideTransferBody: "已验证的工作流分享码导入导出中心现在位于工具箱。配方包导入导出、素材库文件导入和工坊文件导出暂不提供。保存到本地库、使用已有素材,以及图片和工作流附带的哈希信息不受影响。",
|
||||
updateGuideLibraryTitle: "素材库词卡,自动来到工坊",
|
||||
updateGuideLibraryBody: "打开提示词工坊后,素材库提示词会自动同步,并标注来源。源素材更新或删除时,词卡会随自动刷新变化。它们由素材库管理,不能在工坊删除;加入拼装台的副本仍可自由编辑。",
|
||||
promptFullTextEditHint: '修改全文会将当前方向已启用的词块合并为一个词块。',
|
||||
materialNoPromptContent: '这份素材没有已明确分类的提示词,请先在详情中检查提示词角色。',
|
||||
promptDrawerReplace: '替换这组文字',
|
||||
@@ -1126,6 +1153,33 @@ export const i18n = {
|
||||
recipeCardSpecsResolution: '尺寸',
|
||||
},
|
||||
en: {
|
||||
actionTools: "Tools",
|
||||
actionScan: "Scan",
|
||||
actionDoctor: "Fix",
|
||||
actionAssistant: "Node",
|
||||
actionMaterials: "Lib",
|
||||
actionSettings: "Prefs",
|
||||
actionToolsHint: "Open prompt tools, model sources and workflow import/export",
|
||||
actionScanHint: "Scan local models for metadata and previews",
|
||||
actionDoctorHint: "Find missing workflow models and repair their paths",
|
||||
actionAssistantHint: "Work with the selected node and apply models or parameter sets",
|
||||
actionMaterialsHint: "Browse and use saved prompts, parameters and workflow materials",
|
||||
actionSettingsHint: "Adjust language, appearance and plugin preferences",
|
||||
updateGuideTitle: "What's new",
|
||||
updateGuideReplay: "View update guide",
|
||||
updateGuideSkip: "Skip",
|
||||
updateGuideBack: "Back",
|
||||
updateGuideNext: "Next",
|
||||
updateGuideDone: "Got it",
|
||||
updateGuideProgress: "Step {current} of {total}",
|
||||
updateGuideNoticeLabel: "Guide",
|
||||
updateGuideNoticeTooltip: "💡 What's New in this update (Click to view)",
|
||||
updateGuideNavigationTitle: "Hover to identify each action",
|
||||
updateGuideNavigationBody: "The lower-left buttons keep their compact size. Hover or focus with the keyboard to replace an icon with a short label; pause for its full description. The actions are Toolbox, Scan, Model Doctor, Node Assistant, Material Library and Settings.",
|
||||
updateGuideTransferTitle: "Workflow sharing lives in Toolbox",
|
||||
updateGuideTransferBody: "The verified workflow share-code Import / Export Center now lives in Toolbox. Recipe package transfers, library file import and studio file export are unavailable. Local saves, existing materials and image/workflow hash metadata are unaffected.",
|
||||
updateGuideLibraryTitle: "Library prompts sync automatically",
|
||||
updateGuideLibraryBody: "Open Prompt Studio to sync Material Library prompts automatically, with their source shown. Source edits and deletions appear on refresh. These cards are managed in the library and cannot be deleted in the studio; copies in the mixer remain editable.",
|
||||
promptFullTextEditHint: 'Editing the full text merges the enabled blocks in this direction into one block.',
|
||||
materialNoPromptContent: 'This material has no classified prompts. Check its prompt roles in details first.',
|
||||
promptDrawerReplace: 'Replace this text',
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { translate as t } from './locales.js';
|
||||
|
||||
// Labels are deliberately short enough for the existing 32px buttons.
|
||||
const ACTIONS = [
|
||||
['anomalous-toolbox-btn', 'actionTools', 'sidebarToolbox', 'actionToolsHint'],
|
||||
['anomalous-scan-btn', 'actionScan', 'sidebarScanWizard', 'actionScanHint'],
|
||||
['anomalous-doctor-btn', 'actionDoctor', 'sidebarDoctor', 'actionDoctorHint'],
|
||||
['anomalous-assistant-btn', 'actionAssistant', 'sidebarAssistant', 'actionAssistantHint'],
|
||||
['anomalous-materials-btn', 'actionMaterials', 'materialLibrary', 'actionMaterialsHint'],
|
||||
['anomalous-global-settings-btn', 'actionSettings', 'sidebarSettings', 'actionSettingsHint'],
|
||||
];
|
||||
|
||||
export function configureSidebarActions(root) {
|
||||
for (const [id] of ACTIONS) {
|
||||
const button = root.querySelector(`#${id}`);
|
||||
if (button) configureSidebarAction(button);
|
||||
}
|
||||
}
|
||||
|
||||
export function configureSidebarAction(button) {
|
||||
const spec = ACTIONS.find(([id]) => id === button.id);
|
||||
if (!spec) return;
|
||||
const [, labelKey, nameKey, hintKey] = spec;
|
||||
let label = button.querySelector('.anomalous-action-label');
|
||||
if (!label) {
|
||||
label = document.createElement('span');
|
||||
label.className = 'anomalous-action-label';
|
||||
label.setAttribute('aria-hidden', 'true');
|
||||
button.appendChild(label);
|
||||
}
|
||||
label.textContent = t(labelKey);
|
||||
button.setAttribute('aria-label', t(nameKey));
|
||||
button.setAttribute('data-tooltip', `${t(nameKey)}\n${t(hintKey)}`);
|
||||
button.setAttribute('data-tooltip-pos', 'top');
|
||||
button.removeAttribute('title');
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import { translate } from './locales.js';
|
||||
import { escapeHtml } from './safe_dom.js';
|
||||
import { updateScanProgress, finishScanProgress, failScanProgress } from './scan_progress.js';
|
||||
import { openModelSourcesModal } from './ui_model_sources.js';
|
||||
import { showUpdateGuide, hasAcknowledged } from './ui_update_guide.js';
|
||||
import { CURRENT_UPDATE_GUIDE } from './update_guide_data.js';
|
||||
|
||||
const t = (key, params) => translate(key, params);
|
||||
|
||||
@@ -1181,6 +1183,41 @@ export function createDOM() {
|
||||
closeBtn.innerHTML = '×';
|
||||
closeBtn.onclick = () => this.close();
|
||||
|
||||
const updateNoticeBtn = document.createElement('button');
|
||||
updateNoticeBtn.id = 'anomalous-update-notice-btn';
|
||||
updateNoticeBtn.className = 'anomalous-update-notice-btn anomalous-tooltip-target';
|
||||
updateNoticeBtn.setAttribute('data-tooltip', t('updateGuideNoticeTooltip'));
|
||||
updateNoticeBtn.setAttribute('data-tooltip-pos', 'bottom');
|
||||
updateNoticeBtn.setAttribute('aria-label', t('updateGuideNoticeTooltip'));
|
||||
updateNoticeBtn.innerHTML = `<span class="anomalous-update-notice-icon">!</span><span class="anomalous-btn-text">${t('updateGuideNoticeLabel')}</span>`;
|
||||
updateNoticeBtn.onclick = () => {
|
||||
showUpdateGuide(this, { force: true });
|
||||
};
|
||||
|
||||
const updateNoticeState = () => {
|
||||
const isSeen = hasAcknowledged(CURRENT_UPDATE_GUIDE.id);
|
||||
if (isSeen) {
|
||||
updateNoticeBtn.style.display = 'none';
|
||||
} else {
|
||||
updateNoticeBtn.style.display = 'inline-flex';
|
||||
updateNoticeBtn.style.opacity = '1';
|
||||
updateNoticeBtn.style.transform = 'none';
|
||||
}
|
||||
};
|
||||
updateNoticeState();
|
||||
this.refreshUpdateNotice = updateNoticeState;
|
||||
|
||||
window.addEventListener('anomalous-update-guide-acknowledged', (e) => {
|
||||
if (e.detail?.id === CURRENT_UPDATE_GUIDE.id) {
|
||||
updateNoticeBtn.style.opacity = '0';
|
||||
updateNoticeBtn.style.transform = 'scale(0.8)';
|
||||
setTimeout(() => {
|
||||
updateNoticeBtn.style.display = 'none';
|
||||
}, 300);
|
||||
}
|
||||
});
|
||||
|
||||
rightGroup.appendChild(updateNoticeBtn);
|
||||
rightGroup.appendChild(dockBtn);
|
||||
rightGroup.appendChild(closeBtn);
|
||||
|
||||
@@ -1234,6 +1271,11 @@ export function createDOM() {
|
||||
dockBtn.setAttribute('data-tooltip', t('dockTitle'));
|
||||
dockBtn.setAttribute('data-tooltip-pos', 'bottom');
|
||||
}
|
||||
if (updateNoticeBtn) {
|
||||
updateNoticeBtn.setAttribute('data-tooltip', t('updateGuideNoticeTooltip'));
|
||||
updateNoticeBtn.setAttribute('aria-label', t('updateGuideNoticeTooltip'));
|
||||
updateNoticeBtn.innerHTML = `<span class="anomalous-update-notice-icon">!</span><span class="anomalous-btn-text">${t('updateGuideNoticeLabel')}</span>`;
|
||||
}
|
||||
|
||||
// Reset dynamic panels so they re-render in new language
|
||||
if (window.anomalousBrowserInstance) {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { CURRENT_UPDATE_GUIDE, validateUpdateGuide } from './update_guide_data.js';
|
||||
import { i18n, translate as t } from './locales.js';
|
||||
import { createViewScope } from './ui_lifecycle.js';
|
||||
import { text } from './ui_dom.js';
|
||||
|
||||
const acknowledgedThisSession = new Set();
|
||||
let activeGuide = null;
|
||||
const storageKey = id => `anomalous_update_guide_seen:${id}`;
|
||||
|
||||
export function hasAcknowledged(id = CURRENT_UPDATE_GUIDE.id) {
|
||||
if (acknowledgedThisSession.has(id)) return true;
|
||||
try { return localStorage.getItem(storageKey(id)) === '1'; }
|
||||
catch (error) { return false; } // Storage may be unavailable; session state still works.
|
||||
}
|
||||
|
||||
export function closeUpdateGuide(owner, acknowledge = false) {
|
||||
if (!activeGuide || activeGuide.owner !== owner) return;
|
||||
const current = activeGuide;
|
||||
activeGuide = null;
|
||||
if (acknowledge) {
|
||||
acknowledgedThisSession.add(current.id);
|
||||
try { localStorage.setItem(storageKey(current.id), '1'); }
|
||||
catch (error) { console.warn('[AMB] Update guide dismissal is saved for this session only.', error); }
|
||||
if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function') {
|
||||
window.dispatchEvent(new CustomEvent('anomalous-update-guide-acknowledged', { detail: { id: current.id } }));
|
||||
}
|
||||
}
|
||||
current.scope.dispose();
|
||||
}
|
||||
|
||||
/** Automatic on browser open; force replays from Help. No graph or file writes. */
|
||||
export function showUpdateGuide(owner, { force = false, guide = CURRENT_UPDATE_GUIDE } = {}) {
|
||||
if (!owner.modal?.classList.contains('visible')) return false;
|
||||
if (!validateUpdateGuide(guide, i18n)) {
|
||||
console.warn('[AMB] Invalid update guide configuration; guide skipped.');
|
||||
return false;
|
||||
}
|
||||
if (activeGuide?.owner === owner && activeGuide.id === guide.id) return false;
|
||||
if (!force && hasAcknowledged(guide.id)) return false;
|
||||
if (activeGuide) closeUpdateGuide(activeGuide.owner);
|
||||
|
||||
const scope = createViewScope();
|
||||
const previousFocus = document.activeElement;
|
||||
const dialog = document.createElement('dialog');
|
||||
dialog.className = 'anomalous-update-guide';
|
||||
dialog.setAttribute('aria-labelledby', 'anomalous-update-guide-title');
|
||||
dialog.setAttribute('aria-describedby', 'anomalous-update-guide-body');
|
||||
activeGuide = { owner, id: guide.id, scope };
|
||||
scope.onDispose(() => {
|
||||
if (dialog.open) dialog.close();
|
||||
dialog.remove();
|
||||
if (previousFocus?.isConnected) previousFocus.focus({ preventScroll: true });
|
||||
});
|
||||
|
||||
const header = text(dialog, 'div', '', 'anomalous-update-guide-header');
|
||||
text(header, 'span', t('updateGuideTitle'));
|
||||
const dismiss = text(header, 'button', '×', 'anomalous-update-guide-close');
|
||||
dismiss.type = 'button';
|
||||
dismiss.setAttribute('aria-label', t('close'));
|
||||
dismiss.onclick = () => closeUpdateGuide(owner, true);
|
||||
const icon = text(dialog, 'div', '', 'anomalous-update-guide-icon');
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
const title = text(dialog, 'h2', '');
|
||||
title.id = 'anomalous-update-guide-title';
|
||||
const body = text(dialog, 'p', '');
|
||||
body.id = 'anomalous-update-guide-body';
|
||||
const progress = text(dialog, 'p', '', 'anomalous-update-guide-progress');
|
||||
progress.setAttribute('aria-live', 'polite');
|
||||
const footer = text(dialog, 'div', '', 'anomalous-update-guide-footer');
|
||||
const skip = text(footer, 'button', t('updateGuideSkip'), 'anomalous-btn-ghost');
|
||||
const back = text(footer, 'button', t('updateGuideBack'), 'anomalous-btn-ghost');
|
||||
const next = text(footer, 'button', '', 'anomalous-btn-primary');
|
||||
for (const button of [skip, back, next]) button.type = 'button';
|
||||
let index = 0;
|
||||
const render = () => {
|
||||
const step = guide.steps[index];
|
||||
icon.textContent = step.icon;
|
||||
title.textContent = t(step.titleKey);
|
||||
body.textContent = t(step.bodyKey);
|
||||
progress.textContent = t('updateGuideProgress', { current: index + 1, total: guide.steps.length });
|
||||
back.disabled = index === 0;
|
||||
next.textContent = t(index === guide.steps.length - 1 ? 'updateGuideDone' : 'updateGuideNext');
|
||||
};
|
||||
skip.onclick = () => closeUpdateGuide(owner, true);
|
||||
back.onclick = () => { if (index > 0) { index--; render(); } };
|
||||
next.onclick = () => {
|
||||
if (index === guide.steps.length - 1) closeUpdateGuide(owner, true);
|
||||
else { index++; render(); }
|
||||
};
|
||||
scope.listen(dialog, 'keydown', event => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
closeUpdateGuide(owner, true);
|
||||
}
|
||||
});
|
||||
scope.listen(dialog, 'cancel', event => { event.preventDefault(); closeUpdateGuide(owner, true); });
|
||||
scope.listen(dialog, 'close', () => closeUpdateGuide(owner));
|
||||
scope.listen(dialog, 'click', event => { if (event.target === dialog) closeUpdateGuide(owner, true); });
|
||||
render();
|
||||
document.body.appendChild(dialog);
|
||||
dialog.showModal();
|
||||
next.focus();
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Content only. Maintenance checklist: docs/architecture/update-guide.md.
|
||||
// Change the ID only when users should see a new guide, never for typo fixes.
|
||||
export const CURRENT_UPDATE_GUIDE = Object.freeze({
|
||||
id: '2026-09-navigation-and-library',
|
||||
steps: Object.freeze([
|
||||
{ id: 'navigation', icon: '◎', titleKey: 'updateGuideNavigationTitle', bodyKey: 'updateGuideNavigationBody' },
|
||||
{ id: 'transfers', icon: '⇄', titleKey: 'updateGuideTransferTitle', bodyKey: 'updateGuideTransferBody' },
|
||||
{ id: 'library', icon: '▧', titleKey: 'updateGuideLibraryTitle', bodyKey: 'updateGuideLibraryBody' },
|
||||
]),
|
||||
});
|
||||
|
||||
export function validateUpdateGuide(guide, locales) {
|
||||
if (!guide || typeof guide.id !== 'string' || !/^[a-z0-9][a-z0-9-]{0,79}$/.test(guide.id)) return false;
|
||||
if (!Array.isArray(guide.steps) || guide.steps.length < 1 || guide.steps.length > 5) return false;
|
||||
const ids = new Set();
|
||||
return guide.steps.every(step => {
|
||||
if (!step || typeof step.id !== 'string' || !step.id || ids.has(step.id)) return false;
|
||||
ids.add(step.id);
|
||||
if (typeof step.icon !== 'string' || !step.icon || step.icon.length > 8) return false;
|
||||
return ['titleKey', 'bodyKey'].every(field => typeof step[field] === 'string'
|
||||
&& ['zh', 'en'].every(locale => typeof locales[locale]?.[step[field]] === 'string'
|
||||
&& locales[locale][step[field]].trim().length > 0));
|
||||
});
|
||||
}
|
||||
@@ -13956,4 +13956,51 @@ button:hover .anomalous-btn-icon {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
UPDATE GUIDE NOTICE INDICATOR (RESTRAINED AMBER BREATH)
|
||||
============================================================================= */
|
||||
#anomalous-header button.anomalous-update-notice-btn {
|
||||
background: rgba(245, 158, 11, 0.12) !important;
|
||||
border-color: rgba(245, 158, 11, 0.38) !important;
|
||||
color: #fbbf24 !important;
|
||||
padding: 0 9px 0 6px !important;
|
||||
font-weight: 600 !important;
|
||||
letter-spacing: 0.3px !important;
|
||||
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s ease, transform 0.3s ease;
|
||||
animation: anomalous-amber-breath 3.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
#anomalous-header button.anomalous-update-notice-btn:hover {
|
||||
background: rgba(245, 158, 11, 0.22) !important;
|
||||
border-color: rgba(245, 158, 11, 0.65) !important;
|
||||
color: #ffffff !important;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 0 10px rgba(245, 158, 11, 0.35) !important;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.anomalous-update-notice-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: #f59e0b;
|
||||
color: #18181b;
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
@keyframes anomalous-amber-breath {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0);
|
||||
border-color: rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 8px 1px rgba(245, 158, 11, 0.28);
|
||||
border-color: rgba(245, 158, 11, 0.55);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user