@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.
- package/LICENSE +21 -0
- package/README.md +21 -2
- package/dist/ai-sdk.cjs +921 -0
- package/dist/ai-sdk.cjs.map +1 -0
- package/dist/ai-sdk.d.cts +95 -0
- package/dist/ai-sdk.d.ts +95 -0
- package/dist/ai-sdk.js +150 -0
- package/dist/ai-sdk.js.map +1 -0
- package/dist/bin.cjs +944 -0
- package/dist/bin.cjs.map +1 -0
- package/dist/bin.d.cts +1 -0
- package/dist/bin.d.ts +1 -0
- package/dist/bin.js +12 -0
- package/dist/bin.js.map +1 -0
- package/dist/chunk-APXU3MTX.js +958 -0
- package/dist/chunk-APXU3MTX.js.map +1 -0
- package/dist/chunk-L65R2SM5.js +228 -0
- package/dist/chunk-L65R2SM5.js.map +1 -0
- package/dist/chunk-O6TADCND.js +317 -0
- package/dist/chunk-O6TADCND.js.map +1 -0
- package/dist/chunk-UBRK4CTM.js +156 -0
- package/dist/chunk-UBRK4CTM.js.map +1 -0
- package/dist/chunk-XJOQ5IUT.js +110 -0
- package/dist/chunk-XJOQ5IUT.js.map +1 -0
- package/dist/cli.cjs +964 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +7 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +9 -0
- package/dist/cli.js.map +1 -0
- package/dist/disclosure-5a3UIVQZ.d.cts +70 -0
- package/dist/disclosure-C7Y_uY73.d.ts +70 -0
- package/dist/elements-auto.cjs +649 -0
- package/dist/elements-auto.cjs.map +1 -0
- package/dist/elements-auto.d.cts +3 -0
- package/dist/elements-auto.d.ts +3 -0
- package/dist/elements-auto.js +16 -0
- package/dist/elements-auto.js.map +1 -0
- package/dist/elements.cjs +646 -0
- package/dist/elements.cjs.map +1 -0
- package/dist/elements.d.cts +47 -0
- package/dist/elements.d.ts +47 -0
- package/dist/elements.js +13 -0
- package/dist/elements.js.map +1 -0
- package/dist/image-B7YvdITx.d.ts +49 -0
- package/dist/image-BNrEO-hj.d.cts +49 -0
- package/dist/index.cjs +1344 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +124 -0
- package/dist/index.d.ts +124 -0
- package/dist/index.js +77 -0
- package/dist/index.js.map +1 -0
- package/dist/types-WWHzrNlc.d.cts +90 -0
- package/dist/types-WWHzrNlc.d.ts +90 -0
- package/package.json +123 -3
- package/src/ai-sdk.ts +252 -0
- package/src/bin.ts +6 -0
- package/src/cli.ts +160 -0
- package/src/disclosure.ts +172 -0
- package/src/elements-auto.ts +5 -0
- package/src/elements.ts +363 -0
- package/src/i18n.ts +259 -0
- package/src/image.ts +552 -0
- package/src/index.ts +66 -0
- package/src/inflate.ts +189 -0
- package/src/marking.ts +237 -0
- package/src/types.ts +115 -0
- package/src/watermark.ts +165 -0
- 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
|
+
}
|
package/src/elements.ts
ADDED
|
@@ -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
|
+
}
|