forked from OurHike/OurHike
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdesktopLayout.test.ts
More file actions
209 lines (181 loc) · 9.87 KB
/
Copy pathdesktopLayout.test.ts
File metadata and controls
209 lines (181 loc) · 9.87 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
// The desktop layout's CSS contract.
//
// jsdom does not do layout, so - as with appShellLayout.test.ts and
// siteLayout.test.ts - this asserts the contract rather than the pixels.
//
// The contract that matters is WEBSITE.md §8's: the desktop layout must not
// regress the phone layout, which is the one that gets used on trail. That is
// enforced structurally here rather than by review - every layout rule in
// desktop.css lives inside a media query, so none of them can match a phone at
// all. This file proves that property still holds, which is the kind of thing
// that decays the first time someone adds "just one" unguarded rule.
//
// Resolved from the Vitest root (client/), which vite.config.ts pins.
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { DESKTOP_MIN_WIDTH } from '../lib/useDesktop'
const css = readFileSync(resolve(process.cwd(), 'src/desktop.css'), 'utf8')
/** The declarations of one selector, comments removed - so a rule can be
* asserted on without a comment that mentions it counting as a match. */
function declarationsOf(selector: string): string {
const bare = css.replace(/\/\*[\s\S]*?\*\//g, '')
const start = bare.indexOf(`${selector} {`)
if (start === -1) throw new Error(`no rule for ${selector} in desktop.css`)
return bare.slice(start, bare.indexOf('}', start))
}
/** Strip comments, then every balanced @media block, leaving only rules that
* apply unconditionally. */
function unguardedRules(source: string): string {
let rest = source.replace(/\/\*[\s\S]*?\*\//g, '')
for (;;) {
const start = rest.indexOf('@media')
if (start === -1) return rest
let depth = 0
let i = rest.indexOf('{', start)
if (i === -1) return rest
for (; i < rest.length; i += 1) {
if (rest[i] === '{') depth += 1
else if (rest[i] === '}') {
depth -= 1
if (depth === 0) break
}
}
rest = rest.slice(0, start) + rest.slice(i + 1)
}
}
describe('desktop layout contract', () => {
it('puts every layout rule behind a media query', () => {
// The §8 guarantee. A rule out here reaches a 375px phone on a mountain.
const unguarded = unguardedRules(css)
expect(unguarded).not.toMatch(/\.tab-bar/)
expect(unguarded).not.toMatch(/\.map-screen/)
expect(unguarded).not.toMatch(/\.legend/)
expect(unguarded).not.toMatch(/\.app__screen/)
})
it('leaves focus rings unguarded, which is the one deliberate exception', () => {
// :focus-visible is already silent for a tap, so gating it on width or
// pointer would only take focus rings away from a phone with a bluetooth
// keyboard - a bug dressed as consistency.
expect(unguardedRules(css)).toMatch(/:focus-visible/)
})
it('breaks at the width useDesktop() breaks at', () => {
// A CSS breakpoint at 960 and a JS one at 900 would give a band of widths
// showing a sidebar with a modal legend inside it - a layout nobody would
// think to open.
expect(css).toMatch(new RegExp(`@media \\(min-width: ${DESKTOP_MIN_WIDTH}px\\)`))
})
it('keys touch-target sizing on the pointer rather than the width', () => {
// A 1024px tablet is a wide touch screen and still needs 44px targets.
// Tying this to the breakpoint would shrink them on the device where that
// hurts most.
const pointerBlock = css.slice(css.indexOf('@media (pointer: fine)'))
expect(pointerBlock).toMatch(/--min-touch-target/)
expect(css.slice(0, css.indexOf('@media (pointer: fine)'))).not.toMatch(
/--min-touch-target/,
)
})
it('keeps the search out of the flow of the map canvas', () => {
// The one rule here that a jsdom test cannot see the effect of, so it is
// pinned as text instead. .map-screen__canvas is `display: flex`, so a
// statically positioned .search stops overlaying the map and becomes a
// flex sibling of it. The map does not give the width back, so the canvas
// grows past the frame: measured in Chromium at 1440px, the document
// gained 327px of horizontal overflow and the legend panel started at
// x=1462 - off the screen entirely.
const block = declarationsOf('.search')
expect(block).not.toMatch(/position:\s*(static|relative)/)
})
// The brand mark differs by layout rather than existing in only one, so both
// halves are asserted together rather than one per stylesheet. A phone gets
// the icon at the left end of the bar; a desktop gets icon over wordmark at
// the foot of the sidebar.
const chromeCss = readFileSync(resolve(process.cwd(), 'src/chrome/chrome.css'), 'utf8')
const bareChrome = chromeCss.replace(/\/\*[\s\S]*?\*\//g, '')
function chromeRule(selector: string): string {
const at = bareChrome.indexOf(`${selector} {`)
expect(at, `no ${selector} rule in chrome.css`).toBeGreaterThan(-1)
return bareChrome.slice(at, bareChrome.indexOf('}', at))
}
it('keeps the wordmark off the phone, where the bar is a row of thumb targets', () => {
// Hidden by the component's own stylesheet...
expect(chromeRule('.tab-bar__brand-wordmark')).toMatch(/display:\s*none/)
// ...and turned back on only from inside the media query, which is what
// makes "cannot reach a phone" structural rather than a review promise.
expect(unguardedRules(css)).not.toMatch(/\.tab-bar__brand-wordmark/)
expect(css).toMatch(/\.tab-bar__brand-wordmark\s*\{[^}]*display:\s*block/)
})
it('pulls the mark ahead of the tabs on a phone and back after them on a desktop', () => {
// The mark is last in the DOM because on a desktop it is the foot of a
// column. Only the phone needs it first, and the desktop has to put that
// back - otherwise the sidebar grows a logo above its own navigation.
expect(chromeRule('.tab-bar__brand')).toMatch(/order:\s*-1/)
expect(declarationsOf('.map-screen > .tab-bar .tab-bar__brand')).toMatch(/order:\s*0/)
})
it('sizes the mark for the layout it is in, not once for both', () => {
// 24px beside three thumb targets, 64px in a 13rem column. The element is
// an <img> precisely so CSS can say that; <Logo />'s inline width and
// border-radius would need !important at one of the two sizes.
expect(chromeRule('.tab-bar__brand-icon')).toMatch(/width:\s*24px/)
expect(declarationsOf('.map-screen > .tab-bar .tab-bar__brand-icon')).toMatch(
/width:\s*64px/,
)
})
it('lets the tab list grow, which is what carries the mark to the bottom edge', () => {
// The mark is the last child of the sidebar column and is NOT pinned there
// by absolute positioning - the tab list growing is what pushes it down. A
// fixed height on either would let a longer tab set slide under the mark.
const block = declarationsOf('.map-screen > .tab-bar .tab-bar__brand')
expect(block).not.toMatch(/position:\s*absolute/)
expect(block).toMatch(/display:\s*flex/)
})
it('pushes the whole download block to the foot, not the link inside it', () => {
// The background choice and the way to the download are one block at the
// foot of the legend (chrome/Legend.tsx). Pushing only .downloads-link -
// which is what this rule used to do, back when the picker opened the
// panel - would put the full height of a desktop panel between the two
// halves of one question, which is the arrangement they were moved out of.
expect(css).toMatch(
/\.legend--persistent \.legend__downloads\s*\{[^}]*margin-top:\s*auto/,
)
expect(css).not.toMatch(/\.legend--persistent \.downloads-link\s*\{/)
})
it('does not hide the legend close button without the component also dropping it', () => {
// Belt and braces, and the test says so: the CSS hides the control and the
// component omits it. Either alone would leave a release where a panel
// that cannot be reopened has a button that closes it.
expect(css).toMatch(/\.legend--persistent \.legend__close\s*\{[^}]*display:\s*none/)
})
it('keeps the persistent legend positioned, which static quietly was not', () => {
// The phone sheet's `position: absolute` is also what anchors the hidden
// radios of the background picker at its foot. This rule used to say
// `static` - the same place in the flow, but no longer an anchor, so a
// desktop legend long enough to scroll handed those radios to the
// document and the page grew a scrollbar of its own (#631). Relative is
// static's layout with absolute's anchoring.
const block = declarationsOf('.legend--persistent')
expect(block).toMatch(/position:\s*relative/)
expect(block).not.toMatch(/position:\s*static/)
})
it('paints the chrome from its own aliases, which is what lets the theme re-point it', () => {
// The frame is pine under the light theme and ink under the dark one, and
// one stylesheet can only say both by reading the --*-chrome tokens -
// themeTokens.test.ts is what keeps base palette names out of this file,
// and this is what keeps these rules from quietly going back to
// --bg-surface, which would put the white sidebar back.
expect(declarationsOf('.map-screen > .tab-bar')).toMatch(/var\(--bg-chrome\)/)
expect(declarationsOf('.map-screen .status-strip')).toMatch(/var\(--bg-chrome\)/)
expect(declarationsOf('.map-screen .map-header')).toMatch(/var\(--bg-chrome\)/)
expect(
declarationsOf(".map-screen > .tab-bar .tab-bar__tab[aria-selected='true']"),
).toMatch(/var\(--accent-chrome\)/)
})
it('restates the focus ring on the chrome, where the global ring is invisible', () => {
// The unguarded rule at the foot of desktop.css draws --brand-primary
// rings: forest on pine is 1.9:1. The chrome zones restate the colour -
// and only the colour - in their own foreground. Guarded like every other
// rule that mentions the chrome; test one above already proves that.
expect(css).toMatch(/\.map-header :focus-visible/)
expect(css).toMatch(/outline-color: var\(--fg-chrome-1\)/)
})
})