Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
c9291d3
customise pool buttons
yafred Feb 24, 2026
cd75b98
fixes
yafred Feb 24, 2026
c865715
add game type icon to customised buttons
yafred Feb 24, 2026
067577d
override lobby setup when clicking on a customised button
yafred Feb 24, 2026
8da1459
submit with presets
yafred Feb 24, 2026
3d6725b
use transp in custom buttons
yafred Feb 24, 2026
ef2ac55
don't show the modal dialog when clicking on a customised button
yafred Feb 24, 2026
d7e0bbf
show quick pairing button if we are forced into a pool
yafred Feb 24, 2026
faca208
fix irresponsive lobby button after custom submit
yafred Feb 25, 2026
bb3bae2
don't let icons eat pointer events
yafred Feb 25, 2026
64d2eda
relocate test to prevent modal dialog when needed
yafred Feb 25, 2026
5a03407
refactor customised button rendering
yafred Feb 25, 2026
853799e
choose gameType in modal is undefined
yafred Feb 26, 2026
aa252f6
add icon to restore action
yafred Feb 26, 2026
353d8f3
for Fen valid if no gameType
yafred Feb 26, 2026
816bdb7
rated has no meaning for ai games
yafred Feb 26, 2026
599ee7e
load props before redrawing
yafred Feb 26, 2026
206e2b8
nice pencil when customisable
yafred Feb 26, 2026
da6d91d
cleanup
yafred Feb 26, 2026
fa61742
add ai level to customised button
yafred Feb 26, 2026
24df3c4
Merge branch 'master' into custom-pairing-buttons
yafred Feb 26, 2026
df0dd51
port Simek modifs
yafred Feb 26, 2026
6a7dacd
prettier
yafred Feb 26, 2026
5788c3c
fix lint
yafred Feb 26, 2026
9455501
give icons their own div to allow styling and positioning
yafred Feb 28, 2026
fcc307b
position icons (minimum impact on the current look)
yafred Feb 28, 2026
f3186d0
prettier
yafred Feb 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions ui/lobby/css/_table.scss
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@
&--ai.button::before {
content: $licon-Cpu;
}
&--restore.button::before {
content: $licon-Back;
}
&--friend-user.button::before {
content: $licon-Swords;
}
Expand Down
37 changes: 37 additions & 0 deletions ui/lobby/css/app/_pool.scss
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,41 @@
&.transp {
opacity: 0.35;
}

&.selected {
background: $m-accent--fade-80 !important;
color: $c-font;
}

&.customisable {
position: relative;
background: $m-accent--fade-80 !important;
color: $c-font;
&::after {
@extend %data-icon;
content: $licon-Pencil;
position: absolute;
top: 0.5em;
right: 0.5em;
font-size: 1.2em;
color: $c-font-dimmer;
}
}

&.custom {
position: relative;

.icons {
position: absolute;
top: 0.2em;
left: 0.2em;
display: flex;
gap: 0.2em;
flex-direction: column;
}
}

[data-icon] {
pointer-events: none;
}
}
16 changes: 16 additions & 0 deletions ui/lobby/src/ctrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import { storage, type LichessStorage } from 'lib/storage';
import { pubsub } from 'lib/pubsub';
import { wsPingInterval } from 'lib/socket';
import { colors, type ColorChoice } from 'lib/setup/color';
import { toggle } from 'lib';
import * as customiser from './customiser';

