From 9db91378c52f981b45586f73c1504a8c320e1d2c Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 17:52:10 +0000 Subject: [PATCH 1/2] Phone glyphs: icons inside IconButtons and the nav chevron draw larger on phones, box unchanged folds draws a glyph at 16-24 px; on a phone the 16/18/20 px ones read small in their buttons. One global rule per folds Icon size scales the glyph (22/24/24/26/28 px) at paint time inside an IconButton, so no button, PhoneHit area or row changes size and desktop is untouched. phoneGlyph.test.ts pins the folds class names, the px range and that the rule is a phone-only scale. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/room-nav/styles.css.ts | 8 ++++ src/app/phoneGlyph.test.ts | 53 +++++++++++++++++++++++++ src/index.tsx | 1 + src/phoneGlyph.css.ts | 28 +++++++++++++ 4 files changed, 90 insertions(+) create mode 100644 src/app/phoneGlyph.test.ts create mode 100644 src/phoneGlyph.css.ts diff --git a/src/app/features/room-nav/styles.css.ts b/src/app/features/room-nav/styles.css.ts index 1d46e596..afa0c1a0 100644 --- a/src/app/features/room-nav/styles.css.ts +++ b/src/app/features/room-nav/styles.css.ts @@ -3,6 +3,7 @@ import { config } from 'folds'; import { surfaceInk } from '../scene/sceneShell.css'; import { skyLabelInk } from '../scene/skyLabelInk'; import { NAV_ICON_REST_OPACITY } from '../../components/nav/styles.css'; +import { PHONE_GLYPHS } from '../../../phoneGlyph.css'; export const CategoryButton = style({ flexGrow: 1, @@ -32,4 +33,11 @@ export const CategoryButton = style({ }); export const CategoryButtonIcon = style({ opacity: NAV_ICON_REST_OPACITY, + // A phone draws the 16 px chevron as its IconButton neighbours are drawn (see phoneGlyph.css.ts): paint-time, so + // the gutter and the row keep their layout. + '@media': { + 'screen and (max-width: 750px)': { + scale: `${PHONE_GLYPHS['50'].phonePx / PHONE_GLYPHS['50'].px}`, + }, + }, }); diff --git a/src/app/phoneGlyph.test.ts b/src/app/phoneGlyph.test.ts new file mode 100644 index 00000000..0e54c22a --- /dev/null +++ b/src/app/phoneGlyph.test.ts @@ -0,0 +1,53 @@ +import { readFileSync } from 'fs'; +import { describe, expect, it } from 'vitest'; +import { MOBILE_BREAKPOINT } from './hooks/useScreenSize'; +import { emitted } from './phoneHitTestKit'; +import { ICON_BUTTON_CLASS, PHONE_GLYPHS } from '../phoneGlyph.css'; + +const foldsJs = readFileSync(`${process.cwd()}/node_modules/folds/dist/index.js`, 'utf8'); + +describe('phone glyphs inside IconButtons', () => { + it("name folds' own classes: the IconButton base class and one Icon class per size", () => { + expect(foldsJs).toContain( + `var IconButton$1 = createRuntimeFn({ defaultClassName: "${ICON_BUTTON_CLASS} ` + ); + const iconSizes = foldsJs.match( + /var Icon\$1 = createRuntimeFn\(\{[^\n]*variantClassNames: \{ size: \{([^}]*)\}/ + )?.[1]; + expect(iconSizes).toBeDefined(); + Object.entries(PHONE_GLYPHS).forEach(([size, { cls }]) => + expect(iconSizes).toContain(`"${size}": "${cls}"`) + ); + }); + + it('draw the folds Icon px (16 18 20 22 24) at 22..28 px, never smaller, never past the smallest button box', () => { + // folds IconButton padding is 4 px a side at the smallest size, so its box is the glyph plus 8. + Object.values(PHONE_GLYPHS).forEach(({ px, phonePx }) => { + expect(phonePx).toBeGreaterThanOrEqual(22); + expect(phonePx).toBeLessThanOrEqual(28); + expect(phonePx).toBeGreaterThan(px); + expect(phonePx).toBeLessThanOrEqual(px + 8); + }); + }); + + it('are a paint-time scale on phones only: no size, margin or padding, and no rule outside the phone query', async () => { + const css = await emitted('../phoneGlyph.css.ts'); + const phone = `@media screen and (max-width: ${MOBILE_BREAKPOINT}px)`; + expect( + css.replace(new RegExp(`${phone.replace(/[()]/g, '\\$&')} \\{\\n[\\s\\S]*?\\n\\}`, 'g'), '') + ).not.toMatch(/\.brs7ce5/); + Object.entries(PHONE_GLYPHS).forEach(([size, { cls, px, phonePx }]) => { + const rule = css.match( + new RegExp(`\\.${ICON_BUTTON_CLASS} > \\.${cls} \\{\\n([^}]*)\\}`) + )?.[1]; + expect(rule, `rule for Icon size ${size}`).toBeDefined(); + expect( + rule! + .trim() + .split('\n') + .map((l) => l.trim().split(':')[0]) + ).toEqual(['scale']); + expect(Number(rule!.match(/scale: ([\d.]+)/)![1])).toBeCloseTo(phonePx / px, 6); + }); + }); +}); diff --git a/src/index.tsx b/src/index.tsx index 51e9760f..eca2bc07 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -14,6 +14,7 @@ import '@fontsource/montserrat/200.css'; import '@fontsource/montserrat/300.css'; import '@fontsource/ibm-plex-mono/400.css'; import 'folds/dist/style.css'; +import './phoneGlyph.css'; import { configClass, varsClass } from 'folds'; enableMapSet(); diff --git a/src/phoneGlyph.css.ts b/src/phoneGlyph.css.ts new file mode 100644 index 00000000..14707f63 --- /dev/null +++ b/src/phoneGlyph.css.ts @@ -0,0 +1,28 @@ +import { globalStyle } from '@vanilla-extract/css'; +import { MOBILE_BREAKPOINT } from './app/hooks/useScreenSize'; + +// Phone glyph size. On a phone the glyph inside an IconButton is drawn larger, by `scale` (a paint-time transform: +// the button's box, its PhoneHit area and every row of buttons keep the layout they have, so nothing reflows at +// 320 px). folds draws a glyph at one of its Icon sizes and the IconButton's box is padding plus that glyph, so the +// factor is chosen per Icon size. Desktop is not touched. +// +// The class names are folds 2.7.2's own (the Icon size classes and the IconButton base class); phoneGlyph.test.ts +// reads them back from folds' dist, so a folds upgrade that renames them fails there instead of silently here. +export const ICON_BUTTON_CLASS = 'brs7ce5'; +/** folds Icon size -> [its class, its px, the px it is drawn at on a phone]. The 24 px glyph is the folds default and + * reads small in a 40 px button, so it grows too; 28 px and up are already large. */ +export const PHONE_GLYPHS = { + '50': { cls: 'cpipac7', px: 16, phonePx: 22 }, + '100': { cls: 'cpipac8', px: 18, phonePx: 24 }, + '200': { cls: 'cpipac9', px: 20, phonePx: 24 }, + '300': { cls: 'cpipaca', px: 22, phonePx: 26 }, + '400': { cls: 'cpipacb', px: 24, phonePx: 28 }, +} as const; + +const PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`; + +Object.values(PHONE_GLYPHS).forEach(({ cls, px, phonePx }) => + globalStyle(`.${ICON_BUTTON_CLASS} > .${cls}`, { + '@media': { [PHONE]: { scale: `${phonePx / px}` } }, + }) +); -- 2.47.3 From 9d65a345079507d14df0f7416b43fd28fa145d74 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 18:20:12 +0000 Subject: [PATCH 2/2] phoneGlyph test: pin each Icon size's px to folds' own CSS Co-Authored-By: Claude Sonnet 5.5 --- src/app/phoneGlyph.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/app/phoneGlyph.test.ts b/src/app/phoneGlyph.test.ts index 0e54c22a..6969da0e 100644 --- a/src/app/phoneGlyph.test.ts +++ b/src/app/phoneGlyph.test.ts @@ -5,6 +5,14 @@ import { emitted } from './phoneHitTestKit'; import { ICON_BUTTON_CLASS, PHONE_GLYPHS } from '../phoneGlyph.css'; const foldsJs = readFileSync(`${process.cwd()}/node_modules/folds/dist/index.js`, 'utf8'); +const foldsCss = readFileSync(`${process.cwd()}/node_modules/folds/dist/style.css`, 'utf8'); + +/** The px folds draws an Icon class at: its width variable's rem value, at the 16 px root. */ +const foldsPx = (cls: string) => { + const v = foldsCss.match(new RegExp(`\\.${cls} \\{\\s*width: var\\((--[\\w-]+)\\)`))?.[1]; + const rem = v && foldsCss.match(new RegExp(`${v}: ([\\d.]+)rem`))?.[1]; + return rem ? Number(rem) * 16 : undefined; +}; describe('phone glyphs inside IconButtons', () => { it("name folds' own classes: the IconButton base class and one Icon class per size", () => { @@ -20,6 +28,9 @@ describe('phone glyphs inside IconButtons', () => { ); }); + it("start from the px folds draws each Icon size at (the scale's divisor is not a guess)", () => + Object.values(PHONE_GLYPHS).forEach(({ cls, px }) => expect(foldsPx(cls)).toBe(px))); + it('draw the folds Icon px (16 18 20 22 24) at 22..28 px, never smaller, never past the smallest button box', () => { // folds IconButton padding is 4 px a side at the smallest size, so its box is the glyph plus 8. Object.values(PHONE_GLYPHS).forEach(({ px, phonePx }) => { -- 2.47.3