@birdapi/velinstyle 1.2.0 → 1.2.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 (39) hide show
  1. package/README.de.md +22 -19
  2. package/README.md +24 -25
  3. package/cli/cli-manifest.json +1 -1
  4. package/cli/docgen/extract-attributes.js +2 -0
  5. package/cli/index.js +32 -3
  6. package/cli/transparency.js +221 -0
  7. package/core/a11y/component-contracts.json +1 -1
  8. package/core/attributes/registry.js +14 -0
  9. package/core/meta/schema.js +10 -0
  10. package/core/transparency/attach.js +74 -0
  11. package/core/transparency/claims.js +97 -0
  12. package/core/transparency/doctor.js +142 -0
  13. package/core/transparency/engine.js +75 -0
  14. package/core/transparency/export.js +98 -0
  15. package/core/transparency/index.js +30 -0
  16. package/core/transparency/migrate.js +130 -0
  17. package/core/transparency/normalize.js +125 -0
  18. package/core/transparency/policy.js +105 -0
  19. package/core/transparency/providers.js +235 -0
  20. package/core/transparency/registry.js +104 -0
  21. package/core/transparency/renderer.js +111 -0
  22. package/core/transparency/reporter.js +113 -0
  23. package/core/transparency/validator.js +112 -0
  24. package/dist/chunks/attach-H2ZSEAE6.js +365 -0
  25. package/dist/chunks/attributes-DZCXX2RZ.js +404 -0
  26. package/dist/chunks/chunk-CQMTCEI6.js +113 -0
  27. package/dist/chunks/runtime-entry.js +1 -1
  28. package/dist/llms.txt +3 -2
  29. package/dist/search-index.json +28 -2
  30. package/dist/velin-agent.json +10 -6
  31. package/dist/velinstyle-components.iife.js +411 -2
  32. package/dist/velinstyle-components.js +411 -2
  33. package/dist/velinstyle-components.min.js +113 -113
  34. package/dist/velinstyle.css +160 -0
  35. package/dist/velinstyle.min.css +1 -1
  36. package/package.json +2 -1
  37. package/packages/velinstyle-skills/catalog.json +1 -1
  38. package/src/components/transparency.css +138 -0
  39. package/src/velinstyle.css +1 -0
