oh-my-hi 0.4.2 → 0.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@
10
10
  {
11
11
  "name": "oh-my-hi",
12
12
  "description": "Visual dashboard for Claude Code harness — usage/token analysis of skills, agents, plugins, hooks, memory, MCP servers, rules, and principles",
13
- "version": "0.4.2",
13
+ "version": "0.4.7",
14
14
  "author": {
15
15
  "name": "Jae Sung Park",
16
16
  "email": "alberto.park@gmail.com"
package/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.7] - 2026-04-04
4
+
5
+ ### Fixed
6
+ - Banner "seen" state now tracked via URL `?seen=<generatedAt>` instead of `localStorage` — `localStorage` is blocked on `file://` URLs in Chrome, causing the banner to show on every refresh. `history.replaceState` persists across refreshes without any storage API
7
+ - `_dateRange` now included in all builds (was only set during first-run), so banner always shows date range
8
+ - Banner auto-hide: added `setTimeout` fallback in case `transitionend` event does not fire
9
+
10
+ ## [0.4.6] - 2026-04-04
11
+
12
+ ### Changed
13
+ - First-run completion banner now shows only when new data has been generated (compares `generatedAt` with localStorage), and auto-hides after 3 seconds with a fade-out transition
14
+
15
+ ## [0.4.5] - 2026-04-04
16
+
17
+ ### Fixed
18
+ - SKILL.md: `find` now picks the highest semver version from cache (`sort -V | tail -1`) instead of the first filesystem match, preventing older cached versions from being used after an update
19
+
20
+ ## [0.4.4] - 2026-04-04
21
+
22
+ ### Fixed
23
+ - SKILL.md: show explicit error message (`ERROR — generate-dashboard.mjs not found`) and exit 1 when script is not found, instead of silently failing
24
+
25
+ ### Added
26
+ - Tests: `test/firstrun.test.mjs` — 10 tests covering `computeDateRange` logic (empty data, null timestamps, multi-scope aggregation) and SKILL.md bash command correctness
27
+ - Tests: `web-ui.test.mjs` — `showFirstRunBanner` function, `_firstRun`/`_dateRange` references, CSS classes, locale keys
28
+ - Tests: `build.test.mjs` — normal builds must not include `_firstRun` or `_partial` flags
29
+
30
+ ## [0.4.3] - 2026-04-04
31
+
32
+ ### Fixed
33
+ - Plugin path resolution: SKILL.md `find` command now searches `plugins/` cache directory instead of relying on `CLAUDE_PLUGIN_ROOT` (which points to the marketplace mirror, not the versioned install cache with `scripts/`)
34
+
35
+ ### Added
36
+ - First-run completion banner: after full data loads on first run, a green banner shows the parsed date range (e.g. "✅ Full data loaded · Jan 1, 2025 – Apr 4, 2026") with an × close button that only disappears on user click
37
+
3
38
  ## [0.4.2] - 2026-04-03
4
39
 
5
40
  ### Changed
package/CLAUDE.md CHANGED
@@ -58,6 +58,8 @@ output/ # Generated artifacts (gitignored)
58
58
 
59
59
  ## Release Workflow
60
60
 
61
+ **IMPORTANT: Do NOT run the release process automatically after completing a task. Only execute when the user explicitly requests a release (e.g. "릴리스해줘", "버전 올려줘", "release", "publish").**
62
+
61
63
  Execute in order when a version bump is requested:
62
64
 
63
65
  1. `npm test` — abort on failure
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-my-hi",
3
- "version": "0.4.2",
3
+ "version": "0.4.7",
4
4
  "description": "Claude Code harness insights dashboard",
5
5
  "repository": {
6
6
  "type": "git",
@@ -346,7 +346,7 @@ async function main() {
346
346
 
347
347
  console.log(' [4/4] loading full history (this may take a moment)...');
348
348
  const phase2ScopeData = await collectAllScopes(scopes, { days: 0, cache, cachePath: CACHE_PATH, progress: true });
349
- const phase2Data = buildDataObject(scopes, phase2ScopeData, systemLocale);
349
+ const phase2Data = buildDataObject(scopes, phase2ScopeData, systemLocale, { _firstRun: true, _dateRange: computeDateRange(phase2ScopeData) });
350
350
  writeDataJs(phase2Data, dataPath);
351
351
  openOrRefreshBrowser(indexPath);
352
352
  } else {
@@ -355,7 +355,7 @@ async function main() {
355
355
  console.log(` [1/3] scanning ${scopes.length} workspace(s)...`);
356
356
  const scopeData = await collectAllScopes(scopes, { days: 0, cachePath: CACHE_PATH, progress: true });
357
357
  console.log(' [2/3] building dashboard...');
358
- const data = buildDataObject(scopes, scopeData, systemLocale);
358
+ const data = buildDataObject(scopes, scopeData, systemLocale, { _dateRange: computeDateRange(scopeData) });
359
359
  writeDataJs(data, dataPath);
360
360
  console.log(' [3/3] opening browser...');
361
361
  openOrRefreshBrowser(indexPath);
@@ -523,6 +523,20 @@ async function collectAllScopes(scopes, { days = 0, cache, cachePath, progress =
523
523
  }
524
524
 
525
525
  /** Build data object from scope data (strips internal _cacheStats) */
526
+ /** Compute min/max date range from all tokenEntries across all scopes */
527
+ function computeDateRange(scopeData) {
528
+ let minTs = null, maxTs = null;
529
+ for (const sdata of Object.values(scopeData)) {
530
+ for (const entry of (sdata?.usage?.tokenEntries || [])) {
531
+ const ts = entry.timestamp;
532
+ if (!ts) continue;
533
+ if (!minTs || ts < minTs) minTs = ts;
534
+ if (!maxTs || ts > maxTs) maxTs = ts;
535
+ }
536
+ }
537
+ return minTs && maxTs ? { from: minTs, to: maxTs } : null;
538
+ }
539
+
526
540
  function buildDataObject(scopes, scopeData, systemLocale, extra = {}) {
527
541
  // Strip _cacheStats from usage data before building output
528
542
  const cleanScopeData = {};
@@ -42,14 +42,20 @@ If the user chooses **enable**, run `--enable-auto`. If they choose **disable**,
42
42
  - **Auto-refresh**: `--enable-auto` registers a Stop hook — rebuilds on every session end
43
43
  - **Browser reuse**: macOS AppleScript tab detection; Windows/Linux fallback to system open
44
44
 
45
- Find the oh-my-hi plugin installation path and run the script:
45
+ Find and run the script (picks the latest version from cache, falls back to marketplaces):
46
46
 
47
47
  ```bash
48
- OMH_ROOT=$(dirname "$(find "${CLAUDE_CONFIG_DIR:-$HOME/.claude}" -path "*/oh-my-hi/*/scripts/generate-dashboard.mjs" -print -quit 2>/dev/null)") && node "$OMH_ROOT/generate-dashboard.mjs" $ARGUMENTS
49
- ```
50
-
51
- If `CLAUDE_PLUGIN_ROOT` is available, use it directly:
52
-
53
- ```bash
54
- node "${CLAUDE_PLUGIN_ROOT}/scripts/generate-dashboard.mjs" $ARGUMENTS
48
+ PLUGINS_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/plugins"
49
+ # Pick the highest semver version from cache (sort -V handles semantic versioning)
50
+ SCRIPT=$(find "$PLUGINS_DIR/cache" -name "generate-dashboard.mjs" -path "*/scripts/generate-dashboard.mjs" 2>/dev/null \
51
+ | sort -V | tail -1)
52
+ # Fallback to marketplaces directory
53
+ if [ -z "$SCRIPT" ]; then
54
+ SCRIPT=$(find "$PLUGINS_DIR/marketplaces" -name "generate-dashboard.mjs" -path "*/scripts/generate-dashboard.mjs" -print -quit 2>/dev/null)
55
+ fi
56
+ if [ -z "$SCRIPT" ]; then
57
+ echo "oh-my-hi: ERROR — generate-dashboard.mjs not found. Try: /omh --update"
58
+ exit 1
59
+ fi
60
+ node "$SCRIPT" $ARGUMENTS
55
61
  ```
package/templates/app.js CHANGED
@@ -4034,7 +4034,37 @@
4034
4034
  document.body.prepend(banner);
4035
4035
  }
4036
4036
 
4037
+ // ── New-data banner ──
4038
+ function showFirstRunBanner() {
4039
+ const current = DATA.generatedAt;
4040
+ if (!current) return;
4041
+ // Use URL ?seen=<generatedAt> to track "already shown" state.
4042
+ // history.replaceState works on file:// URLs and persists across refreshes.
4043
+ const params = new URLSearchParams(location.search);
4044
+ if (params.get('seen') === current) return;
4045
+ params.set('seen', current);
4046
+ try { history.replaceState(null, '', location.pathname + '?' + params.toString() + location.hash); } catch (e) { /* ignore */ }
4047
+ const dr = DATA._dateRange;
4048
+ let dateStr = '';
4049
+ if (dr && dr.from && dr.to) {
4050
+ const fmt = d => new Date(d).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
4051
+ dateStr = ' · ' + fmt(dr.from) + ' – ' + fmt(dr.to);
4052
+ }
4053
+ const banner = document.createElement('div');
4054
+ banner.className = 'firstrun-banner';
4055
+ banner.innerHTML = '<span>' + t('firstRunBannerMsg') + dateStr + '</span>'
4056
+ + '<button class="firstrun-close" onclick="this.parentElement.remove()" title="' + t('close') + '">✕</button>';
4057
+ document.body.prepend(banner);
4058
+ setTimeout(() => {
4059
+ banner.classList.add('firstrun-banner--hiding');
4060
+ const removeBanner = () => { if (banner.isConnected) banner.remove(); };
4061
+ banner.addEventListener('transitionend', removeBanner, { once: true });
4062
+ setTimeout(removeBanner, 500); // fallback if transitionend doesn't fire
4063
+ }, 3000);
4064
+ }
4065
+
4037
4066
  // ── Boot ──
4038
4067
  init();
4039
4068
  showPartialBanner();
4069
+ showFirstRunBanner();
4040
4070
  checkDataVersion();
@@ -339,6 +339,8 @@
339
339
  "updateBannerMsg": "⚡ New data detected. Refresh the page to see the latest data.",
340
340
  "updateBannerRefresh": "Refresh",
341
341
  "partialBannerMsg": "Showing last 7 days. Full data is loading in the background\u2026",
342
+ "firstRunBannerMsg": "✅ Full data loaded",
343
+ "close": "Close",
342
344
  "catDescSkills": "Skills are reusable prompt templates invoked via /commands. Provided from the skills/ directory or plugins, each SKILL.md defines name, description, and argument hints in its frontmatter. They can also be auto-invoked via the Skill tool during conversations.",
343
345
  "catDescAgents": "Agents are subprocesses that autonomously handle complex tasks. Defined as markdown in the agents/ directory, they specify model (haiku/sonnet/opus), available tools, and domain expertise. Dispatched via the Agent tool, they run in independent contexts.",
344
346
  "catDescRules": "Rules are instruction files that control Claude's behavior. Stored as markdown files in {{configDir}}/rules/, they are automatically loaded into context in every session. Unlike CLAUDE.md, they allow separation of concerns into individual files.",
@@ -339,6 +339,8 @@
339
339
  "updateBannerMsg": "⚡ 새로운 데이터가 감지되었습니다. 페이지를 새로고침하면 최신 데이터를 확인할 수 있습니다.",
340
340
  "updateBannerRefresh": "새로고침",
341
341
  "partialBannerMsg": "최근 7일 데이터로 표시 중입니다. 전체 데이터를 백그라운드에서 로딩하고 있습니다\u2026",
342
+ "firstRunBannerMsg": "✅ 전체 데이터 파싱이 완료되었습니다",
343
+ "close": "닫기",
342
344
  "catDescSkills": "Skills는 사용자가 /명령어로 호출하는 재사용 가능한 프롬프트 템플릿입니다. skills/ 디렉토리 또는 플러그인을 통해 제공되며, 각 SKILL.md 파일의 frontmatter에 이름·설명·인자 힌트를 정의합니다. Skill 도구를 통해 대화 중 자동으로 호출될 수도 있습니다.",
343
345
  "catDescAgents": "Agents는 복잡한 작업을 자율적으로 수행하는 서브프로세스입니다. agents/ 디렉토리에 마크다운으로 정의하며, 모델(haiku/sonnet/opus), 사용 가능 도구, 전문 영역을 설정할 수 있습니다. Agent 도구로 디스패치되어 독립적인 컨텍스트에서 실행됩니다.",
344
346
  "catDescRules": "Rules는 Claude의 동작을 제어하는 지시 파일입니다. {{configDir}}/rules/ 디렉토리에 마크다운 파일로 저장되며, 모든 세션에서 자동으로 컨텍스트에 로드됩니다. CLAUDE.md와 달리 개별 파일로 분리하여 관심사를 구분할 수 있습니다.",
@@ -1829,7 +1829,8 @@ body.dark .bb-tooltip .value {
1829
1829
 
1830
1830
  /* ── Top Banners (shared base) ── */
1831
1831
  .update-banner,
1832
- .partial-banner {
1832
+ .partial-banner,
1833
+ .firstrun-banner {
1833
1834
  position: fixed;
1834
1835
  top: 0;
1835
1836
  left: var(--sidebar-width);
@@ -2055,6 +2056,37 @@ body.dark .bb-tooltip .value {
2055
2056
  background: #e67700;
2056
2057
  }
2057
2058
 
2059
+ .firstrun-banner {
2060
+ gap: 12px;
2061
+ background: #2f9e44;
2062
+ transition: opacity 0.4s ease, transform 0.4s ease;
2063
+ }
2064
+
2065
+ .firstrun-banner--hiding {
2066
+ opacity: 0;
2067
+ transform: translateY(-100%);
2068
+ }
2069
+
2070
+ .firstrun-banner span {
2071
+ flex: 1;
2072
+ }
2073
+
2074
+ .firstrun-close {
2075
+ background: rgba(255,255,255,0.2);
2076
+ border: 1px solid rgba(255,255,255,0.3);
2077
+ color: #fff;
2078
+ padding: 2px 8px;
2079
+ border-radius: var(--radius-sm);
2080
+ cursor: pointer;
2081
+ font-size: 13px;
2082
+ line-height: 1.5;
2083
+ flex-shrink: 0;
2084
+ }
2085
+
2086
+ .firstrun-close:hover {
2087
+ background: rgba(255,255,255,0.35);
2088
+ }
2089
+
2058
2090
  .partial-spinner {
2059
2091
  display: inline-block;
2060
2092
  width: 14px;
@@ -78,6 +78,16 @@ describe('Build', () => {
78
78
  // Test runs from the git repo, so _devBuild should be true
79
79
  assert.equal(data._devBuild, true);
80
80
  });
81
+
82
+ it('should NOT have _firstRun flag in normal --data-only build', () => {
83
+ // _firstRun is only set during the first full run (no cache)
84
+ // After that, subsequent builds must not carry the flag
85
+ assert.equal(data._firstRun, undefined, '_firstRun must be absent in cached builds');
86
+ });
87
+
88
+ it('should NOT have _partial flag in normal --data-only build', () => {
89
+ assert.equal(data._partial, undefined, '_partial must be absent in full builds');
90
+ });
81
91
  });
82
92
 
83
93
  describe('index.html', () => {
@@ -0,0 +1,131 @@
1
+ import { describe, it, before } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import { fileURLToPath } from 'url';
6
+
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+ const ROOT = path.resolve(__dirname, '..');
9
+
10
+ // ── computeDateRange (inline — not exported, so logic is replicated here) ──
11
+ // Keep in sync with scripts/generate-dashboard.mjs:computeDateRange
12
+ function computeDateRange(scopeData) {
13
+ let minTs = null, maxTs = null;
14
+ for (const sdata of Object.values(scopeData)) {
15
+ for (const entry of (sdata?.usage?.tokenEntries || [])) {
16
+ const ts = entry.timestamp;
17
+ if (!ts) continue;
18
+ if (!minTs || ts < minTs) minTs = ts;
19
+ if (!maxTs || ts > maxTs) maxTs = ts;
20
+ }
21
+ }
22
+ return minTs && maxTs ? { from: minTs, to: maxTs } : null;
23
+ }
24
+
25
+ describe('computeDateRange', () => {
26
+ it('returns null for empty scopeData', () => {
27
+ assert.equal(computeDateRange({}), null);
28
+ });
29
+
30
+ it('returns null when no tokenEntries exist', () => {
31
+ const scopeData = { global: { usage: { tokenEntries: [] } } };
32
+ assert.equal(computeDateRange(scopeData), null);
33
+ });
34
+
35
+ it('returns null when tokenEntries have no timestamp', () => {
36
+ const scopeData = { global: { usage: { tokenEntries: [{ model: 'haiku' }, { model: 'sonnet' }] } } };
37
+ assert.equal(computeDateRange(scopeData), null);
38
+ });
39
+
40
+ it('returns from === to for a single entry', () => {
41
+ const ts = '2025-06-15T10:00:00.000Z';
42
+ const scopeData = { global: { usage: { tokenEntries: [{ timestamp: ts }] } } };
43
+ const result = computeDateRange(scopeData);
44
+ assert.deepEqual(result, { from: ts, to: ts });
45
+ });
46
+
47
+ it('returns correct min/max across multiple entries', () => {
48
+ const scopeData = {
49
+ global: {
50
+ usage: {
51
+ tokenEntries: [
52
+ { timestamp: '2025-03-01T00:00:00.000Z' },
53
+ { timestamp: '2025-01-15T00:00:00.000Z' },
54
+ { timestamp: '2025-06-30T00:00:00.000Z' },
55
+ ],
56
+ },
57
+ },
58
+ };
59
+ const result = computeDateRange(scopeData);
60
+ assert.equal(result.from, '2025-01-15T00:00:00.000Z');
61
+ assert.equal(result.to, '2025-06-30T00:00:00.000Z');
62
+ });
63
+
64
+ it('aggregates across multiple scopes', () => {
65
+ const scopeData = {
66
+ global: { usage: { tokenEntries: [{ timestamp: '2025-03-01T00:00:00.000Z' }] } },
67
+ proj1: { usage: { tokenEntries: [{ timestamp: '2024-11-01T00:00:00.000Z' }] } },
68
+ proj2: { usage: { tokenEntries: [{ timestamp: '2025-08-20T00:00:00.000Z' }] } },
69
+ };
70
+ const result = computeDateRange(scopeData);
71
+ assert.equal(result.from, '2024-11-01T00:00:00.000Z');
72
+ assert.equal(result.to, '2025-08-20T00:00:00.000Z');
73
+ });
74
+
75
+ it('skips scopes with missing usage', () => {
76
+ const scopeData = {
77
+ global: { usage: { tokenEntries: [{ timestamp: '2025-05-10T00:00:00.000Z' }] } },
78
+ noUsage: {},
79
+ nullUsage: { usage: null },
80
+ };
81
+ const result = computeDateRange(scopeData);
82
+ assert.deepEqual(result, { from: '2025-05-10T00:00:00.000Z', to: '2025-05-10T00:00:00.000Z' });
83
+ });
84
+
85
+ it('skips entries with falsy timestamp', () => {
86
+ const scopeData = {
87
+ global: {
88
+ usage: {
89
+ tokenEntries: [
90
+ { timestamp: null },
91
+ { timestamp: '' },
92
+ { timestamp: '2025-04-04T00:00:00.000Z' },
93
+ { timestamp: undefined },
94
+ ],
95
+ },
96
+ },
97
+ };
98
+ const result = computeDateRange(scopeData);
99
+ assert.deepEqual(result, { from: '2025-04-04T00:00:00.000Z', to: '2025-04-04T00:00:00.000Z' });
100
+ });
101
+ });
102
+
103
+ // ── SKILL.md bash command validation ──
104
+
105
+ describe('SKILL.md', () => {
106
+ let skillContent;
107
+ before(() => {
108
+ skillContent = fs.readFileSync(path.join(ROOT, 'skills', 'omh', 'SKILL.md'), 'utf-8');
109
+ });
110
+
111
+ it('should search plugins directory broadly (not rely on CLAUDE_PLUGIN_ROOT)', () => {
112
+ assert.ok(
113
+ skillContent.includes('plugins') && skillContent.includes('generate-dashboard.mjs'),
114
+ 'bash command searches plugins directory for the script',
115
+ );
116
+ assert.ok(
117
+ !skillContent.includes('CLAUDE_PLUGIN_ROOT'),
118
+ 'CLAUDE_PLUGIN_ROOT removed — it points to marketplace mirror, not install cache',
119
+ );
120
+ });
121
+
122
+ it('should output an error message when script is not found', () => {
123
+ assert.ok(skillContent.includes('ERROR'), 'error message present when script not found');
124
+ assert.ok(skillContent.includes('exit 1'), 'exits with non-zero on failure');
125
+ });
126
+
127
+ it('should not use the old broken find pattern with extra wildcard level', () => {
128
+ // old pattern: */oh-my-hi/*/scripts/ — extra * meant it never matched the actual install path
129
+ assert.ok(!skillContent.includes('oh-my-hi/*/scripts'), 'old broken pattern removed');
130
+ });
131
+ });
@@ -129,6 +129,33 @@ describe('Web UI — Templates', () => {
129
129
  assert.ok(js.includes('dayNames'));
130
130
  assert.ok(js.includes("dow"));
131
131
  });
132
+
133
+ it('should have showFirstRunBanner function called at boot', () => {
134
+ assert.ok(js.includes('function showFirstRunBanner'), 'showFirstRunBanner function defined');
135
+ assert.ok(js.includes('showFirstRunBanner()'), 'showFirstRunBanner called at boot');
136
+ });
137
+
138
+ it('should reference generatedAt and _dateRange from DATA', () => {
139
+ assert.ok(js.includes('DATA.generatedAt'), 'generatedAt used for new-data detection');
140
+ assert.ok(js.includes('DATA._dateRange'), '_dateRange used for date range display');
141
+ });
142
+
143
+ it('should use URL ?seen param to track shown state', () => {
144
+ assert.ok(js.includes('URLSearchParams'), 'URLSearchParams used for seen-state tracking');
145
+ assert.ok(js.includes('history.replaceState'), 'history.replaceState updates ?seen param');
146
+ assert.ok(js.includes("params.get('seen')"), 'seen param checked against generatedAt');
147
+ });
148
+
149
+ it('should auto-hide banner after 3 seconds', () => {
150
+ assert.ok(js.includes('setTimeout'), 'setTimeout used for auto-hide');
151
+ assert.ok(js.includes('firstrun-banner--hiding'), 'hiding class applied for fade-out');
152
+ assert.ok(js.includes('transitionend'), 'banner removed after transition ends');
153
+ });
154
+
155
+ it('should have firstrun close button inline handler', () => {
156
+ assert.ok(js.includes('firstrun-banner'), 'firstrun-banner class used');
157
+ assert.ok(js.includes('firstrun-close'), 'firstrun-close class used');
158
+ });
132
159
  });
133
160
 
134
161
  describe('styles.css', () => {
@@ -175,6 +202,17 @@ describe('Web UI — Templates', () => {
175
202
  assert.ok(css.includes('.cost-trend-label'));
176
203
  });
177
204
 
205
+ it('should have first-run completion banner styles', () => {
206
+ assert.ok(css.includes('.firstrun-banner'), '.firstrun-banner class defined');
207
+ assert.ok(css.includes('.firstrun-close'), '.firstrun-close button class defined');
208
+ });
209
+
210
+ it('should include firstrun-banner in shared banner base rule', () => {
211
+ // .firstrun-banner must share position/layout base with .update-banner and .partial-banner
212
+ const sharedRuleMatch = css.match(/\.update-banner[^{]*\.partial-banner[^{]*\.firstrun-banner|\.firstrun-banner[^{]*\.update-banner|\.update-banner[^{]*\.firstrun-banner/);
213
+ assert.ok(sharedRuleMatch, '.firstrun-banner included in shared banner base selector');
214
+ });
215
+
178
216
  it('should have responsive styles', () => {
179
217
  assert.ok(css.includes('@media'));
180
218
  assert.ok(css.includes('max-width: 768px'));
@@ -207,6 +245,7 @@ describe('Web UI — Templates', () => {
207
245
  'compareToggle', 'comparePrev',
208
246
  'unusedCleanupTipTitle', 'unusedCleanupTipDetail',
209
247
  'cacheTipLowHitTitle', 'cacheTipGoodTitle', 'cacheTipHighCreationTitle', 'cacheTipNoSessionTitle',
248
+ 'firstRunBannerMsg', 'close',
210
249
  ];
211
250
  for (const key of requiredKeys) {
212
251
  assert.ok(en[key], `missing en key: ${key}`);