@lifecorner/angular-ui-kit 3.26.0 → 3.27.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/fesm2022/lifecorner-angular-ui-kit-callout.mjs +2 -2
- package/fesm2022/lifecorner-angular-ui-kit-callout.mjs.map +1 -1
- package/fesm2022/lifecorner-angular-ui-kit-chat.mjs +2 -2
- package/fesm2022/lifecorner-angular-ui-kit-chat.mjs.map +1 -1
- package/fesm2022/lifecorner-angular-ui-kit-color-picker.mjs +2 -2
- package/fesm2022/lifecorner-angular-ui-kit-color-picker.mjs.map +1 -1
- package/fesm2022/lifecorner-angular-ui-kit-date-range-picker.mjs +2 -2
- package/fesm2022/lifecorner-angular-ui-kit-date-range-picker.mjs.map +1 -1
- package/fesm2022/lifecorner-angular-ui-kit-datepicker.mjs +2 -2
- package/fesm2022/lifecorner-angular-ui-kit-datepicker.mjs.map +1 -1
- package/fesm2022/lifecorner-angular-ui-kit-dependency-viewer.mjs +47 -9
- package/fesm2022/lifecorner-angular-ui-kit-dependency-viewer.mjs.map +1 -1
- package/fesm2022/lifecorner-angular-ui-kit-description-list.mjs +2 -2
- package/fesm2022/lifecorner-angular-ui-kit-description-list.mjs.map +1 -1
- package/fesm2022/lifecorner-angular-ui-kit-document-viewer.mjs +2 -2
- package/fesm2022/lifecorner-angular-ui-kit-document-viewer.mjs.map +1 -1
- package/fesm2022/lifecorner-angular-ui-kit-file-upload.mjs +4 -4
- package/fesm2022/lifecorner-angular-ui-kit-file-upload.mjs.map +1 -1
- package/fesm2022/lifecorner-angular-ui-kit-filter-bar.mjs +2 -2
- package/fesm2022/lifecorner-angular-ui-kit-filter-bar.mjs.map +1 -1
- package/fesm2022/lifecorner-angular-ui-kit-gallery.mjs +2 -2
- package/fesm2022/lifecorner-angular-ui-kit-gallery.mjs.map +1 -1
- package/fesm2022/lifecorner-angular-ui-kit-gantt-chart.mjs +2 -2
- package/fesm2022/lifecorner-angular-ui-kit-gantt-chart.mjs.map +1 -1
- package/fesm2022/lifecorner-angular-ui-kit-header.mjs +2 -2
- package/fesm2022/lifecorner-angular-ui-kit-header.mjs.map +1 -1
- package/fesm2022/lifecorner-angular-ui-kit-input.mjs +2 -2
- package/fesm2022/lifecorner-angular-ui-kit-input.mjs.map +1 -1
- package/fesm2022/lifecorner-angular-ui-kit-kanban-board.mjs +2 -2
- package/fesm2022/lifecorner-angular-ui-kit-kanban-board.mjs.map +1 -1
- package/fesm2022/lifecorner-angular-ui-kit-log-viewer.mjs +4 -4
- package/fesm2022/lifecorner-angular-ui-kit-log-viewer.mjs.map +1 -1
- package/fesm2022/lifecorner-angular-ui-kit-notification-center.mjs +2 -2
- package/fesm2022/lifecorner-angular-ui-kit-notification-center.mjs.map +1 -1
- package/fesm2022/lifecorner-angular-ui-kit-number-input.mjs +2 -2
- package/fesm2022/lifecorner-angular-ui-kit-number-input.mjs.map +1 -1
- package/fesm2022/lifecorner-angular-ui-kit-rich-text-editor.mjs +2 -2
- package/fesm2022/lifecorner-angular-ui-kit-rich-text-editor.mjs.map +1 -1
- package/fesm2022/lifecorner-angular-ui-kit-scatter-plot.mjs +2 -2
- package/fesm2022/lifecorner-angular-ui-kit-scatter-plot.mjs.map +1 -1
- package/fesm2022/lifecorner-angular-ui-kit-search-input.mjs +2 -2
- package/fesm2022/lifecorner-angular-ui-kit-search-input.mjs.map +1 -1
- package/fesm2022/lifecorner-angular-ui-kit-select.mjs +2 -2
- package/fesm2022/lifecorner-angular-ui-kit-select.mjs.map +1 -1
- package/fesm2022/lifecorner-angular-ui-kit-split-pane.mjs +2 -2
- package/fesm2022/lifecorner-angular-ui-kit-split-pane.mjs.map +1 -1
- package/fesm2022/lifecorner-angular-ui-kit-table.mjs +4 -4
- package/fesm2022/lifecorner-angular-ui-kit-table.mjs.map +1 -1
- package/fesm2022/lifecorner-angular-ui-kit-tabs.mjs +2 -2
- package/fesm2022/lifecorner-angular-ui-kit-tabs.mjs.map +1 -1
- package/fesm2022/lifecorner-angular-ui-kit-tag-input.mjs +2 -2
- package/fesm2022/lifecorner-angular-ui-kit-tag-input.mjs.map +1 -1
- package/fesm2022/lifecorner-angular-ui-kit-textarea.mjs +2 -2
- package/fesm2022/lifecorner-angular-ui-kit-textarea.mjs.map +1 -1
- package/fesm2022/lifecorner-angular-ui-kit-toggle-group.mjs +2 -2
- package/fesm2022/lifecorner-angular-ui-kit-toggle-group.mjs.map +1 -1
- package/package.json +1 -1
- package/src/styles/_reset.scss +4 -2
- package/src/styles/_theme-dark.scss +154 -137
- package/src/styles/_theme-light.scss +158 -138
- package/src/styles/focus-indicators.spec.ts +205 -0
- package/src/styles/theme-contrast.spec.ts +14 -2
- package/src/styles/theme-modes.spec.ts +40 -0
- package/src/styles/touch-targets-contrast.spec.ts +286 -0
- package/types/lifecorner-angular-ui-kit-dependency-viewer.d.ts +33 -4
- package/types/lifecorner-angular-ui-kit-file-upload.d.ts +6 -1
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { compile } from 'sass';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Request 042 (3.27, design 042 D-1 to D-9): the guard for F-18 and F-19.
|
|
6
|
+
* Every element of design §6.1 rows B–F has a `:focus-visible` indicator of its
|
|
7
|
+
* own (kit ring: 2 px `--color-primary-500`), `Highlight` in forced colours;
|
|
8
|
+
* the global button reset only takes the ring from mouse focus; a field's hover
|
|
9
|
+
* rule never applies while it is focused. Read from the compiled SCSS sources.
|
|
10
|
+
*/
|
|
11
|
+
const sheets = new Map<string, string>();
|
|
12
|
+
const css = (file: string): string => {
|
|
13
|
+
if (!sheets.has(file)) sheets.set(file, compile(resolve(__dirname, file.startsWith('_') ? file : `../lib/${file}`)).css);
|
|
14
|
+
return sheets.get(file) as string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const rulesOf = (text: string) =>
|
|
18
|
+
[...text.matchAll(/([^{}]+)\{([^{}]*)\}/g)].map((m) => ({
|
|
19
|
+
selectors: m[1].split(/,(?![^(]*\))/).map((s) => s.trim()),
|
|
20
|
+
body: m[2],
|
|
21
|
+
}));
|
|
22
|
+
const decls = (body: string): Record<string, string> =>
|
|
23
|
+
Object.fromEntries([...body.matchAll(/([\w-]+):\s*([^;]+);/g)].map((m) => [m[1], m[2].replace(/\s+/g, ' ').trim()]));
|
|
24
|
+
const forcedBlock = /@media \(forced-colors: active\) \{((?:[^{}]*\{[^{}]*\})*)\s*\}/g;
|
|
25
|
+
|
|
26
|
+
/** Every rule naming `selector`, merged in source order, forced-colours blocks left out. */
|
|
27
|
+
const fills = (file: string, selector: string): Record<string, string> => {
|
|
28
|
+
const found = rulesOf(css(file).replace(forcedBlock, '')).filter((r) => r.selectors.includes(selector));
|
|
29
|
+
expect([file, selector, found.length > 0]).toEqual([file, selector, true]);
|
|
30
|
+
return Object.assign({}, ...found.map((r) => decls(r.body)));
|
|
31
|
+
};
|
|
32
|
+
/** The same, inside the forced-colours blocks only. */
|
|
33
|
+
const forcedFills = (file: string, selector: string): Record<string, string> => {
|
|
34
|
+
const inner = [...css(file).matchAll(forcedBlock)].map((m) => m[1]).join('\n');
|
|
35
|
+
return Object.assign({}, ...rulesOf(inner).filter((r) => r.selectors.includes(selector)).map((r) => decls(r.body)));
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const RING = '2px solid var(--color-primary-500)';
|
|
39
|
+
|
|
40
|
+
/** Design 042 §3: [sheet, element class, outline offset]. Rows B–D, kanban (D-3), remove (D-8), dep canvas (D-7). */
|
|
41
|
+
const rings: [string, string, string][] = [
|
|
42
|
+
['rich-text-editor/rich-text-editor.component.scss', '.lc-rich-text-editor__toolbar-btn', '2px'],
|
|
43
|
+
['rich-text-editor/rich-text-editor.component.scss', '.lc-rich-text-editor__mode-btn', '2px'],
|
|
44
|
+
['document-viewer/document-viewer.component.scss', '.doc-viewer__btn', '2px'],
|
|
45
|
+
['document-viewer/document-viewer.component.scss', '.doc-viewer__download-btn', '2px'],
|
|
46
|
+
['filter-bar/filter-bar.component.scss', '.lc-filter-bar__toggle-btn', '-2px'],
|
|
47
|
+
// 2px → 0 by review 043 r1 F3: the button grew from 20 to 24 px (2 px padding
|
|
48
|
+
// inside a −2 px margin), so offset 0 draws the ring where 2 px round 20 px did.
|
|
49
|
+
['select/select.component.scss', '.lc-select__clear', '0'],
|
|
50
|
+
['gallery/gallery.component.scss', '.gallery__filter-btn', '2px'],
|
|
51
|
+
['log-viewer/log-viewer.component.scss', '.lc-log-viewer__level-pill', '2px'],
|
|
52
|
+
['callout/callout.component.scss', '.callout__dismiss', '2px'],
|
|
53
|
+
['notification-center/notification-center.component.scss', '.lc-nc__action-btn', '2px'],
|
|
54
|
+
['notification-center/notification-center.component.scss', '.lc-nc__filter', '2px'],
|
|
55
|
+
['tabs/tabs.component.scss', '.lc-tabs__panel', '-2px'],
|
|
56
|
+
['chat/chat.component.scss', '.lc-chat__attachment', '2px'],
|
|
57
|
+
['chat/chat.component.scss', '.lc-chat__attach-btn', '2px'],
|
|
58
|
+
['chat/chat.component.scss', '.lc-chat__send-btn', '2px'],
|
|
59
|
+
['chat/chat.component.scss', '.lc-chat__pending-remove', '2px'],
|
|
60
|
+
['description-list/description-list.component.scss', '.lc-dl__link', '2px'],
|
|
61
|
+
['color-picker/color-picker.component.scss', '.color-picker__native', '2px'],
|
|
62
|
+
['table/table.component.scss', '.lc-table__checkbox', '2px'],
|
|
63
|
+
['number-input/number-input.component.scss', '.number-input__button', '-2px'],
|
|
64
|
+
['tag-input/tag-input.component.scss', '.lc-tag-input__tag-remove', '2px'],
|
|
65
|
+
['file-upload/file-upload.component.scss', '.file-upload__file-remove', '2px'],
|
|
66
|
+
// Review 042 r1: inset, an outside ring measured 2.19:1 on an over-limit column (light).
|
|
67
|
+
['kanban-board/kanban-board.component.scss', '.lc-kanban__card', '-2px'],
|
|
68
|
+
['dependency-viewer/dependency-viewer.component.scss', '.dep-viewer__canvas--scroll', '-2px'],
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
describe('Focus indicators (request 042)', () => {
|
|
72
|
+
describe('D-1: the global button reset', () => {
|
|
73
|
+
it('AC-5: takes the outline from mouse focus only, so a button with no ring of its own keeps the browser ring on Tab', () => {
|
|
74
|
+
const reset = css('_reset.scss');
|
|
75
|
+
expect(reset).toMatch(/\nbutton:focus:not\(:focus-visible\) \{\s*outline: none;\s*\}/);
|
|
76
|
+
expect(reset).not.toMatch(/\nbutton:focus \{/);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('D-2: the kit ring on every §6.1 B–D element', () => {
|
|
81
|
+
it.each(rings)('AC-2: %s %s draws a solid 2 px primary-500 outline (offset %s), no halo', (file, cls, offset) => {
|
|
82
|
+
const ring = fills(file, `${cls}:focus-visible`);
|
|
83
|
+
expect([ring.outline, ring['outline-offset']]).toEqual([RING, offset]);
|
|
84
|
+
expect(ring['box-shadow']).toBeUndefined();
|
|
85
|
+
expect(ring.transition).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it.each(rings)('AC-3: %s %s turns Highlight in forced colours', (file, cls) => {
|
|
89
|
+
expect(forcedFills(file, `${cls}:focus-visible`)['outline-color']).toBe('Highlight');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it.each(rings)('AC-4: %s %s draws no ring on mouse focus (no outline on a bare :focus rule)', (file, cls) => {
|
|
93
|
+
const bare = rulesOf(css(file).replace(forcedBlock, '')).filter((r) => r.selectors.includes(`${cls}:focus`));
|
|
94
|
+
for (const r of bare) expect([cls, decls(r.body).outline ?? 'none']).toEqual([cls, 'none']);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('AC-2: the dimmed buttons turn fully opaque on focus (the ring is not dimmed with them)', () => {
|
|
98
|
+
expect(fills('callout/callout.component.scss', '.callout__dismiss').opacity).toBe('0.6');
|
|
99
|
+
expect(fills('callout/callout.component.scss', '.callout__dismiss:focus-visible').opacity).toBe('1');
|
|
100
|
+
expect(fills('tag-input/tag-input.component.scss', '.lc-tag-input__tag-remove').opacity).toBe('0.7');
|
|
101
|
+
expect(fills('tag-input/tag-input.component.scss', '.lc-tag-input__tag-remove:focus-visible').opacity).toBe('1');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Offset 2px → -2px by review 042 r1: outside, the ring sat on the column tint
|
|
105
|
+
// (2.19:1 on an over-limit column, light); inset it sits on the card (4.36 / 9.12).
|
|
106
|
+
it('AC-2: the kanban card drops its 20 % halo (1.28:1) for an inset ring on the card and keeps the border change', () => {
|
|
107
|
+
expect(fills('kanban-board/kanban-board.component.scss', '.lc-kanban__card:focus-visible')).toEqual({
|
|
108
|
+
outline: RING,
|
|
109
|
+
'outline-offset': '-2px',
|
|
110
|
+
'border-color': 'var(--color-primary-500)',
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('D-3: indicators that forced colours drop', () => {
|
|
116
|
+
const forced: [string, string, Record<string, string>][] = [
|
|
117
|
+
['toggle-group/toggle-group.component.scss', '.lc-toggle-group__btn:focus-visible', { outline: '2px solid Highlight', 'outline-offset': '-2px' }],
|
|
118
|
+
['list/list.component.scss', '.lc-list__item:focus-visible', { outline: '2px solid Highlight', 'outline-offset': '-2px' }],
|
|
119
|
+
['gantt-chart/gantt-chart.component.scss', '.lc-gantt__bar:focus-visible', { outline: '2px solid Highlight', 'outline-offset': '2px' }],
|
|
120
|
+
['split-pane/split-pane.component.scss', '.lc-split-pane__separator:focus-visible', { outline: '2px solid Highlight', 'outline-offset': '-2px' }],
|
|
121
|
+
['scatter-plot/scatter-plot.component.scss', '.lc-scatter-plot__dot:focus-visible', { stroke: 'Highlight' }],
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
it.each(forced)('AC-3: %s %s has its own forced-colours indicator', (file, selector, expected) => {
|
|
125
|
+
expect(forcedFills(file, selector)).toEqual(expected);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('AC-7: their normal-mode indicators are unchanged', () => {
|
|
129
|
+
expect(fills('toggle-group/toggle-group.component.scss', '.lc-toggle-group__btn:focus-visible')['box-shadow']).toBe('inset 0 0 0 2px var(--color-primary-500)');
|
|
130
|
+
expect(fills('list/list.component.scss', '.lc-list__item:focus-visible')['box-shadow']).toBe('inset 0 0 0 2px var(--color-primary-500)');
|
|
131
|
+
expect(fills('gantt-chart/gantt-chart.component.scss', '.lc-gantt__bar:focus-visible')['box-shadow']).toBe('0 0 0 2px var(--color-surface), 0 0 0 4px var(--color-primary-500)');
|
|
132
|
+
expect(fills('split-pane/split-pane.component.scss', '.lc-split-pane__separator:focus-visible::before').background).toBe('var(--color-primary-500)');
|
|
133
|
+
expect(fills('scatter-plot/scatter-plot.component.scss', '.lc-scatter-plot__dot:focus-visible').stroke).toBe('var(--color-text-primary)');
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('D-9: the rich-text editing surface', () => {
|
|
138
|
+
const frame = '.lc-rich-text-editor:has(.lc-rich-text-editor__rich:focus-visible, .lc-rich-text-editor__textarea:focus-visible)';
|
|
139
|
+
const file = 'rich-text-editor/rich-text-editor.component.scss';
|
|
140
|
+
|
|
141
|
+
it('AC-2: the frame shows the field ring (lc-input pattern); the surfaces keep outline: none', () => {
|
|
142
|
+
expect(fills(file, frame)).toEqual({ 'border-color': 'var(--color-primary-500)', outline: RING, 'outline-offset': '0px' });
|
|
143
|
+
expect(fills(file, '.lc-rich-text-editor__rich').outline).toBe('none');
|
|
144
|
+
expect(fills(file, '.lc-rich-text-editor__textarea').outline).toBe('none');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('AC-3: the ancestor ring has its own Highlight rule', () => {
|
|
148
|
+
expect(forcedFills(file, frame)).toEqual({ 'border-color': 'Highlight', 'outline-color': 'Highlight' });
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('D-4: the light header on a dark page', () => {
|
|
153
|
+
const file = 'header/header.component.scss';
|
|
154
|
+
const own = ['.lc-header__nav-link', '.lc-header__logo', '.lc-header__context-info', '.lc-header__profile-trigger'];
|
|
155
|
+
|
|
156
|
+
it('AC-2: the header-owned rings read the private ring colour, primary-500 by default', () => {
|
|
157
|
+
for (const cls of own) {
|
|
158
|
+
expect([cls, fills(file, `${cls}:focus-visible`).outline]).toEqual([cls, '2px solid var(--_lc-header-ring, var(--color-primary-500))']);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('AC-2: theme="light" pins the light primary-500: 4.36:1 on its white bar in both themes (dark primary-500 was 1.89)', () => {
|
|
163
|
+
const light = fills(file, '.lc-header--light');
|
|
164
|
+
expect([light['--_lc-header-ring'], light['--lc-header-bg']]).toEqual(['#208497', '#ffffff']);
|
|
165
|
+
const lum = (hex: string) =>
|
|
166
|
+
[1, 3, 5]
|
|
167
|
+
.map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
|
|
168
|
+
.map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4))
|
|
169
|
+
.reduce((sum, v, i) => sum + v * [0.2126, 0.7152, 0.0722][i], 0);
|
|
170
|
+
expect(Math.floor(((lum('#ffffff') + 0.05) / (lum('#208497') + 0.05)) * 100) / 100).toBe(4.36);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe('D-6: focus wins over hover in every field with the pattern (F-19)', () => {
|
|
175
|
+
// [sheet, hover selector after the fix, declarations it keeps]
|
|
176
|
+
// Request 043 (F-23, design D-5) changed the first seven rows on purpose:
|
|
177
|
+
// they also exclude the error class (an error field kept no error border
|
|
178
|
+
// under the pointer), and the textarea/datepicker outline hover moved from
|
|
179
|
+
// --color-primary-400 (1.89:1 light) to --color-text-secondary (7.55:1).
|
|
180
|
+
const hovers: [string, string, Record<string, string>][] = [
|
|
181
|
+
['textarea/textarea.component.scss', '.lc-textarea--outline:hover:not(:disabled):not(:read-only):not(.lc-textarea--error):not(:focus)', { 'border-color': 'var(--color-text-secondary)' }],
|
|
182
|
+
['textarea/textarea.component.scss', '.lc-textarea--filled:hover:not(:disabled):not(:read-only):not(.lc-textarea--error):not(:focus)', { 'background-color': 'var(--color-surface-hover)' }],
|
|
183
|
+
['datepicker/datepicker.component.scss', '.lc-datepicker:hover:not(:disabled):not([readonly]):not(.lc-datepicker--error):not(:focus)', { 'border-color': 'var(--color-text-secondary)' }],
|
|
184
|
+
['datepicker/datepicker.component.scss', '.lc-datepicker.lc-datepicker--filled:hover:not(:disabled):not([readonly]):not(.lc-datepicker--error):not(:focus)', { 'background-color': 'var(--color-surface-hover)', 'border-color': 'var(--color-border)' }],
|
|
185
|
+
['input/input.component.scss', '.input-container:hover:not(.input-disabled):not(.input-readonly):not(.input-invalid):not(:focus-within)', { 'border-color': 'var(--color-text-secondary)' }],
|
|
186
|
+
['select/select.component.scss', '.lc-select:hover:not(.lc-select--disabled):not(.lc-select--error):not(:focus)', { 'border-color': 'var(--color-text-secondary)' }],
|
|
187
|
+
['select/select.component.scss', '.lc-select.lc-select--filled:hover:not(.lc-select--disabled):not(.lc-select--error):not(:focus)', { 'background-color': 'var(--color-surface-hover)', 'border-color': 'var(--color-border)' }],
|
|
188
|
+
['search-input/search-input.component.scss', '.search-input:hover:not(.search-input--disabled):not(:focus-within)', { 'border-color': 'var(--color-text-secondary)' }],
|
|
189
|
+
['tag-input/tag-input.component.scss', '.lc-tag-input__container:hover:not(.lc-tag-input__container--focused)', { 'border-color': 'var(--color-text-secondary)' }],
|
|
190
|
+
['date-range-picker/date-range-picker.component.scss', '.lc-drp__field:hover:not(.lc-drp__field--disabled):not(:focus-within)', { 'border-color': 'var(--color-text-secondary)' }],
|
|
191
|
+
];
|
|
192
|
+
|
|
193
|
+
it.each(hovers)('AC-6/AC-7: %s %s keeps its look and excludes focus', (file, selector, expected) => {
|
|
194
|
+
expect(fills(file, selector)).toEqual(expected);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it.each(hovers)('AC-6: %s has no other hover rule on that field', (file, selector) => {
|
|
198
|
+
const field = selector.slice(0, selector.indexOf(':hover'));
|
|
199
|
+
const others = rulesOf(css(file).replace(forcedBlock, ''))
|
|
200
|
+
.flatMap((r) => r.selectors)
|
|
201
|
+
.filter((s) => s.startsWith(`${field}:hover`) && s !== selector);
|
|
202
|
+
expect(others).toEqual([]);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -981,9 +981,21 @@ describe('Component pairings of 3.22.4 (request 034)', () => {
|
|
|
981
981
|
['diff', '.lc-diff__num', { color: secondary }],
|
|
982
982
|
// F-4: the lc-badge primary pair with a primary border.
|
|
983
983
|
['card', '.card__badge--primary', { 'background-color': 'var(--color-primary-subtle)', color: 'var(--color-on-primary-subtle)', border: '1px solid var(--color-primary-500)' }],
|
|
984
|
-
// F-5: a
|
|
984
|
+
// F-5: a hit area into half of each gap; the visible pill is unchanged.
|
|
985
|
+
// Request 043 (F-22) raised it from 40 to 44 px high (and at least 44 wide):
|
|
986
|
+
// the spacing exception is no longer relied on.
|
|
985
987
|
['log', '.lc-log-viewer__level-pill', { position: 'relative', padding: '0.125rem 0.5rem', 'font-size': '0.6875rem' }],
|
|
986
|
-
[
|
|
988
|
+
[
|
|
989
|
+
'log',
|
|
990
|
+
'.lc-log-viewer__level-pill::before',
|
|
991
|
+
{
|
|
992
|
+
content: '""',
|
|
993
|
+
position: 'absolute',
|
|
994
|
+
'inset-inline': 'min(-0.125rem, (100% - var(--size-min-touch-height, 2.75rem)) / 2)',
|
|
995
|
+
top: 'calc(50% - var(--size-min-touch-height, 2.75rem) / 2)',
|
|
996
|
+
bottom: 'calc(50% - var(--size-min-touch-height, 2.75rem) / 2)',
|
|
997
|
+
},
|
|
998
|
+
],
|
|
987
999
|
// F-7: three text steps instead of opacity, in the log and the terminal.
|
|
988
1000
|
['log', '.lc-log-viewer__line--debug', { color: secondary }],
|
|
989
1001
|
...meta.map((m): Row => ['log', `.lc-log-viewer__${m}`, { color: secondary }]),
|
|
@@ -58,3 +58,43 @@ describe('Theme modes', () => {
|
|
|
58
58
|
expect(tokens['--app-background-image']).toContain('rgba(78, 63, 132, 0.14)');
|
|
59
59
|
});
|
|
60
60
|
});
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Request 043 (F-25, design D-7): a header with `theme="light"` / `"dark"`
|
|
64
|
+
* re-declares every colour token of that theme, so its slot content (buttons,
|
|
65
|
+
* links, the theme toggle, the account menu) resolves the header's palette
|
|
66
|
+
* instead of the page's. Derived tokens resolve where they are declared, so
|
|
67
|
+
* the set must be complete.
|
|
68
|
+
*/
|
|
69
|
+
describe('Theme scope of a themed header', () => {
|
|
70
|
+
const COLOUR = /^--(color-|gradient-|shadow-|elevation-|accent-line$|app-background-image$)/;
|
|
71
|
+
const colours = (tokens: Record<string, string>) =>
|
|
72
|
+
Object.fromEntries(Object.entries(tokens).filter(([name]) => COLOUR.test(name)));
|
|
73
|
+
|
|
74
|
+
it('AC-6: .lc-header--light declares exactly the colour tokens of the light root (172), with the same values', () => {
|
|
75
|
+
const root = tokensOf(light, ':root,');
|
|
76
|
+
const header = tokensOf(light, '\n.lc-header--light {');
|
|
77
|
+
expect(Object.keys(header)).toHaveLength(172);
|
|
78
|
+
expect(header).toEqual(colours(root));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('AC-7: .lc-header--dark declares exactly the colour tokens of the dark root (172), with the same values', () => {
|
|
82
|
+
const root = tokensOf(dark, ':root.dark,');
|
|
83
|
+
const header = tokensOf(dark, '\n.lc-header--dark {');
|
|
84
|
+
expect(Object.keys(header)).toHaveLength(172);
|
|
85
|
+
expect(header).toEqual(colours(root));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('AC-12: the root rules still declare the full token set (252 per theme), colours and the rest', () => {
|
|
89
|
+
expect(Object.keys(tokensOf(light, ':root,'))).toHaveLength(252);
|
|
90
|
+
expect(Object.keys(tokensOf(dark, ':root.dark,'))).toHaveLength(252);
|
|
91
|
+
expect(Object.keys(colours(tokensOf(light, ':root,')))).toHaveLength(172);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('scopes only the header: each theme file emits the header rule once, after its root rules', () => {
|
|
95
|
+
expect(light.match(/\.lc-header--light \{/g)).toHaveLength(1);
|
|
96
|
+
expect(dark.match(/\.lc-header--dark \{/g)).toHaveLength(1);
|
|
97
|
+
expect(light.indexOf('.lc-header--light {')).toBeGreaterThan(light.indexOf(':root,'));
|
|
98
|
+
expect(dark.indexOf('.lc-header--dark {')).toBeGreaterThan(dark.indexOf(':root.system {'));
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { compile } from 'sass';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Request 043 (3.27.1, design 043 D-1 to D-10): the follow-ups F-22 to F-27 of
|
|
6
|
+
* the 042 sweep, read from the compiled SCSS and the theme files. Hit areas
|
|
7
|
+
* grow without paint and the visible boxes keep their size (D-1, D-3); the
|
|
8
|
+
* dense toolbars get real 44 px buttons (D-2); field hover keeps 3:1 and never
|
|
9
|
+
* hides the error state (D-5); the rich-text frame is a field boundary (D-6);
|
|
10
|
+
* the F-26 inks and the kanban tint reach their contrast (D-8, D-10).
|
|
11
|
+
*/
|
|
12
|
+
const sheets = new Map<string, string>();
|
|
13
|
+
const css = (file: string): string => {
|
|
14
|
+
if (!sheets.has(file)) sheets.set(file, compile(resolve(__dirname, file.startsWith('_') ? file : `../lib/${file}`)).css);
|
|
15
|
+
return sheets.get(file) as string;
|
|
16
|
+
};
|
|
17
|
+
const forcedBlock = /@media \(forced-colors: active\) \{((?:[^{}]*\{[^{}]*\})*)\s*\}/g;
|
|
18
|
+
const rulesOf = (text: string) =>
|
|
19
|
+
[...text.replace(forcedBlock, '').matchAll(/([^{}]+)\{([^{}]*)\}/g)].map((m) => ({
|
|
20
|
+
selectors: m[1].split(/,(?![^(]*\))/).map((s) => s.trim()),
|
|
21
|
+
body: m[2],
|
|
22
|
+
}));
|
|
23
|
+
const decls = (body: string): Record<string, string> =>
|
|
24
|
+
Object.fromEntries([...body.matchAll(/([\w-]+):\s*([^;]+);/g)].map((m) => [m[1], m[2].replace(/\s+/g, ' ').trim()]));
|
|
25
|
+
/** Every rule naming `selector`, merged in source order, forced-colours blocks left out. */
|
|
26
|
+
const fills = (file: string, selector: string): Record<string, string> => {
|
|
27
|
+
const found = rulesOf(css(file)).filter((r) => r.selectors.includes(selector));
|
|
28
|
+
expect([file, selector, found.length > 0]).toEqual([file, selector, true]);
|
|
29
|
+
return Object.assign({}, ...found.map((r) => decls(r.body)));
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// ── Colours: the root rule of each theme file, var() followed ─────────────
|
|
33
|
+
type Rgba = [number, number, number, number];
|
|
34
|
+
function rootTokens(file: string, selector: string): Record<string, string> {
|
|
35
|
+
const text = css(file);
|
|
36
|
+
const open = text.indexOf('{', text.indexOf(selector));
|
|
37
|
+
const tokens: Record<string, string> = {};
|
|
38
|
+
for (const m of text.slice(open, text.indexOf('}', open)).matchAll(/(--[\w-]+):\s*([^;]+);/g)) tokens[m[1]] = m[2].replace(/\s+/g, ' ').trim();
|
|
39
|
+
return tokens;
|
|
40
|
+
}
|
|
41
|
+
const themes: [string, Record<string, string>][] = [
|
|
42
|
+
['light', rootTokens('_theme-light.scss', ':root,')],
|
|
43
|
+
['dark', rootTokens('_theme-dark.scss', ':root.dark,')],
|
|
44
|
+
];
|
|
45
|
+
function parse(value: string): Rgba {
|
|
46
|
+
const rgba = value.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+)\s*)?\)$/);
|
|
47
|
+
if (rgba) return [Number(rgba[1]), Number(rgba[2]), Number(rgba[3]), rgba[4] === undefined ? 1 : Number(rgba[4])];
|
|
48
|
+
if (!/^#[0-9a-f]{6}$/i.test(value)) throw new Error(`not a colour: ${value}`);
|
|
49
|
+
return [1, 3, 5].map((i) => parseInt(value.slice(i, i + 2), 16)).concat(1) as Rgba;
|
|
50
|
+
}
|
|
51
|
+
/** A declared value (`var(--x)`, `var(--x, fallback)`, hex, rgba) in one theme. */
|
|
52
|
+
function colour(tokens: Record<string, string>, value: string, depth = 0): Rgba {
|
|
53
|
+
const ref = value.trim().match(/^var\((--[\w-]+)(?:,\s*(.+))?\)$/);
|
|
54
|
+
if (!ref) return parse(value.trim());
|
|
55
|
+
if (depth > 10) throw new Error(`${value} does not resolve`);
|
|
56
|
+
return tokens[ref[1]] !== undefined ? colour(tokens, tokens[ref[1]], depth + 1) : colour(tokens, ref[2], depth + 1);
|
|
57
|
+
}
|
|
58
|
+
const over = (top: Rgba, base: Rgba): Rgba => [0, 1, 2].map((i) => top[i] * top[3] + base[i] * (1 - top[3])).concat(1) as Rgba;
|
|
59
|
+
const mix = (a: Rgba, share: number, b: Rgba): Rgba => [0, 1, 2].map((i) => a[i] * share + b[i] * (1 - share)).concat(1) as Rgba;
|
|
60
|
+
const lum = (c: Rgba) =>
|
|
61
|
+
c
|
|
62
|
+
.slice(0, 3)
|
|
63
|
+
.map((v) => v / 255)
|
|
64
|
+
.map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4))
|
|
65
|
+
.reduce((sum, v, i) => sum + v * [0.2126, 0.7152, 0.0722][i], 0);
|
|
66
|
+
/** WCAG ratio, floored to two decimals (as the design's measure script). */
|
|
67
|
+
const ratio = (a: Rgba, b: Rgba) => {
|
|
68
|
+
const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x);
|
|
69
|
+
return Math.floor(((hi + 0.05) / (lo + 0.05)) * 100) / 100;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const TOUCH = 'var(--size-min-touch-height, 2.75rem)';
|
|
73
|
+
|
|
74
|
+
describe('Touch targets, hover borders and contrast (request 043)', () => {
|
|
75
|
+
it('the touch size token is 44 px in both themes', () => {
|
|
76
|
+
for (const [, t] of themes) expect(t['--size-min-touch-height']).toBe('2.75rem');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('D-1 / D-3: hit areas without paint; the visible control keeps its size (AC-1, AC-2)', () => {
|
|
80
|
+
const centred = { 'inset-inline': `min(0px, (100% - ${TOUCH}) / 2)`, top: `calc(50% - ${TOUCH} / 2)`, bottom: `calc(50% - ${TOUCH} / 2)` };
|
|
81
|
+
// [sheet, control, its hit area, the visible box that must not change]
|
|
82
|
+
const areas: [string, string, Record<string, string>, Record<string, string>][] = [
|
|
83
|
+
['notification-center/notification-center.component.scss', '.lc-nc__action-btn', centred, { padding: '4px 8px', 'font-size': '0.75rem' }],
|
|
84
|
+
['notification-center/notification-center.component.scss', '.lc-nc__filter', centred, { padding: '4px 10px', 'font-size': '0.6875rem', 'white-space': 'nowrap' }],
|
|
85
|
+
[
|
|
86
|
+
'log-viewer/log-viewer.component.scss',
|
|
87
|
+
'.lc-log-viewer__level-pill',
|
|
88
|
+
{ 'inset-inline': `min(-0.125rem, (100% - ${TOUCH}) / 2)`, top: centred.top, bottom: centred.bottom },
|
|
89
|
+
{ padding: '0.125rem 0.5rem', 'font-size': '0.6875rem' },
|
|
90
|
+
],
|
|
91
|
+
['callout/callout.component.scss', '.callout__dismiss', { inset: `calc((1.5rem - ${TOUCH}) / 2)` }, { width: '1.5rem', height: '1.5rem' }],
|
|
92
|
+
['chat/chat.component.scss', '.lc-chat__send-btn', { inset: `calc((2rem - ${TOUCH}) / 2)` }, { width: '2rem', height: '2rem' }],
|
|
93
|
+
['chat/chat.component.scss', '.lc-chat__attach-btn', { inset: `calc((2rem - ${TOUCH}) / 2)` }, { width: '2rem', height: '2rem' }],
|
|
94
|
+
// D-3: 24 × 24 (WCAG 2.5.8 by size): 44 would cover the next chip row.
|
|
95
|
+
['tag-input/tag-input.component.scss', '.lc-tag-input__tag-remove', { inset: '-0.25rem' }, { width: '16px', height: '16px' }],
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
it.each(areas)('%s %s: a positioned host and a transparent ::before with exactly the hit-area box', (file, cls, area, box) => {
|
|
99
|
+
expect(fills(file, cls).position).toBe('relative');
|
|
100
|
+
expect(fills(file, `${cls}::before`)).toEqual({ content: '""', position: 'absolute', ...area });
|
|
101
|
+
const host = fills(file, cls);
|
|
102
|
+
for (const [property, value] of Object.entries(box)) expect([cls, property, host[property]]).toEqual([cls, property, value]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Review 043 r1 F3: the select × was a 20 px box with a 24 px `::before`; axe
|
|
106
|
+
// `target-size` reads the element's own box and flagged it (×12). It is now a
|
|
107
|
+
// 24 px box whose 2 px padding the negative margin takes back: the layout
|
|
108
|
+
// footprint, the 20 px hover fill (content-box) and the ring (offset 0 on 24 px
|
|
109
|
+
// = 2 px round 20 px) are unchanged; 44 px would leave the field.
|
|
110
|
+
it('the select × is a 24 × 24 px box with the 20 px layout, hover fill and ring of 3.27.0', () => {
|
|
111
|
+
const file = 'select/select.component.scss';
|
|
112
|
+
expect(fills(file, '.lc-select__clear')).toMatchObject({
|
|
113
|
+
width: '1.5rem',
|
|
114
|
+
height: '1.5rem',
|
|
115
|
+
margin: '-0.125rem',
|
|
116
|
+
padding: '0.125rem',
|
|
117
|
+
'background-clip': 'content-box',
|
|
118
|
+
'border-radius': 'calc(var(--border-radius-sm) + 0.125rem)',
|
|
119
|
+
});
|
|
120
|
+
expect(rulesOf(css(file)).some((r) => r.selectors.includes('.lc-select__clear::before'))).toBe(false);
|
|
121
|
+
expect(fills(file, '.lc-select__clear:focus-visible')['outline-offset']).toBe('0');
|
|
122
|
+
expect(fills(file, '.lc-select__clear:hover')['background-clip']).toBeUndefined();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('the 24 px areas are 24 px: 1.5rem with 24 − 2 × 2 = 20 in the layout (select ×) and 16 + 2 × 4 (tag ×)', () => {
|
|
126
|
+
expect(1.5 * 16).toBe(24);
|
|
127
|
+
expect(1.5 * 16 - 2 * 0.125 * 16).toBe(20);
|
|
128
|
+
expect(16 + 2 * 0.25 * 16).toBe(24);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('the rows the hit areas sit in grew just enough: NC filters 9 px (26.5 + 2 × 8.75), log toolbar 8 px', () => {
|
|
132
|
+
expect(fills('notification-center/notification-center.component.scss', '.lc-nc__filters').padding).toBe('9px 16px');
|
|
133
|
+
expect(fills('log-viewer/log-viewer.component.scss', '.lc-log-viewer__toolbar').padding).toBe('0.5rem');
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('D-2: real 44 px buttons where the pitch leaves no room (AC-1, AC-2)', () => {
|
|
138
|
+
it('rich-text toolbar buttons are 44 × 44, mode buttons at least 44 × 44', () => {
|
|
139
|
+
const btn = fills('rich-text-editor/rich-text-editor.component.scss', '.lc-rich-text-editor__toolbar-btn');
|
|
140
|
+
expect([btn.width, btn.height]).toEqual([TOUCH, TOUCH]);
|
|
141
|
+
const mode = fills('rich-text-editor/rich-text-editor.component.scss', '.lc-rich-text-editor__mode-btn');
|
|
142
|
+
expect([mode['min-width'], mode['min-height'], mode.padding]).toEqual([TOUCH, TOUCH, 'var(--spacing-1) var(--spacing-2)']);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('document-viewer tools are 44 × 44 and the toolbar wraps instead of overflowing', () => {
|
|
146
|
+
const file = 'document-viewer/document-viewer.component.scss';
|
|
147
|
+
const btn = fills(file, '.doc-viewer__btn');
|
|
148
|
+
expect([btn.width, btn.height]).toEqual([TOUCH, TOUCH]);
|
|
149
|
+
expect(fills(file, '.doc-viewer__toolbar')['flex-wrap']).toBe('wrap');
|
|
150
|
+
expect(fills(file, '.doc-viewer__toolbar-left')).toMatchObject({ flex: '1 1 10rem', 'min-width': '0' });
|
|
151
|
+
expect(fills(file, '.doc-viewer__toolbar-right')).toMatchObject({
|
|
152
|
+
'flex-wrap': 'wrap',
|
|
153
|
+
'justify-content': 'flex-end',
|
|
154
|
+
'row-gap': 'var(--spacing-1)',
|
|
155
|
+
'flex-shrink': '1',
|
|
156
|
+
'min-width': '0',
|
|
157
|
+
'margin-inline-start': 'auto',
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('D-5: field hover (F-23, AC-3, AC-4)', () => {
|
|
163
|
+
// [sheet, the field's selector prefix, its error class]
|
|
164
|
+
const fields: [string, RegExp, string][] = [
|
|
165
|
+
['input/input.component.scss', /^\.input-container[:.]/, '.input-invalid'],
|
|
166
|
+
['select/select.component.scss', /^\.lc-select[:.]/, '.lc-select--error'],
|
|
167
|
+
['textarea/textarea.component.scss', /^\.lc-textarea--(outline|filled)[:.]/, '.lc-textarea--error'],
|
|
168
|
+
['datepicker/datepicker.component.scss', /^\.lc-datepicker[:.]/, '.lc-datepicker--error'],
|
|
169
|
+
];
|
|
170
|
+
|
|
171
|
+
it.each(fields)('AC-4: %s: every hover rule on the field skips the error state', (file, field, error) => {
|
|
172
|
+
const hovers = rulesOf(css(file))
|
|
173
|
+
.flatMap((r) => r.selectors)
|
|
174
|
+
.filter((s) => field.test(s) && s.includes(':hover'));
|
|
175
|
+
expect(hovers.length).toBeGreaterThan(0);
|
|
176
|
+
for (const s of hovers) expect([s, s.includes(`:not(${error})`)]).toEqual([s, true]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it.each(fields)('AC-3: %s: no hover border below 3:1 (primary-400 is gone)', (file, field) => {
|
|
180
|
+
const borders = rulesOf(css(file))
|
|
181
|
+
.filter((r) => r.selectors.some((s) => field.test(s) && s.includes(':hover')))
|
|
182
|
+
.map((r) => decls(r.body)['border-color'])
|
|
183
|
+
.filter(Boolean);
|
|
184
|
+
expect(borders.every((b) => b === 'var(--color-text-secondary)' || b === 'var(--color-border)')).toBe(true);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('AC-3: the outline hover border (--color-text-secondary) is ≥ 3:1 on the page, the surface and surface-2', () => {
|
|
188
|
+
const got = themes.map(([, t]) =>
|
|
189
|
+
['--color-background', '--color-surface', '--color-surface-2'].map((bg) => ratio(colour(t, 'var(--color-text-secondary)'), colour(t, `var(${bg})`))),
|
|
190
|
+
);
|
|
191
|
+
expect(got).toEqual([
|
|
192
|
+
[7.55, 7.55, 7.23],
|
|
193
|
+
[12.16, 11.15, 9.25],
|
|
194
|
+
]);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('AC-4: the error border at rest (and so under the pointer) is ≥ 3:1: 8.39 light, 9.47 dark on the page', () => {
|
|
198
|
+
expect(themes.map(([, t]) => ratio(colour(t, 'var(--color-error-600)'), colour(t, 'var(--color-background)')))).toEqual([8.39, 9.47]);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe('D-6: the rich-text frame is a field boundary (F-24, AC-5)', () => {
|
|
203
|
+
it('uses lc-input’s --color-field-border: 4.83 light, 7.31 / 6.70 dark (page / surface)', () => {
|
|
204
|
+
expect(fills('rich-text-editor/rich-text-editor.component.scss', '.lc-rich-text-editor').border).toBe('1px solid var(--color-field-border)');
|
|
205
|
+
expect(fills('input/input.component.scss', '.input-container').border).toBe('1px solid var(--color-field-border)');
|
|
206
|
+
const got = themes.map(([, t]) =>
|
|
207
|
+
['--color-background', '--color-surface'].map((bg) => ratio(colour(t, 'var(--color-field-border)'), colour(t, `var(${bg})`))),
|
|
208
|
+
);
|
|
209
|
+
expect(got).toEqual([
|
|
210
|
+
[4.83, 4.83],
|
|
211
|
+
[7.31, 6.7],
|
|
212
|
+
]);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe('D-8: inks (F-26, AC-8)', () => {
|
|
217
|
+
it('RTE mode .active and the gallery --active chip use the fill pair: 6.21:1 in both themes', () => {
|
|
218
|
+
expect(fills('rich-text-editor/rich-text-editor.component.scss', '.lc-rich-text-editor__mode-btn.active')).toEqual({
|
|
219
|
+
'background-color': 'var(--color-primary-fill)',
|
|
220
|
+
color: 'var(--color-on-primary)',
|
|
221
|
+
});
|
|
222
|
+
expect(fills('gallery/gallery.component.scss', '.gallery__filter-btn--active')).toEqual({
|
|
223
|
+
background: 'var(--color-primary-fill)',
|
|
224
|
+
'border-color': 'var(--color-primary-500)',
|
|
225
|
+
color: 'var(--color-on-primary)',
|
|
226
|
+
});
|
|
227
|
+
expect(themes.map(([, t]) => ratio(colour(t, 'var(--color-on-primary)'), colour(t, 'var(--color-primary-fill)')))).toEqual([6.21, 6.21]);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('gallery hover: the active chip darkens to the fill hover (9.13:1), an inactive chip takes the link ink', () => {
|
|
231
|
+
const file = 'gallery/gallery.component.scss';
|
|
232
|
+
expect(fills(file, '.gallery__filter-btn--active:hover')).toMatchObject({ background: 'var(--color-primary-fill-hover)', color: 'var(--color-on-primary)' });
|
|
233
|
+
expect(fills(file, '.gallery__filter-btn:hover').color).toBe('var(--color-link)');
|
|
234
|
+
expect(themes.map(([, t]) => ratio(colour(t, 'var(--color-on-primary)'), colour(t, 'var(--color-primary-fill-hover)')))).toEqual([9.13, 9.13]);
|
|
235
|
+
expect(themes.map(([, t]) => ratio(colour(t, 'var(--color-link)'), colour(t, 'var(--color-background)')))).toEqual([6.21, 11.78]);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('tag chip text on its tint over surface-2: 7.18 light, 6.34 dark (the dark value is today’s)', () => {
|
|
239
|
+
const chip = fills('tag-input/tag-input.component.scss', '.lc-tag-input__tag');
|
|
240
|
+
expect(chip.color).toBe('var(--color-on-primary-subtle)');
|
|
241
|
+
const got = themes.map(([, t]) => {
|
|
242
|
+
const bg = over(parse(chip.background), colour(t, 'var(--color-surface-2)'));
|
|
243
|
+
return ratio(colour(t, chip.color), bg);
|
|
244
|
+
});
|
|
245
|
+
expect(got).toEqual([7.18, 6.34]);
|
|
246
|
+
expect(colour(themes[1][1], 'var(--color-on-primary-subtle)')).toEqual(colour(themes[1][1], 'var(--color-primary-500)'));
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('chat monogram on its 18 % tint: 7.00 light, 5.11 dark (unchanged)', () => {
|
|
250
|
+
const mono = fills('chat/chat.component.scss', '.lc-chat__monogram');
|
|
251
|
+
expect([mono.color, mono.background]).toEqual([
|
|
252
|
+
'var(--color-on-primary-subtle)',
|
|
253
|
+
'color-mix(in srgb, var(--color-primary-500) 18%, var(--color-surface-2))',
|
|
254
|
+
]);
|
|
255
|
+
const got = themes.map(([, t]) => ratio(colour(t, mono.color), mix(colour(t, 'var(--color-primary-500)'), 0.18, colour(t, 'var(--color-surface-2)'))));
|
|
256
|
+
expect(got).toEqual([7, 5.11]);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('no raw white is left in the changed rules', () => {
|
|
260
|
+
for (const [file, selector] of [
|
|
261
|
+
['rich-text-editor/rich-text-editor.component.scss', '.lc-rich-text-editor__mode-btn.active'],
|
|
262
|
+
['gallery/gallery.component.scss', '.gallery__filter-btn--active'],
|
|
263
|
+
['gallery/gallery.component.scss', '.gallery__filter-btn--active:hover'],
|
|
264
|
+
]) {
|
|
265
|
+
expect(Object.values(fills(file, selector)).join(' ')).not.toMatch(/#fff|\bwhite\b/i);
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
describe('D-10: the over-limit kanban column (F-27, AC-10)', () => {
|
|
271
|
+
it('is --color-error-subtle: the title (text-secondary) reads 6.58 light, ≥ 4.5 dark on the page and the surface', () => {
|
|
272
|
+
const file = 'kanban-board/kanban-board.component.scss';
|
|
273
|
+
expect(fills(file, '.lc-kanban__column--over-limit').background).toBe('var(--color-error-subtle)');
|
|
274
|
+
expect(fills(file, '.lc-kanban__column-header').color).toBe('var(--color-text-secondary)');
|
|
275
|
+
const got = themes.map(([, t]) =>
|
|
276
|
+
['--color-background', '--color-surface'].map((bg) => ratio(colour(t, 'var(--color-text-secondary)'), over(colour(t, 'var(--color-error-subtle)'), colour(t, `var(${bg})`)))),
|
|
277
|
+
);
|
|
278
|
+
expect(got[0]).toEqual([6.58, 6.58]);
|
|
279
|
+
expect(Math.min(...got[1])).toBeGreaterThanOrEqual(4.5);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('the inset card ring sits on the card surface: 4.36 light, 9.12 dark', () => {
|
|
283
|
+
expect(themes.map(([, t]) => ratio(colour(t, 'var(--color-primary-500)'), colour(t, 'var(--color-surface)')))).toEqual([4.36, 9.12]);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
});
|
|
@@ -269,19 +269,30 @@ export declare class DependencyViewerComponent {
|
|
|
269
269
|
* `contain` fits both axes, so everything is visible at once but a big graph
|
|
270
270
|
* shrinks to get there. `fit-width` fits the width only and lets the viewer grow
|
|
271
271
|
* as tall as the graph needs, so the nodes keep a readable size and the *page*
|
|
272
|
-
* scrolls;
|
|
273
|
-
* process view generally wants `fit-width`.
|
|
272
|
+
* scrolls; past `minNodeSize`, a graph wider than the frame scrolls sideways
|
|
273
|
+
* inside it. Past roughly 20 nodes a process view generally wants `fit-width`.
|
|
274
274
|
*/
|
|
275
275
|
readonly fitMode: import("@angular/core").InputSignal<DependencyFitMode | null>;
|
|
276
276
|
/**
|
|
277
277
|
* Smallest node width, in px, that fitting may shrink to. Below it the fit stops
|
|
278
278
|
* scaling down and lets the graph overflow instead — which `fit-width` turns into
|
|
279
|
-
*
|
|
279
|
+
* sideways scrolling of the frame (pinned to the start, see `regionLabel`), and
|
|
280
|
+
* `contain` into clipping. Pair it with `fit-width`.
|
|
280
281
|
*
|
|
281
282
|
* Nodes are 160px wide unscaled (wider — up to 240px — when the graph's labels
|
|
282
|
-
* need it), so e.g. `80` allows shrinking to half size.
|
|
283
|
+
* need it), so e.g. `80` allows shrinking to half size. `200` keeps the 12 px
|
|
284
|
+
* labels at 10 px or more whatever the node width (160–240 px): the phone
|
|
285
|
+
* recipe is `fitMode="fit-width"` with `minNodeSize` 200.
|
|
283
286
|
*/
|
|
284
287
|
readonly minNodeSize: import("@angular/core").InputSignal<number | null>;
|
|
288
|
+
/**
|
|
289
|
+
* Accessible name of the canvas while it scrolls sideways (`fitMode="fit-width"`
|
|
290
|
+
* with a `minNodeSize` the frame can't hold). It is then a focusable
|
|
291
|
+
* `role="region"`, scrolled with the arrow keys. Set a short, localised name
|
|
292
|
+
* that is unique on the page. Not used otherwise.
|
|
293
|
+
* @default 'Dependency graph'
|
|
294
|
+
*/
|
|
295
|
+
readonly regionLabel: import("@angular/core").InputSignal<string>;
|
|
285
296
|
/**
|
|
286
297
|
* Viewport navigation: drag-to-pan, wheel-zoom and the toolbar's zoom buttons.
|
|
287
298
|
* `false` freezes the viewport — node selection, expansion and collapse keep
|
|
@@ -378,6 +389,19 @@ export declare class DependencyViewerComponent {
|
|
|
378
389
|
protected effectiveFitMode: import("@angular/core").Signal<DependencyFitMode>;
|
|
379
390
|
/** The host only gives up its configured height when the fit made it grow. */
|
|
380
391
|
protected hostHeight: import("@angular/core").Signal<string>;
|
|
392
|
+
/**
|
|
393
|
+
* Horizontal extent of the graph, in layout units, while `fit-width` is held
|
|
394
|
+
* at `minNodeSize` and the graph is wider than the frame; `null` otherwise.
|
|
395
|
+
* The canvas then scrolls sideways instead of clipping both ends.
|
|
396
|
+
*/
|
|
397
|
+
private scrollBox;
|
|
398
|
+
/** Whether the canvas scrolls sideways (see `scrollBox`). */
|
|
399
|
+
protected scrolling: import("@angular/core").Signal<boolean>;
|
|
400
|
+
/**
|
|
401
|
+
* Scroll width of the canvas: the graph's right edge at the current pan and
|
|
402
|
+
* zoom. The svg's own box is unscaled, so it can't be the scroll extent.
|
|
403
|
+
*/
|
|
404
|
+
protected scrollWidth: import("@angular/core").Signal<string | null>;
|
|
381
405
|
/** `minNodeSize` expressed as the scale it forbids fitting to go below. */
|
|
382
406
|
private minFitScale;
|
|
383
407
|
constructor();
|
|
@@ -500,6 +524,11 @@ export declare class DependencyViewerComponent {
|
|
|
500
524
|
"required": false;
|
|
501
525
|
"isSignal": true;
|
|
502
526
|
};
|
|
527
|
+
"regionLabel": {
|
|
528
|
+
"alias": "regionLabel";
|
|
529
|
+
"required": false;
|
|
530
|
+
"isSignal": true;
|
|
531
|
+
};
|
|
503
532
|
"interactive": {
|
|
504
533
|
"alias": "interactive";
|
|
505
534
|
"required": false;
|
|
@@ -78,7 +78,12 @@ export declare class FileUploadComponent {
|
|
|
78
78
|
* @default 'Max:'
|
|
79
79
|
*/
|
|
80
80
|
maxSizeLabel: import("@angular/core").InputSignal<string>;
|
|
81
|
-
/**
|
|
81
|
+
/**
|
|
82
|
+
* Accessible name of a selected file's remove button, followed by the file
|
|
83
|
+
* name ("Remove file bericht.csv"), so each button in the list has its own
|
|
84
|
+
* name.
|
|
85
|
+
* @default 'Remove file'
|
|
86
|
+
*/
|
|
82
87
|
removeFileAriaLabel: import("@angular/core").InputSignal<string>;
|
|
83
88
|
/**
|
|
84
89
|
* BCP 47 locale of the shown sizes (max line, drop zone name, file list):
|