Phone glyphs: bigger icons in IconButtons and nav chevrons on phones #144

Merged
void merged 2 commits from void/phone-glyphs into dev 2026-11-13 18:24:25 +00:00
4 changed files with 101 additions and 0 deletions

View file

@ -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}`,
},
},
});

View file

@ -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);
});
});
});

View file

@ -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();

28
src/phoneGlyph.css.ts Normal file
View file

@ -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}` } },
})
);