mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
fix(mobile): fold padding keeps phone sheets flush, scopes the palette rule, caps the response viewer under 430px
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>
This commit is contained in:
@@ -3850,3 +3850,15 @@ html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-close {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Folding devices, tabletop pose: cap the response viewer to the bottom
|
||||
segment. Twin of the rule at the end of styles.css, needed here because the
|
||||
430px block above sets `max-height: 92dvh` on the same selector at the same
|
||||
specificity, and this file loads later, so the styles.css copy loses on a
|
||||
phone-width foldable. Keep the value identical; test/foldable-layout.test.ts
|
||||
compares them. */
|
||||
@media (vertical-viewport-segments: 2) {
|
||||
.response-viewer {
|
||||
max-height: min(88vh, env(viewport-segment-height 0 1, 88vh));
|
||||
}
|
||||
}
|
||||
|
||||
+40
-10
@@ -18088,8 +18088,10 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
|
||||
⚠️ Each rule re-states the overlay's OWN gutter, because a
|
||||
later `padding-right` longhand beats the earlier `padding`
|
||||
shorthand it is composing with and would otherwise erase it.
|
||||
test/iphone-duo-fold.test.ts reads both numbers out of this file
|
||||
and fails if they drift apart.
|
||||
test/foldable-layout.test.ts reads both numbers out of this file
|
||||
and fails if they drift apart, and simulates the cascade at every
|
||||
breakpoint so a base gutter overridden by a later @media block (the
|
||||
phone path picker below) needs its own zero-base restatement.
|
||||
|
||||
⚠️ Physical sides, not logical ones: dialogs go in the LEFT
|
||||
segment (and the TOP one in tabletop pose) in every language. The
|
||||
@@ -18125,13 +18127,19 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
|
||||
padding-bottom: var(--fold-block-end);
|
||||
}
|
||||
|
||||
/* Specificity 0,2,0 on purpose: mobile.css loads after this file and gives the
|
||||
palette a `padding` SHORTHAND under 768px, exactly the width where a folding
|
||||
phone lives, so a bare .command-palette-modal rule here would lose to it. The
|
||||
0.75rem side gutter is that mobile rule's; the desktop rule sets no side
|
||||
padding, so composing with it is a no-op above 768px. */
|
||||
.modal.command-palette-modal {
|
||||
padding-right: calc(0.75rem + var(--fold-inline-end));
|
||||
/* The palette is a .modal, so the generic rule above covers it everywhere
|
||||
EXCEPT the 430-768px band, where mobile.css (loaded after this file) pads
|
||||
it with the SHORTHAND `10vh 0.75rem 0`: that shorthand beats the generic
|
||||
rule on both sides, so this compound rule (0,2,0) restates that band's own
|
||||
gutters, 0.75rem at the side and none at the bottom, plus the fold strips.
|
||||
Scoped to the same band on purpose: unscoped, it ADDED 0.75rem on every
|
||||
other width, where the palette has no side gutter to compose with, and
|
||||
pushed the shell 6px off centre (measured at 393, 900 and 1400). */
|
||||
@media (max-width: 768px) and (min-width: 430px) {
|
||||
.modal.command-palette-modal {
|
||||
padding-right: calc(0.75rem + var(--fold-inline-end));
|
||||
padding-bottom: var(--fold-block-end);
|
||||
}
|
||||
}
|
||||
|
||||
.path-picker-overlay {
|
||||
@@ -18144,6 +18152,24 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
|
||||
padding-bottom: calc(18px + var(--fold-block-end));
|
||||
}
|
||||
|
||||
/* Under 600px both dialogs are flush sheets: their `@media (max-width: 600px)`
|
||||
rules drop the gutter to 0 (borderless right and bottom edges, the picker
|
||||
docked to the bottom). The two rules above sit later in the file at the same
|
||||
specificity, so on their own they put a 16px and 18px gutter back on every
|
||||
phone, fold or no fold (measured at 393 and 500: dialog edges floating 16px
|
||||
off the screen edge). Restate the fold strip on a ZERO base here. */
|
||||
@media (max-width: 600px) {
|
||||
.path-picker-overlay {
|
||||
padding-right: var(--fold-inline-end);
|
||||
padding-bottom: var(--fold-block-end);
|
||||
}
|
||||
|
||||
.path-preview-overlay {
|
||||
padding-right: var(--fold-inline-end);
|
||||
padding-bottom: var(--fold-block-end);
|
||||
}
|
||||
}
|
||||
|
||||
.offline-overlay {
|
||||
padding-right: calc(20px + var(--fold-inline-end));
|
||||
padding-bottom: calc(20px + var(--safe-area-bottom) + var(--fold-block-end));
|
||||
@@ -18163,7 +18189,11 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
|
||||
/* The response viewer is a bottom sheet, so a vertical hinge running through it
|
||||
is fine, since it is a wide surface like the terminal and inset dialogs are what
|
||||
the fold guidance is about. A horizontal hinge is not: in tabletop pose the
|
||||
sheet would climb out of the bottom segment and fold away mid-transcript. */
|
||||
sheet would climb out of the bottom segment and fold away mid-transcript.
|
||||
⚠️ mobile.css carries an identical twin at its end: its 430px block sets
|
||||
`max-height: 92dvh` at the same specificity and loads later, so this copy
|
||||
alone loses on a phone-width foldable. test/foldable-layout.test.ts pins
|
||||
the two values equal. */
|
||||
@media (vertical-viewport-segments: 2) {
|
||||
.response-viewer {
|
||||
max-height: min(88vh, env(viewport-segment-height 0 1, 88vh));
|
||||
|
||||
+234
-17
@@ -8,7 +8,7 @@
|
||||
* Segments media features and env() variables, so the styles.css section this
|
||||
* file guards is the whole mechanism.
|
||||
*
|
||||
* Two things about it fail silently and neither is observable without the
|
||||
* 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
|
||||
@@ -19,6 +19,14 @@
|
||||
* 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
|
||||
@@ -91,17 +99,160 @@ function composed(base: string | null, foldVar: string): string {
|
||||
return `calc(${inner} + var(${foldVar}))`;
|
||||
}
|
||||
|
||||
/** The rule that adds the fold inset to `selector`, wherever it lives. */
|
||||
function foldRuleFor(selector: string): Rule | undefined {
|
||||
return STYLES.nodes
|
||||
.filter((n): n is Rule => n.type === 'rule')
|
||||
.find((rule) => {
|
||||
if (!rule.selectors.some((s) => s === selector || s.endsWith(selector))) return false;
|
||||
const d = declsOf(rule);
|
||||
return Object.values(d).some((v) => v.includes('--fold-inline-end') || v.includes('--fold-block-end'));
|
||||
});
|
||||
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);
|
||||
@@ -159,18 +310,84 @@ describe('fold reserved region: every centred overlay is covered', () => {
|
||||
expect(d['padding-bottom']).toBe(composed(effectivePadding(o.decls, 'bottom'), '--fold-block-end'));
|
||||
});
|
||||
|
||||
it('outranks the padding shorthand mobile.css gives the command palette', () => {
|
||||
/**
|
||||
* 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 under 768px, exactly the width a folding phone
|
||||
// lives at, so a bare .command-palette-modal rule here would lose to it.
|
||||
// .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 sideGutter = declsOf(mobileRule!).padding.trim().split(/\s+/)[1];
|
||||
const fold = foldRuleFor('.command-palette-modal');
|
||||
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'));
|
||||
});
|
||||
|
||||
expect(fold?.selector).toBe('.modal.command-palette-modal');
|
||||
expect(declsOf(fold!)['padding-right']).toBe(`calc(${sideGutter} + var(--fold-inline-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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user