@things-factory/shell 10.1.20 → 10.1.26
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/client/themes/dark.css +52 -45
- package/client/themes/layout-theme.css +8 -10
- package/client/themes/light.css +52 -45
- package/client/themes/material-theme.css +57 -0
- package/client/themes/spacing.css +12 -5
- package/client/themes/theme-base.css +5 -1
- package/client/themes/ui-tokens.css +22 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/tests/control-height-tokens.test.ts +142 -0
- package/tests/error-colour-contrast.test.ts +72 -0
- package/tests/md-sys-slot-owners.test.ts +100 -0
- package/tests/popup-buttons-class-shrinks.test.ts +83 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/shell",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.26",
|
|
4
4
|
"description": "Core module for framework",
|
|
5
5
|
"bin": {
|
|
6
6
|
"things-factory": "bin/things-factory",
|
|
@@ -126,5 +126,5 @@
|
|
|
126
126
|
"pg": "^8.7.3",
|
|
127
127
|
"sqlite3": "^5.0.8"
|
|
128
128
|
},
|
|
129
|
-
"gitHead": "
|
|
129
|
+
"gitHead": "151bda14d899442c6846532ddded1e2bfe23a2f5"
|
|
130
130
|
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Control heights: the shell theme declares the tokens, and @operato/styles declares the same names
|
|
3
|
+
* with the same defaults (ADR-0060 decision 6). The theme is the value of record; the components need
|
|
4
|
+
* a fallback because they cannot see the shell. Written in two places, so this test holds them
|
|
5
|
+
* together - change one and it fails until the other follows.
|
|
6
|
+
*
|
|
7
|
+
* Two bundles: button heights (ACTION_HEIGHT_TOKENS, ui-tokens.css) and input heights
|
|
8
|
+
* (FORM_ELEMENT_HEIGHT_TOKENS, spacing.css). Material buttons, text fields, selects and chips are
|
|
9
|
+
* sized once in the theme from those tokens.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, readFileSync } from 'fs'
|
|
13
|
+
import path from 'path'
|
|
14
|
+
|
|
15
|
+
const THEMES = path.resolve(__dirname, '../client/themes')
|
|
16
|
+
|
|
17
|
+
function stylesConstants(file: string, name: string): Record<string, number> {
|
|
18
|
+
const root = path.dirname(require.resolve('@operato/styles/package.json'))
|
|
19
|
+
const built = path.join(root, 'dist/src', file)
|
|
20
|
+
|
|
21
|
+
if (!existsSync(built)) {
|
|
22
|
+
throw new Error(`the installed @operato/styles (${root}) has no ${file} - it predates ADR-0060 decision 6`)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const body = readFileSync(built, 'utf-8').match(new RegExp(`${name}\\s*=\\s*\\{([\\s\\S]*?)\\}`))
|
|
26
|
+
if (!body) throw new Error(`${name} was not found in @operato/styles ${file}`)
|
|
27
|
+
|
|
28
|
+
return Object.fromEntries([...body[1].matchAll(/'(--[a-z-]+)':\s*(\d+)/g)].map(m => [m[1], Number(m[2])]))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function themeDeclarations(file: string): Record<string, string> {
|
|
32
|
+
const css = readFileSync(path.join(THEMES, file), 'utf-8').replace(/\/\*[\s\S]*?\*\//g, '')
|
|
33
|
+
return Object.fromEntries([...css.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;]+);/g)].map(m => [m[1], m[2].trim()]))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function expectSameValues(constants: Record<string, number>, theme: Record<string, string>) {
|
|
37
|
+
for (const [token, px] of Object.entries(constants)) {
|
|
38
|
+
expect({ token, value: theme[token] }).toEqual({ token, value: `${px}px` })
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('button height tokens', () => {
|
|
43
|
+
it('the shell theme declares every @operato/styles action height with the same value', () => {
|
|
44
|
+
const constants = stylesConstants('action-heights.js', 'ACTION_HEIGHT_TOKENS')
|
|
45
|
+
|
|
46
|
+
expect(Object.keys(constants).sort()).toEqual([
|
|
47
|
+
'--cell-action-height',
|
|
48
|
+
'--context-action-height',
|
|
49
|
+
'--form-action-height',
|
|
50
|
+
'--popup-action-height'
|
|
51
|
+
])
|
|
52
|
+
expectSameValues(constants, themeDeclarations('ui-tokens.css'))
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('Material buttons take the form height, set once in the theme', () => {
|
|
56
|
+
const theme = themeDeclarations('material-theme.css')
|
|
57
|
+
|
|
58
|
+
for (const kind of ['filled', 'outlined', 'text', 'elevated', 'filled-tonal']) {
|
|
59
|
+
expect({ kind, value: theme[`--md-${kind}-button-container-height`] }).toEqual({
|
|
60
|
+
kind,
|
|
61
|
+
value: 'var(--form-action-height)'
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('assist chips draw page actions, so they take the page action height', () => {
|
|
67
|
+
expect(themeDeclarations('layout-theme.css')['--md-assist-chip-container-height']).toBe(
|
|
68
|
+
'var(--context-action-height)'
|
|
69
|
+
)
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('input height tokens', () => {
|
|
74
|
+
it('the shell theme declares small, medium and large with the @operato/styles values, and nothing else', () => {
|
|
75
|
+
const constants = stylesConstants('form-element-heights.js', 'FORM_ELEMENT_HEIGHT_TOKENS')
|
|
76
|
+
const theme = themeDeclarations('spacing.css')
|
|
77
|
+
|
|
78
|
+
expect(Object.keys(constants).sort()).toEqual([
|
|
79
|
+
'--form-element-height-large',
|
|
80
|
+
'--form-element-height-medium',
|
|
81
|
+
'--form-element-height-small'
|
|
82
|
+
])
|
|
83
|
+
expectSameValues(constants, theme)
|
|
84
|
+
expect(
|
|
85
|
+
Object.keys(theme)
|
|
86
|
+
.filter(name => name.startsWith('--form-element-height-'))
|
|
87
|
+
.sort()
|
|
88
|
+
).toEqual(Object.keys(constants).sort())
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('an input lines up with a button on the same row (a UX layout rule)', () => {
|
|
92
|
+
/*
|
|
93
|
+
* The tokens are separate so one use can change on its own (user decision 2026-09-17). This test
|
|
94
|
+
* is a layout rule on top of that: a field and a button that stand on one row have one height.
|
|
95
|
+
* If a change here is intended, change this rule - do not put the value back to make it pass.
|
|
96
|
+
*/
|
|
97
|
+
const actions = stylesConstants('action-heights.js', 'ACTION_HEIGHT_TOKENS')
|
|
98
|
+
const inputs = stylesConstants('form-element-heights.js', 'FORM_ELEMENT_HEIGHT_TOKENS')
|
|
99
|
+
const pairs = [
|
|
100
|
+
['--form-element-height-medium', '--form-action-height'],
|
|
101
|
+
['--form-element-height-small', '--cell-action-height']
|
|
102
|
+
] as const
|
|
103
|
+
|
|
104
|
+
for (const [input, button] of pairs) {
|
|
105
|
+
if (inputs[input] !== actions[button]) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
`${input} (${inputs[input]}px) and ${button} (${actions[button]}px) stand on one row but differ in height. ` +
|
|
108
|
+
'If that is intended, change this layout rule; do not put the value back to make it pass.'
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('outlined Material fields take medium, filled ones take large', () => {
|
|
115
|
+
const theme = themeDeclarations('material-theme.css')
|
|
116
|
+
const space = (size: string, less: number) => `calc((var(--form-element-height-${size}) - ${less}px) / 2)`
|
|
117
|
+
|
|
118
|
+
for (const name of [
|
|
119
|
+
'--md-outlined-text-field-top-space',
|
|
120
|
+
'--md-outlined-text-field-bottom-space',
|
|
121
|
+
'--md-outlined-field-top-space',
|
|
122
|
+
'--md-outlined-field-bottom-space'
|
|
123
|
+
]) {
|
|
124
|
+
expect({ name, value: theme[name] }).toEqual({ name, value: space('medium', 20) })
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
for (const name of [
|
|
128
|
+
'--md-filled-text-field-top-space',
|
|
129
|
+
'--md-filled-text-field-bottom-space',
|
|
130
|
+
'--md-filled-field-top-space',
|
|
131
|
+
'--md-filled-field-bottom-space'
|
|
132
|
+
]) {
|
|
133
|
+
expect({ name, value: theme[name] }).toEqual({ name, value: space('large', 20) })
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
for (const name of ['--md-filled-text-field-with-label-top-space', '--md-filled-field-with-label-bottom-space']) {
|
|
137
|
+
expect({ name, value: theme[name] }).toEqual({ name, value: space('large', 34) })
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
expect(theme['--md-filter-chip-container-height']).toBe('var(--form-element-height-small)')
|
|
141
|
+
})
|
|
142
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error text has to be readable - on its own container, as a filled chip, and on the page.
|
|
3
|
+
*
|
|
4
|
+
* Until 2026-09-17 it was not: light `error` on `surface` measured 2.95 and `on-error-container`
|
|
5
|
+
* on `error-container` 2.41; dark `on-error` on `error` and `on-error-container` on
|
|
6
|
+
* `error-container` were both 1.75. WCAG 1.4.3 asks 4.5 for text. A refusal message drawn in the
|
|
7
|
+
* error colour was the hardest line on the screen to read, on exactly the screens where reading it
|
|
8
|
+
* matters.
|
|
9
|
+
*
|
|
10
|
+
* The fix kept each colour's OKLCH hue and chroma and only moved lightness, so red is still the
|
|
11
|
+
* same red - only darker where it sits on light, and a dark red under the pink in dark mode.
|
|
12
|
+
*
|
|
13
|
+
* Every app without its own light.css / dark.css receives these values, so this is a platform-wide
|
|
14
|
+
* change on purpose: a failing contrast is a defect, not a choice.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { readFileSync } from 'fs'
|
|
18
|
+
import path from 'path'
|
|
19
|
+
|
|
20
|
+
const THEMES = path.resolve(__dirname, '../client/themes')
|
|
21
|
+
|
|
22
|
+
/** WCAG 1.4.3 minimum for normal text. */
|
|
23
|
+
const TEXT = 4.5
|
|
24
|
+
|
|
25
|
+
function slotsOf(file: string): Map<string, string> {
|
|
26
|
+
const css = readFileSync(path.join(THEMES, file), 'utf-8')
|
|
27
|
+
return new Map([...css.matchAll(/--md-sys-color-([a-z-]+)\s*:\s*([^;]+);/g)].map(m => [m[1], m[2].trim()]))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Accepts `#rrggbb` and `rgb(r g b)`; a `var(--x, <colour>)` resolves to its fallback. */
|
|
31
|
+
function rgbOf(value: string): [number, number, number] {
|
|
32
|
+
const fallback = value.match(/^var\(--[a-z-]+, (.*)\)$/)
|
|
33
|
+
const v = fallback ? fallback[1] : value
|
|
34
|
+
|
|
35
|
+
const hex = v.match(/^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/)
|
|
36
|
+
if (hex) return [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)]
|
|
37
|
+
|
|
38
|
+
const rgb = v.match(/^rgb\((\d+)\s+(\d+)\s+(\d+)\)$/)
|
|
39
|
+
if (rgb) return [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])]
|
|
40
|
+
|
|
41
|
+
throw new Error(`cannot read colour: ${value}`)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function luminance([r, g, b]: [number, number, number]): number {
|
|
45
|
+
const lin = (c: number) => {
|
|
46
|
+
const s = c / 255
|
|
47
|
+
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
|
|
48
|
+
}
|
|
49
|
+
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function contrast(a: string, b: string): number {
|
|
53
|
+
const [hi, lo] = [luminance(rgbOf(a)), luminance(rgbOf(b))].sort((x, y) => y - x)
|
|
54
|
+
return (hi + 0.05) / (lo + 0.05)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** [text slot, background slot] - where the error colour is read as text. */
|
|
58
|
+
const PAIRS: [string, string][] = [
|
|
59
|
+
['on-error', 'error'],
|
|
60
|
+
['on-error-container', 'error-container'],
|
|
61
|
+
['error', 'surface'],
|
|
62
|
+
['error', 'surface-container-highest']
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
describe.each(['light.css', 'dark.css'])('error colours in shell %s', file => {
|
|
66
|
+
const slots = slotsOf(file)
|
|
67
|
+
|
|
68
|
+
it.each(PAIRS)('%s on %s reaches 4.5:1', (text, background) => {
|
|
69
|
+
expect(slots.has(text) && slots.has(background)).toBe(true)
|
|
70
|
+
expect(contrast(slots.get(text)!, slots.get(background)!)).toBeGreaterThanOrEqual(TEXT)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who may set each md-sys colour slot - decided by the shell theme, not by a document.
|
|
3
|
+
*
|
|
4
|
+
* ── Why the shell opens slots instead of apps replacing files ─────────────────
|
|
5
|
+
* The theme plugin replaces a stylesheet by file name, so an app that ships its own `light.css`
|
|
6
|
+
* takes all 49 slots: neutrals, outline, error. Products then disagree on what grey and red mean, and
|
|
7
|
+
* a fix to the shared `light.css` silently never reaches them. Ordering cannot fix it either: the
|
|
8
|
+
* plugin puts app files before the shell's, so an app's `.light { --md-sys-color-primary }` loses to
|
|
9
|
+
* the shell `light.css` behind it at equal specificity (measured on a running app bundle, 2026-09-17).
|
|
10
|
+
*
|
|
11
|
+
* So the shell declares slots as `var(--<owner>-<slot>, <shell value>)`. Custom properties resolve at
|
|
12
|
+
* compute time, so bundle order stops mattering, and a slot that is not opened cannot be reached.
|
|
13
|
+
*
|
|
14
|
+
* Three owners, one mechanism - no reliance on bundle order or specificity:
|
|
15
|
+
* --brand-<slot> a product's accent (its own brand.css) 26 slots
|
|
16
|
+
* --suite-<slot> the operato suite's shared greys (@things-factory/operato-suite) 17 slots
|
|
17
|
+
* closed error x4, shadow, scrim - literal, nobody above the platform sets them 6 slots
|
|
18
|
+
* A tenant's domain theme still wins over all of these: it writes --md-sys-color-* directly, later in
|
|
19
|
+
* the bundle. Apps with neither brand.css nor the suite get the fallbacks, the shell values of before.
|
|
20
|
+
*
|
|
21
|
+
* If md adds a slot the counts go red on purpose: someone has to decide whose it is.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { readFileSync } from 'fs'
|
|
25
|
+
import path from 'path'
|
|
26
|
+
|
|
27
|
+
const THEMES = path.resolve(__dirname, '../client/themes')
|
|
28
|
+
|
|
29
|
+
const ACCENTS = ['primary', 'secondary', 'tertiary']
|
|
30
|
+
|
|
31
|
+
const BRAND = new Set([
|
|
32
|
+
...ACCENTS.flatMap(a => [
|
|
33
|
+
a,
|
|
34
|
+
`on-${a}`,
|
|
35
|
+
`${a}-container`,
|
|
36
|
+
`on-${a}-container`,
|
|
37
|
+
`${a}-fixed`,
|
|
38
|
+
`on-${a}-fixed`,
|
|
39
|
+
`${a}-fixed-dim`,
|
|
40
|
+
`on-${a}-fixed-variant`
|
|
41
|
+
]),
|
|
42
|
+
'surface-tint',
|
|
43
|
+
'inverse-primary'
|
|
44
|
+
])
|
|
45
|
+
|
|
46
|
+
const SUITE = new Set([
|
|
47
|
+
'background',
|
|
48
|
+
'on-background',
|
|
49
|
+
'surface',
|
|
50
|
+
'on-surface',
|
|
51
|
+
'surface-variant',
|
|
52
|
+
'on-surface-variant',
|
|
53
|
+
'outline',
|
|
54
|
+
'outline-variant',
|
|
55
|
+
'inverse-surface',
|
|
56
|
+
'inverse-on-surface',
|
|
57
|
+
'surface-dim',
|
|
58
|
+
'surface-bright',
|
|
59
|
+
'surface-container-lowest',
|
|
60
|
+
'surface-container-low',
|
|
61
|
+
'surface-container',
|
|
62
|
+
'surface-container-high',
|
|
63
|
+
'surface-container-highest'
|
|
64
|
+
])
|
|
65
|
+
|
|
66
|
+
function slotsOf(file: string): Map<string, string> {
|
|
67
|
+
const css = readFileSync(path.join(THEMES, file), 'utf-8')
|
|
68
|
+
return new Map([...css.matchAll(/--md-sys-color-([a-z-]+)\s*:\s*([^;]+);/g)].map(m => [m[1], m[2].trim()]))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const opened = (prefix: string, slot: string) =>
|
|
72
|
+
new RegExp(`^var\\(--${prefix}-${slot}, (#[0-9a-fA-F]{3,8}|rgba?\\([^()]*\\))\\)$`)
|
|
73
|
+
|
|
74
|
+
describe.each(['light.css', 'dark.css'])('shell %s opens accent to products and neutrals to the suite', file => {
|
|
75
|
+
const slots = slotsOf(file)
|
|
76
|
+
const names = [...slots.keys()]
|
|
77
|
+
|
|
78
|
+
it('declares 49 slots: 26 brand, 17 suite, 6 closed', () => {
|
|
79
|
+
expect(BRAND.size).toBe(26)
|
|
80
|
+
expect(SUITE.size).toBe(17)
|
|
81
|
+
expect(slots.size).toBe(49)
|
|
82
|
+
expect(names.filter(s => BRAND.has(s)).length).toBe(26)
|
|
83
|
+
expect(names.filter(s => SUITE.has(s)).length).toBe(17)
|
|
84
|
+
expect(names.filter(s => !BRAND.has(s) && !SUITE.has(s)).length).toBe(6)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('reads brand slots from --brand-<slot> and suite slots from --suite-<slot>, each with a colour fallback', () => {
|
|
88
|
+
for (const [slot, value] of slots) {
|
|
89
|
+
if (BRAND.has(slot)) expect({ slot, value }).toEqual({ slot, value: expect.stringMatching(opened('brand', slot)) })
|
|
90
|
+
if (SUITE.has(slot)) expect({ slot, value }).toEqual({ slot, value: expect.stringMatching(opened('suite', slot)) })
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('keeps the closed slots literal', () => {
|
|
95
|
+
for (const [slot, value] of slots) {
|
|
96
|
+
if (BRAND.has(slot) || SUITE.has(slot)) continue
|
|
97
|
+
expect({ slot, value }).toEqual({ slot, value: expect.not.stringContaining('var(') })
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `.buttons` row of CommonPopupStyles is on its way out; this list may only shrink.
|
|
3
|
+
*
|
|
4
|
+
* ── Why ──────────────────────────────────────────────────────────────────────
|
|
5
|
+
* Popup command rows were written as markup under a CSS convention (`<div class="buttons">`), and
|
|
6
|
+
* nine different rows grew out of that. The row is now an element, `<ox-popup-actions>` from
|
|
7
|
+
* @operato/popup, which owns order, height, shape and the destructive colour.
|
|
8
|
+
*
|
|
9
|
+
* Until every popup has moved, the old rule and the element coexist. Two things keep that from
|
|
10
|
+
* becoming permanent:
|
|
11
|
+
*
|
|
12
|
+
* - a file on this list that no longer uses `.buttons` fails - the move happened, so the list must
|
|
13
|
+
* say so (a list that is never updated stops watching anything);
|
|
14
|
+
* - a file not on this list that starts using `.buttons` fails - new popups use the element.
|
|
15
|
+
*
|
|
16
|
+
* When the list is empty here, and in operato-application's own copy of this test, the `.buttons`
|
|
17
|
+
* rule itself is removed from @operato/styles.
|
|
18
|
+
*
|
|
19
|
+
* This test reads only this repository. Each repository keeps its own list.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { readdirSync, readFileSync, statSync } from 'fs'
|
|
23
|
+
import path from 'path'
|
|
24
|
+
|
|
25
|
+
const REPO_ROOT = path.resolve(__dirname, '../../..')
|
|
26
|
+
const PACKAGES = path.join(REPO_ROOT, 'packages')
|
|
27
|
+
|
|
28
|
+
const SKIP = new Set(['node_modules', 'dist', 'dist-client', 'dist-server', 'dist-shared', 'coverage'])
|
|
29
|
+
|
|
30
|
+
/** Files that still render the CommonPopupStyles `.buttons` row, as of 2026-09-17. */
|
|
31
|
+
const STILL_USING = [
|
|
32
|
+
'auth-ui/client/components/app-binding-detail.ts',
|
|
33
|
+
'auth-ui/client/components/appliance-editor.ts',
|
|
34
|
+
'auth-ui/client/components/application-editor.ts',
|
|
35
|
+
'organization/client/component/approval-line-items-editor-popup.ts',
|
|
36
|
+
'organization/client/component/approval-line-selector.ts',
|
|
37
|
+
'organization/client/component/assignees-editor-popup.ts',
|
|
38
|
+
'organization/client/component/department-selector.ts',
|
|
39
|
+
'organization/client/component/recipients-editor-popup.ts'
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
function clientSources(dir: string, found: string[] = []): string[] {
|
|
43
|
+
for (const entry of readdirSync(dir)) {
|
|
44
|
+
if (SKIP.has(entry) || entry.startsWith('.')) continue
|
|
45
|
+
|
|
46
|
+
const full = path.join(dir, entry)
|
|
47
|
+
let stat
|
|
48
|
+
try {
|
|
49
|
+
stat = statSync(full)
|
|
50
|
+
} catch {
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (stat.isDirectory()) clientSources(full, found)
|
|
55
|
+
else if (entry.endsWith('.ts') && full.includes(`${path.sep}client${path.sep}`)) found.push(full)
|
|
56
|
+
}
|
|
57
|
+
return found
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Uses the row: takes CommonPopupStyles and renders an element with the `buttons` class. */
|
|
61
|
+
function usesButtonsRow(source: string): boolean {
|
|
62
|
+
return /\bCommonPopupStyles\b/.test(source) && /class\s*=\s*["'](?:[^"']*\s)?buttons(?:\s[^"']*)?["']/.test(source)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('the CommonPopupStyles .buttons row only shrinks', () => {
|
|
66
|
+
const files = clientSources(PACKAGES)
|
|
67
|
+
const using = files
|
|
68
|
+
.filter(file => usesButtonsRow(readFileSync(file, 'utf-8')))
|
|
69
|
+
.map(file => path.relative(PACKAGES, file).split(path.sep).join('/'))
|
|
70
|
+
.sort()
|
|
71
|
+
|
|
72
|
+
it('scanned enough client sources for the answer to mean something', () => {
|
|
73
|
+
expect(files.length).toBeGreaterThanOrEqual(800)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('has no file on the list that already moved to ox-popup-actions', () => {
|
|
77
|
+
expect(STILL_USING.filter(file => !using.includes(file))).toEqual([])
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('has no new file using the row', () => {
|
|
81
|
+
expect(using.filter(file => !STILL_USING.includes(file))).toEqual([])
|
|
82
|
+
})
|
|
83
|
+
})
|