Skip to content

Commit 30981bd

Browse files
authored
feat: add modern system font stacks (#4123)
Closes #4121 ## Description We discovered this repository of awesome system fonts with fallbacks, well organized. https://github.com/system-fonts/modern-font-stacks Assumption here is that most people don't actually need a very specific font rendered, but rather need a type of a font and they are perfectly fine with fallbacks, but without this awesome, tested fallbacks list, how can you know which fallback to use. With this we will encourage more people to use system fonts. <img width="482" alt="image" src="https://github.com/user-attachments/assets/2253c198-284b-4e3e-bbc2-44fa9af3c14b"> ## Steps for reproduction 1. click button 2. expect xyz ## Code Review - [ ] hi @kof, I need you to do - conceptual review (architecture, feature-correctness) - detailed review (read every line) - test it on preview ## Before requesting a review - [ ] made a self-review - [ ] added inline comments where things may be not obvious (the "why", not "what") ## Before merging - [ ] tested locally and on preview environment (preview dev login: 5de6) - [ ] updated [test cases](https://github.com/webstudio-is/webstudio/blob/main/apps/builder/docs/test-cases.md) document - [ ] added tests - [ ] if any new env variables are added, added them to `.env` file
1 parent 45ca543 commit 30981bd

File tree

7 files changed

+377
-60
lines changed

7 files changed

+377
-60
lines changed

apps/builder/app/builder/features/style-panel/controls/font-family/font-family-control.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ export const FontFamilyControl = () => {
6161
return toValue(value, (value) => value).replace(/"/g, "");
6262
}, [value]);
6363

64+
if (value.type !== "fontFamily") {
65+
return;
66+
}
67+
6468
return (
6569
<Flex>
6670
<Combobox<Item>
@@ -69,8 +73,8 @@ export const FontFamilyControl = () => {
6973
title="Fonts"
7074
content={
7175
<FontsManager
72-
value={toValue(value)}
73-
onChange={(newValue) => {
76+
value={value}
77+
onChange={(newValue = itemValue) => {
7478
setValue({ type: "fontFamily", value: [newValue] });
7579
}}
7680
/>

apps/builder/app/builder/shared/fonts-manager/fonts-manager.tsx

Lines changed: 81 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,31 +5,31 @@ import {
55
theme,
66
useSearchFieldKeys,
77
findNextListItemIndex,
8+
Tooltip,
9+
Text,
10+
rawTheme,
11+
Link,
12+
Flex,
813
} from "@webstudio-is/design-system";
914
import {
1015
AssetsShell,
1116
deleteAssets,
1217
Separator,
1318
useAssets,
1419
} from "~/builder/shared/assets";
15-
import { useEffect, useMemo, useState } from "react";
20+
import { useMemo, useState } from "react";
1621
import { useMenu } from "./item-menu";
17-
import { CheckMarkIcon } from "@webstudio-is/icons";
22+
import { CheckMarkIcon, InfoCircleIcon } from "@webstudio-is/icons";
1823
import {
1924
type Item,
2025
filterIdsByFamily,
2126
filterItems,
2227
groupItemsByType,
2328
toItems,
2429
} from "./item-utils";
30+
import type { FontFamilyValue } from "@webstudio-is/css-engine";
2531

26-
const useLogic = ({
27-
onChange,
28-
value,
29-
}: {
30-
onChange: (value: string) => void;
31-
value: string;
32-
}) => {
32+
const useLogic = ({ onChange, value }: FontsManagerProps) => {
3333
const { assetContainers } = useAssets("font");
3434
const [selectedIndex, setSelectedIndex] = useState(-1);
3535
const fontItems = useMemo(() => toItems(assetContainers), [assetContainers]);
@@ -61,16 +61,14 @@ const useLogic = ({
6161
() => groupItemsByType(filteredItems),
6262
[filteredItems]
6363
);
64-
const [currentIndex, setCurrentIndex] = useState(-1);
6564

66-
useEffect(() => {
67-
setCurrentIndex(groupedItems.findIndex((item) => item.label === value));
65+
const currentIndex = useMemo(() => {
66+
return groupedItems.findIndex((item) => item.label === value.value[0]);
6867
}, [groupedItems, value]);
6968

7069
const handleChangeCurrent = (nextCurrentIndex: number) => {
7170
const item = groupedItems[nextCurrentIndex];
7271
if (item !== undefined) {
73-
setCurrentIndex(nextCurrentIndex);
7472
onChange(item.label);
7573
}
7674
};
@@ -88,7 +86,7 @@ const useLogic = ({
8886
const ids = filterIdsByFamily(family, assetContainers);
8987
deleteAssets(ids);
9088
if (index === currentIndex) {
91-
setCurrentIndex(-1);
89+
onChange(undefined);
9290
}
9391
};
9492

@@ -106,8 +104,8 @@ const useLogic = ({
106104
};
107105

108106
type FontsManagerProps = {
109-
value: string;
110-
onChange: (value: string) => void;
107+
value: FontFamilyValue;
108+
onChange: (value?: string) => void;
111109
};
112110

113111
export const FontsManager = ({ value, onChange }: FontsManagerProps) => {
@@ -137,7 +135,41 @@ export const FontsManager = ({ value, onChange }: FontsManagerProps) => {
137135
{...itemProps}
138136
key={key}
139137
prefix={itemProps.current ? <CheckMarkIcon /> : undefined}
140-
suffix={item.type === "uploaded" ? renderMenu(index) : undefined}
138+
suffix={
139+
item.type === "uploaded" ? (
140+
renderMenu(index)
141+
) : itemProps.state === "selected" && item.description ? (
142+
<Tooltip
143+
variant="wrapped"
144+
content={
145+
<Flex
146+
direction="column"
147+
gap="2"
148+
css={{ maxWidth: theme.spacing[28] }}
149+
>
150+
<Text variant="titles">{item.label}</Text>
151+
<Text
152+
variant="monoBold"
153+
color="moreSubtle"
154+
userSelect="text"
155+
css={{
156+
whiteSpace: "break-spaces",
157+
cursor: "text",
158+
}}
159+
>
160+
{`font-family: ${item.stack.join(", ")};`}
161+
</Text>
162+
<Text>{item.description}</Text>
163+
</Flex>
164+
}
165+
>
166+
<InfoCircleIcon
167+
tabIndex={0}
168+
color={rawTheme.colors.foregroundSubtle}
169+
/>
170+
</Tooltip>
171+
) : undefined
172+
}
141173
>
142174
{item.label}
143175
</DeprecatedListItem>
@@ -167,7 +199,38 @@ export const FontsManager = ({ value, onChange }: FontsManagerProps) => {
167199
{uploadedItems.length !== 0 && (
168200
<Separator css={{ mx: theme.spacing[9] }} />
169201
)}
170-
<DeprecatedListItem state="disabled">{"System"}</DeprecatedListItem>
202+
<DeprecatedListItem
203+
state="disabled"
204+
suffix={
205+
<Tooltip
206+
variant="wrapped"
207+
content={
208+
<Text>
209+
{
210+
"System font stack CSS organized by typeface classification for every modern OS. No downloading, no layout shifts, no flashes— just instant renders. Learn more about "
211+
}
212+
<Link
213+
href="https://github.com/system-fonts/modern-font-stacks"
214+
target="_blank"
215+
color="inherit"
216+
variant="inherit"
217+
>
218+
modern font stacks
219+
</Link>
220+
.
221+
</Text>
222+
}
223+
>
224+
<InfoCircleIcon
225+
tabIndex={0}
226+
color={rawTheme.colors.foregroundSubtle}
227+
style={{ pointerEvents: "auto" }}
228+
/>
229+
</Tooltip>
230+
}
231+
>
232+
System
233+
</DeprecatedListItem>
171234
</>
172235
)}
173236
{systemItems.map((item, index) =>

apps/builder/app/builder/shared/fonts-manager/item-utils.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,13 @@ import type { AssetContainer } from "../assets";
55
export type Item = {
66
label: string;
77
type: "uploaded" | "system";
8+
description?: string;
9+
stack: Array<string>;
810
};
911

1012
export const toItems = (
1113
assetContainers: Array<AssetContainer>
1214
): Array<Item> => {
13-
const system = Array.from(SYSTEM_FONTS.keys()).map((label) => ({
14-
label,
15-
type: "system",
16-
}));
1715
// We can have 2+ assets with the same family name, so we use a map to dedupe.
1816
const uploaded = new Map();
1917
for (const assetContainer of assetContainers) {
@@ -30,6 +28,16 @@ export const toItems = (
3028
});
3129
}
3230
}
31+
32+
const system = [];
33+
for (const [label, config] of SYSTEM_FONTS) {
34+
system.push({
35+
label,
36+
type: "system",
37+
description: config.description,
38+
stack: config.stack,
39+
});
40+
}
3341
return [...uploaded.values(), ...system];
3442
};
3543

packages/css-engine/src/core/to-value.test.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,14 @@ describe("Convert WS CSS Values to native CSS strings", () => {
4747
});
4848

4949
test("fontFamily", () => {
50-
const value = toValue({
51-
type: "fontFamily",
52-
value: ["Courier New"],
53-
});
54-
expect(value).toBe('"Courier New", monospace');
50+
expect(
51+
toValue({
52+
type: "fontFamily",
53+
value: ["Humanist"],
54+
})
55+
).toBe(
56+
'Seravek, "Gill Sans Nova", Ubuntu, Calibri, "DejaVu Sans", source-sans-pro, sans-serif'
57+
);
5558
});
5659

5760
test("Transform font family value to override default fallback", () => {
@@ -64,12 +67,12 @@ describe("Convert WS CSS Values to native CSS strings", () => {
6467
if (styleValue.type === "fontFamily") {
6568
return {
6669
type: "fontFamily",
67-
value: [styleValue.value[0]],
70+
value: ["A B"],
6871
};
6972
}
7073
}
7174
);
72-
expect(value).toBe('"Courier New"');
75+
expect(value).toBe('"A B"');
7376
});
7477

7578
test("array", () => {

packages/css-engine/src/core/to-value.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,10 @@ export type TransformValue = (styleValue: StyleValue) => undefined | StyleValue;
66

77
const fallbackTransform: TransformValue = (styleValue) => {
88
if (styleValue.type === "fontFamily") {
9-
const firstFontFamily = styleValue.value[0];
10-
11-
const fontFamily = styleValue.value;
12-
const fallbacks = SYSTEM_FONTS.get(firstFontFamily) ?? [
9+
const fonts = SYSTEM_FONTS.get(styleValue.value[0])?.stack ?? [
1310
DEFAULT_FONT_FALLBACK,
1411
];
15-
const value = Array.from(new Set([...fontFamily, ...fallbacks]));
12+
const value = Array.from(new Set(fonts));
1613

1714
return {
1815
type: "fontFamily",

packages/design-system/src/components/__DEPRECATED__/list.tsx

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -27,22 +27,13 @@ const ListItemBase = styled("li", {
2727
listStyle: "none",
2828
outline: 0,
2929
position: "relative",
30-
variants: {
31-
state: {
32-
disabled: {
33-
pointerEvents: "none",
34-
},
35-
selected: {
36-
"&:before": {
37-
content: "''",
38-
position: "absolute",
39-
pointerEvents: "none",
40-
inset: `0 ${theme.spacing[3]}`,
41-
borderRadius: theme.borderRadius[4],
42-
border: `2px solid ${theme.colors.borderFocus}`,
43-
},
44-
},
45-
},
30+
"&[aria-selected]::before": {
31+
content: "''",
32+
position: "absolute",
33+
pointerEvents: "none",
34+
inset: `0 ${theme.spacing[3]}`,
35+
borderRadius: theme.borderRadius[4],
36+
border: `2px solid ${theme.colors.borderFocus}`,
4637
},
4738
});
4839

@@ -66,9 +57,9 @@ export const DeprecatedListItem = forwardRef<
6657
return (
6758
<ListItemBase
6859
ref={ref}
69-
state={state}
7060
tabIndex={state === "disabled" ? -1 : 0}
7161
role="option"
62+
{...(state === "disabled" ? { "aria-disabled": true } : undefined)}
7263
{...(state === "selected" ? { "aria-selected": true } : undefined)}
7364
{...(current ? { "aria-current": true } : undefined)}
7465
{...props}
@@ -82,7 +73,7 @@ export const DeprecatedListItem = forwardRef<
8273
<Text
8374
variant="labelsSentenceCase"
8475
truncate
85-
color={state === "disabled" ? "disabled" : "main"}
76+
color={state === "disabled" ? "subtle" : "main"}
8677
>
8778
{children}
8879
</Text>
@@ -123,6 +114,9 @@ export const useDeprecatedList = ({
123114
onMouseEnter() {
124115
onSelect(index);
125116
},
117+
onMouseLeave() {
118+
onSelect(-1);
119+
},
126120
onClick() {
127121
onChangeCurrent(index);
128122
},

0 commit comments

Comments
 (0)