@oicl/openbridge-webcomponents-full-bundle 2.0.0-next.119 → 2.0.0-next.121

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.
Files changed (51) hide show
  1. package/bundle/openbridge-webcomponents.bundle.js +118 -14
  2. package/bundle/openbridge-webcomponents.bundle.js.map +1 -1
  3. package/custom-elements.json +45 -1
  4. package/dist/components/alert-button/alert-button.d.ts +3 -0
  5. package/dist/components/alert-button/alert-button.d.ts.map +1 -1
  6. package/dist/components/alert-button/alert-button.js +12 -0
  7. package/dist/components/alert-button/alert-button.js.map +1 -1
  8. package/dist/components/alert-frame/alert-frame.css.js +1 -0
  9. package/dist/components/alert-frame/alert-frame.css.js.map +1 -1
  10. package/dist/components/alert-frame/alert-frame.d.ts +5 -0
  11. package/dist/components/alert-frame/alert-frame.d.ts.map +1 -1
  12. package/dist/components/alert-frame/alert-frame.js +24 -0
  13. package/dist/components/alert-frame/alert-frame.js.map +1 -1
  14. package/dist/components/alert-icon/alert-icon.d.ts +5 -0
  15. package/dist/components/alert-icon/alert-icon.d.ts.map +1 -1
  16. package/dist/components/alert-icon/alert-icon.js +26 -0
  17. package/dist/components/alert-icon/alert-icon.js.map +1 -1
  18. package/dist/integration-systems/integration-button/integration-button.css.js +20 -13
  19. package/dist/integration-systems/integration-button/integration-button.css.js.map +1 -1
  20. package/dist/integration-systems/integration-button/integration-button.d.ts +1 -1
  21. package/dist/integration-systems/integration-button/integration-button.js +1 -1
  22. package/dist/integration-systems/integration-button/integration-button.js.map +1 -1
  23. package/dist/integration-systems/integration-dropdown-button/integration-dropdown-button.css.js +1 -0
  24. package/dist/integration-systems/integration-dropdown-button/integration-dropdown-button.css.js.map +1 -1
  25. package/dist/openbridge.css +0 -73
  26. package/dist/palettes/blinking.d.ts +6 -0
  27. package/dist/palettes/blinking.d.ts.map +1 -0
  28. package/dist/palettes/blinking.js +44 -0
  29. package/dist/palettes/blinking.js.map +1 -0
  30. package/package.json +4 -2
  31. package/script/agent-docs/emitters.ts +189 -0
  32. package/script/agent-docs/frontmatter.ts +118 -0
  33. package/script/check-slot-event-docs.ts +2 -2
  34. package/script/sync-agent-docs.test.ts +298 -0
  35. package/script/sync-agent-docs.ts +156 -0
  36. package/src/components/alert-button/alert-button.spec.ts +58 -0
  37. package/src/components/alert-button/alert-button.ts +16 -0
  38. package/src/components/alert-frame/alert-frame.css +1 -0
  39. package/src/components/alert-frame/alert-frame.spec.ts +122 -0
  40. package/src/components/alert-frame/alert-frame.stories.ts +12 -0
  41. package/src/components/alert-frame/alert-frame.ts +37 -0
  42. package/src/components/alert-icon/alert-icon.spec.ts +76 -0
  43. package/src/components/alert-icon/alert-icon.ts +38 -0
  44. package/src/icons/AGENTS.md +6 -0
  45. package/src/integration-systems/integration-button/integration-button.css +20 -13
  46. package/src/integration-systems/integration-button/integration-button.stories.ts +70 -44
  47. package/src/integration-systems/integration-button/integration-button.ts +2 -2
  48. package/src/integration-systems/integration-dropdown-button/integration-dropdown-button.css +1 -0
  49. package/src/palettes/blinking.ts +46 -0
  50. package/src/palettes/manual.css +0 -42
  51. package/src/palettes/variables.css +0 -36