@@ -0,0 +1,125 @@
1
+ import { deriveClaims, primaryLabel } from './claims.js';
2
+ import { stableDisclosureId } from './registry.js';
3
+
4
+ const PROVENANCE_KEYS = [
5
+ 'createdBy', 'createdAt', 'reviewedAt', 'approvedBy', 'source', 'license', 'version', 'publishedAt',
6
+ ];
7
+
8
+ /**
9
+ * Normalize a draft disclosure into a registry record.
10
+ * @param {Record<string, unknown>} draft
11
+ * @param {{ provider?: string, file?: string, lang?: 'en'|'de' }} [ctx]
12
+ */
13
+ export function normalizeDisclosure(draft = {}, ctx = {}) {
14
+ const provider = String(draft.provider || ctx.provider || 'api');
15
+ const type = String(draft.type || 'ai').toLowerCase();
16
+ const status = draft.status != null ? String(draft.status).toLowerCase() : undefined;
17
+ const review = draft.review != null ? String(draft.review).toLowerCase() : undefined;
18
+ const provenance = normalizeProvenance(draft.provenance || draft);
19
+ if (draft.license && !provenance.license) provenance.license = String(draft.license);
20
+ if (draft.model && !provenance.source) provenance.source = `model:${draft.model}`;
21
+ if (draft.generated === true && !status) {
22
+ // generated flag without status
23
+ }
24
+ const resolvedStatus = status || (draft.generated === true ? 'generated' : undefined);
25
+ const claims = deriveClaims({
26
+ status: resolvedStatus,
27
+ review,
28
+ license: provenance.license,
29
+ claims: draft.claims,
30
+ });
31
+ const target = {
32
+ selector: draft.target?.selector || draft.selector || undefined,
33
+ tag: draft.target?.tag || draft.tag || undefined,
34
+ src: draft.target?.src || draft.src || undefined,
35
+ file: draft.target?.file || ctx.file || draft.file || undefined,
36
+ };
37
+ const id = stableDisclosureId({
38
+ id: draft.id || draft.velinTransparencyId,
39
+ selector: target.selector,
40
+ src: target.src,
41
+ tag: target.tag,
42
+ type,
43
+ file: target.file,
44
+ });
45
+ const lang = ctx.lang === 'de' ? 'de' : 'en';
46
+ const label = draft.label ? String(draft.label) : primaryLabel(claims, lang);
47
+ const updated = draft.updated || provenance.publishedAt || provenance.reviewedAt || provenance.createdAt || undefined;
48
+
49
+ return {
50
+ id,
51
+ type,
52
+ status: resolvedStatus,
53
+ review,
54
+ provider,
55
+ claims,
56
+ provenance,
57
+ updated,
58
+ label,
59
+ description: draft.description ? String(draft.description) : undefined,
60
+ renderer: draft.renderer || draft.overlay || 'badge',
61
+ tone: draft.tone || toneFromClaims(claims),
62
+ position: draft.position || 'top-right',
63
+ target,
64
+ meta: draft.meta && typeof draft.meta === 'object' ? draft.meta : undefined,
65
+ };
66
+ }
67
+
68
+ function normalizeProvenance(src = {}) {
69
+ /** @type {Record<string, string>} */
70
+ const out = {};
71
+ for (const key of PROVENANCE_KEYS) {
72
+ const v = src[key] ?? src[key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`)];
73
+ if (v != null && String(v).trim()) out[key] = String(v).trim();
74
+ }
75
+ // attribute-style aliases
76
+ if (src['created-by']) out.createdBy = String(src['created-by']);
77
+ if (src['created-at']) out.createdAt = String(src['created-at']);
78
+ if (src['reviewed-at']) out.reviewedAt = String(src['reviewed-at']);
79
+ if (src['approved-by']) out.approvedBy = String(src['approved-by']);
80
+ if (src['published-at']) out.publishedAt = String(src['published-at']);
81
+ return out;
82
+ }
83
+
84
+ function toneFromClaims(claims) {
85
+ if (claims.includes('ai.generated')) return 'generated';
86
+ if (claims.includes('review.verified') || claims.includes('trust.official')) return 'verified';
87
+ if (claims.includes('review.human')) return 'human';
88
+ if (claims.includes('ai.assisted')) return 'mixed';
89
+ return 'neutral';
90
+ }
91
+
92
+ /**
93
+ * Merge two records; higher priority (lower rank number) wins field-by-field.
94
+ * @param {import('./registry.js').DisclosureRecord} a
95
+ * @param {import('./registry.js').DisclosureRecord} b
96
+ * @param {(id: string) => number} rankFn
97
+ */
98
+ export function mergeDisclosures(a, b, rankFn) {
99
+ const aRank = rankFn(a.provider);
100
+ const bRank = rankFn(b.provider);
101
+ const primary = aRank <= bRank ? a : b;
102
+ const secondary = aRank <= bRank ? b : a;
103
+ const conflictKeys = [];
104
+ for (const key of ['type', 'status', 'review']) {
105
+ if (secondary[key] && primary[key] && secondary[key] !== primary[key]) conflictKeys.push(key);
106
+ }
107
+ const claims = [...new Set([...(primary.claims || []), ...(secondary.claims || [])])];
108
+ const provenance = { ...secondary.provenance, ...primary.provenance };
109
+ return {
110
+ record: {
111
+ ...secondary,
112
+ ...primary,
113
+ claims,
114
+ provenance,
115
+ provider: primary.provider,
116
+ meta: {
117
+ ...(secondary.meta || {}),
118
+ ...(primary.meta || {}),
119
+ mergedFrom: [secondary.provider, primary.provider],
120
+ conflicts: conflictKeys,
121
+ },
122
+ },
123
+ conflicts: conflictKeys,
124
+ };
125
+ }
@@ -0,0 +1,105 @@
1
+ /** Default and merge helpers for transparency policy. */
2
+
3
+ /**
4
+ * Soft framework defaults so existing sites are not hard-failed by `velinstyle check`.
5
+ * Projects opt into strict media/provenance rules via velin.transparency.policy.json.
6
+ */
7
+ export const DEFAULT_POLICY = {
8
+ media: {
9
+ images: 'optional',
10
+ videos: 'optional',
11
+ audio: 'optional',
12
+ text: 'optional',
13
+ pdf: 'optional',
14
+ },
15
+ rules: {
16
+ requireReview: false,
17
+ allowGenerated: true,
18
+ minimumStatus: null,
19
+ staleDays: 365,
20
+ },
21
+ provenance: {
22
+ required: [],
23
+ recommended: ['approvedBy', 'source', 'version', 'createdAt', 'license'],
24
+ images: { required: [] },
25
+ videos: { required: [] },
26
+ audio: { required: [] },
27
+ pdf: { required: [] },
28
+ },
29
+ providers: {
30
+ priority: ['api', 'json', 'meta', 'html'],
31
+ },
32
+ };
33
+
34
+ /** Strict example policy for teams that require disclosure on media. */
35
+ export const STRICT_MEDIA_POLICY = {
36
+ media: {
37
+ images: 'required',
38
+ videos: 'required',
39
+ audio: 'required',
40
+ text: 'optional',
41
+ pdf: 'required',
42
+ },
43
+ provenance: {
44
+ required: [],
45
+ recommended: ['approvedBy', 'source', 'version'],
46
+ images: { required: ['createdAt', 'license', 'source'] },
47
+ videos: { required: ['createdAt'] },
48
+ audio: { required: ['createdAt'] },
49
+ pdf: { required: ['license'] },
50
+ },
51
+ };
52
+
53
+ /**
54
+ * @param {unknown} raw
55
+ */
56
+ export function normalizePolicy(raw) {
57
+ const input = raw && typeof raw === 'object' ? raw : {};
58
+ const nested = input.policy && typeof input.policy === 'object' ? input.policy : input;
59
+ return {
60
+ media: { ...DEFAULT_POLICY.media, ...(nested.media || {}) },
61
+ rules: { ...DEFAULT_POLICY.rules, ...(nested.rules || {}) },
62
+ provenance: {
63
+ ...DEFAULT_POLICY.provenance,
64
+ ...(nested.provenance || {}),
65
+ images: { ...DEFAULT_POLICY.provenance.images, ...(nested.provenance?.images || {}) },
66
+ videos: { ...DEFAULT_POLICY.provenance.videos, ...(nested.provenance?.videos || {}) },
67
+ audio: { ...DEFAULT_POLICY.provenance.audio, ...(nested.provenance?.audio || {}) },
68
+ pdf: { ...DEFAULT_POLICY.provenance.pdf, ...(nested.provenance?.pdf || {}) },
69
+ },
70
+ providers: {
71
+ ...DEFAULT_POLICY.providers,
72
+ ...(nested.providers || {}),
73
+ priority: nested.providers?.priority || DEFAULT_POLICY.providers.priority,
74
+ },
75
+ };
76
+ }
77
+
78
+ /**
79
+ * @param {string} mediaKind images|videos|audio|text|pdf
80
+ * @param {ReturnType<typeof normalizePolicy>} policy
81
+ */
82
+ export function mediaRequirement(mediaKind, policy) {
83
+ return policy.media[mediaKind] || 'optional';
84
+ }
85
+
86
+ /**
87
+ * @param {string} mediaKind
88
+ * @param {ReturnType<typeof normalizePolicy>} policy
89
+ */
90
+ export function requiredProvenanceFields(mediaKind, policy) {
91
+ const base = policy.provenance.required || [];
92
+ const specific = policy.provenance[mediaKind]?.required || [];
93
+ return [...new Set([...base, ...specific])];
94
+ }
95
+
96
+ /**
97
+ * Provider priority index (lower = higher priority).
98
+ * @param {string} providerId
99
+ * @param {ReturnType<typeof normalizePolicy>} policy
100
+ */
101
+ export function providerRank(providerId, policy) {
102
+ const list = policy.providers.priority || DEFAULT_POLICY.providers.priority;
103
+ const idx = list.indexOf(providerId);
104
+ return idx === -1 ? 999 : idx;
105
+ }
@@ -0,0 +1,235 @@
1
+ import { normalizeDisclosure, mergeDisclosures } from './normalize.js';
2
+ import { providerRank } from './policy.js';
3
+
4
+ /** @type {Map<string, { collect: Function, apply?: Function }>} */
5
+ const providers = new Map();
6
+
7
+ export function registerTransparencyProvider(id, handler) {
8
+ if (!id || typeof handler?.collect !== 'function') {
9
+ throw new Error('registerTransparencyProvider requires id and collect()');
10
+ }
11
+ providers.set(id, handler);
12
+ }
13
+
14
+ export function listTransparencyProviders() {
15
+ return [...providers.keys()];
16
+ }
17
+
18
+ export function getTransparencyProvider(id) {
19
+ return providers.get(id) || null;
20
+ }
21
+
22
+ function mergeById(records, policy) {
23
+ const map = new Map();
24
+ const conflicts = [];
25
+ const rankFn = (pid) => providerRank(pid, policy);
26
+ for (const rec of records) {
27
+ if (!map.has(rec.id)) {
28
+ map.set(rec.id, rec);
29
+ continue;
30
+ }
31
+ const { record, conflicts: c } = mergeDisclosures(map.get(rec.id), rec, rankFn);
32
+ map.set(rec.id, record);
33
+ if (c.length) {
34
+ conflicts.push({ id: rec.id, keys: c, providers: record.meta?.mergedFrom });
35
+ }
36
+ }
37
+ return { records: [...map.values()], conflicts };
38
+ }
39
+
40
+ /**
41
+ * Collect from all providers, normalize, merge by id using policy priority.
42
+ * @param {string|Document|ParentNode|object} root
43
+ * @param {{ policy: object, file?: string, lang?: string, meta?: object, apiDisclosures?: object[] }} ctx
44
+ */
45
+ export async function collectAllDisclosures(root, ctx) {
46
+ const drafts = [];
47
+ for (const [id, handler] of providers) {
48
+ const items = await handler.collect(root, { ...ctx, providerId: id });
49
+ for (const item of items || []) {
50
+ drafts.push(normalizeDisclosure({ ...item, provider: item.provider || id }, {
51
+ provider: item.provider || id,
52
+ file: ctx.file,
53
+ lang: ctx.lang,
54
+ }));
55
+ }
56
+ }
57
+ return mergeById(drafts, ctx.policy);
58
+ }
59
+
60
+ function attr(tag, name) {
61
+ const re = new RegExp(`\\b${name}=["']([^"']*)["']`, 'i');
62
+ return tag.match(re)?.[1];
63
+ }
64
+
65
+ function hasAttr(tag, name) {
66
+ return new RegExp(`(?:\\s|^|<)${name}(?:[\\s>=/]|$)`, 'i').test(tag);
67
+ }
68
+
69
+ /**
70
+ * Parse HTML string for velin-transparency hosts and JSON blocks.
71
+ */
72
+ export function collectFromHtmlString(html) {
73
+ const items = [];
74
+ const tagRe = /<([a-z0-9-]+)(\s[^>]*)?>/gi;
75
+ let m;
76
+ while ((m = tagRe.exec(html))) {
77
+ const full = m[0];
78
+ if (!hasAttr(full, 'velin-transparency') && !hasAttr(full, 'velin-disclosure')) continue;
79
+ const tagName = m[1].toUpperCase();
80
+ const id = attr(full, 'velin-transparency-id') || attr(full, 'id');
81
+ const type = attr(full, 'velin-type') || 'ai';
82
+ const status = attr(full, 'velin-status');
83
+ const review = attr(full, 'velin-review');
84
+ const license = attr(full, 'velin-license');
85
+ const label = attr(full, 'velin-label') || attr(full, 'velin-ai-label');
86
+ const description = attr(full, 'velin-description') || attr(full, 'velin-ai-description');
87
+ const overlay = attr(full, 'velin-overlay') || attr(full, 'velin-renderer') || attr(full, 'velin-ai-overlay');
88
+ const tone = attr(full, 'velin-tone') || attr(full, 'velin-ai-tone');
89
+ const position = attr(full, 'velin-position') || attr(full, 'velin-ai-position');
90
+ const src = attr(full, 'src') || attr(full, 'href');
91
+ items.push({
92
+ id,
93
+ type,
94
+ status,
95
+ review,
96
+ license,
97
+ label,
98
+ description,
99
+ overlay,
100
+ tone,
101
+ position,
102
+ src,
103
+ tag: tagName,
104
+ selector: id ? `#${id}` : undefined,
105
+ provenance: {
106
+ createdBy: attr(full, 'velin-created-by'),
107
+ createdAt: attr(full, 'velin-created-at'),
108
+ reviewedAt: attr(full, 'velin-reviewed-at'),
109
+ approvedBy: attr(full, 'velin-approved-by'),
110
+ source: attr(full, 'velin-source'),
111
+ license: license || undefined,
112
+ version: attr(full, 'velin-version'),
113
+ publishedAt: attr(full, 'velin-published-at'),
114
+ },
115
+ provider: 'html',
116
+ });
117
+ }
118
+
119
+ const scriptRe = /<script\b[^>]*type=["']application\/vnd\.velinstyle\.transparency\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
120
+ let sm;
121
+ while ((sm = scriptRe.exec(html))) {
122
+ try {
123
+ const data = JSON.parse(sm[1].trim());
124
+ const list = Array.isArray(data) ? data : data.items || [data];
125
+ for (const item of list) items.push({ ...item, provider: item.provider || 'json' });
126
+ } catch { /* ignore */ }
127
+ }
128
+
129
+ const jsonAttrRe = /<([a-z0-9-]+)([^>]*\bvelin-transparency-json=["']([^"']+)["'][^>]*)>/gi;
130
+ let jm;
131
+ while ((jm = jsonAttrRe.exec(html))) {
132
+ try {
133
+ const data = JSON.parse(jm[3].replace(/&quot;/g, '"'));
134
+ items.push({ ...data, tag: jm[1].toUpperCase(), provider: 'json' });
135
+ } catch { /* ignore */ }
136
+ }
137
+
138
+ return items;
139
+ }
140
+
141
+ function collectFromDom(root) {
142
+ if (typeof root?.querySelectorAll !== 'function') return [];
143
+ const items = [];
144
+ root.querySelectorAll('[velin-transparency], [velin-disclosure]').forEach((el) => {
145
+ const get = (n) => el.getAttribute(n);
146
+ items.push({
147
+ id: get('velin-transparency-id') || el.id || undefined,
148
+ type: get('velin-type') || 'ai',
149
+ status: get('velin-status'),
150
+ review: get('velin-review'),
151
+ license: get('velin-license'),
152
+ label: get('velin-label') || get('velin-ai-label'),
153
+ description: get('velin-description'),
154
+ overlay: get('velin-overlay') || get('velin-renderer'),
155
+ tone: get('velin-tone'),
156
+ position: get('velin-position'),
157
+ src: el.getAttribute('src') || el.getAttribute('href') || undefined,
158
+ tag: el.tagName,
159
+ selector: el.id ? `#${el.id}` : undefined,
160
+ provenance: {
161
+ createdBy: get('velin-created-by'),
162
+ createdAt: get('velin-created-at'),
163
+ reviewedAt: get('velin-reviewed-at'),
164
+ approvedBy: get('velin-approved-by'),
165
+ source: get('velin-source'),
166
+ license: get('velin-license'),
167
+ version: get('velin-version'),
168
+ publishedAt: get('velin-published-at'),
169
+ },
170
+ provider: 'html',
171
+ });
172
+ });
173
+ root.querySelectorAll('script[type="application/vnd.velinstyle.transparency+json"]').forEach((script) => {
174
+ try {
175
+ const data = JSON.parse(script.textContent || '{}');
176
+ const list = Array.isArray(data) ? data : data.items || [data];
177
+ for (const item of list) items.push({ ...item, provider: item.provider || 'json' });
178
+ } catch { /* ignore */ }
179
+ });
180
+ return items;
181
+ }
182
+
183
+ registerTransparencyProvider('html', {
184
+ async collect(root) {
185
+ if (typeof root === 'string') return collectFromHtmlString(root).filter((i) => i.provider === 'html');
186
+ return collectFromDom(root);
187
+ },
188
+ });
189
+
190
+ registerTransparencyProvider('json', {
191
+ async collect(root) {
192
+ if (root && typeof root === 'object' && !root.querySelectorAll && (Array.isArray(root) || root.type || root.items)) {
193
+ const list = Array.isArray(root) ? root : root.items || [root];
194
+ return list.map((item) => ({ ...item, provider: 'json' }));
195
+ }
196
+ if (typeof root === 'string') {
197
+ return collectFromHtmlString(root).filter((i) => i.provider === 'json');
198
+ }
199
+ return [];
200
+ },
201
+ });
202
+
203
+ registerTransparencyProvider('meta', {
204
+ async collect(root, ctx = {}) {
205
+ if (ctx.meta?.transparency) {
206
+ const t = ctx.meta.transparency;
207
+ const list = Array.isArray(t) ? t : t.disclosures || t.items || [];
208
+ return list.map((item) => ({ ...item, provider: 'meta' }));
209
+ }
210
+ if (typeof root === 'string') {
211
+ const m = root.match(
212
+ /<script\b[^>]*(?:id=["']velin-meta["']|type=["']application\/vnd\.velinstyle\.meta\+json["'])[^>]*>([\s\S]*?)<\/script>/i,
213
+ );
214
+ if (!m) return [];
215
+ try {
216
+ const meta = JSON.parse(m[1].trim());
217
+ const t = meta.transparency || meta.aiDisclosure;
218
+ if (!t) return [];
219
+ const list = Array.isArray(t) ? t : t.disclosures || t.items || [];
220
+ return list.map((item) => ({ ...item, provider: 'meta' }));
221
+ } catch {
222
+ return [];
223
+ }
224
+ }
225
+ return [];
226
+ },
227
+ });
228
+
229
+ registerTransparencyProvider('api', {
230
+ async collect(_root, ctx = {}) {
231
+ return Array.isArray(ctx.apiDisclosures)
232
+ ? ctx.apiDisclosures.map((i) => ({ ...i, provider: 'api' }))
233
+ : [];
234
+ },
235
+ });
@@ -0,0 +1,104 @@
1
+ /** In-memory disclosure registry. */
2
+
3
+ /**
4
+ * @typedef {object} Provenance
5
+ * @property {string} [createdBy]
6
+ * @property {string} [createdAt]
7
+ * @property {string} [reviewedAt]
8
+ * @property {string} [approvedBy]
9
+ * @property {string} [source]
10
+ * @property {string} [license]
11
+ * @property {string} [version]
12
+ * @property {string} [publishedAt]
13
+ */
14
+
15
+ /**
16
+ * @typedef {object} DisclosureRecord
17
+ * @property {string} id
18
+ * @property {string} [type]
19
+ * @property {string} [status]
20
+ * @property {string} [review]
21
+ * @property {string} provider
22
+ * @property {string[]} claims
23
+ * @property {Provenance} provenance
24
+ * @property {string} [updated]
25
+ * @property {string} [label]
26
+ * @property {string} [description]
27
+ * @property {string} [renderer]
28
+ * @property {string} [tone]
29
+ * @property {string} [position]
30
+ * @property {{ selector?: string, tag?: string, src?: string, file?: string }} [target]
31
+ * @property {Record<string, unknown>} [meta]
32
+ */
33
+
34
+ export function createRegistry() {
35
+ /** @type {Map<string, DisclosureRecord>} */
36
+ const map = new Map();
37
+
38
+ return {
39
+ register(record) {
40
+ if (!record?.id) throw new Error('DisclosureRecord requires id');
41
+ map.set(record.id, structuredCloneSafe(record));
42
+ return map.get(record.id);
43
+ },
44
+ get(id) {
45
+ return map.get(id) || null;
46
+ },
47
+ has(id) {
48
+ return map.has(id);
49
+ },
50
+ remove(id) {
51
+ return map.delete(id);
52
+ },
53
+ list() {
54
+ return [...map.values()].map((r) => structuredCloneSafe(r));
55
+ },
56
+ query(predicate) {
57
+ return this.list().filter(predicate);
58
+ },
59
+ clear() {
60
+ map.clear();
61
+ },
62
+ size() {
63
+ return map.size;
64
+ },
65
+ export() {
66
+ return { schema: 'velinstyle.transparency.registry', version: 1, items: this.list() };
67
+ },
68
+ diff(otherList = []) {
69
+ const other = new Map(otherList.map((r) => [r.id, r]));
70
+ const added = [];
71
+ const removed = [];
72
+ const changed = [];
73
+ for (const r of map.values()) {
74
+ if (!other.has(r.id)) added.push(r.id);
75
+ else if (JSON.stringify(r) !== JSON.stringify(other.get(r.id))) changed.push(r.id);
76
+ }
77
+ for (const id of other.keys()) {
78
+ if (!map.has(id)) removed.push(id);
79
+ }
80
+ return { added, removed, changed };
81
+ },
82
+ };
83
+ }
84
+
85
+ function structuredCloneSafe(obj) {
86
+ return JSON.parse(JSON.stringify(obj));
87
+ }
88
+
89
+ /**
90
+ * Stable id from target hints.
91
+ * @param {{ id?: string, selector?: string, src?: string, tag?: string, type?: string, file?: string }} parts
92
+ */
93
+ export function stableDisclosureId(parts = {}) {
94
+ if (parts.id) return String(parts.id).trim();
95
+ const raw = [parts.file || '', parts.selector || '', parts.src || '', parts.tag || '', parts.type || '']
96
+ .join('|')
97
+ .toLowerCase();
98
+ let h = 2166136261;
99
+ for (let i = 0; i < raw.length; i += 1) {
100
+ h ^= raw.charCodeAt(i);
101
+ h = Math.imul(h, 16777619);
102
+ }
103
+ return `tx-${(h >>> 0).toString(16)}`;
104
+ }
@@ -0,0 +1,111 @@
1
+ /** @type {Map<string, (el: HTMLElement, record: object) => void>} */
2
+ const renderers = new Map();
3
+
4
+ export function registerTransparencyRenderer(name, fn) {
5
+ renderers.set(name, fn);
6
+ }
7
+
8
+ export function listTransparencyRenderers() {
9
+ return [...renderers.keys()];
10
+ }
11
+
12
+ /**
13
+ * Render a disclosure mark onto an element.
14
+ * @param {HTMLElement} el
15
+ * @param {import('./registry.js').DisclosureRecord} record
16
+ */
17
+ export function renderDisclosure(el, record) {
18
+ if (typeof document === 'undefined' || !el) return null;
19
+ const name = record.renderer || 'badge';
20
+ const custom = renderers.get(name);
21
+ if (custom) {
22
+ custom(el, record);
23
+ return el.querySelector('.velin-transparency');
24
+ }
25
+ return defaultRender(el, record);
26
+ }
27
+
28
+ function defaultRender(el, record) {
29
+ const cs = getComputedStyle(el);
30
+ if (cs.position === 'static') el.style.position = 'relative';
31
+
32
+ let mark = el.querySelector(':scope > .velin-transparency');
33
+ if (!mark) {
34
+ mark = document.createElement('div');
35
+ el.prepend(mark);
36
+ }
37
+
38
+ const renderer = record.renderer || 'badge';
39
+ const tone = record.tone || 'neutral';
40
+ const position = record.position || 'top-right';
41
+ mark.className = [
42
+ 'velin-transparency',
43
+ `velin-transparency--${renderer}`,
44
+ `velin-transparency--tone-${tone}`,
45
+ `velin-transparency--pos-${position}`,
46
+ ].join(' ');
47
+ mark.setAttribute('data-velin-transparency-id', record.id);
48
+ mark.setAttribute('role', 'note');
49
+
50
+ const claimsText = (record.claims || []).join(', ');
51
+ const prov = record.provenance || {};
52
+ const sr = document.createElement('span');
53
+ sr.className = 'velin-sr-only';
54
+ const lang = (el.closest('[lang]')?.getAttribute('lang') || document.documentElement.lang || 'en').startsWith('de')
55
+ ? 'de'
56
+ : 'en';
57
+ sr.textContent = lang === 'de'
58
+ ? `Transparenzhinweis: ${record.label}. ${claimsText}. ${provenanceSr(prov, 'de')}`
59
+ : `Transparency notice: ${record.label}. ${claimsText}. ${provenanceSr(prov, 'en')}`;
60
+
61
+ const visible = document.createElement('span');
62
+ visible.className = 'velin-transparency__label';
63
+ visible.textContent = record.label || 'Transparency';
64
+
65
+ const details = document.createElement('span');
66
+ details.className = 'velin-transparency__details';
67
+ details.hidden = renderer === 'badge' || renderer === 'icon';
68
+ details.textContent = formatDetails(record, lang);
69
+
70
+ mark.replaceChildren(sr, visible, details);
71
+
72
+ if (record.description) mark.title = record.description;
73
+ else if (details.textContent) mark.title = details.textContent;
74
+
75
+ el.setAttribute('data-velin-transparency', record.id);
76
+ return mark;
77
+ }
78
+
79
+ function provenanceSr(p, lang) {
80
+ const parts = [];
81
+ if (p.createdBy) parts.push(lang === 'de' ? `Erstellt von ${p.createdBy}` : `Created by ${p.createdBy}`);
82
+ if (p.createdAt) parts.push(lang === 'de' ? `am ${p.createdAt}` : `on ${p.createdAt}`);
83
+ if (p.approvedBy) parts.push(lang === 'de' ? `Freigegeben von ${p.approvedBy}` : `Approved by ${p.approvedBy}`);
84
+ if (p.license) parts.push(p.license);
85
+ if (p.version) parts.push(`v${p.version}`);
86
+ return parts.join('. ');
87
+ }
88
+
89
+ function formatDetails(record, lang) {
90
+ const p = record.provenance || {};
91
+ const bits = [];
92
+ if (p.approvedBy) bits.push(lang === 'de' ? `Freigabe: ${p.approvedBy}` : `Approved: ${p.approvedBy}`);
93
+ if (p.license) bits.push(p.license);
94
+ if (p.version) bits.push(`v${p.version}`);
95
+ if (p.updated || record.updated) bits.push(record.updated || p.publishedAt || '');
96
+ return bits.filter(Boolean).join(' · ');
97
+ }
98
+
99
+ // Register alias names to default path
100
+ for (const name of ['overlay', 'badge', 'inline', 'tooltip', 'footer', 'ribbon', 'panel', 'icon', 'corner-badge', 'stamp', 'sidebar', 'floating-card', 'banner']) {
101
+ registerTransparencyRenderer(name, (el, record) => {
102
+ defaultRender(el, { ...record, renderer: normalizeRendererName(name) });
103
+ });
104
+ }
105
+
106
+ function normalizeRendererName(name) {
107
+ if (name === 'corner-badge' || name === 'stamp') return 'badge';
108
+ if (name === 'floating-card' || name === 'sidebar') return 'panel';
109
+ if (name === 'banner') return 'footer';
110
+ return name;
111
+ }