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..6969da0e --- /dev/null +++ b/src/app/phoneGlyph.test.ts @@ -0,0 +1,64 @@ +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'); +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", () => { + 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("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 }) => { + 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}` } }, + }) +);