@@ -0,0 +1,298 @@
1
+ import {readFileSync} from 'node:fs';
2
+ import {describe, expect, it} from 'vitest';
3
+ import {parseAgentDoc, type AgentDoc} from './agent-docs/frontmatter.js';
4
+ import {
5
+ ROUTING_MARKER,
6
+ renderClaudeMd,
7
+ renderCopilotInstructions,
8
+ renderCursorRule,
9
+ renderInstructionsFile,
10
+ renderRoutingTable,
11
+ replaceMarkedBlock,
12
+ rewriteSiblingLinks,
13
+ } from './agent-docs/emitters.js';
14
+
15
+ const VALID = `---
16
+ name: a11y
17
+ description: Accessibility (WCAG 2.1 AA) — keyboard nav, ARIA, focus
18
+ globs:
19
+ - packages/openbridge-webcomponents/src/components/**
20
+ - packages/openbridge-webcomponents/src/automation/**
21
+ ---
22
+
23
+ # Accessibility Instructions
24
+
25
+ Body text.
26
+ `;
27
+
28
+ describe('parseAgentDoc', () => {
29
+ it('parses name, description and globs', () => {
30
+ const doc = parseAgentDoc(VALID, 'docs/agents/a11y.md');
31
+ expect(doc.name).toBe('a11y');
32
+ expect(doc.description).toBe(
33
+ 'Accessibility (WCAG 2.1 AA) — keyboard nav, ARIA, focus'
34
+ );
35
+ expect(doc.globs).toEqual([
36
+ 'packages/openbridge-webcomponents/src/components/**',
37
+ 'packages/openbridge-webcomponents/src/automation/**',
38
+ ]);
39
+ });
40
+
41
+ it('returns the body without the frontmatter', () => {
42
+ const doc = parseAgentDoc(VALID, 'docs/agents/a11y.md');
43
+ expect(doc.body).toBe('# Accessibility Instructions\n\nBody text.\n');
44
+ });
45
+
46
+ it('strips single quotes used to escape leading "!"', () => {
47
+ const raw = `---
48
+ name: jsdoc
49
+ description: JSDoc rules
50
+ globs:
51
+ - packages/openbridge-webcomponents/src/**/*.ts
52
+ - '!packages/openbridge-webcomponents/src/icons/**'
53
+ ---
54
+ body
55
+ `;
56
+ expect(parseAgentDoc(raw, 'docs/agents/jsdoc.md').globs[1]).toBe(
57
+ '!packages/openbridge-webcomponents/src/icons/**'
58
+ );
59
+ });
60
+
61
+ it('throws when name does not match the filename', () => {
62
+ expect(() => parseAgentDoc(VALID, 'docs/agents/wrong.md')).toThrow(
63
+ /name "a11y" does not match filename "wrong"/
64
+ );
65
+ });
66
+
67
+ it('throws on an unsupported key', () => {
68
+ const raw = `---
69
+ name: x
70
+ description: d
71
+ globs:
72
+ - a/**
73
+ alwaysApply: true
74
+ ---
75
+ body
76
+ `;
77
+ expect(() => parseAgentDoc(raw, 'docs/agents/x.md')).toThrow(
78
+ /unsupported key "alwaysApply" on line 6/
79
+ );
80
+ });
81
+
82
+ it('throws when globs is empty', () => {
83
+ const raw = `---
84
+ name: x
85
+ description: d
86
+ globs:
87
+ ---
88
+ body
89
+ `;
90
+ expect(() => parseAgentDoc(raw, 'docs/agents/x.md')).toThrow(
91
+ /"globs" must list at least one pattern/
92
+ );
93
+ });
94
+
95
+ it('throws when there is no frontmatter', () => {
96
+ expect(() =>
97
+ parseAgentDoc('# no frontmatter\n', 'docs/agents/x.md')
98
+ ).toThrow(/must start with a "---" frontmatter block/);
99
+ });
100
+ });
101
+
102
+ const DOC: AgentDoc = {
103
+ name: 'a11y',
104
+ description: 'Accessibility (WCAG 2.1 AA)',
105
+ globs: ['packages/openbridge-webcomponents/src/components/**'],
106
+ body: '# Accessibility\n\nBody.\n',
107
+ sourcePath: 'docs/agents/a11y.md',
108
+ };
109
+
110
+ describe('renderInstructionsFile', () => {
111
+ it('writes applyTo from globs and keeps the body verbatim', () => {
112
+ const out = renderInstructionsFile(DOC);
113
+ expect(out).toContain(
114
+ 'applyTo: "packages/openbridge-webcomponents/src/components/**"'
115
+ );
116
+ expect(out).toContain('# Accessibility\n\nBody.\n');
117
+ });
118
+
119
+ it('joins multiple globs with commas', () => {
120
+ const out = renderInstructionsFile({...DOC, globs: ['a/**', 'b/**']});
121
+ expect(out).toContain('applyTo: "a/**,b/**"');
122
+ });
123
+
124
+ it('includes a do-not-edit banner naming the source and the command', () => {
125
+ const out = renderInstructionsFile(DOC);
126
+ expect(out).toContain('GENERATED FILE — DO NOT EDIT');
127
+ expect(out).toContain('docs/agents/a11y.md');
128
+ expect(out).toContain('npm run agents:sync');
129
+ });
130
+
131
+ it('puts the frontmatter first so Copilot can read applyTo', () => {
132
+ expect(renderInstructionsFile(DOC).startsWith('---\napplyTo:')).toBe(true);
133
+ });
134
+ });
135
+
136
+ describe('renderRoutingTable', () => {
137
+ it('emits one row per doc with description and globs', () => {
138
+ const table = renderRoutingTable([DOC]);
139
+ expect(table).toContain('[a11y](docs/agents/a11y.md)');
140
+ expect(table).toContain('Accessibility (WCAG 2.1 AA)');
141
+ expect(table).toContain(
142
+ '`packages/openbridge-webcomponents/src/components/**`'
143
+ );
144
+ });
145
+
146
+ it('sorts rows by name regardless of input order', () => {
147
+ const z: AgentDoc = {
148
+ ...DOC,
149
+ name: 'zebra',
150
+ sourcePath: 'docs/agents/zebra.md',
151
+ };
152
+ const table = renderRoutingTable([z, DOC]);
153
+ expect(table.indexOf('[a11y]')).toBeLessThan(table.indexOf('[zebra]'));
154
+ });
155
+ });
156
+
157
+ describe('renderCopilotInstructions', () => {
158
+ it('points at AGENTS.md and carries the routing table but no bodies', () => {
159
+ const out = renderCopilotInstructions([DOC]);
160
+ expect(out).toContain('AGENTS.md');
161
+ expect(out).toContain('[a11y](docs/agents/a11y.md)');
162
+ expect(out).not.toContain('Body.');
163
+ });
164
+ });
165
+
166
+ describe('replaceMarkedBlock', () => {
167
+ it('replaces only the content between the markers', () => {
168
+ const content = [
169
+ 'before',
170
+ `<!-- ${ROUTING_MARKER}:start -->`,
171
+ 'OLD',
172
+ `<!-- ${ROUTING_MARKER}:end -->`,
173
+ 'after',
174
+ ].join('\n');
175
+ const out = replaceMarkedBlock(content, ROUTING_MARKER, 'NEW');
176
+ expect(out).toBe(
177
+ [
178
+ 'before',
179
+ `<!-- ${ROUTING_MARKER}:start -->`,
180
+ 'NEW',
181
+ `<!-- ${ROUTING_MARKER}:end -->`,
182
+ 'after',
183
+ ].join('\n')
184
+ );
185
+ });
186
+
187
+ it('throws when the markers are missing', () => {
188
+ expect(() => replaceMarkedBlock('no markers', ROUTING_MARKER, 'x')).toThrow(
189
+ /markers .* not found/
190
+ );
191
+ });
192
+ });
193
+
194
+ describe('renderClaudeMd', () => {
195
+ it('points at AGENTS.md and docs/agents', () => {
196
+ const out = renderClaudeMd();
197
+ expect(out).toContain('AGENTS.md');
198
+ expect(out).toContain('docs/agents/');
199
+ expect(out).toContain('GENERATED FILE — DO NOT EDIT');
200
+ });
201
+
202
+ it('carries no rules of its own — it is a pointer, not a source', () => {
203
+ const out = renderClaudeMd();
204
+ expect(out).toContain('intentionally adds nothing of its own');
205
+ // No numbered rule list: team rules belong in AGENTS.md, path-scoped rules
206
+ // in docs/agents/. Neither is tool-specific.
207
+ expect(out).not.toMatch(/^\d+\. \*\*/m);
208
+ });
209
+ });
210
+
211
+ describe('CLI failure semantics', () => {
212
+ const cli = 'script/sync-agent-docs.ts';
213
+
214
+ it('write mode never exits non-zero, so `prepare` cannot break npm ci', () => {
215
+ const src = readFileSync(cli, 'utf8');
216
+ expect(src).toContain('if (CHECK) process.exit(1);');
217
+ expect(src).not.toMatch(/^\s*process\.exit\(1\);\s*$/m);
218
+ });
219
+
220
+ it('only skips negative globs in the resolution check', () => {
221
+ const src = readFileSync(cli, 'utf8');
222
+ expect(src).toContain("if (g.startsWith('!')) continue;");
223
+ });
224
+ });
225
+
226
+ describe('renderCursorRule', () => {
227
+ it('emits globs + alwaysApply:false — Cursor\'s "Apply to Specific Files" type', () => {
228
+ const out = renderCursorRule(DOC);
229
+ expect(out.startsWith('---\n')).toBe(true);
230
+ expect(out).toContain(
231
+ 'globs: packages/openbridge-webcomponents/src/components/**'
232
+ );
233
+ expect(out).toContain('alwaysApply: false');
234
+ });
235
+
236
+ it('omits description, which would select "Apply Intelligently" instead', () => {
237
+ expect(renderCursorRule(DOC)).not.toContain('description:');
238
+ });
239
+
240
+ it('joins multiple globs with commas', () => {
241
+ expect(renderCursorRule({...DOC, globs: ['a/**', 'b/**']})).toContain(
242
+ 'globs: a/**,b/**'
243
+ );
244
+ });
245
+
246
+ it('drops negative globs, which Cursor does not document', () => {
247
+ const out = renderCursorRule({...DOC, globs: ['a/**', '!b/**', 'c/**']});
248
+ expect(out).toContain('globs: a/**,c/**');
249
+ expect(out).not.toContain('!b/**');
250
+ });
251
+
252
+ it('keeps the body verbatim and carries the do-not-edit banner', () => {
253
+ const out = renderCursorRule(DOC);
254
+ expect(out).toContain('# Accessibility\n\nBody.\n');
255
+ expect(out).toContain('GENERATED FILE — DO NOT EDIT');
256
+ });
257
+ });
258
+
259
+ describe('rewriteSiblingLinks', () => {
260
+ const siblings = new Set(['jsdoc', 'a11y', 'css-postcss']);
261
+
262
+ it('repoints bare sibling links at the canonical doc', () => {
263
+ expect(rewriteSiblingLinks('see [`jsdoc.md`](jsdoc.md).', siblings)).toBe(
264
+ 'see [`jsdoc.md`](../../docs/agents/jsdoc.md).'
265
+ );
266
+ });
267
+
268
+ it('preserves anchors', () => {
269
+ expect(rewriteSiblingLinks('[x](a11y.md#section-2)', siblings)).toBe(
270
+ '[x](../../docs/agents/a11y.md#section-2)'
271
+ );
272
+ });
273
+
274
+ it('leaves ../../ links alone — they already resolve from every adapter dir', () => {
275
+ const link = '[g](../../IMPLEMENTATION_GUIDELINES.md#-postcss)';
276
+ expect(rewriteSiblingLinks(link, siblings)).toBe(link);
277
+ });
278
+
279
+ it('leaves external and absolute links alone', () => {
280
+ const body = '[a](https://example.com/x.md) [b](/abs/y.md)';
281
+ expect(rewriteSiblingLinks(body, siblings)).toBe(body);
282
+ });
283
+
284
+ it('leaves bare links that are not canonical docs alone', () => {
285
+ const link = '[r](README.md)';
286
+ expect(rewriteSiblingLinks(link, siblings)).toBe(link);
287
+ });
288
+
289
+ it('is applied by both adapter renderers', () => {
290
+ const doc = {...DOC, body: 'see [`jsdoc.md`](jsdoc.md).\n'};
291
+ expect(renderInstructionsFile(doc, siblings)).toContain(
292
+ '](../../docs/agents/jsdoc.md)'
293
+ );
294
+ expect(renderCursorRule(doc, siblings)).toContain(
295
+ '](../../docs/agents/jsdoc.md)'
296
+ );
297
+ });
298
+ });
@@ -0,0 +1,156 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import {globSync} from 'glob';
4
+ import * as prettier from 'prettier';
5
+ import {parseAgentDoc, type AgentDoc} from './agent-docs/frontmatter.js';
6
+ import {
7
+ ROUTING_MARKER,
8
+ renderClaudeMd,
9
+ renderCopilotInstructions,
10
+ renderCursorRule,
11
+ renderInstructionsFile,
12
+ renderRoutingTable,
13
+ replaceMarkedBlock,
14
+ } from './agent-docs/emitters.js';
15
+
16
+ const ROOT = path.resolve(import.meta.dirname, '../../..');
17
+ const AGENTS_DIR = path.join(ROOT, 'docs/agents');
18
+ const INSTRUCTIONS_DIR = path.join(ROOT, '.github/instructions');
19
+ const CURSOR_RULES_DIR = path.join(ROOT, '.cursor/rules');
20
+ const CHECK = process.argv.includes('--check');
21
+
22
+ const problems: string[] = [];
23
+ const writes = new Map<string, string>();
24
+
25
+ function plan(absPath: string, content: string): void {
26
+ writes.set(absPath, content);
27
+ }
28
+
29
+ function loadDocs(): AgentDoc[] {
30
+ if (!fs.existsSync(AGENTS_DIR)) {
31
+ throw new Error(
32
+ `docs/agents does not exist at ${AGENTS_DIR} — nothing to sync`
33
+ );
34
+ }
35
+ return fs
36
+ .readdirSync(AGENTS_DIR)
37
+ .filter((f) => f.endsWith('.md'))
38
+ .sort()
39
+ .map((f) => {
40
+ const rel = path.posix.join('docs/agents', f);
41
+ return parseAgentDoc(
42
+ fs.readFileSync(path.join(AGENTS_DIR, f), 'utf8'),
43
+ rel
44
+ );
45
+ });
46
+ }
47
+
48
+ /** Check 1 — every positive glob must match at least one real path. */
49
+ function checkGlobs(docs: AgentDoc[]): void {
50
+ for (const doc of docs) {
51
+ for (const g of doc.globs) {
52
+ if (g.startsWith('!')) continue;
53
+ if (globSync(g, {cwd: ROOT, dot: true}).length === 0) {
54
+ problems.push(`${doc.sourcePath}: glob matches nothing → ${g}`);
55
+ }
56
+ }
57
+ }
58
+ }
59
+
60
+ /** Check 4 — no generated adapter without a canonical source. */
61
+ function checkOrphans(docs: AgentDoc[]): void {
62
+ const adapters: [string, string, string][] = [
63
+ [INSTRUCTIONS_DIR, '.instructions.md', '.github/instructions'],
64
+ [CURSOR_RULES_DIR, '.mdc', '.cursor/rules'],
65
+ ];
66
+ for (const [dir, ext, rel] of adapters) {
67
+ if (!fs.existsSync(dir)) continue;
68
+ const expected = new Set(docs.map((d) => `${d.name}${ext}`));
69
+ for (const f of fs.readdirSync(dir)) {
70
+ if (f.endsWith(ext) && !expected.has(f)) {
71
+ problems.push(
72
+ `${rel}/${f}: no matching docs/agents/ source (delete it or add the source)`
73
+ );
74
+ }
75
+ }
76
+ }
77
+ }
78
+
79
+ const docs = loadDocs();
80
+ checkGlobs(docs);
81
+ checkOrphans(docs);
82
+
83
+ const routable = docs;
84
+ const docNames = new Set(docs.map((d) => d.name));
85
+
86
+ for (const doc of routable) {
87
+ plan(
88
+ path.join(INSTRUCTIONS_DIR, `${doc.name}.instructions.md`),
89
+ renderInstructionsFile(doc, docNames)
90
+ );
91
+ plan(
92
+ path.join(CURSOR_RULES_DIR, `${doc.name}.mdc`),
93
+ renderCursorRule(doc, docNames)
94
+ );
95
+ }
96
+ plan(
97
+ path.join(ROOT, '.github/copilot-instructions.md'),
98
+ renderCopilotInstructions(routable)
99
+ );
100
+
101
+ const agentsPath = path.join(ROOT, 'AGENTS.md');
102
+ plan(
103
+ agentsPath,
104
+ replaceMarkedBlock(
105
+ fs.readFileSync(agentsPath, 'utf8'),
106
+ ROUTING_MARKER,
107
+ renderRoutingTable(routable)
108
+ )
109
+ );
110
+
111
+ plan(path.join(ROOT, 'CLAUDE.md'), renderClaudeMd());
112
+
113
+ /**
114
+ * Runs generated Markdown through Prettier before writing or comparing.
115
+ *
116
+ * Without this the generator's compact tables would differ from what
117
+ * `npm run format:check` (and the lint-staged `.md` hook) demand, so every
118
+ * sync would leave the repo format-dirty and CI would fail. Formatting here
119
+ * makes generator output and Prettier output the same artefact by
120
+ * construction, and keeps `--check` comparing like with like.
121
+ */
122
+ async function format(content: string, absPath: string): Promise<string> {
123
+ const config = await prettier.resolveConfig(absPath);
124
+ return prettier.format(content, {
125
+ ...config,
126
+ filepath: absPath,
127
+ parser: 'markdown',
128
+ });
129
+ }
130
+
131
+ for (const [absPath, raw] of writes) {
132
+ const rel = path.relative(ROOT, absPath);
133
+ const content = await format(raw, absPath);
134
+ const current = fs.existsSync(absPath)
135
+ ? fs.readFileSync(absPath, 'utf8')
136
+ : null;
137
+ if (current === content) continue;
138
+ if (CHECK) {
139
+ problems.push(`${rel}: out of date — run \`npm run agents:sync\``);
140
+ } else {
141
+ fs.mkdirSync(path.dirname(absPath), {recursive: true});
142
+ fs.writeFileSync(absPath, content);
143
+ console.log(`${current === null ? 'created' : 'updated'} ${rel}`);
144
+ }
145
+ }
146
+
147
+ if (problems.length > 0) {
148
+ console.error(`\nagent-docs: ${problems.length} problem(s)`);
149
+ for (const p of problems) console.error(` ${CHECK ? '✗' : 'warning:'} ${p}`);
150
+ // Write mode reports but never fails. `agents:sync` runs from the root
151
+ // `prepare` script, so a stale glob or a missing source must not be able to
152
+ // break `npm ci` for everyone. `lint:agents` is the gate; it runs in CI after
153
+ // the build steps, when any generated paths actually exist.
154
+ if (CHECK) process.exit(1);
155
+ }
156
+ console.log(CHECK ? 'agent-docs: up to date' : 'agent-docs: sync complete');
@@ -0,0 +1,58 @@
1
+ import {describe, expect, it} from 'vitest';
2
+ import './alert-button.js';
3
+ import {ObcAlertButton} from './alert-button.js';
4
+ import {render} from 'vitest-browser-lit';
5
+ import {html} from 'lit';
6
+
7
+ describe('obc-alert-button blinking lifecycle', () => {
8
+ async function setup() {
9
+ const screen = render(html`<obc-alert-button></obc-alert-button>`);
10
+ const el = screen.baseElement.querySelector(
11
+ 'obc-alert-button'
12
+ ) as ObcAlertButton;
13
+ await el.updateComplete;
14
+ return el;
15
+ }
16
+
17
+ it('installs the blink animations on first render', async () => {
18
+ const el = await setup();
19
+
20
+ expect(el.getAnimations().length).toBeGreaterThan(0);
21
+ });
22
+
23
+ it('cancels the blink animations on disconnect', async () => {
24
+ const el = await setup();
25
+
26
+ el.parentElement!.removeChild(el);
27
+
28
+ expect(el.getAnimations()).toHaveLength(0);
29
+ });
30
+
31
+ it('resumes blinking after disconnect and reconnect', async () => {
32
+ const el = await setup();
33
+ const parent = el.parentElement!;
34
+ const initial = el.getAnimations().length;
35
+
36
+ parent.removeChild(el);
37
+ expect(el.getAnimations()).toHaveLength(0);
38
+
39
+ // Reconnect without touching any property. firstUpdated() will not run
40
+ // again, so this only passes if blinking is reinstalled on update.
41
+ parent.appendChild(el);
42
+ await el.updateComplete;
43
+
44
+ expect(el.getAnimations()).toHaveLength(initial);
45
+ });
46
+
47
+ it('does not accumulate animations across repeated updates', async () => {
48
+ const el = await setup();
49
+ const initial = el.getAnimations().length;
50
+
51
+ el.nAlerts = 3;
52
+ await el.updateComplete;
53
+ el.nAlerts = 5;
54
+ await el.updateComplete;
55
+
56
+ expect(el.getAnimations()).toHaveLength(initial);
57
+ });
58
+ });
@@ -20,6 +20,7 @@ import {
20
20
  } from '../../alert-severity.js';
