mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Rebased over #390, which moved the phone tier's cutoff from 430px to 600px. The palette's compound fold rule now lives in the 600-768px band mobile.css pads, the cascade samples the palette inside that band, and the closed iPhone Duo (466pt) is a phone rather than a small tablet while the open one (626pt) stays a tablet. Comments in both stylesheets, the device registry, CLAUDE.md and architecture-invariants say 600. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
453 lines
20 KiB
TypeScript
453 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 600 and 768px) and, loading later, can
|
|
* outrank a same-specificity rule (the response viewer under 600px). 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
|
|
// 600-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', 626, false)).toBe('0.75rem');
|
|
expect(cascadedPadding(palette, 'bottom', 626, 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 600 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 600px 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 the closed iPhone Duo the phone layout and the open one the tablet layout', () => {
|
|
// 466 sits under the 600px phone cut (#390 moved it up from 430) and 626
|
|
// above it, below 768. Deliberate (see shouldUseMobileOverview), and pinned
|
|
// because the tier flipping under a fold is the kind of thing that looks
|
|
// like a bug later: closed, the Duo is a phone; open, it is a small tablet.
|
|
expect(detectionFor(postures[0].ua, postures[0].w).getDeviceType()).toBe('mobile');
|
|
expect(detectionFor(postures[1].ua, postures[1].w).getDeviceType()).toBe('tablet');
|
|
});
|
|
});
|