mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Three cascade problems in the fold reserved-region CSS, each measured by computed style in headless Chromium (styles.css + mobile.css in index.html link order): - The unconditional .path-picker-overlay / .path-preview-overlay fold rules at the end of the file beat the `padding: 0` both overlays set under 600px, so every phone got a 16px and 18px gutter on dialogs built flush (393 and 500px: edges floating off the screen). The fold strip is now restated on a ZERO base inside the same media query: 0/0 without a fold, the strip alone with one, 16/18 plus the strip from 626px up as before. - .modal.command-palette-modal was unscoped, so outside the 430-768px band (where mobile.css pads the palette with a shorthand) it ADDED 0.75rem with no gutter to compose with and pushed the shell 6px off centre at 393, 900 and 1400px, while inside the band the shorthand beat the generic .modal rule on the bottom side and the palette lost its block-end gutter. The compound rule now lives inside that band and restates both sides. - The tabletop cap on .response-viewer lost to mobile.css's `max-height: 92dvh` under 430px (same specificity, later file). mobile.css now carries an identical twin at its end. test/foldable-layout.test.ts simulates the padding cascade across both files at every breakpoint, with and without the fold rules, and requires the two to differ by exactly the fold strip; it also pins the palette rule to the band mobile.css keys on and the response-viewer twin to the styles.css value. Its model reproduces the Chromium numbers, and against the pre-fix stylesheets it fails on all three problems. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
452 lines
20 KiB
TypeScript
452 lines
20 KiB
TypeScript
/**
|
|
* @fileoverview Folding devices: dialogs stay off the hinge, and a fold never
|
|
* changes which settings the device is using.
|
|
*
|
|
* Apple's "Designing for iPhone Duo" calls the band a partly-open display folds
|
|
* through a RESERVED REGION: content avoids covering it and system components
|
|
* move aside for it. On the web that region is described by the CSS Viewport
|
|
* Segments media features and env() variables, so the styles.css section this
|
|
* file guards is the whole mechanism.
|
|
*
|
|
* Three things about it fail silently and none is observable without the
|
|
* hardware, which is why they are pinned here rather than left to a device lab:
|
|
*
|
|
* 1. Each fold rule RE-STATES the overlay's own gutter, because a later
|
|
* `padding-right` longhand beats the earlier `padding` shorthand it composes
|
|
* with and would otherwise erase it. The two numbers are read out of the
|
|
* stylesheet below and compared, so changing one alone fails here.
|
|
* 2. The overlay list is DERIVED, not typed out: every `position: fixed;
|
|
* inset: 0` flex-centring box in styles.css must have a fold rule. A new
|
|
* overlay added without one would centre its dialog on the hinge, and
|
|
* nothing else in the suite would notice.
|
|
* 3. The gutter an overlay ends up with is a CASCADE across two files and
|
|
* several breakpoints, not one rule: a later @media block can zero it (the
|
|
* phone path picker under 600px), mobile.css can replace it with a
|
|
* shorthand (the palette between 430 and 768px) and, loading later, can
|
|
* outrank a same-specificity rule (the response viewer under 430px). So the
|
|
* cascade is simulated at every breakpoint, once with the fold rules and
|
|
* once without, and the two results must differ by exactly the fold strip.
|
|
* Each of the three shipped once with the top-level-only comparison green.
|
|
*
|
|
* Parsed with postcss rather than regexes because the values are calc()
|
|
* expressions and some of the rules live in @media blocks. Rendered behaviour
|
|
* needs a real foldable; this is the cheap regression fence. Port: N/A.
|
|
*/
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import vm from 'node:vm';
|
|
import postcss, { type Rule } from 'postcss';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
|
const STYLES = postcss.parse(readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8'));
|
|
const MOBILE = postcss.parse(readFileSync(resolve(PUBLIC, 'mobile.css'), 'utf8'));
|
|
|
|
type Decls = Record<string, string>;
|
|
|
|
function declsOf(rule: Rule): Decls {
|
|
const out: Decls = {};
|
|
rule.walkDecls((d) => {
|
|
out[d.prop] = d.value;
|
|
});
|
|
return out;
|
|
}
|
|
|
|
/** Every rule in a stylesheet whose selector list contains `selector`. */
|
|
function rulesFor(root: postcss.Root, selector: string): Rule[] {
|
|
const found: Rule[] = [];
|
|
root.walkRules((rule) => {
|
|
if (rule.selectors.includes(selector)) found.push(rule);
|
|
});
|
|
return found;
|
|
}
|
|
|
|
/**
|
|
* The centred overlays, derived from the stylesheet. `.modal` is `display:none`
|
|
* until `.modal.active`, so display is deliberately not part of the shape.
|
|
*/
|
|
const CENTRED_OVERLAYS: { selector: string; decls: Decls }[] = [];
|
|
STYLES.walkRules((rule) => {
|
|
const d = declsOf(rule);
|
|
if (d.position === 'fixed' && d.inset === '0' && d['justify-content'] === 'center') {
|
|
CENTRED_OVERLAYS.push({ selector: rule.selector, decls: d });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* The side of a `padding` shorthand that applies to `side`. Every centred
|
|
* overlay uses a one-value shorthand today; anything else throws rather than
|
|
* being guessed at, since a wrong guess would silently weaken the comparison.
|
|
*/
|
|
function shorthandSide(value: string): string {
|
|
const parts = value.trim().split(/\s+/);
|
|
if (parts.length !== 1) throw new Error(`multi-value padding shorthand not handled: ${value}`);
|
|
return parts[0];
|
|
}
|
|
|
|
/** What an overlay's padding on `side` resolves to before the fold rule. */
|
|
function effectivePadding(decls: Decls, side: 'right' | 'bottom'): string | null {
|
|
const longhand = decls[`padding-${side}`];
|
|
if (longhand) return longhand;
|
|
if (decls.padding) return shorthandSide(decls.padding);
|
|
return null;
|
|
}
|
|
|
|
/** The value a fold rule must carry to add `foldVar` without dropping `base`. */
|
|
function composed(base: string | null, foldVar: string): string {
|
|
if (base === null || base === '0' || base === '0px') return `var(${foldVar})`;
|
|
const inner = base.startsWith('calc(') ? base.slice('calc('.length, -1) : base;
|
|
return `calc(${inner} + var(${foldVar}))`;
|
|
}
|
|
|
|
function isFoldValue(value: string): boolean {
|
|
return value.includes('--fold-inline-end') || value.includes('--fold-block-end');
|
|
}
|
|
|
|
/** Every rule that adds the fold inset to `selector`, top level or inside @media, in source order. */
|
|
function foldRulesFor(selector: string): Rule[] {
|
|
const found: Rule[] = [];
|
|
STYLES.walkRules((rule) => {
|
|
if (!rule.selectors.some((s) => s === selector || s.endsWith(selector))) return;
|
|
if (Object.values(declsOf(rule)).some(isFoldValue)) found.push(rule);
|
|
});
|
|
return found;
|
|
}
|
|
|
|
/** The first (unconditional, for the derived overlays) fold rule for `selector`. */
|
|
function foldRuleFor(selector: string): Rule | undefined {
|
|
return foldRulesFor(selector)[0];
|
|
}
|
|
|
|
// ─── Cascade simulation ──────────────────────────────────────────────────────
|
|
//
|
|
// A small model of what the browser does for one element's padding: every rule
|
|
// in styles.css then mobile.css (index.html link order) whose selector is a
|
|
// class compound matching the element, whose enclosing @media matches the
|
|
// width, ordered by specificity then source order, shorthand expanded to the
|
|
// side asked for. Deliberately narrow: rules nested inside another rule (the
|
|
// skin block) or under an at-rule other than @media / @supports are ignored,
|
|
// and a media query with any feature other than min/max-width is treated as
|
|
// not matching, which is right for a FLAT device (viewport-segments queries
|
|
// only match while bent). The numbers it produces were checked against
|
|
// getComputedStyle in headless Chromium at every width below.
|
|
|
|
type Side = 'right' | 'bottom';
|
|
|
|
interface PaddingDecl {
|
|
order: number;
|
|
file: 'styles.css' | 'mobile.css';
|
|
classes: string[];
|
|
specificity: number;
|
|
media: string | null;
|
|
prop: 'padding' | `padding-${Side}`;
|
|
value: string;
|
|
fold: boolean;
|
|
}
|
|
|
|
/** `.a.b` -> ['a', 'b']; anything that is not a pure class compound -> null. */
|
|
function classCompound(selector: string): string[] | null {
|
|
const trimmed = selector.trim();
|
|
if (!/^(\.[A-Za-z0-9_-]+)+$/.test(trimmed)) return null;
|
|
return trimmed.slice(1).split('.');
|
|
}
|
|
|
|
const PADDING_DECLS: PaddingDecl[] = [];
|
|
{
|
|
let order = 0;
|
|
for (const [file, root] of [
|
|
['styles.css', STYLES],
|
|
['mobile.css', MOBILE],
|
|
] as const) {
|
|
root.walkRules((rule) => {
|
|
const media: string[] = [];
|
|
let nested = false;
|
|
for (let p = rule.parent; p && p.type !== 'root'; p = p.parent) {
|
|
if (p.type === 'rule') nested = true;
|
|
else if (p.type === 'atrule' && p.name === 'media') media.push(p.params);
|
|
else if (p.type === 'atrule' && p.name !== 'supports') nested = true;
|
|
}
|
|
if (nested) return;
|
|
for (const selector of rule.selectors) {
|
|
const classes = classCompound(selector);
|
|
if (!classes) continue;
|
|
rule.each((node) => {
|
|
if (node.type !== 'decl') return;
|
|
if (!/^padding(-right|-bottom)?$/.test(node.prop)) return;
|
|
PADDING_DECLS.push({
|
|
order: order++,
|
|
file,
|
|
classes,
|
|
specificity: classes.length,
|
|
media: media.length ? media.join(' and ') : null,
|
|
prop: node.prop as PaddingDecl['prop'],
|
|
value: node.value,
|
|
fold: isFoldValue(node.value),
|
|
});
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/** Does a width-only media query match `width`? Anything else is "not on a flat device". */
|
|
function mediaMatches(params: string, width: number): boolean {
|
|
return params.split(',').some((alt) =>
|
|
alt.split(/\s+and\s+/).every((term) => {
|
|
const t = term.trim();
|
|
if (t === 'screen' || t === 'all') return true;
|
|
const m = /^\((max|min)-width:\s*(\d+(?:\.\d+)?)px\)$/.exec(t);
|
|
if (!m) return false;
|
|
return m[1] === 'max' ? width <= Number(m[2]) : width >= Number(m[2]);
|
|
})
|
|
);
|
|
}
|
|
|
|
/** Split a shorthand on whitespace outside parentheses. */
|
|
function tokens(value: string): string[] {
|
|
const out: string[] = [];
|
|
let depth = 0;
|
|
let cur = '';
|
|
for (const ch of value.trim()) {
|
|
if (ch === '(') depth++;
|
|
if (ch === ')') depth--;
|
|
if (/\s/.test(ch) && depth === 0) {
|
|
if (cur) out.push(cur);
|
|
cur = '';
|
|
} else cur += ch;
|
|
}
|
|
if (cur) out.push(cur);
|
|
return out;
|
|
}
|
|
|
|
/** The side of a 1-4 value `padding` shorthand. */
|
|
function shorthandSideOf(value: string, side: Side): string {
|
|
const t = tokens(value);
|
|
if (t.length < 1 || t.length > 4) throw new Error(`padding shorthand not handled: ${value}`);
|
|
const [top, right = top, bottom = top, left = right] = t;
|
|
void left;
|
|
return side === 'right' ? right : bottom;
|
|
}
|
|
|
|
/**
|
|
* What `padding-<side>` resolves to for an element carrying `classes` at
|
|
* `width`, as the declaration VALUE that wins (null when nothing sets it).
|
|
* `withFold: false` drops every declaration that references a fold variable,
|
|
* which is the cascade a non-folding build would have.
|
|
*/
|
|
function cascadedPadding(classes: string[], side: Side, width: number, withFold: boolean): string | null {
|
|
const have = new Set(classes);
|
|
const winners = PADDING_DECLS.filter(
|
|
(d) =>
|
|
(withFold || !d.fold) &&
|
|
(d.file === 'styles.css' || width <= 1023) &&
|
|
(d.media === null || mediaMatches(d.media, width)) &&
|
|
d.classes.every((c) => have.has(c)) &&
|
|
(d.prop === 'padding' || d.prop === `padding-${side}`)
|
|
);
|
|
winners.sort((a, b) => a.specificity - b.specificity || a.order - b.order);
|
|
const last = winners.at(-1);
|
|
if (!last) return null;
|
|
return last.prop === 'padding' ? shorthandSideOf(last.value, side) : last.value;
|
|
}
|
|
|
|
/** Every breakpoint either stylesheet keys on, plus a phone, a Duo posture and a desktop. */
|
|
const WIDTHS = [393, 430, 500, 600, 626, 768, 900, 1400];
|
|
|
|
describe('fold reserved region: custom properties', () => {
|
|
it('defaults to zero, so nothing moves on a device that does not fold', () => {
|
|
const roots = rulesFor(STYLES, ':root').map(declsOf);
|
|
const defaults = roots.filter((d) => d['--fold-inline-end'] || d['--fold-block-end']);
|
|
|
|
// The overriding definitions live inside @media blocks, which walkRules
|
|
// reaches too, so the unconditional one is the last top-level :root.
|
|
expect(defaults.length).toBeGreaterThanOrEqual(3);
|
|
expect(defaults[0]['--fold-inline-end']).toBe('0px');
|
|
expect(defaults[0]['--fold-block-end']).toBe('0px');
|
|
});
|
|
|
|
it('measures the strip from the LEADING segment in each axis', () => {
|
|
// env() indices are [column, row] with (0,0) the top-left segment, so the
|
|
// left segment's right edge is `0 0` and the top segment's bottom edge is
|
|
// `0 0` as well. Swapping an index silently measures the wrong strip.
|
|
const byQuery = new Map<string, Decls>();
|
|
STYLES.walkAtRules('media', (at) => {
|
|
at.walkRules(':root', (rule) => byQuery.set(at.params, declsOf(rule)));
|
|
});
|
|
|
|
expect(byQuery.get('(horizontal-viewport-segments: 2)')?.['--fold-inline-end']).toBe(
|
|
'calc(100vw - env(viewport-segment-right 0 0, 100vw))'
|
|
);
|
|
expect(byQuery.get('(vertical-viewport-segments: 2)')?.['--fold-block-end']).toBe(
|
|
'calc(100vh - env(viewport-segment-bottom 0 0, 100vh))'
|
|
);
|
|
});
|
|
|
|
it('caps the response viewer to the bottom segment in tabletop pose', () => {
|
|
// A vertical hinge through a full-width bottom sheet is fine; a horizontal
|
|
// one folds the transcript away mid-read.
|
|
const rule = rulesFor(STYLES, '.response-viewer').find((r) =>
|
|
declsOf(r)['max-height']?.includes('viewport-segment')
|
|
);
|
|
|
|
expect(rule?.parent).toMatchObject({ params: '(vertical-viewport-segments: 2)' });
|
|
expect(declsOf(rule!)['max-height']).toBe('min(88vh, env(viewport-segment-height 0 1, 88vh))');
|
|
});
|
|
});
|
|
|
|
describe('fold reserved region: every centred overlay is covered', () => {
|
|
it('finds the overlays it is meant to guard', () => {
|
|
// A rename that empties this list would turn every assertion below into a
|
|
// no-op, so the count is pinned.
|
|
expect(CENTRED_OVERLAYS.length).toBe(7);
|
|
});
|
|
|
|
it.each(CENTRED_OVERLAYS.map((o) => [o.selector, o] as const))('%s keeps its dialog out of the hinge', (_, o) => {
|
|
const fold = foldRuleFor(o.selector);
|
|
expect(fold, `${o.selector} has no fold rule`).toBeDefined();
|
|
|
|
const d = declsOf(fold!);
|
|
expect(d['padding-right']).toBe(composed(effectivePadding(o.decls, 'right'), '--fold-inline-end'));
|
|
expect(d['padding-bottom']).toBe(composed(effectivePadding(o.decls, 'bottom'), '--fold-block-end'));
|
|
});
|
|
|
|
/**
|
|
* The elements whose padding cascade is simulated: every derived overlay as
|
|
* a bare element, plus the open command palette, which is a `.modal` wearing
|
|
* two more classes and the one overlay mobile.css pads with a shorthand.
|
|
*/
|
|
const ELEMENTS: { name: string; classes: string[] }[] = [
|
|
...CENTRED_OVERLAYS.map((o) => ({ name: o.selector, classes: classCompound(o.selector)! })),
|
|
{ name: '.modal.command-palette-modal.active', classes: ['modal', 'command-palette-modal', 'active'] },
|
|
];
|
|
|
|
it('simulates the cascade the browser measured', () => {
|
|
// Anchors for the model, all read off getComputedStyle in headless
|
|
// Chromium (styles.css + mobile.css in index.html link order): the phone
|
|
// path picker is flush under 600px and keeps its 16px gutter above it;
|
|
// the palette carries mobile.css's 0.75rem side gutter only inside the
|
|
// 430-768px band. A model that cannot reproduce these numbers proves
|
|
// nothing about the fold rules built on top of them.
|
|
const picker = ['path-picker-overlay'];
|
|
expect(cascadedPadding(picker, 'right', 393, false)).toBe('0');
|
|
expect(cascadedPadding(picker, 'right', 626, false)).toBe('16px');
|
|
const palette = ELEMENTS.at(-1)!.classes;
|
|
expect(cascadedPadding(palette, 'right', 393, false)).toBeNull();
|
|
expect(cascadedPadding(palette, 'right', 500, false)).toBe('0.75rem');
|
|
expect(cascadedPadding(palette, 'bottom', 500, false)).toBe('0');
|
|
expect(cascadedPadding(palette, 'right', 900, false)).toBeNull();
|
|
});
|
|
|
|
it.each(ELEMENTS.map((e) => [e.name, e.classes] as const))(
|
|
'%s ends up with exactly its own gutter plus the fold strip at every breakpoint',
|
|
(_, classes) => {
|
|
for (const width of WIDTHS) {
|
|
for (const side of ['right', 'bottom'] as const) {
|
|
const foldVar = side === 'right' ? '--fold-inline-end' : '--fold-block-end';
|
|
const base = cascadedPadding(classes, side, width, false);
|
|
const actual = cascadedPadding(classes, side, width, true);
|
|
expect(actual, `padding-${side} at ${width}px (base ${base})`).toBe(composed(base, foldVar));
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
it('composes with the padding shorthand mobile.css gives the command palette, inside that band only', () => {
|
|
// mobile.css loads after styles.css and sets a `padding` SHORTHAND on
|
|
// .command-palette-modal between 430 and 768px, exactly where a folding
|
|
// phone lives, so a bare .command-palette-modal rule would lose to it and
|
|
// the compound rule has to restate BOTH of that band's gutters. Scoped to
|
|
// the same band: unscoped, it added 0.75rem where the palette has no side
|
|
// gutter at all and pushed the shell 6px off centre.
|
|
const mobileRule = rulesFor(MOBILE, '.command-palette-modal').find((r) => declsOf(r).padding);
|
|
expect(mobileRule, 'mobile.css no longer pads the palette; this rule can be simplified').toBeDefined();
|
|
const band = mobileRule!.parent;
|
|
expect(band).toMatchObject({ type: 'atrule', name: 'media' });
|
|
|
|
const shorthand = declsOf(mobileRule!).padding;
|
|
const fold = foldRulesFor('.command-palette-modal');
|
|
expect(fold).toHaveLength(1);
|
|
expect(fold[0].selector).toBe('.modal.command-palette-modal');
|
|
expect(fold[0].parent).toMatchObject({ type: 'atrule', name: 'media', params: (band as postcss.AtRule).params });
|
|
expect(declsOf(fold[0])['padding-right']).toBe(composed(shorthandSideOf(shorthand, 'right'), '--fold-inline-end'));
|
|
expect(declsOf(fold[0])['padding-bottom']).toBe(composed(shorthandSideOf(shorthand, 'bottom'), '--fold-block-end'));
|
|
});
|
|
|
|
it('gives the response viewer cap a later twin in mobile.css', () => {
|
|
// mobile.css sets `max-height` on .response-viewer at the same specificity
|
|
// under 430px and loads later, so the styles.css cap alone loses on a
|
|
// phone-width foldable. The twin must come after that rule and carry the
|
|
// identical value.
|
|
const capOf = (root: postcss.Root) =>
|
|
rulesFor(root, '.response-viewer').find((r) => declsOf(r)['max-height']?.includes('viewport-segment'));
|
|
const styles = capOf(STYLES);
|
|
const mobile = capOf(MOBILE);
|
|
expect(mobile, 'mobile.css has no twin of the tabletop cap').toBeDefined();
|
|
expect(mobile!.parent).toMatchObject({ params: '(vertical-viewport-segments: 2)' });
|
|
expect(declsOf(mobile!)['max-height']).toBe(declsOf(styles!)['max-height']);
|
|
|
|
const competing = rulesFor(MOBILE, '.response-viewer').filter((r) => r !== mobile && declsOf(r)['max-height']);
|
|
expect(competing.length).toBeGreaterThan(0);
|
|
for (const rule of competing) expect(rule.source!.start!.line).toBeLessThan(mobile!.source!.start!.line);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Load the real MobileDetection against a given UA and viewport width.
|
|
* `const MobileDetection = {...}` is lexical, so the export rides the same
|
|
* script, the recipe used by the other mobile-handlers tests.
|
|
*/
|
|
function detectionFor(userAgent: string, width: number) {
|
|
const context = vm.createContext({
|
|
console,
|
|
navigator: { userAgent, maxTouchPoints: 5 },
|
|
window: {
|
|
innerWidth: width,
|
|
innerHeight: 800,
|
|
addEventListener: () => {},
|
|
matchMedia: () => ({ matches: true }),
|
|
},
|
|
document: { body: { classList: { add: () => {}, remove: () => {} } }, addEventListener: () => {} },
|
|
setTimeout: () => 1,
|
|
clearTimeout: () => {},
|
|
});
|
|
vm.runInContext(
|
|
`${readFileSync(resolve(PUBLIC, 'mobile-handlers.js'), 'utf8')}\nglobalThis.__MD = MobileDetection;`,
|
|
context,
|
|
{ filename: 'mobile-handlers.js' }
|
|
);
|
|
return (context as unknown as { __MD: { isHandheldDevice(): boolean; getDeviceType(): string } }).__MD;
|
|
}
|
|
|
|
describe('a fold never changes which settings the device is using', () => {
|
|
// Per-device settings are namespaced on isHandheldDevice(), which is
|
|
// form-factor based precisely so it holds still while getDeviceType() (a
|
|
// layout decision) follows the width. A posture change that flipped the
|
|
// namespace would drop every opt-in setting the user saved while folded, and
|
|
// an Android foldable really does reload the page when it opens.
|
|
const postures = [
|
|
{ name: 'iPhone Duo (outer)', ua: 'Mozilla/5.0 (iPhone; CPU iPhone OS 26_0 like Mac OS X) Mobile/15E148', w: 466 },
|
|
{ name: 'iPhone Duo (inner)', ua: 'Mozilla/5.0 (iPhone; CPU iPhone OS 26_0 like Mac OS X) Mobile/15E148', w: 626 },
|
|
{ name: 'Find N5 (folded)', ua: 'Mozilla/5.0 (Linux; Android 15; CPH2671) Mobile Safari/537.36', w: 404 },
|
|
{ name: 'Find N5 (unfolded)', ua: 'Mozilla/5.0 (Linux; Android 15; CPH2671) Mobile Safari/537.36', w: 1124 },
|
|
];
|
|
|
|
it.each(postures)('$name stays handheld', ({ ua, w }) => {
|
|
expect(detectionFor(ua, w).isHandheldDevice()).toBe(true);
|
|
});
|
|
|
|
it('lets the layout follow the width even when it crosses a breakpoint', () => {
|
|
const n5 = postures[3];
|
|
expect(detectionFor(n5.ua, n5.w).getDeviceType()).toBe('desktop');
|
|
expect(detectionFor(postures[2].ua, postures[2].w).getDeviceType()).toBe('mobile');
|
|
});
|
|
|
|
it('gives both iPhone Duo displays the tablet layout', () => {
|
|
// 466 and 626 both sit above the 430px phone cut and below 768. Deliberate
|
|
// (see shouldUseMobileOverview), and pinned because a 5.4" phone landing in
|
|
// the tablet band is the kind of thing that looks like a bug later.
|
|
expect(detectionFor(postures[0].ua, postures[0].w).getDeviceType()).toBe('tablet');
|
|
expect(detectionFor(postures[1].ua, postures[1].w).getDeviceType()).toBe('tablet');
|
|
});
|
|
});
|