21
21
  import {classMap} from 'lit/directives/class-map.js';
22
22
  import {customElement} from '../../decorator.js';
23
+ import {blinkingAll} from '../../palettes/blinking.js';
23
24
 
24
25
  /**
25
26
  * `ObcAlertButtonType` – Enum for alert button visual and behavioral variants.
@@ -202,10 +203,14 @@ export class ObcAlertButton extends LitElement {
202
203
  override connectedCallback() {
203
204
  super.connectedCallback();
204
205
  window.addEventListener('resize', this.resizeListener);
206
+ if (this.hasUpdated) {
207
+ this.installBlinking();
208
+ }
205
209
  }
206
210
 
207
211
  override disconnectedCallback() {
208
212
  window.removeEventListener('resize', this.resizeListener);
213
+ this._blinkAnimationCancel?.();
209
214
  super.disconnectedCallback();
210
215
  }
211
216
 
@@ -280,6 +285,17 @@ export class ObcAlertButton extends LitElement {
280
285
  );
281
286
  }
282
287
 
288
+ private _blinkAnimationCancel?: () => void;
289
+
290
+ private installBlinking() {
291
+ this._blinkAnimationCancel?.();
292
+ this._blinkAnimationCancel = blinkingAll(this);
293
+ }
294
+
295
+ override updated() {
296
+ this.installBlinking();
297
+ }
298
+
283
299
  override render() {
284
300
  const hasAlerts = this.nAlerts > 0;
285
301
  const showCounter =
@@ -78,6 +78,7 @@
78
78
 
79
79
  &.level-critical {
80
80
  --bg-color: var(--critical-enabled-background-color);
81
+ --blink-on: var(--critical-blink-on);
81
82
  color: var(--on-critical-active-color);
82
83
  }
83
84
 
@@ -0,0 +1,122 @@
1
+ import {describe, expect, it} from 'vitest';
2
+ import './alert-frame.js';
3
+ import {ObcAlertFrame, ObcAlertFrameMode} from './alert-frame.js';
4
+ import {render} from 'vitest-browser-lit';
5
+ import {html} from 'lit';
6
+
7
+ describe('obc-alert-frame blinking lifecycle', () => {
8
+ async function setup(mode: ObcAlertFrameMode) {
9
+ const screen = render(
10
+ html`<obc-alert-frame .mode=${mode}></obc-alert-frame>`
11
+ );
12
+ const el = screen.baseElement.querySelector(
13
+ 'obc-alert-frame'
14
+ ) as ObcAlertFrame;
15
+ await el.updateComplete;
16
+ return el;
17
+ }
18
+
19
+ it('blinks while mode is unacked-active', async () => {
20
+ const el = await setup(ObcAlertFrameMode.unackedActive);
21
+
22
+ expect(el.getAnimations().length).toBeGreaterThan(0);
23
+ });
24
+
25
+ it('does not blink in other modes', async () => {
26
+ const el = await setup(ObcAlertFrameMode.ackedActive);
27
+
28
+ expect(el.getAnimations()).toHaveLength(0);
29
+ });
30
+
31
+ describe('mode transitions', () => {
32
+ it('starts blinking when mode becomes unacked-active', async () => {
33
+ const el = await setup(ObcAlertFrameMode.ackedActive);
34
+ expect(el.getAnimations()).toHaveLength(0);
35
+
36
+ el.mode = ObcAlertFrameMode.unackedActive;
37
+ await el.updateComplete;
38
+
39
+ expect(el.getAnimations().length).toBeGreaterThan(0);
40
+ });
41
+
42
+ it('stops blinking when mode leaves unacked-active', async () => {
43
+ const el = await setup(ObcAlertFrameMode.unackedActive);
44
+ expect(el.getAnimations().length).toBeGreaterThan(0);
45
+
46
+ el.mode = ObcAlertFrameMode.ackedActive;
47
+ await el.updateComplete;
48
+
49
+ expect(el.getAnimations()).toHaveLength(0);
50
+ });
51
+
52
+ it('stops blinking when mode becomes unacked-rectified', async () => {
53
+ const el = await setup(ObcAlertFrameMode.unackedActive);
54
+
55
+ el.mode = ObcAlertFrameMode.unackedRectified;
56
+ await el.updateComplete;
57
+
58
+ expect(el.getAnimations()).toHaveLength(0);
59
+ });
60
+
61
+ it('does not accumulate animations across repeated updates', async () => {
62
+ const el = await setup(ObcAlertFrameMode.unackedActive);
63
+ const initial = el.getAnimations().length;
64
+
65
+ el.fullWidth = true;
66
+ await el.updateComplete;
67
+ el.showIcon = true;
68
+ await el.updateComplete;
69
+
70
+ expect(el.getAnimations()).toHaveLength(initial);
71
+ });
72
+ });
73
+
74
+ describe('reconnection', () => {
75
+ it('resumes blinking after disconnect and reconnect', async () => {
76
+ const el = await setup(ObcAlertFrameMode.unackedActive);
77
+ const parent = el.parentElement!;
78
+ expect(el.getAnimations().length).toBeGreaterThan(0);
79
+
80
+ parent.removeChild(el);
81
+ expect(el.getAnimations()).toHaveLength(0);
82
+
83
+ // Reconnect without touching any property. firstUpdated() will not run
84
+ // again, so this only passes if blinking is reinstalled on update.
85
+ parent.appendChild(el);
86
+ await el.updateComplete;
87
+
88
+ expect(el.getAnimations().length).toBeGreaterThan(0);
89
+ });
90
+
91
+ it('does not resume blinking on reconnect when mode is not unacked-active', async () => {
92
+ const el = await setup(ObcAlertFrameMode.ackedActive);
93
+ const parent = el.parentElement!;
94
+
95
+ parent.removeChild(el);
96
+ parent.appendChild(el);
97
+ await el.updateComplete;
98
+
99
+ expect(el.getAnimations()).toHaveLength(0);
100
+ });
101
+
102
+ it('does not blink after disconnection when mode became unacked-active while detached', async () => {
103
+ const el = await setup(ObcAlertFrameMode.ackedActive);
104
+ const parent = el.parentElement!;
105
+
106
+ parent.removeChild(el);
107
+ await el.updateComplete;
108
+
109
+ expect(el.getAnimations().length).toBe(0);
110
+
111
+ el.mode = ObcAlertFrameMode.unackedActive;
112
+ await el.updateComplete;
113
+
114
+ expect(el.getAnimations().length).toBe(0);
115
+
116
+ parent.appendChild(el);
117
+ await el.updateComplete;
118
+
119
+ expect(el.getAnimations().length).toBeGreaterThan(0);
120
+ });
121
+ });
122
+ });
@@ -261,3 +261,15 @@ export const RectifiedUnactive: Story = {
261
261
  mode: ObcAlertFrameMode.unackedRectified,
262
262
  },
263
263
  };
264
+
265
+ export const CriticalUnacked: Story = {
266
+ args: {
267
+ type: ObcAlertFrameType.SmallSideFlip,
268
+ thickness: ObcAlertFrameThickness.Small,
269
+ status: ObcAlertFrameStatus.LevelCritical,
270
+ mode: ObcAlertFrameMode.unackedActive,
271
+ demoWidth: 200,
272
+ showIcon: true,
273
+ showAlertCategoryIcon: true,
274
+ },
275
+ };