@usableapp/cardds 0.7.95 → 0.7.96

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.
@@ -0,0 +1,22 @@
1
+ export type CardGround = {
2
+ /** where the card stands: the screen (`--surface`), a sheet's body (`--card-1-bg`, a `Sheet`'s default tone), a `paper` sheet's body (`--color-paper`) */
3
+ ground: 'screen' | 'sheet' | 'paper sheet';
4
+ tone: 1 | 2 | 3 | 4 | 5;
5
+ /** the card's fill against its ground, as a contrast ratio (1–21) */
6
+ fill: number;
7
+ /** the strongest hard edge (its border, or a shadow with no blur) against the ground; 1 when it has none */
8
+ edge: number;
9
+ /** fill or edge reaches 3:1 (WCAG 2.2 SC 1.4.11, non-text contrast) */
10
+ pass: boolean;
11
+ /** what fails and which tokens to move, by how much; empty when it passes */
12
+ problem: string;
13
+ };
14
+ /**
15
+ * Can the member see each card's boundary? Builds a card of every tone (1–5) on each ground a card stands on (the screen, a
16
+ * sheet's body, a `paper` sheet's body) inside `root`, reads what the browser draws (so the project's own theme.css, palette and
17
+ * finish are what is measured) and returns one row per pair: the fill's contrast with the ground, the edge's, and whether one of
18
+ * them reaches 3:1. Run it where the theme is loaded: a dev page (`console.table(cardGrounds())`) or a Playwright test
19
+ * (`await page.evaluate(cardGrounds)`). It reports; the theme's values stay the project's call. Self-contained on purpose: Playwright
20
+ * sends the function's source to the page, so it reads nothing from outside its own body.
21
+ */
22
+ export declare function cardGrounds(root?: Element): CardGround[];
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Can the member see each card's boundary? Builds a card of every tone (1–5) on each ground a card stands on (the screen, a
3
+ * sheet's body, a `paper` sheet's body) inside `root`, reads what the browser draws (so the project's own theme.css, palette and
4
+ * finish are what is measured) and returns one row per pair: the fill's contrast with the ground, the edge's, and whether one of
5
+ * them reaches 3:1. Run it where the theme is loaded: a dev page (`console.table(cardGrounds())`) or a Playwright test
6
+ * (`await page.evaluate(cardGrounds)`). It reports; the theme's values stay the project's call. Self-contained on purpose: Playwright
7
+ * sends the function's source to the page, so it reads nothing from outside its own body.
8
+ */
9
+ export function cardGrounds(root = document.body) {
10
+ const ctx = document.createElement('canvas').getContext('2d', { willReadFrequently: true });
11
+ const paint = (layers) => {
12
+ ctx.globalCompositeOperation = 'copy';
13
+ ctx.fillStyle = '#fff';
14
+ ctx.fillRect(0, 0, 1, 1);
15
+ ctx.globalCompositeOperation = 'source-over';
16
+ for (const c of layers) {
17
+ ctx.fillStyle = c;
18
+ ctx.fillRect(0, 0, 1, 1);
19
+ }
20
+ return [...ctx.getImageData(0, 0, 1, 1).data.slice(0, 3)];
21
+ };
22
+ const lum = (rgb) => {
23
+ const [r, g, b] = rgb.map((v) => { const s = v / 255; return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; });
24
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
25
+ };
26
+ const ratio = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p); return Math.round(((x + 0.05) / (y + 0.05)) * 100) / 100; };
27
+ const hex = (rgb) => `#${rgb.map((v) => v.toString(16).padStart(2, '0')).join('')}`;
28
+ // a computed box-shadow lists `colour x y blur spread [inset]` per shadow; one with no blur draws a hard edge
29
+ const hardShadows = (value) => {
30
+ if (value === 'none')
31
+ return [];
32
+ const parts = [];
33
+ let depth = 0, from = 0;
34
+ for (let i = 0; i < value.length; i++) {
35
+ if (value[i] === '(')
36
+ depth++;
37
+ else if (value[i] === ')')
38
+ depth--;
39
+ else if (value[i] === ',' && depth === 0) {
40
+ parts.push(value.slice(from, i));
41
+ from = i + 1;
42
+ }
43
+ }
44
+ parts.push(value.slice(from));
45
+ return parts.flatMap((p) => {
46
+ const colour = p.trim().match(/^([a-z-]+\([^)]*\)|[a-z]+)/i)?.[0] ?? 'transparent';
47
+ const [x = 0, y = 0, blur = 0, spread = 0] = (p.replace(colour, '').match(/-?[\d.]+px/g) ?? []).map(parseFloat);
48
+ return !/inset/.test(p) && blur === 0 && (spread > 0 || x !== 0 || y !== 0) ? [colour] : [];
49
+ });
50
+ };
51
+ const below = (el) => {
52
+ const out = [];
53
+ for (let e = el; e; e = e.parentElement)
54
+ out.unshift(getComputedStyle(e).backgroundColor);
55
+ return out;
56
+ };
57
+ const probe = document.createElement('div');
58
+ probe.style.cssText = 'position:absolute;inset-inline-start:-200vw;inset-block-start:0;inline-size:20rem;';
59
+ const grounds = [
60
+ { ground: 'screen', html: '', end: '', token: '--surface' },
61
+ { ground: 'sheet', html: '<article class="card sheet"><div class="sheet__body">', end: '</div></article>', token: '--card-1-bg' },
62
+ { ground: 'paper sheet', html: '<article class="card sheet sheet--paper"><div class="sheet__body">', end: '</div></article>', token: '--color-paper' },
63
+ ];
64
+ const tones = [1, 2, 3, 4, 5];
65
+ probe.innerHTML = grounds.map((g) => `<div>${g.html}${tones.map((t) => `<div class="card${t > 1 ? ` card--${t}` : ''}"></div>`).join('')}${g.end}</div>`).join('');
66
+ root.append(probe);
67
+ try {
68
+ return grounds.flatMap((g, i) => tones.map((tone, j) => {
69
+ const card = probe.children[i].querySelectorAll('.card:not(.sheet)')[j];
70
+ const cs = getComputedStyle(card);
71
+ const under = below(card.parentElement);
72
+ const ground = paint(under), fill = paint([...under, cs.backgroundColor]);
73
+ const edges = [...(parseFloat(cs.borderTopWidth) > 0 ? [cs.borderTopColor] : []), ...hardShadows(cs.boxShadow)];
74
+ const edge = Math.max(1, ...edges.map((c) => ratio(paint([...under, c]), ground)));
75
+ const f = ratio(fill, ground), pass = f >= 3 || edge >= 3;
76
+ const where = g.ground === 'screen' ? 'on the screen' : `in a ${g.ground}'s body`;
77
+ const problem = pass ? '' : `tone ${tone} card ${where}: fill ${f}:1 (--card-${tone}-bg ${hex(fill)} on ${g.token} ${hex(ground)}), edge ${edge}:1 (--card-border); one of them needs 3:1 — the fill is short by ${Math.round((3 - f) * 100) / 100}, or give --card-border a colour of 3:1 on ${hex(ground)}`;
78
+ return { ground: g.ground, tone, fill: f, edge, pass, problem };
79
+ }));
80
+ }
81
+ finally {
82
+ probe.remove();
83
+ }
84
+ }
@@ -0,0 +1,34 @@
1
+ export type InkGround = {
2
+ /** what is read: the element's first class (or its tag) and its words, `icon in .<parent>`, or `glyph .<class>` for a mark drawn as a fill */
3
+ piece: string;
4
+ /** the nearest ancestor that paints a fill under it: its classes, e.g. `.card.card--3` */
5
+ surface: string;
6
+ /** the side that ground declares (`--dark`, or `--accent-dark` where it is the accent fill): its words and glyphs read that side's ink set */
7
+ side: 'light' | 'dark';
8
+ /** the ink set the colour is, read where it is drawn: `onlight`, `ondark`, or `` when it is neither (a fixed colour, a highlight) */
9
+ set: 'onlight' | 'ondark' | '';
10
+ kind: 'words' | 'large words' | 'icon' | 'glyph';
11
+ /** the colour as drawn (its alpha and every opacity on the way down), and the ground it is drawn on */
12
+ ink: string;
13
+ ground: string;
14
+ /** contrast ratio, 1–21 */
15
+ ratio: number;
16
+ /** 4.5 for words, 3 for large words (24px, or 18.66px bold), icons and glyphs: WCAG 2.2 SC 1.4.3 and 1.4.11 */
17
+ needs: 4.5 | 3;
18
+ pass: boolean;
19
+ /** what fails and by how much; empty when it passes */
20
+ problem: string;
21
+ };
22
+ /**
23
+ * Can the member read what stands on each surface? Walks every visible piece of words, every field's value, every icon (`svg.icon`) and
24
+ * every glyph drawn as a fill (a dot, a day's dot, the pager's dots, a check's tick, a switch's knob, a strong calendar mark's ring) inside
25
+ * `root`, finds the ground it is really drawn on (each ancestor's fill, from the page down, through every opacity on the way, to the first
26
+ * opaque one and past it) and returns one row per piece: words 4.5:1, large words, icons and glyphs 3:1. Each row names the side its ground
27
+ * declares and the ink set its colour is (README "Ink follows its ground": light ground → onlight, dark ground → ondark). What the browser
28
+ * draws is what is measured, so the project's theme.css, palette and finish are in it. Not measured: a piece on a picture (a `url()` ground
29
+ * is not one colour; a gradient is read as decoration unless it is one colour, a fill laid as an image), a disabled control (WCAG exempts
30
+ * it), a hidden one, words drawn by CSS `content`. Run it where the theme is loaded:
31
+ * `console.table(inkGrounds().filter((r) => !r.pass))` on a dev page, or `await page.evaluate(inkGrounds)` in Playwright.
32
+ * Self-contained for the same reason as cardGrounds(): Playwright sends the function's source to the page.
33
+ */
34
+ export declare function inkGrounds(root?: Element): InkGround[];
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Can the member read what stands on each surface? Walks every visible piece of words, every field's value, every icon (`svg.icon`) and
3
+ * every glyph drawn as a fill (a dot, a day's dot, the pager's dots, a check's tick, a switch's knob, a strong calendar mark's ring) inside
4
+ * `root`, finds the ground it is really drawn on (each ancestor's fill, from the page down, through every opacity on the way, to the first
5
+ * opaque one and past it) and returns one row per piece: words 4.5:1, large words, icons and glyphs 3:1. Each row names the side its ground
6
+ * declares and the ink set its colour is (README "Ink follows its ground": light ground → onlight, dark ground → ondark). What the browser
7
+ * draws is what is measured, so the project's theme.css, palette and finish are in it. Not measured: a piece on a picture (a `url()` ground
8
+ * is not one colour; a gradient is read as decoration unless it is one colour, a fill laid as an image), a disabled control (WCAG exempts
9
+ * it), a hidden one, words drawn by CSS `content`. Run it where the theme is loaded:
10
+ * `console.table(inkGrounds().filter((r) => !r.pass))` on a dev page, or `await page.evaluate(inkGrounds)` in Playwright.
11
+ * Self-contained for the same reason as cardGrounds(): Playwright sends the function's source to the page.
12
+ */
13
+ export function inkGrounds(root = document.body) {
14
+ const ctx = document.createElement('canvas').getContext('2d', { willReadFrequently: true });
15
+ const parsed = new Map();
16
+ const rgba = (c) => {
17
+ let v = parsed.get(c);
18
+ if (!v) {
19
+ ctx.clearRect(0, 0, 1, 1);
20
+ ctx.fillStyle = '#0000';
21
+ ctx.fillStyle = c;
22
+ ctx.fillRect(0, 0, 1, 1);
23
+ const d = ctx.getImageData(0, 0, 1, 1).data;
24
+ v = [d[0], d[1], d[2], d[3] / 255];
25
+ parsed.set(c, v);
26
+ }
27
+ return v;
28
+ };
29
+ const over = (below, c) => { const [r, g, b, a] = rgba(c); return [r, g, b].map((x, i) => below[i] * (1 - a) + x * a); };
30
+ const lum = (rgb) => {
31
+ const [r, g, b] = rgb.map((v) => { const s = Math.round(v) / 255; return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; });
32
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
33
+ };
34
+ const ratio = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p); return Math.round(((x + 0.05) / (y + 0.05)) * 100) / 100; };
35
+ const hex = (rgb) => `#${rgb.map((v) => Math.round(v).toString(16).padStart(2, '0')).join('')}`;
36
+ const styles = new Map();
37
+ const style = (el) => { let s = styles.get(el); if (!s) {
38
+ s = getComputedStyle(el);
39
+ styles.set(el, s);
40
+ } return s; };
41
+ const name = (el) => (el.classList.length ? `.${[...el.classList].join('.')}` : el.tagName.toLowerCase());
42
+ // what an element paints under its content: its colour, and a gradient of one colour over it (a fill laid as an image); any other
43
+ // gradient is a decoration (a button's sheen, a grip's dots) and is left out; null for a picture, where the ground is not one colour
44
+ const fills = (el) => {
45
+ const cs = style(el), image = cs.backgroundImage;
46
+ if (/url\(/.test(image))
47
+ return null;
48
+ const one = /^linear-gradient\(((?:rgba?|color)\([^)]*\)), \1\)$/.exec(image);
49
+ return one ? [cs.backgroundColor, one[1]] : [cs.backgroundColor];
50
+ };
51
+ // the pixel under a piece: the page's white, then each ancestor's fill; an ancestor's opacity is the lerp from what lies behind it to
52
+ // what it draws (exact for one pixel), so the ink and the ground it fades into are faded together
53
+ const draw = (chain, ink, i = 0, behind = [255, 255, 255]) => {
54
+ if (i === chain.length)
55
+ return ink ? over(behind, ink) : behind;
56
+ const o = parseFloat(style(chain[i]).opacity);
57
+ const inside = draw(chain, ink, i + 1, fills(chain[i]).reduce(over, behind));
58
+ return o === 1 ? inside : inside.map((v, k) => behind[k] + (v - behind[k]) * o);
59
+ };
60
+ // a side switch as the browser holds it (`1`, `0`, or a calc() such as `calc(1 - 1)`), read as a number through a probe's width
61
+ const probe = document.createElement('i');
62
+ probe.style.cssText = 'position:absolute;visibility:hidden;height:0';
63
+ document.body.append(probe);
64
+ const number = (v) => { if (/^[\d.]+$/.test(v))
65
+ return parseFloat(v); probe.style.width = `calc((${v || 0}) * 1px)`; return parseFloat(getComputedStyle(probe).width) || 0; };
66
+ const sideOf = (surface) => {
67
+ if (!surface)
68
+ return 'light';
69
+ // a surface (a card, a tile) paints its own --card-bg and declares --dark; a control filled with the accent paints --accent, whose side is --accent-dark
70
+ const cs = style(surface), fill = rgba(cs.backgroundColor).join(), same = (n) => { const v = cs.getPropertyValue(n).trim(); return v !== '' && rgba(v).join() === fill; };
71
+ const isAccent = !same('--card-bg') && same('--accent');
72
+ return number(cs.getPropertyValue(isAccent ? '--accent-dark' : '--dark').trim()) >= 0.5 ? 'dark' : 'light';
73
+ };
74
+ // which role a failing colour is, read where it is drawn: the token a theme moves to fix it, and the ink set behind it
75
+ const SETS = ['--ink-onlight', '--ink-onlight-muted', '--ink-ondark', '--ink-ondark-muted', ...['ok', 'warn', 'error', 'info'].flatMap((s) => [`--signal-${s}-onlight`, `--signal-${s}-ondark`])];
76
+ const INKS = ['--card-ink', '--card-muted', '--on-accent', '--accent-ink', '--accent', '--action-ink', '--action-muted', '--action-accent', '--on-surface', '--on-surface-muted',
77
+ '--signal-ok', '--signal-warn', '--signal-error', '--signal-info'];
78
+ const GROUNDS = ['--card-bg', '--accent', '--action-bg', '--surface', '--neg', '--pos', ...[1, 2, 3, 4, 5].map((t) => `--card-${t}-bg`), '--highlight-1', '--highlight-2', '--highlight-3', '--highlight-4'];
79
+ const tokenOf = (el, colour, names) => {
80
+ const want = rgba(colour).join();
81
+ return names.find((n) => { const v = style(el).getPropertyValue(n).trim(); return v !== '' && rgba(v).join() === want; }) ?? '';
82
+ };
83
+ const setOf = (token) => (token.includes('ondark') ? 'ondark' : token.includes('onlight') ? 'onlight' : '');
84
+ // GLYPHS drawn as a fill: the selector, and where its colour is: the element's own fill over what is under it, its ::after's fill over
85
+ // the element's, or its inset ring over the element's ground
86
+ const GLYPHS = [
87
+ ['.dot:is(.dot--ok, .dot--warn, .dot--error, .dot--info)', 'fill'], ['.day__dot', 'fill'], ['.pager__at > *', 'fill'],
88
+ ['.check:checked', 'after'], ['.toggle', 'after'],
89
+ ['.calendar__day--mark2', 'ring'],
90
+ ];
91
+ const pieces = new Map();
92
+ const walk = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
93
+ for (let n = walk.nextNode(); n; n = walk.nextNode()) {
94
+ const el = n.parentElement;
95
+ if (el && n.textContent.trim() && !el.closest('svg, style, script, textarea'))
96
+ pieces.set(el, { what: 'words' });
97
+ }
98
+ for (const el of root.querySelectorAll('svg.icon'))
99
+ if (!el.parentElement?.closest('svg'))
100
+ pieces.set(el, { what: 'icon' });
101
+ for (const el of root.querySelectorAll('input:not([type=checkbox], [type=radio], [type=range], [type=hidden], [type=color], [type=file]), textarea, select'))
102
+ if (el.value)
103
+ pieces.set(el, { what: 'value' });
104
+ for (const [sel, read] of GLYPHS)
105
+ for (const el of root.querySelectorAll(sel))
106
+ if (!pieces.has(el))
107
+ pieces.set(el, { what: 'glyph', read });
108
+ const out = [];
109
+ for (const [el, { what, read }] of pieces) {
110
+ if (!el.checkVisibility({ opacityProperty: true, visibilityProperty: true }))
111
+ continue;
112
+ const box = el.getBoundingClientRect();
113
+ if (box.width <= 1 || box.height <= 1)
114
+ continue; // visually hidden: words for a screen reader
115
+ if (el.closest(':disabled, [aria-disabled="true"], label:has(:disabled)'))
116
+ continue; // an inactive control: WCAG asks no contrast of it
117
+ const chain = [];
118
+ for (let e = el; e; e = e.parentElement)
119
+ chain.unshift(e);
120
+ const cs = style(el);
121
+ // a glyph drawn as its own fill stands on what is under it; any other piece stands on its own element's fill too (a chip's label is its text)
122
+ const under = read === 'fill' ? chain.slice(0, -1) : chain;
123
+ let pictured = false, surface;
124
+ for (let k = under.length - 1; k >= 0; k--) {
125
+ const f = fills(under[k]);
126
+ if (!f) {
127
+ pictured = true;
128
+ break;
129
+ }
130
+ const alpha = Math.max(...f.map((c) => rgba(c)[3]));
131
+ if (alpha > 0)
132
+ surface ??= under[k];
133
+ if (alpha === 1)
134
+ break;
135
+ }
136
+ if (pictured)
137
+ continue;
138
+ let inkColour;
139
+ if (what === 'icon')
140
+ inkColour = cs.stroke !== 'none' ? cs.stroke : cs.fill;
141
+ else if (read === 'fill')
142
+ inkColour = cs.backgroundColor;
143
+ else if (read === 'after')
144
+ inkColour = getComputedStyle(el, '::after').backgroundColor;
145
+ else if (read === 'ring')
146
+ inkColour = /^(?:rgba?|color)\([^)]*\)/.exec(cs.boxShadow)?.[0] ?? 'transparent';
147
+ else
148
+ inkColour = cs.color;
149
+ if (what === 'glyph' && rgba(inkColour)[3] === 0)
150
+ continue; // the glyph is not drawn (an unchecked check's tick)
151
+ const ground = draw(under, null);
152
+ let ink = draw(under, inkColour);
153
+ if (read === 'fill') {
154
+ const o = parseFloat(cs.opacity);
155
+ ink = ink.map((v, k) => ground[k] + (v - ground[k]) * o);
156
+ }
157
+ if (read === 'after') {
158
+ const o = parseFloat(getComputedStyle(el, '::after').opacity);
159
+ ink = ink.map((v, k) => ground[k] + (v - ground[k]) * o);
160
+ }
161
+ const px = parseFloat(cs.fontSize), bold = parseInt(cs.fontWeight) >= 700;
162
+ const kind = what === 'icon' ? 'icon' : what === 'glyph' ? 'glyph' : px >= 24 || (px >= 18.66 && bold) ? 'large words' : 'words';
163
+ const needs = kind === 'words' ? 4.5 : 3;
164
+ const r = ratio(ink, ground), pass = r >= needs;
165
+ const words = (what === 'value' ? el.value : el.textContent).trim().replace(/\s+/g, ' ').slice(0, 32);
166
+ const piece = what === 'icon' ? `icon in ${name(el.parentElement)}` : what === 'glyph' ? `glyph ${name(el)}${read === 'fill' ? '' : `'s ${read === 'after' ? 'mark' : 'ring'}`}` : `${name(el)} "${words}"`;
167
+ const on = surface ? name(surface) : 'the page';
168
+ const side = sideOf(surface);
169
+ const set = setOf(tokenOf(el, inkColour, SETS));
170
+ let problem = '';
171
+ if (!pass) {
172
+ const role = tokenOf(el, inkColour, INKS), from = tokenOf(el, inkColour, SETS);
173
+ const named = role || from ? ` (${[role, from].filter(Boolean).join(' = ')})` : '';
174
+ const groundToken = surface ? tokenOf(surface, fills(surface).at(-1), GROUNDS) : '';
175
+ problem = `${piece} on ${on} (${side} side): ${hex(ink)}${named} on ${hex(ground)}${groundToken ? ` (${groundToken})` : ''} is ${r}:1, ${kind} need ${needs}:1`;
176
+ }
177
+ out.push({ piece, surface: on, side, set, kind, ink: hex(ink), ground: hex(ground), ratio: r, needs, pass, problem });
178
+ }
179
+ probe.remove();
180
+ return out;
181
+ }