ds-tis 1.0.0-beta.10

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 (79) hide show
  1. package/LICENSE +49 -0
  2. package/README.md +125 -0
  3. package/css/base/forced-colors.css +62 -0
  4. package/css/base/icons.css +73 -0
  5. package/css/base/index.css +4 -0
  6. package/css/base/reset.css +80 -0
  7. package/css/base/typography.css +211 -0
  8. package/css/components/accordion.css +153 -0
  9. package/css/components/alert.css +161 -0
  10. package/css/components/avatar.css +76 -0
  11. package/css/components/badge.css +83 -0
  12. package/css/components/breadcrumb.css +58 -0
  13. package/css/components/button.css +364 -0
  14. package/css/components/card.css +159 -0
  15. package/css/components/checkbox.css +296 -0
  16. package/css/components/combobox.css +330 -0
  17. package/css/components/divider.css +20 -0
  18. package/css/components/form-field.css +137 -0
  19. package/css/components/index.css +28 -0
  20. package/css/components/input.css +356 -0
  21. package/css/components/link.css +67 -0
  22. package/css/components/menu.css +246 -0
  23. package/css/components/modal.css +236 -0
  24. package/css/components/pagination.css +132 -0
  25. package/css/components/radio.css +280 -0
  26. package/css/components/select.css +399 -0
  27. package/css/components/skeleton.css +52 -0
  28. package/css/components/spinner.css +59 -0
  29. package/css/components/tabs.css +79 -0
  30. package/css/components/textarea.css +218 -0
  31. package/css/components/toggle.css +202 -0
  32. package/css/components/tooltip.css +116 -0
  33. package/css/design-system.css +13 -0
  34. package/css/tokens/generated/component.css +720 -0
  35. package/css/tokens/generated/foundation.css +282 -0
  36. package/css/tokens/generated/index.css +5 -0
  37. package/css/tokens/generated/theme-dark.css +243 -0
  38. package/css/tokens/generated/theme-light.css +244 -0
  39. package/css/tokens/index.css +6 -0
  40. package/css/utilities/elevation.css +24 -0
  41. package/css/utilities/index.css +2 -0
  42. package/css/utilities/layout.css +132 -0
  43. package/docs/agent-consumer-usage.en.md +322 -0
  44. package/docs/agent-consumer-usage.md +294 -0
  45. package/docs/api/adrs.json +186 -0
  46. package/docs/api/components.json +2071 -0
  47. package/docs/api/consumer-context.json +66 -0
  48. package/docs/api/foundations.json +94 -0
  49. package/docs/api/tokens.json +6839 -0
  50. package/docs/llms-full.txt +23699 -0
  51. package/docs/llms.txt +109 -0
  52. package/docs/templates/contact.html +364 -0
  53. package/docs/templates/dashboard.html +318 -0
  54. package/docs/templates/index.html +232 -0
  55. package/docs/templates/login.html +286 -0
  56. package/docs/templates/settings.html +365 -0
  57. package/docs/templates/signup.html +350 -0
  58. package/js/accordion.js +192 -0
  59. package/js/combobox.js +263 -0
  60. package/js/menu.js +301 -0
  61. package/js/modal.js +256 -0
  62. package/js/package.json +3 -0
  63. package/js/tabs.js +200 -0
  64. package/js/theme/apply.js +75 -0
  65. package/js/theme/brand-contrast-audit.js +133 -0
  66. package/js/theme/color.js +149 -0
  67. package/js/theme/config-schema.js +40 -0
  68. package/js/theme/contrast.js +76 -0
  69. package/js/theme/export.js +118 -0
  70. package/js/theme/index.js +21 -0
  71. package/js/theme/overlay.js +29 -0
  72. package/js/theme/package.json +3 -0
  73. package/js/theme/palette.js +103 -0
  74. package/js/theme/radius.js +76 -0
  75. package/js/theme/semantic-mapper.js +138 -0
  76. package/js/theme/typography.js +33 -0
  77. package/js/theme/url-state.js +52 -0
  78. package/js/tooltip.js +227 -0
  79. package/package.json +139 -0
