From a588ab82b3adc4d153ca2eeacab6a66bbf319c91 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 05:00:32 +0100 Subject: [PATCH] GameCult theme, steward-grouped project rooms, preview base override Adds the gamecult-theme (brand palette, softer radii, Ubuntu/Montserrat/Plex Mono), makes it the default, hides the member drawer by default, defaults to bubble layout, groups rooms by org.gamecult.steward in the space nav (collapsed on phones), and lets CINNY_BASE set Vite's base for a sub-path preview build. Co-Authored-By: Claude Opus 5.5 --- build.config.ts | 2 +- package.json | 11 ++- .../steward-groups/stewardGroups.test.ts | 63 +++++++++++++ .../features/steward-groups/stewardGroups.ts | 91 ++++++++++++++++++ .../steward-groups/useStewardGroups.ts | 71 ++++++++++++++ src/app/hooks/useTheme.ts | 18 +++- src/app/pages/ThemeManager.tsx | 17 +--- src/app/pages/client/space/Space.tsx | 35 ++++++- src/app/state/settings.ts | 8 +- src/colors.css.ts | 92 +++++++++++++++++++ src/gamecult.css.ts | 20 ++++ src/index.css | 64 +++++++++++++ src/index.tsx | 7 ++ 13 files changed, 471 insertions(+), 28 deletions(-) create mode 100644 src/app/features/steward-groups/stewardGroups.test.ts create mode 100644 src/app/features/steward-groups/stewardGroups.ts create mode 100644 src/app/features/steward-groups/useStewardGroups.ts create mode 100644 src/gamecult.css.ts diff --git a/build.config.ts b/build.config.ts index ec8a41d0..ac51db97 100644 --- a/build.config.ts +++ b/build.config.ts @@ -1,3 +1,3 @@ export default { - base: '/', + base: process.env.CINNY_BASE ?? '/', }; diff --git a/package.json b/package.json index 8166ccd8..298fbf36 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "typecheck": "tsc --noEmit", "prepare": "husky install", "commit": "git-cz", - "bump": "node scripts/update-version.js" + "bump": "node scripts/update-version.js", + "test": "vitest run" }, "lint-staged": { "*.{ts,tsx,js,jsx}": "eslint", @@ -87,7 +88,10 @@ "slate-dom": "0.123.0", "slate-history": "0.113.1", "slate-react": "0.123.0", - "ua-parser-js": "1.0.35" + "ua-parser-js": "1.0.35", + "@fontsource/ubuntu": "5.2.5", + "@fontsource/montserrat": "5.2.5", + "@fontsource/ibm-plex-mono": "5.2.5" }, "devDependencies": { "@element-hq/element-call-embedded": "0.20.1", @@ -123,6 +127,7 @@ "vite": "5.4.19", "vite-plugin-pwa": "0.20.5", "vite-plugin-static-copy": "1.0.4", - "vite-plugin-top-level-await": "1.4.4" + "vite-plugin-top-level-await": "1.4.4", + "vitest": "1.6.0" } } diff --git a/src/app/features/steward-groups/stewardGroups.test.ts b/src/app/features/steward-groups/stewardGroups.test.ts new file mode 100644 index 00000000..d0d4b7c3 --- /dev/null +++ b/src/app/features/steward-groups/stewardGroups.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { groupByStewards, parseSteward, StewardEntry } from './stewardGroups'; + +const room = (roomId: string, parentId = '!projects') => ({ roomId, parentId }); +const space = (roomId: string) => ({ roomId, space: true as const }); + +const base = { + spaceId: '!top', + isGroupOpen: () => true, + keepVisible: () => false, +}; + +const names = (out: StewardEntry<{ roomId: string }>[]) => + out.map((e) => (e.type === 'group' ? `G:${e.label}:${e.count}` : e.item.roomId)); + +describe('groupByStewards', () => { + const stewards: Record = { '!a': 'Void', '!b': 'Eureka', '!c': 'Void' }; + const stewardOf = (id: string) => stewards[id]; + + it('groups by steward, sorted by name, with unstewarded rooms first', () => { + const out = groupByStewards([room('!a'), room('!x'), room('!b'), room('!c')], { + ...base, + stewardOf, + }); + expect(names(out)).toEqual(['!x', 'G:Eureka:1', '!b', 'G:Void:2', '!a', '!c']); + }); + + it('collapsed groups hide rooms except kept-visible ones', () => { + const out = groupByStewards([room('!a'), room('!b'), room('!c')], { + ...base, + stewardOf, + isGroupOpen: () => false, + keepVisible: (id) => id === '!c', + }); + expect(names(out)).toEqual(['G:Eureka:1', 'G:Void:2', '!c']); + }); + + it('degrades to the original order when no room has steward state', () => { + const out = groupByStewards([room('!1'), space('!s'), room('!2', '!s')], { + ...base, + stewardOf: () => undefined, + }); + expect(names(out)).toEqual(['!1', '!s', '!2']); + }); + + it('does not merge runs separated by a space header', () => { + const out = groupByStewards([room('!a'), space('!s'), room('!c', '!s')], { + ...base, + stewardOf, + }); + expect(out.filter((e) => e.type === 'group')).toHaveLength(2); + }); +}); + +describe('parseSteward', () => { + it('accepts a string and rejects junk', () => { + expect(parseSteward({ steward: ' Void ' })).toBe('Void'); + expect(parseSteward({ steward: 3 })).toBeUndefined(); + expect(parseSteward({ steward: ' ' })).toBeUndefined(); + expect(parseSteward(undefined)).toBeUndefined(); + expect(parseSteward(null)).toBeUndefined(); + }); +}); diff --git a/src/app/features/steward-groups/stewardGroups.ts b/src/app/features/steward-groups/stewardGroups.ts new file mode 100644 index 00000000..c95dd935 --- /dev/null +++ b/src/app/features/steward-groups/stewardGroups.ts @@ -0,0 +1,91 @@ +/** + * Splits runs of rooms that carry an `org.gamecult.steward` state event into + * collapsible per-steward groups. Pure: the caller supplies the lookups, so the + * grouping is testable without a Matrix client. + */ +export const STEWARD_EVENT_TYPE = 'org.gamecult.steward'; + +export type HierarchyLike = { roomId: string; parentId?: string; space?: true }; + +export type StewardEntry = + | { type: 'item'; item: T } + | { type: 'group'; id: string; label: string; open: boolean; count: number }; + +export const makeStewardGroupId = (spaceId: string, parentId: string, steward: string) => + `${spaceId}|${parentId}|steward:${steward}`; + +export type StewardGroupOptions = { + spaceId: string; + /** steward name of a room, or undefined when the room has no (valid) steward state */ + stewardOf: (roomId: string) => string | undefined; + isGroupOpen: (groupId: string) => boolean; + /** rooms that stay visible inside a collapsed group (selected, unread, in a call) */ + keepVisible: (roomId: string) => boolean; +}; + +const byLabel = (a: string, b: string) => + a.localeCompare(b, undefined, { sensitivity: 'base' }) || a.localeCompare(b); + +export const groupByStewards = ( + items: T[], + { spaceId, stewardOf, isGroupOpen, keepVisible }: StewardGroupOptions +): StewardEntry[] => { + const out: StewardEntry[] = []; + + const flush = (run: T[]) => { + if (run.length === 0) return; + const groups = new Map(); + const loose: T[] = []; + run.forEach((item) => { + const steward = stewardOf(item.roomId); + if (steward === undefined) { + loose.push(item); + return; + } + const list = groups.get(steward); + if (list) list.push(item); + else groups.set(steward, [item]); + }); + + // No steward state anywhere in this run: leave the list exactly as Cinny built it. + if (groups.size === 0) { + run.forEach((item) => out.push({ type: 'item', item })); + return; + } + + loose.forEach((item) => out.push({ type: 'item', item })); + Array.from(groups.keys()) + .sort(byLabel) + .forEach((steward) => { + const rooms = groups.get(steward) ?? []; + const id = makeStewardGroupId(spaceId, rooms[0].parentId ?? spaceId, steward); + const open = isGroupOpen(id); + out.push({ type: 'group', id, label: steward, open, count: rooms.length }); + rooms + .filter((item) => open || keepVisible(item.roomId)) + .forEach((item) => out.push({ type: 'item', item })); + }); + }; + + let run: T[] = []; + items.forEach((item) => { + if (item.space) { + flush(run); + run = []; + out.push({ type: 'item', item }); + return; + } + run.push(item); + }); + flush(run); + return out; +}; + +/** Reads `steward` from raw state event content; anything unusable is "no steward". */ +export const parseSteward = (content: unknown): string | undefined => { + if (typeof content !== 'object' || content === null) return undefined; + const { steward } = content as { steward?: unknown }; + if (typeof steward !== 'string') return undefined; + const trimmed = steward.trim(); + return trimmed === '' ? undefined : trimmed; +}; diff --git a/src/app/features/steward-groups/useStewardGroups.ts b/src/app/features/steward-groups/useStewardGroups.ts new file mode 100644 index 00000000..ae1d2a3d --- /dev/null +++ b/src/app/features/steward-groups/useStewardGroups.ts @@ -0,0 +1,71 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { MatrixClient, MatrixEvent, RoomStateEvent } from 'matrix-js-sdk'; +import { atomWithStorage } from 'jotai/utils'; +import { useAtom } from 'jotai'; +import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; +import { + groupByStewards, + HierarchyLike, + parseSteward, + StewardEntry, + STEWARD_EVENT_TYPE, +} from './stewardGroups'; + +// Explicit open/closed choices by group id. Absent = default: closed on phones, open elsewhere. +const stewardGroupChoicesAtom = atomWithStorage>( + 'gamecultStewardGroupOpen', + {} +); + +type Options = { + mx: MatrixClient; + spaceId: string; + items: T[]; + keepVisible: (roomId: string) => boolean; +}; + +export const useStewardGroups = ({ + mx, + spaceId, + items, + keepVisible, +}: Options) => { + const mobile = useScreenSizeContext() === ScreenSize.Mobile; + const [choices, setChoices] = useAtom(stewardGroupChoicesAtom); + const [stateVersion, setStateVersion] = useState(0); + + useEffect(() => { + const onState = (mEvent: MatrixEvent) => { + if (mEvent.getType() === STEWARD_EVENT_TYPE) setStateVersion((v) => v + 1); + }; + mx.on(RoomStateEvent.Events, onState); + return () => { + mx.removeListener(RoomStateEvent.Events, onState); + }; + }, [mx]); + + const stewardOf = useCallback( + (roomId: string) => + // stateVersion is read so a late-arriving steward event re-derives this lookup + stateVersion >= 0 + ? parseSteward( + mx.getRoom(roomId)?.currentState.getStateEvents(STEWARD_EVENT_TYPE, '')?.getContent() + ) + : undefined, + [mx, stateVersion] + ); + + const isGroupOpen = useCallback((id: string) => choices[id] ?? !mobile, [choices, mobile]); + + const entries: StewardEntry[] = useMemo( + () => groupByStewards(items, { spaceId, stewardOf, isGroupOpen, keepVisible }), + [items, spaceId, stewardOf, isGroupOpen, keepVisible] + ); + + const toggleGroup = useCallback( + (id: string) => setChoices((prev) => ({ ...prev, [id]: !(prev[id] ?? !mobile) })), + [setChoices, mobile] + ); + + return { entries, toggleGroup }; +}; diff --git a/src/app/hooks/useTheme.ts b/src/app/hooks/useTheme.ts index cdbb9dba..4bfe01da 100644 --- a/src/app/hooks/useTheme.ts +++ b/src/app/hooks/useTheme.ts @@ -1,7 +1,8 @@ import { lightTheme } from 'folds'; import { createContext, useContext, useEffect, useMemo, useState } from 'react'; import { onDarkFontWeight, onLightFontWeight } from '../../config.css'; -import { butterTheme, darkTheme, silverTheme } from '../../colors.css'; +import { butterTheme, darkTheme, gamecultTheme, silverTheme } from '../../colors.css'; +import '../../gamecult.css'; import { settingsAtom } from '../state/settings'; import { useSetting } from '../state/hooks/settings'; @@ -38,8 +39,14 @@ export const ButterTheme: Theme = { classNames: ['butter-theme', butterTheme, onDarkFontWeight, 'prism-dark'], }; +export const GameCultTheme: Theme = { + id: 'gamecult-theme', + kind: ThemeKind.Dark, + classNames: ['gamecult-theme', gamecultTheme, onDarkFontWeight, 'prism-dark'], +}; + export const useThemes = (): Theme[] => { - const themes: Theme[] = useMemo(() => [LightTheme, SilverTheme, DarkTheme, ButterTheme], []); + const themes: Theme[] = useMemo(() => [GameCultTheme, LightTheme, SilverTheme, DarkTheme, ButterTheme], []); return themes; }; @@ -47,6 +54,7 @@ export const useThemes = (): Theme[] => { export const useThemeNames = (): Record => useMemo( () => ({ + [GameCultTheme.id]: 'GameCult', [LightTheme.id]: 'Light', [SilverTheme.id]: 'Silver', [DarkTheme.id]: 'Dark', @@ -84,15 +92,15 @@ export const useActiveTheme = (): Theme => { const [darkThemeId] = useSetting(settingsAtom, 'darkThemeId'); if (!systemTheme) { - const selectedTheme = themes.find((theme) => theme.id === themeId) ?? LightTheme; + const selectedTheme = themes.find((theme) => theme.id === themeId) ?? GameCultTheme; return selectedTheme; } const selectedTheme = systemThemeKind === ThemeKind.Dark - ? themes.find((theme) => theme.id === darkThemeId) ?? DarkTheme - : themes.find((theme) => theme.id === lightThemeId) ?? LightTheme; + ? themes.find((theme) => theme.id === darkThemeId) ?? GameCultTheme + : themes.find((theme) => theme.id === lightThemeId) ?? GameCultTheme; return selectedTheme; }; diff --git a/src/app/pages/ThemeManager.tsx b/src/app/pages/ThemeManager.tsx index 69d50cdb..983ca684 100644 --- a/src/app/pages/ThemeManager.tsx +++ b/src/app/pages/ThemeManager.tsx @@ -1,29 +1,20 @@ import React, { ReactNode, useEffect } from 'react'; import { configClass, varsClass } from 'folds'; import { - DarkTheme, - LightTheme, + GameCultTheme, ThemeContextProvider, - ThemeKind, useActiveTheme, - useSystemThemeKind, } from '../hooks/useTheme'; import { useSetting } from '../state/hooks/settings'; import { settingsAtom } from '../state/settings'; export function UnAuthRouteThemeManager() { - const systemThemeKind = useSystemThemeKind(); - useEffect(() => { document.body.className = ''; document.body.classList.add(configClass, varsClass); - if (systemThemeKind === ThemeKind.Dark) { - document.body.classList.add(...DarkTheme.classNames); - } - if (systemThemeKind === ThemeKind.Light) { - document.body.classList.add(...LightTheme.classNames); - } - }, [systemThemeKind]); + // The GameCult identity is single-theme: signed-out pages ignore the system scheme. + document.body.classList.add(...GameCultTheme.classNames); + }, []); return null; } diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index 2a7c6199..c822ff38 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -51,6 +51,7 @@ import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav'; import { makeNavCategoryId } from '../../../state/closedNavCategories'; import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; import { useCategoryHandler } from '../../../hooks/useCategoryHandler'; +import { useStewardGroups } from '../../../features/steward-groups/useStewardGroups'; import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper'; import { useRoomName } from '../../../hooks/useRoomMeta'; import { useSpaceJoinedHierarchy } from '../../../hooks/useSpaceHierarchy'; @@ -425,8 +426,18 @@ export function Space() { ) ); + const { entries, toggleGroup } = useStewardGroups({ + mx, + spaceId: space.roomId, + items: hierarchy, + keepVisible: useCallback( + (rId: string) => roomToUnread.has(rId) || rId === selectedRoomId || callEmbed?.roomId === rId, + [roomToUnread, selectedRoomId, callEmbed] + ), + }); + const virtualizer = useVirtualizer({ - count: hierarchy.length, + count: entries.length, getScrollElement: () => scrollRef.current, estimateSize: () => 0, overscan: 10, @@ -491,7 +502,27 @@ export function Space() { }} > {virtualizer.getVirtualItems().map((vItem) => { - const { roomId } = hierarchy[vItem.index] ?? {}; + const entry = entries[vItem.index]; + if (!entry) return null; + if (entry.type === 'group') { + return ( + + + toggleGroup(entry.id)} + closed={!entry.open} + > + {entry.label} ยท {entry.count} + + + + ); + } + const { roomId } = entry.item; const room = mx.getRoom(roomId); if (!room) return null; diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 9fbad4ff..6a894350 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -50,8 +50,8 @@ export interface Settings { } const defaultSettings: Settings = { - themeId: undefined, - useSystemTheme: true, + themeId: 'gamecult-theme', + useSystemTheme: false, lightThemeId: undefined, darkThemeId: undefined, monochromeMode: false, @@ -61,10 +61,10 @@ const defaultSettings: Settings = { pageZoom: 100, hideActivity: false, - isPeopleDrawer: true, + isPeopleDrawer: false, memberSortFilterIndex: 0, enterForNewline: false, - messageLayout: 0, + messageLayout: 2, messageSpacing: '400', hideMembershipEvents: false, hideNickAvatarEvents: true, diff --git a/src/colors.css.ts b/src/colors.css.ts index 268cbf78..9c03c2cb 100644 --- a/src/colors.css.ts +++ b/src/colors.css.ts @@ -236,3 +236,95 @@ export const butterTheme = createTheme(color, { OnContainer: '#F2EED3', }, }); + +// GameCult identity (gamecult-site quartz.config.ts): single dark theme on #07111a, +// orange #ff8a2a accent, sky #59b7ff links. The brand defines no status colours, so +// Success/Warning/Critical are invented here and kept distinct from the orange accent. +export const gamecultTheme = createTheme(color, { + Background: { + Container: '#07111a', + ContainerHover: '#0d1a26', + ContainerActive: '#122130', + ContainerLine: '#16212c', + OnContainer: '#b7c7d9', + }, + Surface: { + Container: '#09141f', + ContainerHover: '#0f1c29', + ContainerActive: '#152433', + ContainerLine: '#16212c', + OnContainer: '#b7c7d9', + }, + SurfaceVariant: { + Container: '#0d1a26', + ContainerHover: '#122130', + ContainerActive: '#16273a', + ContainerLine: '#1b2a38', + OnContainer: '#b7c7d9', + }, + Primary: { + Main: '#ff8a2a', + MainHover: '#ff9640', + MainActive: '#ffa155', + MainLine: '#ffab66', + OnMain: '#2a1300', + Container: '#2b1a0e', + ContainerHover: '#35200f', + ContainerActive: '#402611', + ContainerLine: '#4d2d13', + OnContainer: '#ffd3ab', + }, + Secondary: { + Main: '#eef5ff', + MainHover: '#dbe7f6', + MainActive: '#cddced', + MainLine: '#bfd0e4', + OnMain: '#07111a', + Container: '#16212c', + ContainerHover: '#1c2a38', + ContainerActive: '#233342', + ContainerLine: '#2a3b4c', + OnContainer: '#eef5ff', + }, + Success: { + Main: '#5fd0a4', + MainHover: '#72d8b0', + MainActive: '#84deba', + MainLine: '#93e3c2', + OnMain: '#042014', + Container: '#0c2a21', + ContainerHover: '#10342a', + ContainerActive: '#143e32', + ContainerLine: '#19493a', + OnContainer: '#c4f1de', + }, + Warning: { + Main: '#f2d04b', + MainHover: '#f5d963', + MainActive: '#f7e078', + MainLine: '#f9e68b', + OnMain: '#2a2100', + Container: '#2a2410', + ContainerHover: '#352d13', + ContainerActive: '#403616', + ContainerLine: '#4d411a', + OnContainer: '#f8ebb0', + }, + Critical: { + Main: '#ff7a90', + MainHover: '#ff8fa1', + MainActive: '#ffa0af', + MainLine: '#ffb0bc', + OnMain: '#3a0710', + Container: '#2e1118', + ContainerHover: '#391520', + ContainerActive: '#441a27', + ContainerLine: '#51202e', + OnContainer: '#ffd0d8', + }, + Other: { + FocusRing: 'rgba(89, 183, 255, 0.55)', + Shadow: 'rgba(0, 0, 0, 0.6)', + Overlay: 'rgba(3, 7, 13, 0.8)', + }, +}); diff --git a/src/gamecult.css.ts b/src/gamecult.css.ts new file mode 100644 index 00000000..4aaf3e76 --- /dev/null +++ b/src/gamecult.css.ts @@ -0,0 +1,20 @@ +import { globalStyle } from '@vanilla-extract/css'; +import { config } from 'folds'; + +// Softer, rounder shape language and an airier line height. Scoped to the GameCult +// theme class so the stock themes keep folds' defaults. +const scope = 'body.gamecult-theme'; + +globalStyle(scope, { + vars: { + [config.radii.R300]: '10px', + [config.radii.R400]: '16px', + [config.radii.R500]: '22px', + [config.lineHeight.T300]: '1.6', + [config.lineHeight.T400]: '1.7', + [config.lineHeight.T500]: '1.7', + [config.lineHeight.B400]: '1.6', + [config.lineHeight.B500]: '1.6', + [config.lineHeight.D400]: '1.7', + }, +}); diff --git a/src/index.css b/src/index.css index d4696367..f751b94d 100644 --- a/src/index.css +++ b/src/index.css @@ -135,3 +135,67 @@ li p::before { content: ''; display: inline-block; } + +/* GameCult identity: Ubuntu 300 prose, Montserrat thin titles, IBM Plex Mono labels. */ +.gamecult-theme { + --tc-link: #59b7ff; + + --mx-uc-1: hsl(208, 100%, 72%); + --mx-uc-2: hsl(280, 100%, 78%); + --mx-uc-3: hsl(163, 70%, 65%); + --mx-uc-4: hsl(343, 100%, 76%); + --mx-uc-5: hsl(28, 100%, 66%); + --mx-uc-6: hsl(181, 70%, 62%); + --mx-uc-7: hsl(243, 100%, 80%); + --mx-uc-8: hsl(94, 70%, 70%); + + --font-secondary: 'Ubuntu', var(--font-emoji), sans-serif; + --font-title: 'Montserrat', 'Ubuntu', sans-serif; + --font-mono: 'IBM Plex Mono', monospace; + --gamecult-rule: rgba(148, 163, 184, 0.22); + --gamecult-wash: 0.5; + + background-color: #07111a; + background-image: radial-gradient( + circle at 78% 14%, + rgb(255 138 42 / calc(0.18 * var(--gamecult-wash))), + transparent 18% + ), + radial-gradient( + circle at 18% 10%, + rgb(109 96 255 / calc(0.16 * var(--gamecult-wash))), + transparent 24% + ), + radial-gradient( + circle at 52% 0%, + rgb(89 183 255 / calc(0.14 * var(--gamecult-wash))), + transparent 28% + ), + linear-gradient(180deg, #03070d 0%, #07111a 44%, #09141f 100%); + background-attachment: fixed; + color-scheme: dark; + font-weight: 300; +} + +.gamecult-theme b, +.gamecult-theme strong { + font-weight: 500; +} + +.gamecult-theme h1, +.gamecult-theme h2, +.gamecult-theme h3 { + font-family: var(--font-title); + font-weight: 300; +} + +.gamecult-theme code, +.gamecult-theme pre { + font-family: var(--font-mono); +} + +/* Quiet secondary metadata: timestamps, edited marks and the like sit back. */ +.gamecult-theme time { + opacity: 0.55; + font-size: 0.78em; +} diff --git a/src/index.tsx b/src/index.tsx index 6cc0ca38..672ac5c0 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,6 +3,13 @@ import React from 'react'; import { createRoot } from 'react-dom/client'; import { enableMapSet } from 'immer'; import '@fontsource/inter/variable.css'; +import '@fontsource/ubuntu/300.css'; +import '@fontsource/ubuntu/400.css'; +import '@fontsource/ubuntu/500.css'; +import '@fontsource/ubuntu/700.css'; +import '@fontsource/montserrat/200.css'; +import '@fontsource/montserrat/300.css'; +import '@fontsource/ibm-plex-mono/400.css'; import 'folds/dist/style.css'; import { configClass, varsClass } from 'folds';