@sweberdev/witness 0.0.0-stage → 0.1.1

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 (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +21 -2
  3. package/dist/ai-sdk.cjs +921 -0
  4. package/dist/ai-sdk.cjs.map +1 -0
  5. package/dist/ai-sdk.d.cts +95 -0
  6. package/dist/ai-sdk.d.ts +95 -0
  7. package/dist/ai-sdk.js +150 -0
  8. package/dist/ai-sdk.js.map +1 -0
  9. package/dist/bin.cjs +944 -0
  10. package/dist/bin.cjs.map +1 -0
  11. package/dist/bin.d.cts +1 -0
  12. package/dist/bin.d.ts +1 -0
  13. package/dist/bin.js +12 -0
  14. package/dist/bin.js.map +1 -0
  15. package/dist/chunk-APXU3MTX.js +958 -0
  16. package/dist/chunk-APXU3MTX.js.map +1 -0
  17. package/dist/chunk-L65R2SM5.js +228 -0
  18. package/dist/chunk-L65R2SM5.js.map +1 -0
  19. package/dist/chunk-O6TADCND.js +317 -0
  20. package/dist/chunk-O6TADCND.js.map +1 -0
  21. package/dist/chunk-UBRK4CTM.js +156 -0
  22. package/dist/chunk-UBRK4CTM.js.map +1 -0
  23. package/dist/chunk-XJOQ5IUT.js +110 -0
  24. package/dist/chunk-XJOQ5IUT.js.map +1 -0
  25. package/dist/cli.cjs +964 -0
  26. package/dist/cli.cjs.map +1 -0
  27. package/dist/cli.d.cts +7 -0
  28. package/dist/cli.d.ts +7 -0
  29. package/dist/cli.js +9 -0
  30. package/dist/cli.js.map +1 -0
  31. package/dist/disclosure-5a3UIVQZ.d.cts +70 -0
  32. package/dist/disclosure-C7Y_uY73.d.ts +70 -0
  33. package/dist/elements-auto.cjs +649 -0
  34. package/dist/elements-auto.cjs.map +1 -0
  35. package/dist/elements-auto.d.cts +3 -0
  36. package/dist/elements-auto.d.ts +3 -0
  37. package/dist/elements-auto.js +16 -0
  38. package/dist/elements-auto.js.map +1 -0
  39. package/dist/elements.cjs +646 -0
  40. package/dist/elements.cjs.map +1 -0
  41. package/dist/elements.d.cts +47 -0
  42. package/dist/elements.d.ts +47 -0
  43. package/dist/elements.js +13 -0
  44. package/dist/elements.js.map +1 -0
  45. package/dist/image-B7YvdITx.d.ts +49 -0
  46. package/dist/image-BNrEO-hj.d.cts +49 -0
  47. package/dist/index.cjs +1344 -0
  48. package/dist/index.cjs.map +1 -0
  49. package/dist/index.d.cts +124 -0
  50. package/dist/index.d.ts +124 -0
  51. package/dist/index.js +77 -0
  52. package/dist/index.js.map +1 -0
  53. package/dist/types-WWHzrNlc.d.cts +90 -0
  54. package/dist/types-WWHzrNlc.d.ts +90 -0
  55. package/package.json +123 -3
  56. package/src/ai-sdk.ts +252 -0
  57. package/src/bin.ts +6 -0
  58. package/src/cli.ts +160 -0
  59. package/src/disclosure.ts +172 -0
  60. package/src/elements-auto.ts +5 -0
  61. package/src/elements.ts +363 -0
  62. package/src/i18n.ts +259 -0
  63. package/src/image.ts +552 -0
  64. package/src/index.ts +66 -0
  65. package/src/inflate.ts +189 -0
  66. package/src/marking.ts +237 -0
  67. package/src/types.ts +115 -0
  68. package/src/watermark.ts +165 -0
  69. package/styles.css +103 -0
package/src/cli.ts ADDED
@@ -0,0 +1,160 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { basename, extname, join } from "node:path";
3
+ import { parseArgs } from "node:util";
4
+ import { detectImageFormat, markImage, readImageMarking } from "./image.js";
5
+ import { iptcSourceType } from "./marking.js";
6
+ import type { ContentKind, MarkingInput } from "./types.js";
7
+ import { readTextWatermark } from "./watermark.js";
8
+
9
+ export interface CliIo {
10
+ out: (line: string) => void;
11
+ err: (line: string) => void;
12
+ }
13
+
14
+ const HELP = `witness: AI Act Article 50 marking for images and text
15
+
16
+ Usage
17
+ witness mark <files...> (--out <dir> | --in-place) [options]
18
+ witness inspect <files...> [--json] [--require]
19
+
20
+ mark options
21
+ --generator <name> AI system, e.g. "gpt-image-2"
22
+ --generator-version <v> its version
23
+ --kind <kind> generated (default), edited or deepfake
24
+ --provider <name> your company or product
25
+ --url <url> page that explains how you use AI
26
+ --description <text> free text written to dc:description
27
+ --overwrite-c2pa also rewrite files that carry a C2PA manifest
28
+
29
+ inspect options
30
+ --json one JSON object per file
31
+ --require exit 1 if a file carries no AI marking
32
+
33
+ Images: PNG, JPEG, WebP (XMP with IPTC Digital Source Type).
34
+ Text files are checked for the Witness text watermark.`;
35
+
36
+ const KINDS: ContentKind[] = ["generated", "edited", "deepfake"];
37
+
38
+ export async function runCli(argv: string[], io: CliIo = defaultIo): Promise<number> {
39
+ const [command, ...rest] = argv;
40
+ if (!command || command === "--help" || command === "-h" || command === "help") {
41
+ io.out(HELP);
42
+ return command ? 0 : 1;
43
+ }
44
+ try {
45
+ if (command === "mark") return await mark(rest, io);
46
+ if (command === "inspect") return await inspect(rest, io);
47
+ io.err(`Unknown command "${command}". Run "witness --help".`);
48
+ return 1;
49
+ } catch (error) {
50
+ io.err(error instanceof Error ? error.message : String(error));
51
+ return 1;
52
+ }
53
+ }
54
+
55
+ async function mark(argv: string[], io: CliIo): Promise<number> {
56
+ const { values, positionals } = parseArgs({
57
+ args: argv,
58
+ allowPositionals: true,
59
+ options: {
60
+ out: { type: "string" },
61
+ "in-place": { type: "boolean" },
62
+ generator: { type: "string" },
63
+ "generator-version": { type: "string" },
64
+ kind: { type: "string" },
65
+ provider: { type: "string" },
66
+ url: { type: "string" },
67
+ description: { type: "string" },
68
+ "overwrite-c2pa": { type: "boolean" },
69
+ },
70
+ });
71
+ if (positionals.length === 0) throw new Error("witness mark: no files given.");
72
+ if (!values.out && !values["in-place"]) {
73
+ throw new Error("witness mark: pass --out <dir> or --in-place.");
74
+ }
75
+ const kind = (values.kind ?? "generated") as ContentKind;
76
+ if (!KINDS.includes(kind))
77
+ throw new Error(`witness mark: --kind must be one of ${KINDS.join(", ")}.`);
78
+ const input: MarkingInput = { kind };
79
+ if (values.generator) input.generator = values.generator;
80
+ if (values["generator-version"]) input.generatorVersion = values["generator-version"];
81
+ if (values.provider) input.provider = values.provider;
82
+ if (values.url) input.disclosureUrl = values.url;
83
+ if (values.description) input.description = values.description;
84
+ if (values.out) await mkdir(values.out, { recursive: true });
85
+
86
+ let failed = 0;
87
+ for (const file of positionals) {
88
+ const bytes = new Uint8Array(await readFile(file));
89
+ const result = markImage(bytes, input, {
90
+ c2pa: values["overwrite-c2pa"] ? "overwrite" : "skip",
91
+ });
92
+ if (result.status === "unsupported") {
93
+ io.err(`${file}: not a PNG, JPEG or WebP file, skipped`);
94
+ failed++;
95
+ continue;
96
+ }
97
+ if (result.status === "skipped-c2pa") {
98
+ io.err(`${file}: carries a C2PA manifest, left unchanged (use --overwrite-c2pa to force)`);
99
+ continue;
100
+ }
101
+ const target = values.out ? join(values.out, basename(file)) : file;
102
+ await writeFile(target, result.bytes);
103
+ io.out(`${file}: marked (${result.format}) -> ${target}`);
104
+ }
105
+ return failed > 0 ? 1 : 0;
106
+ }
107
+
108
+ async function inspect(argv: string[], io: CliIo): Promise<number> {
109
+ const { values, positionals } = parseArgs({
110
+ args: argv,
111
+ allowPositionals: true,
112
+ options: { json: { type: "boolean" }, require: { type: "boolean" } },
113
+ });
114
+ if (positionals.length === 0) throw new Error("witness inspect: no files given.");
115
+ let unmarked = 0;
116
+ for (const file of positionals) {
117
+ const bytes = new Uint8Array(await readFile(file));
118
+ const format = detectImageFormat(bytes);
119
+ if (format) {
120
+ const info = readImageMarking(bytes);
121
+ const marked = info.aiGenerated || info.c2pa;
122
+ if (!marked) unmarked++;
123
+ if (values.json) {
124
+ const { xmp: _xmp, ...rest } = info;
125
+ io.out(JSON.stringify({ file, ...rest }));
126
+ } else {
127
+ const parts = [
128
+ `${file}: ${format}`,
129
+ info.sourceType
130
+ ? `source type ${iptcSourceType(info.sourceType)}`
131
+ : "no IPTC source type",
132
+ info.generator ? `generator ${info.generator}` : null,
133
+ info.c2pa ? "C2PA manifest present (not verified)" : null,
134
+ marked ? "AI-marked" : "NOT AI-marked",
135
+ ];
136
+ io.out(parts.filter(Boolean).join(", "));
137
+ }
138
+ continue;
139
+ }
140
+ const ext = extname(file).toLowerCase();
141
+ const text = new TextDecoder().decode(bytes);
142
+ const watermark = readTextWatermark(text);
143
+ if (!watermark) unmarked++;
144
+ if (values.json) {
145
+ io.out(JSON.stringify({ file, format: ext.slice(1) || "text", watermark }));
146
+ } else {
147
+ io.out(
148
+ watermark
149
+ ? `${file}: text watermark found${watermark.generator ? `, generator ${watermark.generator}` : ""}${watermark.createdAt ? `, created ${watermark.createdAt}` : ""}`
150
+ : `${file}: no Witness text watermark`,
151
+ );
152
+ }
153
+ }
154
+ return values.require && unmarked > 0 ? 1 : 0;
155
+ }
156
+
157
+ const defaultIo: CliIo = {
158
+ out: (line) => process.stdout.write(`${line}\n`),
159
+ err: (line) => process.stderr.write(`${line}\n`),
160
+ };
@@ -0,0 +1,172 @@
1
+ import type { DisclosureKind } from "./types.js";
2
+
3
+ /** Minimal key-value storage, e.g. `localStorage`. */
4
+ export interface DisclosureStorage {
5
+ getItem(key: string): string | null;
6
+ setItem(key: string, value: string): void;
7
+ removeItem(key: string): void;
8
+ }
9
+
10
+ export interface DisclosureEvent {
11
+ type: "shown" | "acknowledged";
12
+ /** Your id for this disclosure, e.g. "support-chat". */
13
+ id: string;
14
+ kind: DisclosureKind;
15
+ /** Bump the version when the wording changes, so people see the new text once. */
16
+ version: string;
17
+ locale: string;
18
+ /** ISO 8601 timestamp. */
19
+ at: string;
20
+ }
21
+
22
+ export interface DisclosureOptions {
23
+ /** Your id for this disclosure. Default: the kind. */
24
+ id?: string;
25
+ /** Default `chatbot`. */
26
+ kind?: DisclosureKind;
27
+ /** Default `1`. */
28
+ version?: string;
29
+ /** Locale shown, recorded in events. Default `en`. */
30
+ locale?: string;
31
+ /**
32
+ * Where the acknowledgement is remembered: `local` (default), `session`, `memory` or your
33
+ * own storage. Falls back to memory when browser storage is unavailable.
34
+ */
35
+ storage?: "local" | "session" | "memory" | DisclosureStorage;
36
+ /**
37
+ * Called when the notice is shown or acknowledged. Send these to your log (for example
38
+ * Logarithm) to keep evidence of what people were told and when.
39
+ */
40
+ onEvent?: (event: DisclosureEvent) => void;
41
+ /** Clock for tests. */
42
+ now?: () => Date;
43
+ }
44
+
45
+ export interface Disclosure {
46
+ readonly id: string;
47
+ readonly kind: DisclosureKind;
48
+ readonly version: string;
49
+ /** True until this version was acknowledged. */
50
+ needsAcknowledgement(): boolean;
51
+ /** When this version was acknowledged, or `null`. */
52
+ acknowledgedAt(): string | null;
53
+ /** Records that the notice was rendered. Emits a `shown` event once per instance. */
54
+ markShown(): void;
55
+ /** Records the acknowledgement and emits an `acknowledged` event. */
56
+ acknowledge(): void;
57
+ /** Forgets the acknowledgement. */
58
+ reset(): void;
59
+ /** Called whenever the acknowledgement changes. Returns an unsubscribe function. */
60
+ subscribe(listener: () => void): () => void;
61
+ }
62
+
63
+ const PREFIX = "witness:ack:";
64
+
65
+ /**
66
+ * Tracks whether a person has seen and acknowledged an AI disclosure.
67
+ *
68
+ * Article 50(5) asks for the information "at the latest at the time of the first interaction
69
+ * or exposure". Keep a compact label visible after the acknowledgement: the acknowledgement
70
+ * only collapses the notice, it does not make it disappear.
71
+ */
72
+ export function createDisclosure(options: DisclosureOptions = {}): Disclosure {
73
+ const kind = options.kind ?? "chatbot";
74
+ const id = options.id ?? kind;
75
+ const version = options.version ?? "1";
76
+ const locale = options.locale ?? "en";
77
+ const now = options.now ?? (() => new Date());
78
+ const storage = resolveStorage(options.storage);
79
+ const key = `${PREFIX}${id}`;
80
+ const listeners = new Set<() => void>();
81
+ let shown = false;
82
+
83
+ const read = (): { version: string; at: string } | null => {
84
+ try {
85
+ const raw = storage.getItem(key);
86
+ if (!raw) return null;
87
+ const parsed = JSON.parse(raw) as { version?: unknown; at?: unknown };
88
+ if (typeof parsed.version !== "string" || typeof parsed.at !== "string") return null;
89
+ return { version: parsed.version, at: parsed.at };
90
+ } catch {
91
+ return null;
92
+ }
93
+ };
94
+
95
+ const emit = (type: DisclosureEvent["type"], at: string) => {
96
+ options.onEvent?.({ type, id, kind, version, locale, at });
97
+ };
98
+
99
+ const notify = () => {
100
+ for (const listener of listeners) listener();
101
+ };
102
+
103
+ return {
104
+ id,
105
+ kind,
106
+ version,
107
+ needsAcknowledgement: () => read()?.version !== version,
108
+ acknowledgedAt: () => {
109
+ const entry = read();
110
+ return entry?.version === version ? entry.at : null;
111
+ },
112
+ markShown() {
113
+ if (shown) return;
114
+ shown = true;
115
+ emit("shown", now().toISOString());
116
+ },
117
+ acknowledge() {
118
+ const at = now().toISOString();
119
+ try {
120
+ storage.setItem(key, JSON.stringify({ version, at }));
121
+ } catch {
122
+ // Storage full or blocked: the acknowledgement lasts for this page only.
123
+ }
124
+ emit("acknowledged", at);
125
+ notify();
126
+ },
127
+ reset() {
128
+ try {
129
+ storage.removeItem(key);
130
+ } catch {
131
+ // ignore
132
+ }
133
+ notify();
134
+ },
135
+ subscribe(listener) {
136
+ listeners.add(listener);
137
+ return () => listeners.delete(listener);
138
+ },
139
+ };
140
+ }
141
+
142
+ function resolveStorage(option: DisclosureOptions["storage"]): DisclosureStorage {
143
+ if (option && typeof option === "object") return option;
144
+ if (option !== "memory") {
145
+ try {
146
+ const candidate = option === "session" ? globalThis.sessionStorage : globalThis.localStorage;
147
+ if (candidate) {
148
+ const probe = `${PREFIX}probe`;
149
+ candidate.setItem(probe, "1");
150
+ candidate.removeItem(probe);
151
+ return candidate;
152
+ }
153
+ } catch {
154
+ // fall through to memory
155
+ }
156
+ }
157
+ return memoryStorage();
158
+ }
159
+
160
+ /** In-memory storage, e.g. for server rendering or tests. */
161
+ export function memoryStorage(): DisclosureStorage {
162
+ const map = new Map<string, string>();
163
+ return {
164
+ getItem: (key) => map.get(key) ?? null,
165
+ setItem: (key, value) => {
166
+ map.set(key, value);
167
+ },
168
+ removeItem: (key) => {
169
+ map.delete(key);
170
+ },
171
+ };
172
+ }
@@ -0,0 +1,5 @@
1
+ import { defineWitnessElements } from "./elements.js";
2
+
3
+ defineWitnessElements();
4
+
5
+ export { defineWitnessElements, WitnessLabelElement, WitnessNoticeElement } from "./elements.js";
@@ -0,0 +1,363 @@
1
+ /**
2
+ * Framework-free custom elements:
3
+ *
4
+ * - `<witness-notice>`: the chatbot notice (Art. 50(1)). Expanded until acknowledged, then a
5
+ * compact label that can be reopened.
6
+ * - `<witness-label>`: a badge for AI-generated or AI-edited content with a details popover.
7
+ *
8
+ * Import `@sweberdev/witness/elements` to register both, or call `defineWitnessElements()`.
9
+ */
10
+
11
+ import { createDisclosure, type Disclosure, type DisclosureEvent } from "./disclosure.js";
12
+ import { format, getMessages } from "./i18n.js";
13
+ import { DISCLOSURE_KINDS, type DisclosureKind } from "./types.js";
14
+
15
+ const BaseElement: typeof HTMLElement =
16
+ typeof HTMLElement === "undefined" ? (class {} as unknown as typeof HTMLElement) : HTMLElement;
17
+
18
+ const SHARED_CSS = `
19
+ :host { --witness-accent: #003399; --witness-accent-text: #ffffff; --witness-bg: #ffffff;
20
+ --witness-fg: #1a1a1a; --witness-muted: #555b66; --witness-border: #d7dbe3; --witness-radius: 8px;
21
+ font: inherit; color: var(--witness-fg); }
22
+ @media (prefers-color-scheme: dark) {
23
+ :host { --witness-accent: #8fb0ff; --witness-accent-text: #0b1530; --witness-bg: #15181f;
24
+ --witness-fg: #eef1f6; --witness-muted: #a9b0bd; --witness-border: #2c323d; }
25
+ }
26
+ .icon { display: inline-flex; align-items: center; justify-content: center; min-width: 1.75em;
27
+ height: 1.5em; padding: 0 .3em; border-radius: 4px; background: var(--witness-accent);
28
+ color: var(--witness-accent-text); font-weight: 700; font-size: .75em; letter-spacing: .02em;
29
+ line-height: 1; flex: none; }
30
+ button { font: inherit; cursor: pointer; }
31
+ button:focus-visible, a:focus-visible { outline: 2px solid var(--witness-accent); outline-offset: 2px; }
32
+ a { color: inherit; }
33
+ .muted { color: var(--witness-muted); }
34
+ `;
35
+
36
+ const NOTICE_CSS = `${SHARED_CSS}
37
+ :host { display: block; }
38
+ .notice { display: flex; gap: .75em; align-items: flex-start; padding: .875em 1em;
39
+ border: 1px solid var(--witness-border); border-left: 4px solid var(--witness-accent);
40
+ border-radius: var(--witness-radius); background: var(--witness-bg); }
41
+ .notice h2 { font-size: 1em; margin: 0 0 .25em; }
42
+ .notice p { margin: 0 0 .75em; line-height: 1.45; }
43
+ .actions { display: flex; gap: .75em; align-items: center; flex-wrap: wrap; }
44
+ .ack { padding: .4em .9em; border-radius: 6px; border: 0; background: var(--witness-accent);
45
+ color: var(--witness-accent-text); font-weight: 600; }
46
+ .compact { display: inline-flex; gap: .5em; align-items: center; padding: .25em .5em;
47
+ border: 1px solid var(--witness-border); border-radius: 999px; background: var(--witness-bg);
48
+ color: var(--witness-fg); font-size: .875em; }
49
+ `;
50
+
51
+ const LABEL_CSS = `${SHARED_CSS}
52
+ :host { display: inline-block; position: relative; font-size: .875em; }
53
+ :host([variant="overlay"]) { position: absolute; inset-block-start: .5em; inset-inline-end: .5em; z-index: 1; }
54
+ .badge { display: inline-flex; gap: .4em; align-items: center; padding: .2em .55em .2em .25em;
55
+ border: 1px solid var(--witness-border); border-radius: 999px; background: var(--witness-bg);
56
+ color: var(--witness-fg); }
57
+ .panel { position: absolute; inset-block-start: calc(100% + .4em); inset-inline-end: 0; z-index: 10;
58
+ width: max-content; max-width: min(22em, 80vw); padding: .75em .875em; text-align: start;
59
+ border: 1px solid var(--witness-border); border-radius: var(--witness-radius);
60
+ background: var(--witness-bg); box-shadow: 0 6px 24px rgb(0 0 0 / .15); line-height: 1.45; }
61
+ :host([variant="inline"]) .panel, :host(:not([variant])) .panel { inset-inline-end: auto; inset-inline-start: 0; }
62
+ .panel strong { display: block; margin-bottom: .25em; }
63
+ .panel p { margin: 0 0 .4em; }
64
+ .panel ul { margin: .4em 0; padding-inline-start: 1.1em; }
65
+ .close { position: absolute; inset-block-start: .35em; inset-inline-end: .35em; border: 0;
66
+ background: none; color: var(--witness-muted); font-size: 1.1em; line-height: 1; padding: .2em .35em; }
67
+ [hidden] { display: none !important; }
68
+ `;
69
+
70
+ function locale(el: HTMLElement): string[] {
71
+ const own = el.getAttribute("locale") ?? el.closest("[lang]")?.getAttribute("lang");
72
+ if (own) return [own];
73
+ return typeof navigator !== "undefined" ? [...navigator.languages] : [];
74
+ }
75
+
76
+ function kindOf(el: HTMLElement, fallback: DisclosureKind): DisclosureKind {
77
+ const value = el.getAttribute("kind") as DisclosureKind | null;
78
+ return value && DISCLOSURE_KINDS.includes(value) ? value : fallback;
79
+ }
80
+
81
+ function h<K extends keyof HTMLElementTagNameMap>(
82
+ doc: Document,
83
+ tag: K,
84
+ attrs: Record<string, string> = {},
85
+ ...children: Array<Node | string | null>
86
+ ): HTMLElementTagNameMap[K] {
87
+ const el = doc.createElement(tag);
88
+ for (const [name, value] of Object.entries(attrs)) el.setAttribute(name, value);
89
+ for (const child of children) {
90
+ if (child !== null) el.append(child);
91
+ }
92
+ return el;
93
+ }
94
+
95
+ function icon(doc: Document): HTMLElement {
96
+ return h(doc, "span", { class: "icon", "aria-hidden": "true", part: "icon" }, "AI");
97
+ }
98
+
99
+ /** `<witness-notice kind="chatbot" locale="de" disclosure-id="support" version="2" href="/ki">` */
100
+ export class WitnessNoticeElement extends BaseElement {
101
+ static observedAttributes = ["kind", "locale", "disclosure-id", "version", "href", "storage"];
102
+
103
+ #disclosure: Disclosure | null = null;
104
+ #unsubscribe: (() => void) | null = null;
105
+ #expanded = false;
106
+ /** `id@version` of the disclosure a `shown` event was sent for, so attribute changes don't repeat it. */
107
+ #shownFor: string | null = null;
108
+
109
+ connectedCallback(): void {
110
+ if (!this.shadowRoot) this.attachShadow({ mode: "open" });
111
+ this.#setup();
112
+ }
113
+
114
+ disconnectedCallback(): void {
115
+ this.#unsubscribe?.();
116
+ this.#unsubscribe = null;
117
+ }
118
+
119
+ attributeChangedCallback(_name: string, oldValue: string | null, newValue: string | null): void {
120
+ if (oldValue === newValue) return;
121
+ if (this.isConnected && this.shadowRoot) this.#setup();
122
+ }
123
+
124
+ /** Forgets the acknowledgement and shows the full notice again. */
125
+ reset(): void {
126
+ this.#disclosure?.reset();
127
+ }
128
+
129
+ get disclosure(): Disclosure | null {
130
+ return this.#disclosure;
131
+ }
132
+
133
+ #setup(): void {
134
+ this.#unsubscribe?.();
135
+ const storage = this.getAttribute("storage");
136
+ const kind = kindOf(this, "chatbot");
137
+ const tags = locale(this);
138
+ this.#disclosure = createDisclosure({
139
+ kind,
140
+ id: this.getAttribute("disclosure-id") ?? kind,
141
+ version: this.getAttribute("version") ?? "1",
142
+ locale: getLocaleCode(tags),
143
+ ...(storage === "session" || storage === "memory" || storage === "local" ? { storage } : {}),
144
+ onEvent: (event) => this.#dispatch(event),
145
+ });
146
+ this.#expanded = false;
147
+ this.#unsubscribe = this.#disclosure.subscribe(() => {
148
+ this.#expanded = false;
149
+ this.#render();
150
+ });
151
+ this.#render();
152
+ }
153
+
154
+ #dispatch(event: DisclosureEvent): void {
155
+ this.dispatchEvent(
156
+ new CustomEvent(`witness-${event.type}`, { detail: event, bubbles: true, composed: true }),
157
+ );
158
+ }
159
+
160
+ #render(): void {
161
+ const root = this.shadowRoot;
162
+ const disclosure = this.#disclosure;
163
+ if (!root || !disclosure) return;
164
+ const doc = this.ownerDocument;
165
+ const messages = getMessages(locale(this));
166
+ const text = messages.kinds[disclosure.kind];
167
+ const href = this.getAttribute("href");
168
+ const full = this.#expanded || disclosure.needsAcknowledgement();
169
+
170
+ const style = h(doc, "style", {}, NOTICE_CSS);
171
+ if (full) {
172
+ const ack = h(
173
+ doc,
174
+ "button",
175
+ { type: "button", class: "ack", part: "button" },
176
+ messages.ui.acknowledge,
177
+ );
178
+ ack.addEventListener("click", () => disclosure.acknowledge());
179
+ const link = href ? h(doc, "a", { href, part: "link" }, messages.ui.moreInfo) : null;
180
+ const box = h(
181
+ doc,
182
+ "section",
183
+ { class: "notice", role: "note", "aria-labelledby": "title", part: "notice" },
184
+ icon(doc),
185
+ h(
186
+ doc,
187
+ "div",
188
+ {},
189
+ h(doc, "h2", { id: "title", part: "title" }, text.title),
190
+ h(doc, "p", { part: "body" }, h(doc, "slot", {}, text.body)),
191
+ h(doc, "div", { class: "actions" }, ack, link),
192
+ ),
193
+ );
194
+ root.replaceChildren(style, box);
195
+ const shownFor = `${disclosure.id}@${disclosure.version}`;
196
+ if (this.#shownFor !== shownFor) {
197
+ this.#shownFor = shownFor;
198
+ disclosure.markShown();
199
+ }
200
+ } else {
201
+ const reopen = h(
202
+ doc,
203
+ "button",
204
+ {
205
+ type: "button",
206
+ class: "compact",
207
+ "aria-label": `${text.label}: ${messages.ui.details}`,
208
+ part: "compact",
209
+ },
210
+ icon(doc),
211
+ text.label,
212
+ );
213
+ reopen.addEventListener("click", () => {
214
+ this.#expanded = true;
215
+ this.#render();
216
+ });
217
+ root.replaceChildren(style, reopen);
218
+ }
219
+ }
220
+ }
221
+
222
+ /** `<witness-label kind="generated" generator="Claude" created="2026-10-05" reviewed href="/ki">` */
223
+ export class WitnessLabelElement extends BaseElement {
224
+ static observedAttributes = [
225
+ "kind",
226
+ "locale",
227
+ "generator",
228
+ "generator-version",
229
+ "created",
230
+ "reviewed",
231
+ "href",
232
+ "variant",
233
+ ];
234
+
235
+ #open = false;
236
+ #onKey = (event: KeyboardEvent) => {
237
+ if (event.key === "Escape" && this.#open) {
238
+ this.#toggle(false);
239
+ this.shadowRoot?.querySelector<HTMLButtonElement>(".badge")?.focus();
240
+ }
241
+ };
242
+ #onOutside = (event: Event) => {
243
+ if (this.#open && !event.composedPath().includes(this)) this.#toggle(false);
244
+ };
245
+
246
+ connectedCallback(): void {
247
+ if (!this.shadowRoot) this.attachShadow({ mode: "open" });
248
+ this.ownerDocument.addEventListener("keydown", this.#onKey);
249
+ this.ownerDocument.addEventListener("click", this.#onOutside);
250
+ this.#render();
251
+ }
252
+
253
+ disconnectedCallback(): void {
254
+ this.ownerDocument.removeEventListener("keydown", this.#onKey);
255
+ this.ownerDocument.removeEventListener("click", this.#onOutside);
256
+ }
257
+
258
+ attributeChangedCallback(): void {
259
+ if (this.isConnected && this.shadowRoot) this.#render();
260
+ }
261
+
262
+ #toggle(open: boolean): void {
263
+ this.#open = open;
264
+ this.#render();
265
+ }
266
+
267
+ #render(): void {
268
+ const root = this.shadowRoot;
269
+ if (!root) return;
270
+ const doc = this.ownerDocument;
271
+ const tags = locale(this);
272
+ const messages = getMessages(tags);
273
+ const kind = kindOf(this, "generated");
274
+ const text = messages.kinds[kind];
275
+ const generator = [this.getAttribute("generator"), this.getAttribute("generator-version")]
276
+ .filter(Boolean)
277
+ .join(" ");
278
+ const created = this.getAttribute("created");
279
+ const href = this.getAttribute("href");
280
+
281
+ const badge = h(
282
+ doc,
283
+ "button",
284
+ {
285
+ type: "button",
286
+ class: "badge",
287
+ part: "badge",
288
+ "aria-expanded": String(this.#open),
289
+ "aria-controls": "panel",
290
+ title: messages.ui.details,
291
+ },
292
+ icon(doc),
293
+ h(doc, "span", { part: "label" }, text.label),
294
+ );
295
+ badge.addEventListener("click", () => this.#toggle(!this.#open));
296
+
297
+ const facts = h(doc, "ul", {});
298
+ if (generator) facts.append(h(doc, "li", {}, format(messages.ui.generatedWith, { generator })));
299
+ if (created) {
300
+ facts.append(
301
+ h(doc, "li", {}, format(messages.ui.createdOn, { date: formatDate(created, tags) })),
302
+ );
303
+ }
304
+ if (this.hasAttribute("reviewed")) facts.append(h(doc, "li", {}, messages.ui.humanReviewed));
305
+
306
+ const close = h(
307
+ doc,
308
+ "button",
309
+ { type: "button", class: "close", "aria-label": messages.ui.close },
310
+ "×",
311
+ );
312
+ close.addEventListener("click", () => this.#toggle(false));
313
+
314
+ const panel = h(
315
+ doc,
316
+ "div",
317
+ { id: "panel", class: "panel", role: "dialog", "aria-label": text.title, part: "panel" },
318
+ close,
319
+ h(doc, "strong", {}, text.title),
320
+ h(doc, "p", {}, text.body),
321
+ facts.childElementCount > 0 ? facts : null,
322
+ href ? h(doc, "a", { href, part: "link" }, messages.ui.moreInfo) : null,
323
+ );
324
+ if (!this.#open) panel.hidden = true;
325
+ root.replaceChildren(h(doc, "style", {}, LABEL_CSS), badge, panel);
326
+ }
327
+ }
328
+
329
+ function formatDate(value: string, tags: string[]): string {
330
+ const date = new Date(value);
331
+ if (Number.isNaN(date.getTime())) return value;
332
+ try {
333
+ return new Intl.DateTimeFormat(tags.length > 0 ? tags : undefined, {
334
+ dateStyle: "medium",
335
+ }).format(date);
336
+ } catch {
337
+ return value;
338
+ }
339
+ }
340
+
341
+ function getLocaleCode(tags: string[]): string {
342
+ return tags[0] ?? "en";
343
+ }
344
+
345
+ /** Registers `<witness-notice>` and `<witness-label>` once. Safe to call during SSR. */
346
+ export function defineWitnessElements(): void {
347
+ if (typeof customElements === "undefined") return;
348
+ if (!customElements.get("witness-notice"))
349
+ customElements.define("witness-notice", WitnessNoticeElement);
350
+ if (!customElements.get("witness-label"))
351
+ customElements.define("witness-label", WitnessLabelElement);
352
+ }
353
+
354
+ declare global {
355
+ interface HTMLElementTagNameMap {
356
+ "witness-notice": WitnessNoticeElement;
357
+ "witness-label": WitnessLabelElement;
358
+ }
359
+ interface HTMLElementEventMap {
360
+ "witness-shown": CustomEvent<DisclosureEvent>;
361
+ "witness-acknowledged": CustomEvent<DisclosureEvent>;
362
+ }
363
+ }