export default class LobbyController {
data: LobbyData;
Expand All @@ -42,6 +44,9 @@ export default class LobbyController {
pools: Pool[];
filter: Filter;
setupCtrl: SetupController;
isEditingPoolButtons = toggle(false);
selectedPoolButton?: string;
isHeadlessSubmission = false;

private poolInStorage: LichessStorage;
private flushHooksTimeout?: number;
Expand Down Expand Up @@ -253,6 +258,17 @@ export default class LobbyController {
};

clickPool = (id: string) => {
const customisation = customiser.overrideStoredLobbySetup(id, this.me?.username);
if (this.isEditingPoolButtons()) {
this.selectedPoolButton = id;
this.redraw();
return;
}
if (id != this.poolMember?.id && customisation) {
this.setupCtrl.headlessSubmit(customisation.gameType);
return;
}

if (!this.me) {
xhr.anonPoolSeek(this.pools.find(p => p.id === id)!);
this.setTab('real_time');
Expand Down
168 changes: 168 additions & 0 deletions ui/lobby/src/customiser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { storage } from 'lib/storage';
import { variants } from './options';
import type { Customisation, GameType } from './interfaces';
import * as licon from 'lib/licon';
import type LobbyController from './ctrl';
import { hl, type VNode } from 'lib/view';

const custoStoreKey = (username?: string) => `lobby.customisation.${username || 'anon'}`;
const lobbySetupStoreKey = (username: string | undefined, gameType: GameType) =>
`lobby.setup.${username || 'anon'}.${gameType}`;

export const getAll = (username?: string): Record<string, Customisation> => {
const raw = storage.make(custoStoreKey(username)).get();
if (!raw) return {};
try {
return JSON.parse(raw);
} catch {
return {};
}
};

export const get = (username: string | undefined, id: string): Customisation | undefined =>
getAll(username)[id];

export const set = (username: string | undefined, id: string, pool: Customisation) => {
const all = getAll(username);
all[id] = pool;
storage.make(custoStoreKey(username)).set(JSON.stringify(all));
};

export const remove = (username: string | undefined, id: string) => {
const all = getAll(username);
delete all[id];
storage.make(custoStoreKey(username)).set(JSON.stringify(all));
};

export const overrideStoredLobbySetup = (
poolId: string,
username: string | undefined,
): Customisation | undefined => {
const customisation = get(username, poolId);
if (!customisation) return undefined;

storage
.make(lobbySetupStoreKey(username, customisation.gameType))
.set(JSON.stringify(customisation.settings));

return customisation;
};

export const renderCustomisedButton = (
poolId: string,
customisation: Customisation | undefined,
selected: boolean,
transp: boolean,
customisable: boolean,
): VNode | undefined => {
if (!customisation) return undefined;

const variantDef = variants.find(v => v.key === customisation.settings.variant);
const variantIcon =
customisation.settings.variant !== 'standard' || customisation ? variantDef?.icon : undefined;
const typeIconAttrs =
customisation.gameType === 'hook'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might want to skip group icon to match default tiles, which are hooks. Also icons feels a bit too big.

Copy link
Contributor Author

@yafred yafred Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(I'm happy you think we are at this stage of polishing 😄 )

The default tiles are not really hooks, there are pools:

  • customised tiles should send you to the lobby (unless you configure them with some pool settings)
  • default tiles just blink and wait for another player to click the same tile

My initial idea was to create a second layer of tiles and let the exisiting one in peace (but the reception was ... not great)

If this proposal goes through, we are going to spend a lot of attention on visual details (this is the home page)

? { 'data-icon': licon.Group }
: customisation.gameType === 'friend'
? { 'data-icon': licon.User }
: customisation.gameType === 'ai'
? { 'data-icon': licon.Cpu }
: undefined;
const timeLabel =
customisation.settings.timeMode === 'realTime'
? `${customisation.settings.time}+${customisation.settings.increment}`
: customisation.settings.timeMode === 'correspondence'
? `${customisation.settings.days}d`
: '∞';
const subLabel =
customisation.gameType !== 'ai'
? customisation.settings.gameMode === 'rated'
? i18n.site.rated
: i18n.site.casual
: 'Level ' + customisation.settings.aiLevel;

return hl(
'div.lpool',
{
class: { selected, custom: true, transp, customisable },
attrs: { role: 'button', 'data-id': poolId, tabindex: '0' },
},
[
hl('div.icons', [
hl('span', { attrs: typeIconAttrs }),
variantIcon ? hl('span', { attrs: { 'data-icon': variantIcon } }) : null,
]),
hl('div.clock', timeLabel),
hl('div.perf', subLabel),
],
);
};

export function renderCustomiserModalContent(ctrl: LobbyController): VNode[] | null {
if (!ctrl.isEditingPoolButtons() || !ctrl.selectedPoolButton) return null;
const customisation = get(ctrl.me?.username, ctrl.selectedPoolButton);
return [
hl('div.setup-content', [
hl('div.lobby__table', [
hl('div.lobby__start', [
makeRestoreButton(ctrl, customisation),
...lobbyButtons.map(b => makeCustomiserButton(ctrl, customisation, b)),
]),
]),
]),
];
}

type ButtonInfo = { gameType: GameType; label: string; title?: string };
const lobbyButtons: ButtonInfo[] = [
{
gameType: 'hook',
label: i18n.site.createLobbyGame,
},
{
gameType: 'friend',
label: i18n.site.challengeAFriend,
},
{
gameType: 'ai',
label: i18n.site.playAgainstComputer,
},
];

function makeRestoreButton(ctrl: LobbyController, customisation: Customisation | undefined) {
if (!customisation) return null;

return hl(
'button.button.button-metal.lobby__start__button.lobby__start__button--restore',
{
on: {
click: () => {
remove(ctrl.me?.username, ctrl.selectedPoolButton!);
ctrl.redraw();
},
},
},
'Restore quick pairing',
);
}

function makeCustomiserButton(
ctrl: LobbyController,
customisation: Customisation | undefined,
buttonInfo: ButtonInfo,
) {
return hl(
`button.button.button-metal.lobby__start__button.lobby__start__button--${buttonInfo.gameType}`,
{
on: {
click: () => {
if (customisation) overrideStoredLobbySetup(customisation.gameType, ctrl.me?.username);
ctrl.setupCtrl.gameType = buttonInfo.gameType;
ctrl.setupCtrl.loadPropsFromStore();
ctrl.redraw();
},
},
},
buttonInfo.label + (customisation && customisation.gameType === buttonInfo.gameType ? ' *' : ''),
);
}
5 changes: 5 additions & 0 deletions ui/lobby/src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,8 @@ export interface ForceSetupOptions {
mode?: GameMode;
color?: ColorChoice;
}

export interface Customisation {
gameType: GameType;
settings: SetupStore;
}
28 changes: 25 additions & 3 deletions ui/lobby/src/setupCtrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
type TimeControl,
} from 'lib/setup/timeControl';
import type { ColorChoice, ColorProp } from 'lib/setup/color';
import * as customiser from './customiser';

const getPerf = (variant: VariantKey, tc: TimeControl): Perf =>
variant !== 'standard' && variant !== 'fromPosition' ? variant : tc.speed();
Expand Down Expand Up @@ -69,7 +70,7 @@ export default class SetupController {
aiLevel: 1,
}));

private loadPropsFromStore = (forceOptions?: ForceSetupOptions) => {
loadPropsFromStore = (forceOptions?: ForceSetupOptions) => {
const storeProps = this.store[this.gameType!]();
// Load props from the store, but override any store values with values found in forceOptions
this.variant = propWithEffect(forceOptions?.variant || storeProps.variant, this.onDropdownChange);
Expand Down Expand Up @@ -261,10 +262,11 @@ export default class SetupController {
color,
});

validFen = () => this.variant() !== 'fromPosition' || (!this.fenError && !!this.fen());
validFen = () => !this.gameType || this.variant() !== 'fromPosition' || (!this.fenError && !!this.fen());

valid = () =>
this.validFen() && this.timeControl.valid(this.minimumTimeIfReal()) && this.validConstraints();
!this.gameType ||
(this.validFen() && this.timeControl.valid(this.minimumTimeIfReal()) && this.validConstraints());

private invalid = <A>(forced: A | undefined, current: A) => forced !== undefined && forced !== current;

Expand All @@ -291,10 +293,20 @@ export default class SetupController {
minimumTimeIfReal = () => (this.gameType === 'ai' && this.variant() === 'fromPosition' ? 1 : 0);

submit = async () => {
if (this.root.selectedPoolButton) {
customiser.set(this.root.me?.username, this.root.selectedPoolButton, {
gameType: this.gameType!,
settings: this.store[this.gameType!](),
});
this.closeModal?.();
return;
}

const color = this.color();
const poolMember = this.hookToPoolMember(color);
if (poolMember) {
this.root.enterPool(poolMember);
this.gameType = null;
this.closeModal?.();
return;
}
Expand Down Expand Up @@ -342,4 +354,14 @@ export default class SetupController {
this.closeModal?.();
}
};

headlessSubmit = async (gameType: GameType) => {
this.gameType = gameType;
this.loadPropsFromStore();
this.root.isHeadlessSubmission = true;
await this.submit();
this.root.isHeadlessSubmission = false;
this.gameType = null;
this.root.redraw();
};
}
Loading