package/js/modal.js ADDED
@@ -0,0 +1,256 @@
1
+ /* ============================================================
2
+ modal.js — runtime público para Modal / Dialog (required)
3
+
4
+ Anatomia e estados visuais: css/components/modal.css
5
+ Uso:
6
+ <button data-ds-modal-open="confirm-modal">Delete</button>
7
+ <div class="ds-modal-overlay" id="confirm-modal" hidden>
8
+ <div class="ds-modal" role="dialog" aria-modal="true" aria-labelledby="...">
9
+ ...
10
+ <button class="ds-modal__close" type="button" aria-label="Close modal">...</button>
11
+ </div>
12
+ </div>
13
+
14
+ Ciclo de vida:
15
+ const instances = initModals(root);
16
+ destroyModals(root); // ou instance.destroy()
17
+ ============================================================ */
18
+
19
+ const instances = new Set();
20
+ const triggerControllers = new Map();
21
+
22
+ function getFocusable(container) {
23
+ return [...container.querySelectorAll(
24
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
25
+ )].filter((el) => !el.hasAttribute('disabled') && el.getAttribute('aria-hidden') !== 'true');
26
+ }
27
+
28
+ function trapFocus(overlay, dialog) {
29
+ function onKeyDown(e) {
30
+ if (e.key !== 'Tab') return;
31
+ const focusable = getFocusable(dialog);
32
+ if (focusable.length === 0) {
33
+ e.preventDefault();
34
+ return;
35
+ }
36
+ const first = focusable[0];
37
+ const last = focusable[focusable.length - 1];
38
+ if (e.shiftKey && document.activeElement === first) {
39
+ e.preventDefault();
40
+ last.focus();
41
+ } else if (!e.shiftKey && document.activeElement === last) {
42
+ e.preventDefault();
43
+ first.focus();
44
+ }
45
+ }
46
+ overlay.addEventListener('keydown', onKeyDown);
47
+ return () => overlay.removeEventListener('keydown', onKeyDown);
48
+ }
49
+
50
+ function emit(overlay, name, detail) {
51
+ overlay.dispatchEvent(new CustomEvent(name, { bubbles: true, detail }));
52
+ }
53
+
54
+ function getBackgroundNodes(overlay) {
55
+ const nodes = [];
56
+ let current = overlay;
57
+ while (current && current !== document.body) {
58
+ const parent = current.parentElement;
59
+ if (!parent) break;
60
+ for (const sibling of parent.children) {
61
+ if (sibling !== current) nodes.push(sibling);
62
+ }
63
+ current = parent;
64
+ }
65
+ return [...new Set(nodes)];
66
+ }
67
+
68
+ function syncDocumentOpenClass() {
69
+ const hasOpenModal = [...instances].some((instance) => !instance.overlay.hidden);
70
+ document.documentElement.classList.toggle('ds-modal-open', hasOpenModal);
71
+ }
72
+
73
+ function createInstance(overlay) {
74
+ const dialog = overlay.querySelector('.ds-modal[role="dialog"], .ds-modal');
75
+ if (!dialog) return null;
76
+ if (!dialog.hasAttribute('tabindex')) dialog.setAttribute('tabindex', '-1');
77
+
78
+ let previousFocus = null;
79
+ let releaseFocusTrap = null;
80
+ let inertStates = [];
81
+ const cleanups = [];
82
+
83
+ const on = (target, type, handler, options) => {
84
+ target.addEventListener(type, handler, options);
85
+ cleanups.push(() => target.removeEventListener(type, handler, options));
86
+ };
87
+
88
+ const inst = {
89
+ overlay,
90
+ dialog,
91
+ open() {
92
+ if (!overlay.hidden) return;
93
+ previousFocus = document.activeElement;
94
+ overlay.hidden = false;
95
+ syncDocumentOpenClass();
96
+ inertStates = getBackgroundNodes(overlay).map((node) => ({
97
+ node,
98
+ inert: node.inert,
99
+ marker: node.getAttribute('data-ds-modal-inert'),
100
+ }));
101
+ for (const { node } of inertStates) {
102
+ node.dataset.dsModalInert = 'true';
103
+ node.inert = true;
104
+ }
105
+ releaseFocusTrap = trapFocus(overlay, dialog);
106
+ const focusable = getFocusable(dialog);
107
+ (focusable[0] || dialog).focus();
108
+ emit(overlay, 'ds-modal-open', {
109
+ overlay,
110
+ dialog,
111
+ trigger: previousFocus instanceof HTMLElement ? previousFocus : null,
112
+ });
113
+ },
114
+ close() {
115
+ if (overlay.hidden) return;
116
+ const returnFocus = previousFocus;
117
+ overlay.hidden = true;
118
+ for (const { node, inert, marker } of inertStates) {
119
+ if (marker === null) delete node.dataset.dsModalInert;
120
+ else node.setAttribute('data-ds-modal-inert', marker);
121
+ node.inert = inert;
122
+ }
123
+ inertStates = [];
124
+ if (typeof releaseFocusTrap === 'function') releaseFocusTrap();
125
+ releaseFocusTrap = null;
126
+ if (returnFocus && typeof returnFocus.focus === 'function') returnFocus.focus();
127
+ previousFocus = null;
128
+ syncDocumentOpenClass();
129
+ emit(overlay, 'ds-modal-close', {
130
+ overlay,
131
+ dialog,
132
+ returnFocus: returnFocus instanceof HTMLElement ? returnFocus : null,
133
+ });
134
+ },
135
+ destroy() {
136
+ inst.close();
137
+ while (cleanups.length) cleanups.pop()();
138
+ delete overlay.dataset.dsModalInit;
139
+ instances.delete(inst);
140
+ },
141
+ };
142
+
143
+ on(overlay, 'click', (e) => {
144
+ if (e.target === overlay) inst.close();
145
+ });
146
+
147
+ on(overlay, 'keydown', (e) => {
148
+ if (e.key === 'Escape') {
149
+ e.preventDefault();
150
+ inst.close();
151
+ }
152
+ });
153
+
154
+ for (const closeBtn of overlay.querySelectorAll('.ds-modal__close')) {
155
+ on(closeBtn, 'click', () => inst.close());
156
+ }
157
+
158
+ return inst;
159
+ }
160
+
161
+ function bindTrigger(trigger) {
162
+ if (triggerControllers.has(trigger)) return;
163
+ const controller = new AbortController();
164
+ trigger.addEventListener('click', (e) => {
165
+ e.preventDefault();
166
+ const targetId = trigger.getAttribute('data-ds-modal-open');
167
+ if (!targetId) return;
168
+ const overlay = document.getElementById(targetId.replace(/^#/, ''));
169
+ const inst = [...instances].find((item) => item.overlay === overlay);
170
+ inst?.open();
171
+ }, { signal: controller.signal });
172
+ trigger.dataset.dsModalTriggerInit = 'true';
173
+ triggerControllers.set(trigger, controller);
174
+ }
175
+
176
+ function unbindTrigger(trigger) {
177
+ const controller = triggerControllers.get(trigger);
178
+ if (!controller) return;
179
+ controller.abort();
180
+ triggerControllers.delete(trigger);
181
+ delete trigger.dataset.dsModalTriggerInit;
182
+ }
183
+
184
+ function isInside(root, node) {
185
+ return root === document || root === node || (typeof root.contains === 'function' && root.contains(node));
186
+ }
187
+
188
+ /**
189
+ * @param {ParentNode} [root]
190
+ */
191
+ export function initModals(root = document) {
192
+ const created = [];
193
+
194
+ const overlays = [
195
+ ...(root instanceof Element && root.matches('.ds-modal-overlay') ? [root] : []),
196
+ ...root.querySelectorAll('.ds-modal-overlay'),
197
+ ];
198
+ overlays.forEach((overlay) => {
199
+ if (overlay.dataset.dsModalInit === 'true') return;
200
+ const inst = createInstance(overlay);
201
+ if (inst) {
202
+ overlay.dataset.dsModalInit = 'true';
203
+ instances.add(inst);
204
+ created.push(inst);
205
+ }
206
+ });
207
+
208
+ const triggers = [
209
+ ...(root instanceof Element && root.matches('[data-ds-modal-open]') ? [root] : []),
210
+ ...root.querySelectorAll('[data-ds-modal-open]'),
211
+ ];
212
+ triggers.forEach((trigger) => bindTrigger(trigger));
213
+
214
+ return created;
215
+ }
216
+
217
+ /**
218
+ * Remove listeners e estado de init dentro de `root`.
219
+ * @param {ParentNode} [root]
220
+ */
221
+ export function destroyModals(root = document) {
222
+ for (const inst of [...instances]) {
223
+ if (isInside(root, inst.overlay)) inst.destroy();
224
+ }
225
+ const triggers = [
226
+ ...(root instanceof Element && root.matches('[data-ds-modal-open]') ? [root] : []),
227
+ ...root.querySelectorAll('[data-ds-modal-open]'),
228
+ ];
229
+ triggers.forEach((trigger) => {
230
+ if (isInside(root, trigger)) unbindTrigger(trigger);
231
+ });
232
+ }
233
+
234
+ /**
235
+ * @param {string|HTMLElement} target
236
+ */
237
+ export function openModal(target) {
238
+ const overlay = typeof target === 'string'
239
+ ? document.getElementById(target.replace(/^#/, ''))
240
+ : target;
241
+ const inst = [...instances].find((item) => item.overlay === overlay);
242
+ inst?.open();
243
+ return inst;
244
+ }
245
+
246
+ /**
247
+ * @param {string|HTMLElement} target
248
+ */
249
+ export function closeModal(target) {
250
+ const overlay = typeof target === 'string'
251
+ ? document.getElementById(target.replace(/^#/, ''))
252
+ : target;
253
+ const inst = [...instances].find((item) => item.overlay === overlay);
254
+ inst?.close();
255
+ return inst;
256
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "module"
3
+ }
package/js/tabs.js ADDED
@@ -0,0 +1,200 @@
1
+ /* ============================================================
2
+ tabs.js — runtime público para Tabs (required)
3
+
4
+ Anatomia e estados visuais: css/components/tabs.css
5
+ Uso:
6
+ <div class="ds-tabs" role="tablist" aria-label="…">
7
+ <button class="ds-tab ds-tab--active" role="tab"
8
+ aria-selected="true" aria-controls="panel-1"
9
+ id="tab-1">…</button>
10
+ <button class="ds-tab" role="tab"
11
+ aria-selected="false" aria-controls="panel-2"
12
+ id="tab-2" tabindex="-1">…</button>
13
+ </div>
14
+ <div class="ds-tab-panel" role="tabpanel" id="panel-1"
15
+ aria-labelledby="tab-1">…</div>
16
+ <div class="ds-tab-panel" role="tabpanel" id="panel-2"
17
+ aria-labelledby="tab-2" hidden>…</div>
18
+
19
+ Ciclo de vida:
20
+ const instances = initTabs(root);
21
+ destroyTabs(root); // ou instance.destroy()
22
+ ============================================================ */
23
+
24
+ const instances = new Set();
25
+
26
+ function getTabs(tablist) {
27
+ return [...tablist.querySelectorAll(':scope > .ds-tab[role="tab"]')];
28
+ }
29
+
30
+ function isDisabled(tab) {
31
+ return tab.disabled || tab.getAttribute('aria-disabled') === 'true';
32
+ }
33
+
34
+ function getEnabledTabs(tabs) {
35
+ return tabs.filter((tab) => !isDisabled(tab));
36
+ }
37
+
38
+ function getPanel(tab) {
39
+ const id = tab.getAttribute('aria-controls');
40
+ return id ? tab.ownerDocument.getElementById(id) : null;
41
+ }
42
+
43
+ function emit(tablist, name, detail) {
44
+ tablist.dispatchEvent(new CustomEvent(name, { bubbles: true, detail }));
45
+ }
46
+
47
+ function createInstance(tablist) {
48
+ const tabs = getTabs(tablist);
49
+ if (!tabs.length) return null;
50
+
51
+ const cleanups = [];
52
+ const on = (target, type, handler, options) => {
53
+ target.addEventListener(type, handler, options);
54
+ cleanups.push(() => target.removeEventListener(type, handler, options));
55
+ };
56
+
57
+ const inst = {
58
+ root: tablist,
59
+ select(tab, { focus = false } = {}) {
60
+ if (!tab || !tabs.includes(tab) || isDisabled(tab)) return;
61
+ const previous = tabs.find((item) => item.getAttribute('aria-selected') === 'true') || null;
62
+ if (previous === tab) {
63
+ if (focus) tab.focus();
64
+ return;
65
+ }
66
+
67
+ for (const item of tabs) {
68
+ const selected = item === tab;
69
+ item.setAttribute('aria-selected', String(selected));
70
+ item.classList.toggle('ds-tab--active', selected);
71
+ item.tabIndex = selected ? 0 : -1;
72
+ const panel = getPanel(item);
73
+ if (panel) panel.hidden = !selected;
74
+ }
75
+
76
+ if (focus) tab.focus();
77
+ emit(tablist, 'ds-tabs-change', {
78
+ root: tablist,
79
+ tab,
80
+ panel: getPanel(tab),
81
+ previousTab: previous,
82
+ });
83
+ },
84
+ destroy() {
85
+ while (cleanups.length) cleanups.pop()();
86
+ delete tablist.dataset.dsTabsInit;
87
+ instances.delete(inst);
88
+ },
89
+ };
90
+
91
+ function moveSelection(current, direction) {
92
+ const enabled = getEnabledTabs(tabs);
93
+ if (!enabled.length) return;
94
+ const currentIndex = enabled.indexOf(current);
95
+ if (currentIndex === -1) return;
96
+
97
+ let nextIndex;
98
+ if (direction === 'first') nextIndex = 0;
99
+ else if (direction === 'last') nextIndex = enabled.length - 1;
100
+ else nextIndex = (currentIndex + direction + enabled.length) % enabled.length;
101
+
102
+ inst.select(enabled[nextIndex], { focus: true });
103
+ }
104
+
105
+ // Sync initial state from markup (prefer aria-selected / --active).
106
+ let initial = tabs.find((tab) => tab.getAttribute('aria-selected') === 'true' && !isDisabled(tab))
107
+ || tabs.find((tab) => tab.classList.contains('ds-tab--active') && !isDisabled(tab))
108
+ || getEnabledTabs(tabs)[0]
109
+ || null;
110
+
111
+ for (const tab of tabs) {
112
+ if (tab instanceof HTMLButtonElement && !tab.hasAttribute('type')) {
113
+ tab.type = 'button';
114
+ }
115
+ const selected = tab === initial;
116
+ tab.setAttribute('aria-selected', String(selected));
117
+ tab.classList.toggle('ds-tab--active', selected);
118
+ tab.tabIndex = selected ? 0 : -1;
119
+ const panel = getPanel(tab);
120
+ if (panel) {
121
+ panel.hidden = !selected;
122
+ if (!panel.hasAttribute('tabindex')) panel.tabIndex = 0;
123
+ }
124
+ }
125
+
126
+ for (const tab of tabs) {
127
+ on(tab, 'click', (event) => {
128
+ event.preventDefault();
129
+ if (isDisabled(tab)) return;
130
+ inst.select(tab, { focus: true });
131
+ });
132
+
133
+ on(tab, 'keydown', (e) => {
134
+ if (e.key === 'ArrowRight') {
135
+ e.preventDefault();
136
+ moveSelection(tab, 1);
137
+ } else if (e.key === 'ArrowLeft') {
138
+ e.preventDefault();
139
+ moveSelection(tab, -1);
140
+ } else if (e.key === 'Home') {
141
+ e.preventDefault();
142
+ moveSelection(tab, 'first');
143
+ } else if (e.key === 'End') {
144
+ e.preventDefault();
145
+ moveSelection(tab, 'last');
146
+ }
147
+ });
148
+ }
149
+
150
+ return inst;
151
+ }
152
+
153
+ function isInside(root, node) {
154
+ return root === document || root === node || (typeof root.contains === 'function' && root.contains(node));
155
+ }
156
+
157
+ /**
158
+ * @param {ParentNode} [root]
159
+ */
160
+ export function initTabs(root = document) {
161
+ const created = [];
162
+ const tablists = [];
163
+
164
+ if (root instanceof Element && root.matches('.ds-tabs')) tablists.push(root);
165
+ if (typeof root.querySelectorAll === 'function') {
166
+ tablists.push(...root.querySelectorAll('.ds-tabs'));
167
+ }
168
+
169
+ tablists.forEach((tablist) => {
170
+ if (!tablist.hasAttribute('role')) tablist.setAttribute('role', 'tablist');
171
+ if (tablist.dataset.dsTabsInit === 'true') return;
172
+ const inst = createInstance(tablist);
173
+ if (inst) {
174
+ tablist.dataset.dsTabsInit = 'true';
175
+ instances.add(inst);
176
+ created.push(inst);
177
+ }
178
+ });
179
+
180
+ return created;
181
+ }
182
+
183
+ /**
184
+ * @param {ParentNode} [root]
185
+ */
186
+ export function destroyTabs(root = document) {
187
+ for (const inst of [...instances]) {
188
+ if (isInside(root, inst.root)) inst.destroy();
189
+ }
190
+ }
191
+
192
+ /**
193
+ * @param {HTMLElement} tab
194
+ */
195
+ export function selectTab(tab) {
196
+ const tablist = tab?.closest?.('.ds-tabs');
197
+ const inst = [...instances].find((item) => item.root === tablist);
198
+ inst?.select(tab, { focus: true });
199
+ return inst;
200
+ }
@@ -0,0 +1,75 @@
1
+ /* ============================================================
2
+ apply.js — aplica o tema em runtime no DOM (browser only)
3
+
4
+ Escreve as CSS vars geradas no elemento raiz via inline style,
5
+ com precedência sobre os arquivos gerados. Reversível via reset.
6
+ ============================================================ */
7
+
8
+ import { normalizeConfig } from './config-schema.js';
9
+ import { mapThemeToVars } from './semantic-mapper.js';
10
+
11
+ /** Vars inline setadas na última `applyTheme` por elemento raiz. */
12
+ const appliedByRoot = new WeakMap();
13
+
14
+ function getAppliedSet(el) {
15
+ if (!appliedByRoot.has(el)) appliedByRoot.set(el, new Set());
16
+ return appliedByRoot.get(el);
17
+ }
18
+
19
+ /** Lista de props que este engine controla — usada para reset limpo. */
20
+ export function controlledVarNames(config) {
21
+ const cfg = normalizeConfig(config);
22
+ return Object.keys(mapThemeToVars(cfg, cfg.mode).vars);
23
+ }
24
+
25
+ /**
26
+ * Aplica o config no `root` (default: <html>). Também sincroniza o
27
+ * atributo data-mode. Retorna o relatório de contraste.
28
+ *
29
+ * @param {object} config - ThemeConfig (parcial ou completo).
30
+ * @param {object} [opts]
31
+ * @param {HTMLElement} [opts.root] - alvo (default: documentElement).
32
+ * @returns {{ contrast: object, vars: Record<string,string> }}
33
+ */
34
+ export function applyTheme(config, { root } = {}) {
35
+ const el = root || (typeof document !== 'undefined' ? document.documentElement : null);
36
+ if (!el) throw new Error('applyTheme requer um DOM (browser) ou opts.root');
37
+
38
+ const cfg = normalizeConfig(config);
39
+ const { vars, contrast } = mapThemeToVars(cfg, cfg.mode);
40
+
41
+ const prev = getAppliedSet(el);
42
+ const next = new Set(Object.keys(vars));
43
+
44
+ for (const name of prev) {
45
+ if (!next.has(name)) el.style.removeProperty(name);
46
+ }
47
+
48
+ for (const [name, value] of Object.entries(vars)) {
49
+ el.style.setProperty(name, value);
50
+ }
51
+
52
+ appliedByRoot.set(el, next);
53
+
54
+ if (cfg.mode === 'dark') el.setAttribute('data-mode', 'dark');
55
+ else el.removeAttribute('data-mode');
56
+
57
+ return { contrast, vars };
58
+ }
59
+
60
+ /** Remove todas as props controladas, voltando aos tokens gerados. */
61
+ export function resetTheme(config, { root } = {}) {
62
+ const el = root || (typeof document !== 'undefined' ? document.documentElement : null);
63
+ if (!el) return;
64
+
65
+ const prev = appliedByRoot.get(el);
66
+ if (prev) {
67
+ for (const name of prev) el.style.removeProperty(name);
68
+ appliedByRoot.delete(el);
69
+ return;
70
+ }
71
+
72
+ for (const name of controlledVarNames(config)) {
73
+ el.style.removeProperty(name);
74
+ }
75
+ }
@@ -0,0 +1,133 @@
1
+ /* ============================================================
2
+ brand-contrast-audit.js — pares WCAG alinhados ao contrato semântico
3
+ ============================================================ */
4
+
5
+ import { normalizeConfig } from './config-schema.js';
6
+ import { mapThemeToVars } from './semantic-mapper.js';
7
+ import { generateBrandScale } from './palette.js';
8
+ import { auditPairs, WCAG_AA_TEXT, WCAG_AA_UI } from './contrast.js';
9
+
10
+ /** Superfícies neutras (foundation.color.neutral) para composição de overlays. */
11
+ const SURFACE = {
12
+ light: '#F8FAFC', // neutral-50 — background.default light
13
+ dark: '#070C17', // neutral-950 — background.default dark
14
+ };
15
+
16
+ /** Rótulos bilíngues indexados por chave estável. */
17
+ export const CONTRAST_LABELS = {
18
+ brandFill: { pt: 'Fill brand / foreground', en: 'Brand fill / foreground' },
19
+ toned: { pt: 'Toned / conteúdo', en: 'Toned fill / content' },
20
+ contentBrand: { pt: 'Content brand / superfície', en: 'Brand content / surface' },
21
+ contentBrandTinted: { pt: 'Content brand / fundo tinted', en: 'Brand content / tinted bg' },
22
+ link: { pt: 'Link / superfície', en: 'Link / surface' },
23
+ focus: { pt: 'Focus ring / superfície', en: 'Focus ring / surface' },
24
+ };
25
+
26
+ /**
27
+ * Monta pares de contraste para um modo, espelhando tokens semânticos brand/toned/link.
28
+ * @param {object} config - ThemeConfig normalizado ou parcial.
29
+ * @param {'light'|'dark'} mode
30
+ * @returns {Array<{ key: string, name: string, fg: string, bg: string, under?: string, threshold: number }>}
31
+ */
32
+ export function buildBrandContrastPairs(config, mode) {
33
+ const cfg = normalizeConfig({ ...config, mode });
34
+ const { vars, contrast } = mapThemeToVars(cfg, mode);
35
+ const scale = generateBrandScale(cfg.brand.seed, { chromaBoost: cfg.brand.chromaBoost ?? 1 });
36
+ const surface = SURFACE[mode];
37
+ const tintedBg = mode === 'light'
38
+ ? scale[50]
39
+ : vars['--ds-toned-background-default'];
40
+ const tintedUnder = mode === 'light' ? undefined : surface;
41
+
42
+ return [
43
+ {
44
+ key: 'brandFill',
45
+ name: CONTRAST_LABELS.brandFill.en,
46
+ fg: contrast.foreground,
47
+ bg: contrast.brandFill,
48
+ threshold: WCAG_AA_UI,
49
+ },
50
+ {
51
+ key: 'toned',
52
+ name: CONTRAST_LABELS.toned.en,
53
+ fg: vars['--ds-toned-content-default'],
54
+ bg: vars['--ds-toned-background-default'],
55
+ under: surface,
56
+ threshold: WCAG_AA_UI,
57
+ },
58
+ {
59
+ key: 'contentBrand',
60
+ name: CONTRAST_LABELS.contentBrand.en,
61
+ fg: vars['--ds-content-brand'],
62
+ bg: surface,
63
+ threshold: WCAG_AA_TEXT,
64
+ },
65
+ {
66
+ key: 'contentBrandTinted',
67
+ name: CONTRAST_LABELS.contentBrandTinted.en,
68
+ fg: vars['--ds-content-brand'],
69
+ bg: tintedBg,
70
+ ...(tintedUnder ? { under: tintedUnder } : {}),
71
+ threshold: WCAG_AA_TEXT,
72
+ },
73
+ {
74
+ key: 'link',
75
+ name: CONTRAST_LABELS.link.en,
76
+ fg: vars['--ds-link-content-default'],
77
+ bg: surface,
78
+ threshold: WCAG_AA_TEXT,
79
+ },
80
+ {
81
+ key: 'focus',
82
+ name: CONTRAST_LABELS.focus.en,
83
+ fg: vars['--ds-border-focus'],
84
+ bg: surface,
85
+ threshold: WCAG_AA_UI,
86
+ },
87
+ ];
88
+ }
89
+
90
+ /**
91
+ * Audita light e dark; retorna linhas flat com mode + resultado auditPairs.
92
+ * @param {object} config
93
+ * @returns {{ rows: Array, allPass: boolean, failCount: number }}
94
+ */
95
+ export function auditBrandTheme(config) {
96
+ const rows = [];
97
+ for (const mode of ['light', 'dark']) {
98
+ const pairs = buildBrandContrastPairs(config, mode);
99
+ for (const p of pairs) {
100
+ const audited = auditPairs([{
101
+ name: p.name,
102
+ fg: p.fg,
103
+ bg: p.bg,
104
+ threshold: p.threshold,
105
+ ...(p.under ? { under: p.under } : {}),
106
+ }])[0];
107
+ rows.push({
108
+ mode,
109
+ key: p.key,
110
+ labelKey: p.key,
111
+ ...audited,
112
+ under: p.under,
113
+ });
114
+ }
115
+ }
116
+ const failCount = rows.filter((r) => !r.passes).length;
117
+ return { rows, allPass: failCount === 0, failCount };
118
+ }
119
+
120
+ /** Vars brand-related que o mapper deve emitir (paridade com theme-light/dark.css). */
121
+ export const CANONICAL_BRAND_CSS_VARS = [
122
+ '--ds-color-brand-600',
123
+ '--ds-overlay-brand-600-12',
124
+ '--ds-brand-background-default',
125
+ '--ds-brand-content-default',
126
+ '--ds-toned-background-default',
127
+ '--ds-toned-content-default',
128
+ '--ds-content-brand',
129
+ '--ds-link-content-default',
130
+ '--ds-border-focus',
131
+ '--ds-border-brand',
132
+ '--ds-focus-ring-color',
133
+ ];