Skip to content

Commit e16e200

Browse files
committed
Merge remote-tracking branch 'origin/develop' into search-fixes
2 parents 156341f + 9412110 commit e16e200

30 files changed

Lines changed: 3271 additions & 653 deletions

attack-search/__tests__/search-style.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ describe('search styles', () => {
2020

2121
const badgeStyle = styles.match(/\.search-result-badge\s*\{(?<body>[^}]+)\}/)?.groups?.body ?? '';
2222

23-
expect(badgeStyle).toContain('color: white;');
23+
expect(badgeStyle).toContain('color: color-functions.on-color(active);');
2424
expect(badgeStyle).toContain('font-size: 0.8rem;');
2525

2626
expect(styles).toContain('.search-result-badge-page-type');
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
const fs = require('fs');
2+
const path = require('path');
3+
4+
const themeModulePath = '../../attack-theme/static/scripts/theme.js';
5+
6+
describe('site theme', () => {
7+
let theme;
8+
9+
beforeEach(() => {
10+
jest.resetModules();
11+
theme = require(themeModulePath);
12+
});
13+
14+
test('uses System when no saved override exists', () => {
15+
const storage = createStorage();
16+
const root = createRoot();
17+
18+
expect(theme.readStoredPreference(storage)).toBe('system');
19+
20+
theme.applyPreference(root, 'system');
21+
22+
expect(root.removeAttribute).toHaveBeenCalledWith('data-theme');
23+
expect(root.style.setProperty).not.toHaveBeenCalled();
24+
});
25+
26+
test.each(['light', 'dark'])('applies a saved %s override before controls initialize', preference => {
27+
const storage = createStorage(preference);
28+
const root = createRoot();
29+
30+
const storedPreference = theme.readStoredPreference(storage);
31+
theme.applyPreference(root, storedPreference);
32+
33+
expect(storedPreference).toBe(preference);
34+
expect(root.setAttribute).toHaveBeenCalledWith('data-theme', preference);
35+
// CSS owns color-scheme so the print stylesheet can force light controls.
36+
expect(root.style.setProperty).not.toHaveBeenCalled();
37+
});
38+
39+
test('discards an invalid saved preference', () => {
40+
const storage = createStorage('sepia');
41+
42+
expect(theme.readStoredPreference(storage)).toBe('system');
43+
expect(storage.removeItem).toHaveBeenCalledWith(theme.STORAGE_KEY);
44+
});
45+
46+
test('adds a working archive switch to the existing banner without replacing its content', () => {
47+
const fixture = createControllerFixture({ storedPreference: 'dark' });
48+
const banner = { appendChild: jest.fn(), textContent: 'Currently viewing ATT&CK v3.0' };
49+
fixture.document.querySelector = jest.fn(selector => (
50+
selector === '.version-banner' ? banner : null
51+
));
52+
fixture.document.createElement = jest.fn(() => fixture.toggle);
53+
fixture.document.readyState = 'complete';
54+
55+
theme.bootstrap({ ...fixture, archived: true });
56+
57+
expect(fixture.document.documentElement.setAttribute).toHaveBeenCalledWith('data-archive-theme', '');
58+
expect(banner.appendChild).toHaveBeenCalledWith(fixture.toggle);
59+
expect(banner.textContent).toBe('Currently viewing ATT&CK v3.0');
60+
expect(fixture.toggle.setAttribute).toHaveBeenCalledWith('aria-label', 'Dark mode');
61+
expect(fixture.toggle.setAttribute).toHaveBeenCalledWith('aria-checked', 'true');
62+
fixture.toggle.click();
63+
expect(fixture.storage.setItem).toHaveBeenCalledWith(theme.STORAGE_KEY, 'light');
64+
expect(fixture.toggle.setAttribute).toHaveBeenCalledWith('aria-checked', 'false');
65+
});
66+
67+
test('toggles from the system light theme to a saved dark override', () => {
68+
const fixture = createControllerFixture({ systemDark: false });
69+
const controller = theme.createThemeController(fixture);
70+
71+
controller.init();
72+
fixture.toggle.click();
73+
74+
expect(fixture.document.documentElement.setAttribute).toHaveBeenLastCalledWith('data-theme', 'dark');
75+
expect(fixture.storage.setItem).toHaveBeenCalledWith(theme.STORAGE_KEY, 'dark');
76+
expect(fixture.toggle.setAttribute).toHaveBeenCalledWith('aria-label', 'Switch to light mode');
77+
expect(fixture.toggle.setAttribute).toHaveBeenCalledWith('aria-checked', 'true');
78+
});
79+
80+
test('toggles from the system dark theme to a saved light override', () => {
81+
const fixture = createControllerFixture({ systemDark: true });
82+
const controller = theme.createThemeController(fixture);
83+
84+
controller.init();
85+
fixture.toggle.click();
86+
87+
expect(fixture.document.documentElement.setAttribute).toHaveBeenLastCalledWith('data-theme', 'light');
88+
expect(fixture.storage.setItem).toHaveBeenCalledWith(theme.STORAGE_KEY, 'light');
89+
expect(fixture.toggle.setAttribute).toHaveBeenCalledWith('aria-label', 'Switch to dark mode');
90+
expect(fixture.toggle.setAttribute).toHaveBeenCalledWith('aria-checked', 'false');
91+
});
92+
93+
test.each([
94+
['light', 'dark'],
95+
['dark', 'light'],
96+
])('toggles a saved %s override to %s', (storedPreference, expectedPreference) => {
97+
const fixture = createControllerFixture({ storedPreference });
98+
const controller = theme.createThemeController(fixture);
99+
100+
controller.init();
101+
fixture.toggle.click();
102+
103+
expect(fixture.document.documentElement.setAttribute).toHaveBeenLastCalledWith(
104+
'data-theme',
105+
expectedPreference,
106+
);
107+
expect(fixture.storage.setItem).toHaveBeenCalledWith(theme.STORAGE_KEY, expectedPreference);
108+
});
109+
110+
test('continues applying a choice when storage access fails', () => {
111+
const fixture = createControllerFixture();
112+
fixture.storage.setItem.mockImplementation(() => {
113+
throw new Error('Storage disabled');
114+
});
115+
const controller = theme.createThemeController(fixture);
116+
117+
expect(() => {
118+
controller.init();
119+
fixture.toggle.click();
120+
}).not.toThrow();
121+
expect(fixture.document.documentElement.setAttribute).toHaveBeenLastCalledWith('data-theme', 'dark');
122+
});
123+
124+
test('updates the toggle when the system preference changes before an override', () => {
125+
const fixture = createControllerFixture({ systemDark: false });
126+
const controller = theme.createThemeController(fixture);
127+
128+
controller.init();
129+
expect(fixture.toggle.setAttribute).toHaveBeenCalledWith('aria-label', 'Switch to dark mode');
130+
131+
fixture.mediaQuery.matches = true;
132+
fixture.mediaQuery.dispatchChange();
133+
134+
expect(fixture.toggle.setAttribute).toHaveBeenCalledWith('aria-label', 'Switch to light mode');
135+
});
136+
137+
test('ignores OS preference changes while an explicit override is active', () => {
138+
const fixture = createControllerFixture({ storedPreference: 'light', systemDark: false });
139+
const controller = theme.createThemeController(fixture);
140+
141+
controller.init();
142+
fixture.mediaQuery.matches = true;
143+
fixture.mediaQuery.dispatchChange();
144+
145+
expect(fixture.toggle.setAttribute).toHaveBeenCalledWith('aria-label', 'Switch to dark mode');
146+
});
147+
148+
test('loads the early theme script before styles and renders one toggle before search', () => {
149+
const template = fs.readFileSync(
150+
path.join(__dirname, '../../attack-theme/templates/general/base-template.html'),
151+
'utf8',
152+
);
153+
const navigation = fs.readFileSync(
154+
path.join(__dirname, '../../attack-theme/templates/macros/navigation_menu.html'),
155+
'utf8',
156+
);
157+
158+
expect(template).toContain('<meta name="color-scheme" content="light dark">');
159+
expect(template.indexOf('/theme/scripts/theme.js')).toBeLessThan(template.indexOf('bootstrap.min.css'));
160+
expect(navigation.indexOf('data-theme-toggle')).toBeLessThan(navigation.indexOf('id="search-button"'));
161+
expect(navigation).toContain('role="switch"');
162+
expect(navigation).toContain('class="theme-toggle-track"');
163+
expect(navigation).toContain('class="theme-toggle-thumb"');
164+
expect(navigation).toContain('aria-checked="false"');
165+
expect(navigation).not.toContain('aria-pressed');
166+
expect(navigation).not.toContain('data-theme-option');
167+
expect(navigation).not.toContain('theme-menu');
168+
expect(navigation).not.toContain('dropdown-toggle" type="button" data-theme-toggle');
169+
});
170+
171+
test('uses theme-aware surfaces for the affected resource pages', () => {
172+
const council = fs.readFileSync(
173+
path.join(__dirname, '../../modules/resources/templates/attack-advisory-council-members.html'),
174+
'utf8',
175+
);
176+
const dataTools = fs.readFileSync(
177+
path.join(__dirname, '../../modules/resources/templates/attack-data-and-tools.html'),
178+
'utf8',
179+
);
180+
const attackcon = fs.readFileSync(
181+
path.join(__dirname, '../../modules/resources/templates/attackcon-overview.html'),
182+
'utf8',
183+
);
184+
185+
expect(council).toContain('background: var(--attack-color-body-alternate);');
186+
expect(council).toContain('color: var(--attack-on-color-body);');
187+
expect(dataTools).toContain('class="tab-content card card-body p-3 attack-excel-files"');
188+
expect(dataTools).not.toContain('style="background: #f8f9fa;"');
189+
expect(attackcon).toContain('"ATT&CKcon 4.0", "ATT&CKcon 5.0", "ATT&CKcon 6.0", "ATT&CKcon 7.0"');
190+
expect(attackcon).toContain('attackcon-banner-image{% if con.title in light_banner_titles %} on-light{% endif %}');
191+
});
192+
193+
test('uses theme-aware home controls and announcement banner colors', () => {
194+
const home = fs.readFileSync(
195+
path.join(__dirname, '../../attack-theme/templates/general/attack-index.html'),
196+
'utf8',
197+
);
198+
const colors = fs.readFileSync(
199+
path.join(__dirname, '../../attack-style/themes/_palette.scss'),
200+
'utf8',
201+
);
202+
203+
expect(home).toContain('fa-up-right-from-square external-link-icon');
204+
expect(home).toContain('dropdown-toggle-split random-page-toggle');
205+
expect(home).not.toContain('external-site-dark.jpeg');
206+
expect(home).not.toContain('style="color: #4f7cac; background-color: white;');
207+
expect(colors).toContain('--attack-color-banner: #e7f0f6;');
208+
expect(colors).toContain('--attack-color-banner: #263a49;');
209+
});
210+
211+
test('uses a warm metadata label color only in dark mode', () => {
212+
const colors = fs.readFileSync(
213+
path.join(__dirname, '../../attack-style/themes/_palette.scss'),
214+
'utf8',
215+
);
216+
const layout = fs.readFileSync(
217+
path.join(__dirname, '../../attack-style/layout/_layout.scss'),
218+
'utf8',
219+
);
220+
221+
expect(colors).toContain('--attack-color-property-label: #1d2226;');
222+
expect(colors).toContain('--attack-color-property-label: #f2d2a4;');
223+
expect(layout).toMatch(/\.card-data \.card-title\s*\{\s*color: color-functions\.color\(property-label\);/);
224+
});
225+
});
226+
227+
function createStorage(value = null) {
228+
return {
229+
getItem: jest.fn(() => value),
230+
removeItem: jest.fn(),
231+
setItem: jest.fn(),
232+
};
233+
}
234+
235+
function createRoot() {
236+
return {
237+
removeAttribute: jest.fn(),
238+
setAttribute: jest.fn(),
239+
style: {
240+
removeProperty: jest.fn(),
241+
setProperty: jest.fn(),
242+
},
243+
};
244+
}
245+
246+
function createElement() {
247+
const listeners = {};
248+
return {
249+
classList: { toggle: jest.fn() },
250+
setAttribute: jest.fn(),
251+
addEventListener: jest.fn((eventName, listener) => {
252+
listeners[eventName] = listener;
253+
}),
254+
click: () => listeners.click({ preventDefault: jest.fn() }),
255+
};
256+
}
257+
258+
function createControllerFixture({ storedPreference = null, systemDark = false } = {}) {
259+
const root = createRoot();
260+
const toggle = createElement();
261+
let changeListener;
262+
const mediaQuery = {
263+
matches: systemDark,
264+
addEventListener: jest.fn((eventName, listener) => {
265+
if (eventName === 'change') changeListener = listener;
266+
}),
267+
dispatchChange: () => changeListener({ matches: mediaQuery.matches }),
268+
};
269+
const document = {
270+
documentElement: root,
271+
querySelector: jest.fn(selector => (selector === '[data-theme-toggle]' ? toggle : null)),
272+
};
273+
274+
return {
275+
document,
276+
mediaQuery,
277+
storage: createStorage(storedPreference),
278+
toggle,
279+
};
280+
}

attack-style/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
# ATT&CK Style
22

33
ATT&CK Style is a JavaScript package that builds the CSS styles for the ATT&CK website.
4-
The outputs are simply 2 CSS files:
4+
The outputs are 3 CSS files:
55

66
* `dist/style-attack.css`
77
* `dist/style-user.css`
8+
* `dist/style-archive.css` (preserved-site appearance compatibility)
89

910
These files are then copied into `<ATT&CK-website-git-repo>/attack-theme/static/`.
1011
Currently this is done manually - no automation.
@@ -47,7 +48,7 @@ To set up the ATT&CK Style package, follow these steps:
4748

4849
2. **Copy CSS Files**:
4950

50-
Copy both `dist/style-attack.css` and `dist/style-user.css` to `<ATT&CK-website-git-repo>/attack-theme/static/`.
51+
Copy `dist/style-attack.css`, `dist/style-user.css`, and `dist/style-archive.css` to `<ATT&CK-website-git-repo>/attack-theme/static/`.
5152

5253
```bash
5354
npm run copy

attack-style/abstracts/README.md

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@ Files in this folder should not emit large blocks of CSS on their own unless the
88
| File | Purpose |
99
| --- | --- |
1010
| `_variables.scss` | Defines brand and user color maps plus the semantic `$colors` map used across the site. |
11-
| `_color-functions.scss` | Provides accessors and derived color helpers for entries in `$colors`. |
11+
| `_color-functions.scss` | Provides accessors for runtime semantic color tokens. |
1212
| `_utilities.scss` | Provides small reusable mixins and unit helpers. |
1313
| `_font-faces.scss` | Defines shared font-face declarations. |
1414

1515
## Color Model
1616

17-
`_variables.scss` keeps raw brand values separate from semantic color names.
18-
Most styles should use semantic keys from `$colors`, such as `primary`, `secondary`, `footer`, `active`, `body`, `link`, `matrix-header`, `search-highlight`, and `deemphasis`.
17+
`_variables.scss` keeps raw brand values separate from semantic color names. The theme palette in `themes/_palette.scss` turns those values into CSS custom properties so the appearance can change without loading another stylesheet.
18+
Most styles should use semantic names such as `primary`, `secondary`, `footer`, `active`, `body`, `link`, `matrix-header`, `search-highlight`, and `deemphasis`.
1919

2020
Each color entry may contain:
2121

@@ -28,18 +28,17 @@ Some entries omit `on-color` when they are not meant to contain inner text.
2828

2929
## Helper Functions
3030

31-
Use the functions in `_color-functions.scss` instead of reading `$colors` directly from component or layout files:
31+
Use the functions in `_color-functions.scss` instead of reading `$colors` directly from component or layout files. Each helper returns the appropriate runtime CSS custom property:
3232

3333
| Function | Use |
3434
| --- | --- |
3535
| `color($name)` | Reads the base color for a semantic color name. |
36-
| `on-color($name)` | Reads the readable text color for a semantic color name. |
37-
| `color-alternate($name, $contrast: 1)` | Computes a nearby alternate shade for patterning or subtle contrast. |
38-
| `on-color-emphasis($name)` | Computes a stronger foreground color against a semantic background. |
39-
| `on-color-deemphasis($name)` | Computes a quieter foreground color against a semantic background. |
40-
| `border-color($name)` | Computes a border color for a semantic background. |
41-
| `background-color($name)` | Computes a subtle derived background shade. |
42-
| `escape-color($color)` | Escapes a concrete color for use inside inline SVG data URLs. |
36+
| `on-color($name)` | Reads the readable foreground for a semantic color name. |
37+
| `color-alternate($name, $contrast: 1)` | Reads an explicit alternate surface token. Supported contrast levels are `0.8`, `1`, `1.5`, `2`, and `3`. |
38+
| `on-color-emphasis($name)` | Reads a stronger foreground token. |
39+
| `on-color-deemphasis($name)` | Reads a quieter foreground token. |
40+
| `border-color($name)` | Reads a border token. |
41+
| `background-color($name)` | Reads a related background token. |
4342

4443
## Utility Mixins And Functions
4544

0 commit comments

Comments
 (0)