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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-31 05:00:32 +01:00
commit a588ab82b3
13 changed files with 471 additions and 28 deletions

View file

@ -1,3 +1,3 @@
export default {
base: '/',
base: process.env.CINNY_BASE ?? '/',
};

View file

@ -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"
}
}

View file

@ -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<string, string> = { '!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();
});
});

View file

@ -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<T extends HierarchyLike> =
| { 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 = <T extends HierarchyLike>(
items: T[],
{ spaceId, stewardOf, isGroupOpen, keepVisible }: StewardGroupOptions
): StewardEntry<T>[] => {
const out: StewardEntry<T>[] = [];
const flush = (run: T[]) => {
if (run.length === 0) return;
const groups = new Map<string, T[]>();
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;
};

View file

@ -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<Record<string, boolean>>(
'gamecultStewardGroupOpen',
{}
);
type Options<T> = {
mx: MatrixClient;
spaceId: string;
items: T[];
keepVisible: (roomId: string) => boolean;
};
export const useStewardGroups = <T extends HierarchyLike>({
mx,
spaceId,
items,
keepVisible,
}: Options<T>) => {
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<T>[] = 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 };
};

View file

@ -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<string, string> =>
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;
};

View file

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

View file

@ -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 (
<VirtualTile
virtualItem={vItem}
key={vItem.index}
ref={virtualizer.measureElement}
>
<NavCategoryHeader>
<RoomNavCategoryButton
onClick={() => toggleGroup(entry.id)}
closed={!entry.open}
>
{entry.label} · {entry.count}
</RoomNavCategoryButton>
</NavCategoryHeader>
</VirtualTile>
);
}
const { roomId } = entry.item;
const room = mx.getRoom(roomId);
if (!room) return null;

View file

@ -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,

View file

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

20
src/gamecult.css.ts Normal file
View file

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

View file

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

View file

@ -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';