diff --git a/ui/analyse/src/analyse.nvui.ts b/ui/analyse/src/analyse.nvui.ts new file mode 100644 index 0000000000000..22b011ff38536 --- /dev/null +++ b/ui/analyse/src/analyse.nvui.ts @@ -0,0 +1,34 @@ +import type AnalyseCtrl from './ctrl'; +import type { NvuiPlugin } from './interfaces'; +import * as nvui from 'lib/nvui/chess'; +import type * as studyDeps from './study/studyDeps'; +import { renderNvui, initNvui } from './view/nvuiView'; +import { Notify } from 'lib/nvui/notify'; +import { type Prop, prop } from 'lib'; + +export type NvuiContext = Readonly<{ + ctrl: AnalyseCtrl; + deps?: typeof studyDeps; + analysisInProgress: Prop; + notify: Notify; + moveStyle: ReturnType; + pieceStyle: ReturnType; + prefixStyle: ReturnType; + positionStyle: ReturnType; + boardStyle: ReturnType; +}>; + +export function initModule(ctrl: AnalyseCtrl): NvuiPlugin { + const ctx: NvuiContext = { + ctrl, + notify: new Notify(), + analysisInProgress: prop(false), + moveStyle: nvui.styleSetting(), + pieceStyle: nvui.pieceSetting(), + prefixStyle: nvui.prefixSetting(), + positionStyle: nvui.positionSetting(), + boardStyle: nvui.boardSetting(), + }; + initNvui(ctx); + return { render: deps => renderNvui({ ...ctx, deps }) }; +} diff --git a/ui/analyse/src/ctrl.ts b/ui/analyse/src/ctrl.ts index c64f90542db9b..be2cf3da4c020 100644 --- a/ui/analyse/src/ctrl.ts +++ b/ui/analyse/src/ctrl.ts @@ -692,7 +692,7 @@ export default class AnalyseCtrl { } setAutoShapes = (): void => { - this.chessground?.setAutoShapes(computeAutoShapes(this)); + if (!site.blindMode) this.chessground?.setAutoShapes(computeAutoShapes(this)); }; private onNewCeval = (ev: Tree.ClientEval, path: Tree.Path, isThreat?: boolean): void => { @@ -717,7 +717,7 @@ export default class AnalyseCtrl { this.study?.multiCloudEval?.onLocalCeval(node, ev); this.evalCache.onLocalCeval(); } - this.redraw(); + if (!(site.blindMode && this.retro)) this.redraw(); } }); }; diff --git a/ui/analyse/src/keyboard.ts b/ui/analyse/src/keyboard.ts index 0c232dcb3eea1..c5b95a20338de 100644 --- a/ui/analyse/src/keyboard.ts +++ b/ui/analyse/src/keyboard.ts @@ -63,7 +63,7 @@ export const bind = (ctrl: AnalyseCtrl) => { kbd.bind('space', () => { const gb = ctrl.gamebookPlay(); if (gb) gb.onSpace(); - else if (ctrl.practice || ctrl.studyPractice) return; + else if (ctrl.practice || ctrl.studyPractice || site.blindMode) return; else if (ctrl.ceval.enabled()) ctrl.playBestMove(); else ctrl.toggleCeval(); }); diff --git a/ui/analyse/src/plugins/analyse.nvui.ts b/ui/analyse/src/plugins/analyse.nvui.ts deleted file mode 100644 index fee8df8be5ec3..0000000000000 --- a/ui/analyse/src/plugins/analyse.nvui.ts +++ /dev/null @@ -1,785 +0,0 @@ -import { h, type VNode, type VNodeChildren } from 'snabbdom'; -import { defined, prop, type Prop } from 'lib'; -import { text as xhrText } from 'lib/xhr'; -import type AnalyseController from '../ctrl'; -import { makeConfig as makeCgConfig } from '../ground'; -import type { AnalyseData, NvuiPlugin } from '../interfaces'; -import type { Player } from 'lib/game/game'; -import { - type MoveStyle, - renderSan, - renderPieces, - renderBoard, - renderMainline, - renderComments, - styleSetting, - pieceSetting, - prefixSetting, - boardSetting, - positionSetting, - boardCommandsHandler, - selectionHandler, - arrowKeyHandler, - positionJumpHandler, - pieceJumpingHandler, - castlingFlavours, - inputToMove, - lastCapturedCommandHandler, - type DropMove, - possibleMovesHandler, - renderPockets, - pocketsStr, -} from 'lib/nvui/chess'; -import { renderSetting } from 'lib/nvui/setting'; -import { Notify } from 'lib/nvui/notify'; -import { commands, boardCommands, addBreaks } from 'lib/nvui/command'; -import { bind, noTrans, onInsert, type MaybeVNode, type MaybeVNodes } from 'lib/snabbdom'; -import { throttle } from 'lib/async'; -import explorerView from '../explorer/explorerView'; -import { ops, path as treePath } from 'lib/tree/tree'; -import { view as cevalView, renderEval, type CevalCtrl } from 'lib/ceval/ceval'; -import { next, prev } from '../control'; -import { lichessRules } from 'chessops/compat'; -import { makeSan } from 'chessops/san'; -import { charToRole, opposite, parseUci } from 'chessops/util'; -import { parseFen } from 'chessops/fen'; -import { setupPosition } from 'chessops/variant'; -import { plyToTurn } from 'lib/game/chess'; -import { Chessground as makeChessground } from '@lichess-org/chessground'; -import { pubsub } from 'lib/pubsub'; -import { renderResult, viewContext, type RelayViewContext } from '../view/components'; -import { view as chapterNewFormView } from '../study/chapterNewForm'; -import { view as chapterEditFormView } from '../study/chapterEditForm'; -import renderClocks from '../view/clocks'; -import { renderChat } from 'lib/chat/renderChat'; - -import type * as studyDeps from '../study/studyDeps'; -import type RelayCtrl from '../study/relay/relayCtrl'; -import { playersView } from '../study/relay/relayPlayers'; -import { showInfo as tourOverview } from '../study/relay/relayTourView'; - -const throttled = (sound: string) => throttle(100, () => site.sound.play(sound)); -const selectSound = throttled('select'); -const borderSound = throttled('outOfBound'); -const errorSound = throttled('error'); - -export function initModule(ctrl: AnalyseController): NvuiPlugin { - const notify = new Notify(), - moveStyle = styleSetting(), - pieceStyle = pieceSetting(), - prefixStyle = prefixSetting(), - positionStyle = positionSetting(), - boardStyle = boardSetting(), - analysisInProgress = prop(false); - - pubsub.on('analysis.server.progress', (data: AnalyseData) => { - if (data.analysis && !data.analysis.partial) notify.set('Server-side analysis complete'); - }); - - site.mousetrap.bind('c', () => notify.set(renderEvalAndDepth(ctrl))); - - return { - render(deps?: typeof studyDeps): VNode { - notify.redraw = ctrl.redraw; - const d = ctrl.data, - style = moveStyle.get(), - clocks = renderClocks(ctrl, ctrl.path), - pockets = ctrl.node.crazy?.pockets; - ctrl.chessground = makeChessground(document.createElement('div'), { - ...makeCgConfig(ctrl), - animation: { enabled: false }, - drawable: { enabled: false }, - coordinates: false, - }); - return h('main.analyse', [ - h('div.nvui', [ - studyDetails(ctrl), - h('h1', 'Textual representation'), - h('h2', 'Game info'), - ...['white', 'black'].map((color: Color) => - h('p', [`${i18n.site[color]}: `, renderPlayer(ctrl, playerByColor(d, color))]), - ), - h('p', `${i18n.site[d.game.rated ? 'rated' : 'casual']} ${d.game.perf || d.game.variant.name}`), - d.clock ? h('p', `Clock: ${d.clock.initial / 60} + ${d.clock.increment}`) : null, - h('h2', 'Moves'), - h('p.moves', { attrs: { role: 'log', 'aria-live': 'off' } }, renderCurrentLine(ctrl, style)), - ...(!ctrl.studyPractice - ? [ - h( - 'button', - { - attrs: { 'aria-pressed': `${ctrl.explorer.enabled()}` }, - hook: bind('click', _ => ctrl.explorer.toggle(), ctrl.redraw), - }, - i18n.site.openingExplorerAndTablebase, - ), - explorerView(ctrl), - ] - : []), - h('h2', 'Pieces'), - h('div.pieces', renderPieces(ctrl.chessground.state.pieces, style)), - h('div.pockets', pockets && renderPockets(pockets)), - ...renderAriaResult(ctrl), - h('h2', 'Current position'), - h( - 'p.position.lastMove', - { attrs: { 'aria-live': 'assertive', 'aria-atomic': 'true' } }, - // make sure consecutive positions are different so that they get re-read - renderCurrentNode(ctrl, style) + (ctrl.node.ply % 2 === 0 ? '' : ' '), - ), - clocks && - h('div.clocks', [ - h('h2', `${i18n.site.clock}`), - h('div.clocks', [h('div.topc', clocks[0]), h('div.botc', clocks[1])]), - ]), - h('h2', 'Move form'), - h( - 'form#move-form', - { - hook: { - insert(vnode) { - const $form = $(vnode.elm as HTMLFormElement), - $input = $form.find('.move').val(''); - $form.on('submit', onSubmit(ctrl, notify.set, moveStyle.get, $input)); - }, - }, - }, - [ - h('label', [ - 'Command input', - h('input.move.mousetrap', { - attrs: { name: 'move', type: 'text', autocomplete: 'off' }, - }), - ]), - ], - ), - notify.render(), - h('h2', 'Computer analysis'), - ...cevalView.renderCeval(ctrl), - cevalView.renderPvs(ctrl), - ...(renderAcpl(ctrl, style) || [requestAnalysisButton(ctrl, analysisInProgress, notify.set)]), - h('h2', 'Board'), - h( - 'div.board', - { - hook: { - insert: el => { - const $board = $(el.elm as HTMLElement); - const $buttons = $board.find('button'); - const steps = () => ctrl.tree.getNodeList(ctrl.path); - const fenSteps = () => steps().map(step => step.fen); - const opponentColor = () => (ctrl.node.ply % 2 === 0 ? 'black' : 'white'); - $buttons.on('click', selectionHandler(opponentColor, selectSound)); - $buttons.on('keydown', (e: KeyboardEvent) => { - if (e.shiftKey && e.key.match(/^[ad]$/i)) jumpMoveOrLine(ctrl)(e); - else if (['o', 'l', 't'].includes(e.key)) boardCommandsHandler()(e); - else if (e.key.startsWith('Arrow')) - arrowKeyHandler(ctrl.data.player.color, borderSound)(e); - else if (e.key === 'c') - lastCapturedCommandHandler(fenSteps, pieceStyle.get(), prefixStyle.get())(); - else if (e.code.match(/^Digit([1-8])$/)) positionJumpHandler()(e); - else if (e.key.match(/^[kqrbnp]$/i)) pieceJumpingHandler(selectSound, errorSound)(e); - else if (e.key.toLowerCase() === 'm') - possibleMovesHandler( - ctrl.turnColor(), - ctrl.chessground, - ctrl.data.game.variant.key, - ctrl.nodeList, - )(e); - }); - }, - }, - }, - renderBoard( - ctrl.chessground.state.pieces, - ctrl.data.game.variant.key === 'racingKings' ? 'white' : ctrl.data.player.color, - pieceStyle.get(), - prefixStyle.get(), - positionStyle.get(), - boardStyle.get(), - ), - ), - h( - 'div.boardstatus', - { - attrs: { 'aria-live': 'polite', 'aria-atomic': 'true' }, - }, - '', - ), - h('div.content', { - hook: { - insert: vnode => { - const root = $(vnode.elm as HTMLElement); - root.append($('.blind-content').removeClass('none')); - root.find('.copy-pgn').on('click', function (this: HTMLElement) { - navigator.clipboard.writeText(this.dataset.pgn!).then(() => { - notify.set('PGN copied into clipboard.'); - }); - }); - root.find('.copy-fen').on('click', function (this: HTMLElement) { - const inputFen = document.querySelector( - '.analyse__underboard__fen input', - ) as HTMLInputElement; - const fen = inputFen.value; - navigator.clipboard.writeText(fen).then(() => { - notify.set('FEN copied into clipboard.'); - }); - }); - }, - }, - }), - h('h2', i18n.site.advancedSettings), - h('label', ['Move notation', renderSetting(moveStyle, ctrl.redraw)]), - h('h3', 'Board settings'), - h('label', ['Piece style', renderSetting(pieceStyle, ctrl.redraw)]), - h('label', ['Piece prefix style', renderSetting(prefixStyle, ctrl.redraw)]), - h('label', ['Show position', renderSetting(positionStyle, ctrl.redraw)]), - h('label', ['Board layout', renderSetting(boardStyle, ctrl.redraw)]), - h('h2', i18n.site.keyboardShortcuts), - h( - 'p', - [ - 'Use arrow keys to navigate in the game.', - `l: ${i18n.site.toggleLocalAnalysis}`, - `z: ${i18n.site.toggleAllAnalysis}`, - `space: ${i18n.site.playComputerMove}`, - 'c: announce computer evaluation', - `x: ${i18n.site.showThreat}`, - ].reduce(addBreaks, []), - ), - ...boardCommands(), - h('h2', 'Commands'), - h( - 'p', - [ - 'Type these commands in the command input.', - ...inputCommands - .filter(c => !c.invalid?.(ctrl)) - .flatMap(command => [noTrans(`${command.cmd}: `), command.help]), - ].reduce( - (acc, curr, i) => (i % 2 != 0 ? addBreaks(acc, curr) : acc.concat(curr)), - [], - ), - ), - h('h2', 'Chat'), - ctrl.chatCtrl && renderChat(ctrl.chatCtrl), - ...(deps && ctrl.study?.relay ? tourDetails(ctrl, ctrl.study, ctrl.study.relay, deps) : []), - ]), - ]); - }, - }; -} - -function renderEvalAndDepth(ctrl: AnalyseController): string { - if (ctrl.threatMode()) return `${evalInfo(ctrl.node.threat)} ${depthInfo(ctrl.node.threat, false)}`; - const evs = ctrl.currentEvals(), - bestEv = cevalView.getBestEval(evs); - const evalStr = evalInfo(bestEv); - return !evalStr ? noEvalStr(ctrl.ceval) : `${evalStr} ${depthInfo(evs.client, !!evs.client?.cloud)}`; -} - -const evalInfo = (bestEv: EvalScore | undefined): string => - defined(bestEv?.cp) - ? renderEval(bestEv.cp).replace('-', '−') - : defined(bestEv?.mate) - ? `mate in ${Math.abs(bestEv.mate)} for ${bestEv.mate > 0 ? 'white' : 'black'}` - : ''; - -const depthInfo = (clientEv: Tree.ClientEval | undefined, isCloud: boolean): string => - clientEv ? `${i18n.site.depthX(clientEv.depth || 0)} ${isCloud ? 'Cloud' : ''}` : ''; - -const noEvalStr = (ctrl: CevalCtrl) => - !ctrl.allowed() - ? 'local evaluation not allowed' - : !ctrl.possible - ? 'local evaluation not possible' - : !ctrl.enabled() - ? 'local evaluation not enabled' - : ''; - -function renderBestMove(ctrl: AnalyseController, style: MoveStyle): string { - const noEvalMsg = noEvalStr(ctrl.ceval); - if (noEvalMsg) return noEvalMsg; - const node = ctrl.node, - setup = parseFen(node.fen).unwrap(); - let pvs: Tree.PvData[] = []; - if (ctrl.threatMode() && node.threat) { - pvs = node.threat.pvs; - setup.turn = opposite(setup.turn); - if (setup.turn === 'white') setup.fullmoves += 1; - } else if (node.ceval) pvs = node.ceval.pvs; - const pos = setupPosition(lichessRules(ctrl.ceval.opts.variant.key), setup); - if (pos.isOk && pvs.length > 0 && pvs[0].moves.length > 0) { - const uci = pvs[0].moves[0]; - const san = makeSan(pos.unwrap(), parseUci(uci)!); - return renderSan(san, uci, style); - } - return ''; -} - -function renderAriaResult(ctrl: AnalyseController): VNode[] { - const result = renderResult(ctrl); - const res = result.length ? result : 'No result'; - return [ - h('h2', 'Game status'), - h('div.status', { attrs: { role: 'status', 'aria-live': 'assertive', 'aria-atomic': 'true' } }, res), - ]; -} - -function renderCurrentLine(ctrl: AnalyseController, style: MoveStyle): VNodeChildren { - if (ctrl.path.length === 0) return renderMainline(ctrl.mainline, ctrl.path, style); - else { - const futureNodes = ctrl.node.children.length > 0 ? ops.mainlineNodeList(ctrl.node.children[0]) : []; - return renderMainline(ctrl.nodeList.concat(futureNodes), ctrl.path, style); - } -} - -function onSubmit( - ctrl: AnalyseController, - notify: (txt: string) => void, - style: () => MoveStyle, - $input: Cash, -) { - return (e: SubmitEvent) => { - e.preventDefault(); - const input = castlingFlavours(($input.val() as string).trim()); - // Allow commands with/without a leading '/' - const command = getCommand(input) || getCommand(input.slice(1)); - if (command && !command.invalid?.(ctrl)) command.cb(ctrl, notify, style(), input); - else { - const move = inputToMove(input, ctrl.node.fen, ctrl.chessground); - const isDrop = (u: undefined | string | DropMove) => !!(u && typeof u !== 'string'); - const isInvalidDrop = (d: DropMove) => - !ctrl.crazyValid(d.role, d.key) || ctrl.chessground.state.pieces.has(d.key); - const isInvalidCrazy = isDrop(move) && isInvalidDrop(move); - - if (!move || isInvalidCrazy) notify(`Invalid move: ${input}`); - else sendMove(move, ctrl); - } - $input.val(''); - }; -} - -type Command = 'p' | 's' | 'eval' | 'best' | 'prev' | 'next' | 'prev line' | 'next line' | 'pocket'; -type InputCommand = { - cmd: Command; - help: VNode | string; - cb: (ctrl: AnalyseController, notify: (txt: string) => void, style: MoveStyle, input: string) => void; - invalid?: (ctrl: AnalyseController) => boolean; -}; - -const inputCommands: InputCommand[] = [ - { - cmd: 'p', - help: commands().piece.help, - cb: (ctrl, notify, style, input) => - notify( - commands().piece.apply(input, ctrl.chessground.state.pieces, style) || - `Bad input: ${input}. Exptected format: ${commands().piece.help}`, - ), - }, - { - cmd: 's', - help: commands().scan.help, - cb: (ctrl, notify, style, input) => - notify( - commands().scan.apply(input, ctrl.chessground.state.pieces, style) || - `Bad input: ${input}. Exptected format: ${commands().scan.help}`, - ), - }, - { - cmd: 'eval', - help: noTrans("announce last move's computer evaluation"), - cb: (ctrl, notify) => notify(renderEvalAndDepth(ctrl)), - }, - { - cmd: 'best', - help: noTrans('announce the top engine move'), - cb: (ctrl, notify, style) => notify(renderBestMove(ctrl, style)), - }, - { - cmd: 'prev', - help: noTrans('return to the previous move'), - cb: ctrl => doAndRedraw(ctrl, prev), - }, - { cmd: 'next', help: noTrans('go to the next move'), cb: ctrl => doAndRedraw(ctrl, next) }, - { - cmd: 'prev line', - help: noTrans('switch to the previous variation'), - cb: ctrl => doAndRedraw(ctrl, jumpPrevLine), - }, - { - cmd: 'next line', - help: noTrans('switch to the next variation'), - cb: ctrl => doAndRedraw(ctrl, jumpNextLine), - }, - { - cmd: 'pocket', - help: noTrans('Read out pockets for white or black. Example: "pocket black"'), - cb: (ctrl, notify, _, input) => { - const pockets = ctrl.node.crazy?.pockets; - const color = input.split(' ')?.[1]?.trim(); - return notify( - pockets - ? color - ? pocketsStr(color === 'white' ? pockets[0] : pockets[1]) || i18n.site.none - : 'Expected format: pocket [white|black]' - : 'Command only available in crazyhouse', - ); - }, - invalid: ctrl => ctrl.data.game.variant.key !== 'crazyhouse', - }, -]; - -const getCommand = (input: string) => { - const split = input.split(' '); - const firstWordLowerCase = split[0].toLowerCase(); - return ( - inputCommands.find(c => c.cmd === input) || - inputCommands.find(c => split.length !== 1 && c.cmd === firstWordLowerCase) - ); // 'next line' should not be interpreted as 'next' -}; - -function sendMove(uciOrDrop: string | DropMove, ctrl: AnalyseController) { - if (typeof uciOrDrop === 'string') - ctrl.sendMove( - uciOrDrop.slice(0, 2) as Key, - uciOrDrop.slice(2, 4) as Key, - undefined, - charToRole(uciOrDrop.slice(4)), - ); - else if (ctrl.crazyValid(uciOrDrop.role, uciOrDrop.key)) ctrl.sendNewPiece(uciOrDrop.role, uciOrDrop.key); -} - -function renderAcpl(ctrl: AnalyseController, style: MoveStyle): MaybeVNodes | undefined { - const anal = ctrl.data.analysis; // heh - if (!anal) return undefined; - const analysisGlyphs = ['?!', '?', '??']; - const analysisNodes = ctrl.mainline.filter(n => n.glyphs?.find(g => analysisGlyphs.includes(g.symbol))); - const res: Array = []; - ['white', 'black'].forEach((color: Color) => { - res.push(h('h3', `${color} player: ${anal[color].acpl} ${i18n.site.averageCentipawnLoss}`)); - res.push( - h( - 'select', - { - hook: bind( - 'change', - e => ctrl.jumpToMain(parseInt((e.target as HTMLSelectElement).value)), - ctrl.redraw, - ), - }, - analysisNodes - .filter(n => (n.ply % 2 === 1) === (color === 'white')) - .map(node => - h( - 'option', - { attrs: { value: node.ply, selected: node.ply === ctrl.node.ply } }, - [plyToTurn(node.ply), renderSan(node.san!, node.uci, style), renderComments(node, style)].join( - ' ', - ), - ), - ), - ), - ); - }); - return res; -} - -const requestAnalysisButton = ( - ctrl: AnalyseController, - inProgress: Prop, - notify: (msg: string) => void, -): MaybeVNode => - ctrl.ongoing || ctrl.synthetic - ? undefined - : inProgress() - ? h('p', 'Server-side analysis in progress') - : h( - 'button', - { - hook: bind('click', _ => - xhrText(`/${ctrl.data.game.id}/request-analysis`, { method: 'post' }).then( - () => { - inProgress(true); - notify('Server-side analysis in progress'); - }, - () => notify('Cannot run server-side analysis'), - ), - ), - }, - i18n.site.requestAComputerAnalysis, - ); - -function currentLineIndex(ctrl: AnalyseController): { i: number; of: number } { - if (ctrl.path === treePath.root) return { i: 1, of: 1 }; - const prevNode = ctrl.tree.nodeAtPath(treePath.init(ctrl.path)); - return { - i: prevNode.children.findIndex(node => node.id === ctrl.node.id), - of: prevNode.children.length, - }; -} - -function renderLineIndex(ctrl: AnalyseController): string { - const { i, of } = currentLineIndex(ctrl); - return of > 1 ? `, line ${i + 1} of ${of} ,` : ''; -} - -function renderCurrentNode(ctrl: AnalyseController, style: MoveStyle): string { - const node = ctrl.node; - if (!node.san || !node.uci) return 'Initial position'; - return [ - plyToTurn(node.ply), - renderSan(node.san, node.uci, style), - renderLineIndex(ctrl), - renderComments(node, style), - ] - .join(' ') - .trim(); -} - -const renderPlayer = (ctrl: AnalyseController, player: Player): VNodeChildren => - player.ai ? i18n.site.aiNameLevelAiLevel('Stockfish', player.ai) : userHtml(ctrl, player); - -function userHtml(ctrl: AnalyseController, player: Player) { - const d = ctrl.data, - user = player.user, - perf = user ? user.perfs[d.game.perf] : null, - rating = player.rating ? player.rating : perf && perf.rating, - rd = player.ratingDiff, - ratingDiff = rd ? (rd > 0 ? '+' + rd : rd < 0 ? '−' + -rd : '') : ''; - const studyPlayers = ctrl.study && renderStudyPlayer(ctrl, player.color); - return user - ? h('span', [ - h( - 'a', - { attrs: { href: '/@/' + user.username } }, - user.title ? `${user.title} ${user.username}` : user.username, - ), - rating ? ` ${rating}` : ``, - ' ' + ratingDiff, - ]) - : studyPlayers || h('span', i18n.site.anonymous); -} - -function renderStudyPlayer(ctrl: AnalyseController, color: Color): VNode | undefined { - const player = ctrl.study?.currentChapter().players?.[color]; - const keys = [ - ['name', i18n.site.name], - ['title', 'title'], - ['rating', i18n.site.rating], - ['fed', 'fed'], - ['team', 'team'], - ] as const; - return ( - player && - h( - 'span', - keys - .reduce< - string[] - >((strs, [key, i18n]) => (player[key] ? strs.concat(`${i18n}: ${key === 'fed' ? player[key].name : player[key]}`) : strs), []) - .join(' '), - ) - ); -} - -const playerByColor = (d: AnalyseData, color: Color): Player => - color === d.player.color ? d.player : d.opponent; - -const jumpNextLine = (ctrl: AnalyseController) => jumpLine(ctrl, 1); -const jumpPrevLine = (ctrl: AnalyseController) => jumpLine(ctrl, -1); - -function jumpLine(ctrl: AnalyseController, delta: number) { - const { i, of } = currentLineIndex(ctrl); - if (of === 1) return; - const newI = (i + delta + of) % of; - const prevPath = treePath.init(ctrl.path); - const prevNode = ctrl.tree.nodeAtPath(prevPath); - const newPath = prevPath + prevNode.children[newI].id; - ctrl.userJumpIfCan(newPath); -} -const onInsertHandler = (callback: () => void, el: HTMLElement) => { - el.addEventListener('click', callback); - el.addEventListener('keydown', ev => ev.key === 'Enter' && callback()); -}; - -const redirectToSelectedHook = bind('change', (e: InputEvent) => { - const target = e.target as HTMLSelectElement; - const selectedOption = target.options[target.selectedIndex]; - const url = selectedOption.getAttribute('url'); - if (url) window.location.href = url; -}); - -function tourDetails( - ctrl: AnalyseController, - study: studyDeps.StudyCtrl, - relay: RelayCtrl, - deps: typeof studyDeps, -): VNode[] { - const ctx: RelayViewContext = { ...viewContext(ctrl, deps), study, deps, relay, allowVideo: false }; - const tour = ctx.relay.data.tour; - ctx.relay.redraw = ctrl.redraw; - - return [ - h('h1', 'Tour details'), - h('h2', 'Overview'), - h('div', tourOverview(tour.info, tour.dates)), - h('h2', 'Players'), - h( - 'button', - { - hook: onInsert((el: HTMLButtonElement) => { - const toggle = () => { - ctx.relay.tab('players'); - ctrl.redraw(); - }; - onInsertHandler(toggle, el); - }), - }, - 'Load player list', - ), - ctx.relay.tab() === 'players' ? h('div', playersView(ctx.relay.players, ctx.relay.data.tour)) : h('div'), - ]; -} - -function studyDetails(ctrl: AnalyseController): MaybeVNode { - const study = ctrl.study; - const relayGroups = study?.relay?.data.group; - const relayRounds = study?.relay?.data.rounds; - const tour = study?.relay?.data.tour; - const hash = window.location.hash; - return ( - study && - h('div.study-details', [ - h('h2', 'Study details'), - h('span', `Title: ${study.data.name}. By: ${study.data.ownerId}`), - h('br'), - relayGroups && - h( - 'div.relay-groups', - h('label', [ - 'Current group:', - h( - 'select', - { - attrs: { autofocus: hash === '#group-select' }, - hook: redirectToSelectedHook, - }, - relayGroups.tours.map(t => - h( - 'option', - { attrs: { selected: t.id == tour?.id, url: `/broadcast/-/${t.id}#group-select` } }, - t.name, - ), - ), - ), - ]), - ), - tour && - relayRounds && - h( - 'div.relay-rounds', - h('label', [ - 'Current round:', - h( - 'select', - { - attrs: { autofocus: hash === '#round-select' }, - hook: redirectToSelectedHook, - }, - relayRounds.map(r => - h( - 'option', - { - attrs: { - selected: r.id == study.data.id, - url: `/broadcast/${tour.slug}/${r.slug}/${r.id}#round-select`, - }, - }, - r.name, - ), - ), - ), - ]), - ), - h('div.chapters', [ - h('label', [ - 'Current chapter:', - h( - 'select', - { - attrs: { id: 'chapter-select' }, - hook: bind('change', (e: InputEvent) => { - const target = e.target as HTMLSelectElement; - const selectedOption = target.options[target.selectedIndex]; - const chapterId = selectedOption.getAttribute('chapterId'); - study.setChapter(chapterId!); - }), - }, - study.chapters.list.all().map((ch, i) => - h( - 'option', - { - attrs: { - selected: ch.id === study.currentChapter().id, - chapterId: ch.id, - }, - }, - `${i + 1}. ${ch.name}`, - ), - ), - ), - ]), - study.members.canContribute() - ? h('div.buttons', [ - h( - 'button', - { - hook: onInsert((el: HTMLButtonElement) => { - const toggle = () => { - study.chapters.editForm.toggle(study.currentChapter()); - ctrl.redraw(); - }; - onInsertHandler(toggle, el); - }), - }, - [ - 'Edit current chapter', - study.chapters.editForm.current() && chapterEditFormView(study.chapters.editForm), - ], - ), - h( - 'button', - { - hook: onInsert((el: HTMLButtonElement) => { - const toggle = () => { - study.chapters.newForm.toggle(); - ctrl.redraw(); - }; - onInsertHandler(toggle, el); - }), - }, - [ - 'Add new chapter', - study.chapters.newForm.isOpen() ? chapterNewFormView(study.chapters.newForm) : undefined, - ], - ), - ]) - : undefined, - ]), - ]) - ); -} - -const doAndRedraw = (ctrl: AnalyseController, fn: (ctrl: AnalyseController) => void): void => { - fn(ctrl); - ctrl.redraw(); -}; - -function jumpMoveOrLine(ctrl: AnalyseController) { - return (e: KeyboardEvent) => { - if (e.key === 'A') doAndRedraw(ctrl, e.altKey ? jumpPrevLine : prev); - else if (e.key === 'D') doAndRedraw(ctrl, e.altKey ? jumpNextLine : next); - }; -} diff --git a/ui/analyse/src/retrospect/nvuiRetroView.ts b/ui/analyse/src/retrospect/nvuiRetroView.ts new file mode 100644 index 0000000000000..835317101acf6 --- /dev/null +++ b/ui/analyse/src/retrospect/nvuiRetroView.ts @@ -0,0 +1,133 @@ +import type { NvuiContext } from '../analyse.nvui'; +import { type LooseVNodes, hl } from 'lib/snabbdom'; +import type AnalyseCtrl from '../ctrl'; +import type { RetroCtrl } from '../retrospect/retroCtrl'; +import { renderSan } from 'lib/nvui/chess'; +import { liveText } from 'lib/nvui/notify'; +import { clickHook } from '../view/nvuiView'; + +export function renderRetro(ctx: NvuiContext): LooseVNodes { + const { ctrl } = ctx; + if (ctrl.ongoing || ctrl.synthetic || !ctrl.hasFullComputerAnalysis()) return; + + const nodes: LooseVNodes = [ + hl( + 'button.retro-toggle', + clickHook(ctrl.toggleRetro, ctrl.redraw), + ctrl.retro ? 'Stop learning from mistakes' : i18n.site.learnFromYourMistakes, + ), + ]; + if (ctrl.retro) { + const current = ctrl.retro.current(); + const mistakes = ctrl.retro.completion(); + + let state = ctrl.retro.feedback(); + if (ctrl.retro.isSolving() && current && ctrl.path !== current.prev.path) state = 'offTrack'; + + nodes.push( + hl('label', `Mistake ${Math.min(mistakes[0] + 1, mistakes[1])} of ${mistakes[1]}`), + retroStateBtns[state]?.(ctx as RetroContext), + ); + } + return nodes; +} + +interface RetroContext extends NvuiContext { + readonly ctrl: AnalyseCtrl & { retro: RetroCtrl }; +} + +function solveAndSkipBtns({ ctrl }: RetroContext): LooseVNodes { + return [ + hl( + 'button.retro-solve', + clickHook(() => ctrl.retro.feedback('view'), ctrl.redraw), + i18n.site.viewTheSolution, + ), + hl('button.retro-skip', clickHook(ctrl.retro.skip, ctrl.redraw), i18n.site.skipThisMove), + ]; +} + +function nextMistakeBtn(ctx: RetroContext): LooseVNodes { + const { ctrl } = ctx; + return ctrl.retro.current() + ? hl('button.retro-next', clickHook(ctrl.retro.skip, ctrl.redraw), i18n.site.next) + : doneWithMistakes(ctx); +} + +function doneWithMistakes({ ctrl }: RetroContext, prelude = ''): LooseVNodes { + const noMistakes = !ctrl.retro.completion()[1]; + return [ + liveText( + (prelude ? prelude + '. ' : '') + + i18n.site[ + noMistakes + ? ctrl.retro.color === 'white' + ? 'noMistakesFoundForWhite' + : 'noMistakesFoundForBlack' + : ctrl.retro.color === 'white' + ? 'doneReviewingWhiteMistakes' + : 'doneReviewingBlackMistakes' + ], + ), + !noMistakes && hl('button.retro-again', clickHook(ctrl.retro.reset, ctrl.redraw), i18n.site.doItAgain), + hl( + 'button.retro-flip', + clickHook(ctrl.retro.flip, ctrl.redraw), + i18n.site[ctrl.retro.color === 'white' ? 'reviewBlackMistakes' : 'reviewWhiteMistakes'], + ), + ]; +} + +let debounceRedraw: number; + +const retroStateBtns = { + offTrack({ ctrl }: RetroContext): LooseVNodes { + return [ + hl('p', i18n.site.youBrowsedAway), + hl('button.retro-resume', clickHook(ctrl.retro.jumpToNext, ctrl.redraw), i18n.site.resumeLearning), + ]; + }, + fail(ctx: RetroContext): LooseVNodes { + return retroStateBtns.find(ctx, `${i18n.site.youCanDoBetter}. `, true); + }, + win(ctx: RetroContext): LooseVNodes { + ctx.ctrl.retro.feedback('find'); + return retroStateBtns.find(ctx, `${i18n.study.goodMove}. `); + }, + view(ctx: RetroContext): LooseVNodes { + const { ctrl } = ctx; + if (!ctrl.retro.current()) return doneWithMistakes(ctx); + const node = ctrl.retro.current()!.solution.node; + return [ + liveText(`${i18n.site.solution} ${renderSan(node.san, node.uci, ctx.moveStyle.get())}.`), + nextMistakeBtn(ctx), + ]; + }, + find(ctx: RetroContext, prelude = '', tryAgain = false): LooseVNodes { + const { ctrl } = ctx; + const node = ctrl.retro.current()?.fault.node; + if (!node) return doneWithMistakes(ctx, prelude); + const c = ctrl.retro.color; + const trailer = + c === 'white' + ? tryAgain + ? i18n.site.tryAnotherMoveForWhite + : i18n.site.findBetterMoveForWhite + : tryAgain + ? i18n.site.tryAnotherMoveForBlack + : i18n.site.findBetterMoveForBlack; + return [ + liveText( + prelude + + `Turn ${Math.floor((node.ply + 1) / 2)}. ${i18n.site[c]} ` + + `played ${renderSan(node.san, node.uci, ctx.moveStyle.get())}. ${trailer}`, + ), + solveAndSkipBtns(ctx), + ]; + }, + eval({ ctrl }: RetroContext) { + clearTimeout(debounceRedraw); + debounceRedraw = setTimeout(ctrl.redraw, 200); + return undefined; + }, +}; diff --git a/ui/analyse/src/retrospect/retroCtrl.ts b/ui/analyse/src/retrospect/retroCtrl.ts index 95790e0f036d0..c519a41aa30e6 100644 --- a/ui/analyse/src/retrospect/retroCtrl.ts +++ b/ui/analyse/src/retrospect/retroCtrl.ts @@ -51,7 +51,9 @@ export function make(root: AnalyseCtrl, color: Color): RetroCtrl { const current = prop(null); const feedback = prop('find'); - const redraw = root.redraw; + function safeRedraw() { + if (!site.blindMode) root.redraw(); + } function isPlySolved(ply: Ply): boolean { return solvedPlies.includes(ply); @@ -71,7 +73,7 @@ export function make(root: AnalyseCtrl, color: Color): RetroCtrl { const node = findNextNode(); if (!node) { current(null); - return redraw(); + return safeRedraw(); } const fault = { node, @@ -114,7 +116,7 @@ export function make(root: AnalyseCtrl, color: Color): RetroCtrl { }); } root.userJump(prev.path); - redraw(); + safeRedraw(); } function onJump(): void { @@ -163,8 +165,9 @@ export function make(root: AnalyseCtrl, color: Color): RetroCtrl { function onWin(): void { solveCurrent(); + if (site.blindMode) jumpToNext(); feedback('win'); - redraw(); + safeRedraw(); } function onFail(): void { @@ -175,7 +178,7 @@ export function make(root: AnalyseCtrl, color: Color): RetroCtrl { }; root.userJump(current()!.prev.path); if (!root.tree.pathIsMainline(bad.path) && isEmpty(bad.node.children)) root.tree.deleteNodeAt(bad.path); - redraw(); + safeRedraw(); } function viewSolution() { @@ -190,7 +193,7 @@ export function make(root: AnalyseCtrl, color: Color): RetroCtrl { } function solveCurrent() { - solvedPlies.push(current()!.fault.node.ply); + if (current()) solvedPlies.push(current()!.fault.node.ply); } function hideComputerLine(node: Tree.Node): boolean { @@ -237,7 +240,7 @@ export function make(root: AnalyseCtrl, color: Color): RetroCtrl { if (root.data.game.variant.key !== 'racingKings') root.flip(); else { root.retro = make(root, opposite(color)); - redraw(); + safeRedraw(); } }, preventGoingToNextMove: () => { @@ -246,6 +249,6 @@ export function make(root: AnalyseCtrl, color: Color): RetroCtrl { }, close: root.toggleRetro, node: () => root.node, - redraw, + redraw: root.redraw, }; } diff --git a/ui/analyse/src/view/nvuiView.ts b/ui/analyse/src/view/nvuiView.ts new file mode 100644 index 0000000000000..1f918a76b640a --- /dev/null +++ b/ui/analyse/src/view/nvuiView.ts @@ -0,0 +1,728 @@ +import { type VNode, type LooseVNodes, type VNodeChildren, hl, bind, noTrans } from 'lib/snabbdom'; +import { defined } from 'lib'; +import { text as xhrText } from 'lib/xhr'; +import type AnalyseCtrl from '../ctrl'; +import { makeConfig as makeCgConfig } from '../ground'; +import type { AnalyseData } from '../interfaces'; +import type { Player } from 'lib/game/game'; +import { + renderSan, + renderPieces, + renderBoard, + renderMainline, + renderComments, + boardCommandsHandler, + selectionHandler, + arrowKeyHandler, + positionJumpHandler, + pieceJumpingHandler, + castlingFlavours, + inputToMove, + lastCapturedCommandHandler, + type DropMove, + possibleMovesHandler, + renderPockets, + pocketsStr, +} from 'lib/nvui/chess'; +import { renderSetting } from 'lib/nvui/setting'; +import { commands, boardCommands, addBreaks } from 'lib/nvui/command'; +import explorerView from '../explorer/explorerView'; +import { ops, path as treePath } from 'lib/tree/tree'; +import { view as cevalView, renderEval, type CevalCtrl } from 'lib/ceval/ceval'; +import { next, prev } from '../control'; +import { lichessRules } from 'chessops/compat'; +import { makeSan } from 'chessops/san'; +import { charToRole, opposite, parseUci } from 'chessops/util'; +import { parseFen } from 'chessops/fen'; +import { setupPosition } from 'chessops/variant'; +import { plyToTurn } from 'lib/game/chess'; +import { Chessground as makeChessground } from '@lichess-org/chessground'; +import { pubsub } from 'lib/pubsub'; +import { renderResult, viewContext, type RelayViewContext } from '../view/components'; +import { view as chapterNewFormView } from '../study/chapterNewForm'; +import { view as chapterEditFormView } from '../study/chapterEditForm'; +import renderClocks from '../view/clocks'; +import { renderChat } from 'lib/chat/renderChat'; +import { throttle } from 'lib/async'; +import { renderRetro } from '../retrospect/nvuiRetroView'; +import { playersView } from '../study/relay/relayPlayers'; +import { showInfo as tourOverview } from '../study/relay/relayTourView'; +import { NvuiContext } from '../analyse.nvui'; + +const throttled = (sound: string) => throttle(100, () => site.sound.play(sound)); +const selectSound = throttled('select'); +const borderSound = throttled('outOfBound'); +const errorSound = throttled('error'); + +export function initNvui({ ctrl, notify }: NvuiContext): void { + pubsub.on('analysis.server.progress', (data: AnalyseData) => { + if (data.analysis && !data.analysis.partial) notify.set('Server-side analysis complete'); + }); + site.mousetrap.bind('c', () => notify.set(renderEvalAndDepth(ctrl))); // ? is 'c' for chat or eval? +} + +export function renderNvui(ctx: NvuiContext): VNode { + const { ctrl, deps, notify, moveStyle, pieceStyle, prefixStyle, positionStyle, boardStyle } = ctx; + notify.redraw = ctrl.redraw; + const d = ctrl.data, + style = moveStyle.get(), + clocks = renderClocks(ctrl, ctrl.path), + pockets = ctrl.node.crazy?.pockets; + ctrl.chessground = makeChessground(document.createElement('div'), { + ...makeCgConfig(ctrl), + animation: { enabled: false }, + drawable: { enabled: false }, + coordinates: false, + }); + return hl('main.analyse', [ + hl('div.nvui', [ + studyDetails(ctrl), + hl('h1', 'Textual representation'), + hl('h2', 'Game info'), + ...['white', 'black'].map((color: Color) => + hl('p', [`${i18n.site[color]}: `, renderPlayer(ctrl, playerByColor(d, color))]), + ), + hl('p', `${i18n.site[d.game.rated ? 'rated' : 'casual']} ${d.game.perf || d.game.variant.name}`), + d.clock ? hl('p', `Clock: ${d.clock.initial / 60} + ${d.clock.increment}`) : null, + hl('h2', 'Moves'), + hl('p.moves', { attrs: { role: 'log', 'aria-live': 'off' } }, renderCurrentLine(ctx)), + !ctrl.studyPractice && [ + hl( + 'button', + { + attrs: { 'aria-pressed': `${ctrl.explorer.enabled()}` }, + hook: bind('click', _ => ctrl.explorer.toggle(), ctrl.redraw), + }, + i18n.site.openingExplorerAndTablebase, + ), + explorerView(ctrl), + ], + hl('h2', 'Pieces'), + hl('div.pieces', renderPieces(ctrl.chessground.state.pieces, style)), + hl('div.pockets', pockets && renderPockets(pockets)), + renderAriaResult(ctrl), + hl('h2', 'Current position'), + hl( + 'p.position.lastMove', + ctrl.retro ? {} : { attrs: { 'aria-live': 'assertive', 'aria-atomic': 'true' } }, + // make sure consecutive positions are different so that they get re-read + renderCurrentNode(ctx) + (ctrl.node.ply % 2 === 0 ? '' : ' '), + ), + clocks && + hl('div.clocks', [ + hl('h2', `${i18n.site.clock}`), + hl('div.clocks', [hl('div.topc', clocks[0]), hl('div.botc', clocks[1])]), + ]), + hl('h2', 'Move form'), + hl( + 'form#move-form', + { + hook: { + insert(vnode) { + const $form = $(vnode.elm as HTMLFormElement), + $input = $form.find('.move').val(''); + $form.on('submit', onSubmit(ctx, $input)); + }, + }, + }, + [ + hl('label', [ + 'Command input', + hl('input.move.mousetrap', { + attrs: { name: 'move', type: 'text', autocomplete: 'off' }, + }), + ]), + ], + ), + notify.render(), + renderRetro(ctx), + !ctrl.retro && [ + hl('h2', 'Computer analysis'), + cevalView.renderCeval(ctrl), // beware unsolicted redraws hosing the screen reader + cevalView.renderPvs(ctrl), + renderAcpl(ctx) || requestAnalysisBtn(ctx), + ], + hl('h2', 'Board'), + hl( + 'div.board', + { hook: { insert: el => boardEventsHook(ctx, el.elm as HTMLElement) } }, + renderBoard( + ctrl.chessground.state.pieces, + ctrl.data.game.variant.key === 'racingKings' ? 'white' : ctrl.bottomColor(), + pieceStyle.get(), + prefixStyle.get(), + positionStyle.get(), + boardStyle.get(), + ), + ), + hl('div.boardstatus', { attrs: { 'aria-live': 'polite', 'aria-atomic': 'true' } }, ''), + hl('div.content', { + hook: { + insert: vnode => { + const root = $(vnode.elm as HTMLElement); + root.append($('.blind-content').removeClass('none')); + root.find('.copy-pgn').on('click', function (this: HTMLElement) { + navigator.clipboard.writeText(this.dataset.pgn!).then(() => { + notify.set('PGN copied into clipboard.'); + }); + }); + root.find('.copy-fen').on('click', function (this: HTMLElement) { + const inputFen = document.querySelector('.analyse__underboard__fen input') as HTMLInputElement; + const fen = inputFen.value; + navigator.clipboard.writeText(fen).then(() => { + notify.set('FEN copied into clipboard.'); + }); + }); + }, + }, + }), + hl('h2', i18n.site.advancedSettings), + hl('label', ['Move notation', renderSetting(moveStyle, ctrl.redraw)]), + hl('h3', 'Board settings'), + hl('label', ['Piece style', renderSetting(pieceStyle, ctrl.redraw)]), + hl('label', ['Piece prefix style', renderSetting(prefixStyle, ctrl.redraw)]), + hl('label', ['Show position', renderSetting(positionStyle, ctrl.redraw)]), + hl('label', ['Board layout', renderSetting(boardStyle, ctrl.redraw)]), + hl('h2', i18n.site.keyboardShortcuts), + hl( + 'p', + [ + 'Use arrow keys to navigate in the game.', + `l: ${i18n.site.toggleLocalAnalysis}`, + `z: ${i18n.site.toggleAllAnalysis}`, + `space: ${i18n.site.playComputerMove}`, + 'c: announce computer evaluation', + `x: ${i18n.site.showThreat}`, + ].reduce(addBreaks, []), + ), + boardCommands(), + hl('h2', 'Commands'), + hl( + 'p', + [ + 'Type these commands in the command input.', + ...inputCommands + .filter(c => !c.invalid?.(ctrl)) + .flatMap(command => [noTrans(`${command.cmd}: `), command.help]), + ].reduce( + (acc, curr, i) => (i % 2 != 0 ? addBreaks(acc, curr) : acc.concat(curr)), + [], + ), + ), + hl('h2', 'Chat'), + ctrl.chatCtrl && renderChat(ctrl.chatCtrl), + deps && ctrl.study?.relay && tourDetails(ctx), + ]), + ]); +} + +export function clickHook(main?: (el: HTMLElement) => void, post?: () => void) { + return { + // put unique identifying props on the button container (such as class) + // because snabbdom WILL mix plain adjacent buttons up. + hook: { + insert: (vnode: VNode) => { + const el = vnode.elm as HTMLElement; + el.addEventListener('click', () => { + main?.(el); + post?.(); + }); + }, + }, + }; +} + +function boardEventsHook({ ctrl, pieceStyle, prefixStyle }: NvuiContext, el: HTMLElement): void { + const $board = $(el); + const $buttons = $board.find('button'); + const steps = () => ctrl.tree.getNodeList(ctrl.path); + const fenSteps = () => steps().map(step => step.fen); + const opponentColor = () => (ctrl.node.ply % 2 === 0 ? 'black' : 'white'); + $buttons.on('click', selectionHandler(opponentColor, selectSound)); + $buttons.on('keydown', (e: KeyboardEvent) => { + if (e.shiftKey && e.key.match(/^[ad]$/i)) jumpMoveOrLine(ctrl)(e); + else if (['o', 'l', 't'].includes(e.key)) boardCommandsHandler()(e); + else if (e.key.startsWith('Arrow')) arrowKeyHandler(ctrl.data.player.color, borderSound)(e); + else if (e.key === 'c') lastCapturedCommandHandler(fenSteps, pieceStyle.get(), prefixStyle.get())(); + else if (e.key === 'i') { + e.preventDefault(); + document.querySelector('input.move')?.focus(); + } else if (e.key === 'f') { + if (ctrl.data.game.variant.key !== 'racingKings') ctrl.flip(); + } else if (e.code.match(/^Digit([1-8])$/)) positionJumpHandler()(e); + else if (e.key.match(/^[kqrbnp]$/i)) pieceJumpingHandler(selectSound, errorSound)(e); + else if (e.key.toLowerCase() === 'm') + possibleMovesHandler(ctrl.turnColor(), ctrl.chessground, ctrl.data.game.variant.key, ctrl.nodeList)(e); + }); +} + +function renderEvalAndDepth(ctrl: AnalyseCtrl): string { + if (ctrl.threatMode()) return `${evalInfo(ctrl.node.threat)} ${depthInfo(ctrl.node.threat, false)}`; + const evs = ctrl.currentEvals(), + bestEv = cevalView.getBestEval(evs); + const evalStr = evalInfo(bestEv); + return !evalStr ? noEvalStr(ctrl.ceval) : `${evalStr} ${depthInfo(evs.client, !!evs.client?.cloud)}`; +} + +const evalInfo = (bestEv: EvalScore | undefined): string => + defined(bestEv?.cp) + ? renderEval(bestEv.cp).replace('-', '−') + : defined(bestEv?.mate) + ? `mate in ${Math.abs(bestEv.mate)} for ${bestEv.mate > 0 ? 'white' : 'black'}` + : ''; + +const depthInfo = (clientEv: Tree.ClientEval | undefined, isCloud: boolean): string => + clientEv ? `${i18n.site.depthX(clientEv.depth || 0)} ${isCloud ? 'Cloud' : ''}` : ''; + +const noEvalStr = (ctrl: CevalCtrl) => + !ctrl.allowed() + ? 'local evaluation not allowed' + : !ctrl.possible + ? 'local evaluation not possible' + : !ctrl.enabled() + ? 'local evaluation not enabled' + : ''; + +function renderBestMove({ ctrl, moveStyle }: NvuiContext): string { + const noEvalMsg = noEvalStr(ctrl.ceval); + if (noEvalMsg) return noEvalMsg; + const node = ctrl.node, + setup = parseFen(node.fen).unwrap(); + let pvs: Tree.PvData[] = []; + if (ctrl.threatMode() && node.threat) { + pvs = node.threat.pvs; + setup.turn = opposite(setup.turn); + if (setup.turn === 'white') setup.fullmoves += 1; + } else if (node.ceval) pvs = node.ceval.pvs; + const pos = setupPosition(lichessRules(ctrl.ceval.opts.variant.key), setup); + if (pos.isOk && pvs.length > 0 && pvs[0].moves.length > 0) { + const uci = pvs[0].moves[0]; + const san = makeSan(pos.unwrap(), parseUci(uci)!); + return renderSan(san, uci, moveStyle.get()); + } + return ''; +} + +function renderAriaResult(ctrl: AnalyseCtrl): VNode[] { + const result = renderResult(ctrl); + const res = result.length ? result : 'No result'; + return [ + hl('h3', 'Game status'), + hl('div', { attrs: { role: 'status', 'aria-live': 'assertive', 'aria-atomic': 'true' } }, res), + ]; +} + +function renderCurrentLine({ ctrl, moveStyle }: NvuiContext) { + if (ctrl.path.length === 0) return renderMainline(ctrl.mainline, ctrl.path, moveStyle.get(), !ctrl.retro); + else { + const futureNodes = ctrl.node.children.length > 0 ? ops.mainlineNodeList(ctrl.node.children[0]) : []; + return renderMainline(ctrl.nodeList.concat(futureNodes), ctrl.path, moveStyle.get(), !ctrl.retro); + } +} + +function onSubmit(ctx: NvuiContext, $input: Cash) { + const { ctrl, notify } = ctx; + return (e: SubmitEvent) => { + e.preventDefault(); + const input = castlingFlavours(($input.val() as string).trim()); + // Allow commands with/without a leading '/' + const command = getCommand(input) || getCommand(input.slice(1)); + if (command && !command.invalid?.(ctrl)) command.cb(ctx, input); + else { + const move = inputToMove(input, ctrl.node.fen, ctrl.chessground); + const isDrop = (u: undefined | string | DropMove) => !!(u && typeof u !== 'string'); + const isInvalidDrop = (d: DropMove) => + !ctrl.crazyValid(d.role, d.key) || ctrl.chessground.state.pieces.has(d.key); + const isInvalidCrazy = isDrop(move) && isInvalidDrop(move); + + if (!move || isInvalidCrazy) notify.set(`Invalid move: ${input}`); + else sendMove(move, ctrl); + } + $input.val(''); + }; +} + +type Command = 'p' | 's' | 'eval' | 'best' | 'prev' | 'next' | 'prev line' | 'next line' | 'pocket'; +type InputCommand = { + cmd: Command; + help: VNode | string; + cb: (ctrl: NvuiContext, input: string) => void; + invalid?: (ctrl: AnalyseCtrl) => boolean; +}; + +const inputCommands: InputCommand[] = [ + { + cmd: 'p', + help: commands().piece.help, + cb: ({ ctrl, notify, moveStyle }, input) => + notify.set( + commands().piece.apply(input, ctrl.chessground.state.pieces, moveStyle.get()) || + `Bad input: ${input}. Exptected format: ${commands().piece.help}`, + ), + }, + { + cmd: 's', + help: commands().scan.help, + cb: ({ ctrl, notify, moveStyle }, input) => + notify.set( + commands().scan.apply(input, ctrl.chessground.state.pieces, moveStyle.get()) || + `Bad input: ${input}. Exptected format: ${commands().scan.help}`, + ), + }, + { + cmd: 'eval', + help: noTrans("announce last move's computer evaluation"), + cb: ({ ctrl, notify }) => notify.set(renderEvalAndDepth(ctrl)), + }, + { + cmd: 'best', + help: noTrans('announce the top engine move'), + cb: ctx => ctx.notify.set(renderBestMove(ctx)), + }, + { + cmd: 'prev', + help: noTrans('return to the previous move'), + cb: ({ ctrl }) => doAndRedraw(ctrl, prev), + }, + { cmd: 'next', help: noTrans('go to the next move'), cb: ({ ctrl }) => doAndRedraw(ctrl, next) }, + { + cmd: 'prev line', + help: noTrans('switch to the previous variation'), + cb: ({ ctrl }) => doAndRedraw(ctrl, jumpPrevLine), + }, + { + cmd: 'next line', + help: noTrans('switch to the next variation'), + cb: ({ ctrl }) => doAndRedraw(ctrl, jumpNextLine), + }, + { + cmd: 'pocket', + help: noTrans('Read out pockets for white or black. Example: "pocket black"'), + cb: ({ ctrl, notify }, input) => { + const pockets = ctrl.node.crazy?.pockets; + const color = input.split(' ')?.[1]?.trim(); + return notify.set( + pockets + ? color + ? pocketsStr(color === 'white' ? pockets[0] : pockets[1]) || i18n.site.none + : 'Expected format: pocket [white|black]' + : 'Command only available in crazyhouse', + ); + }, + invalid: ctrl => ctrl.data.game.variant.key !== 'crazyhouse', + }, +]; + +const getCommand = (input: string) => { + const split = input.split(' '); + const firstWordLowerCase = split[0].toLowerCase(); + return ( + inputCommands.find(c => c.cmd === input) || + inputCommands.find(c => split.length !== 1 && c.cmd === firstWordLowerCase) + ); // 'next line' should not be interpreted as 'next' +}; + +function sendMove(uciOrDrop: string | DropMove, ctrl: AnalyseCtrl) { + if (typeof uciOrDrop === 'string') + ctrl.sendMove( + uciOrDrop.slice(0, 2) as Key, + uciOrDrop.slice(2, 4) as Key, + undefined, + charToRole(uciOrDrop.slice(4)), + ); + else if (ctrl.crazyValid(uciOrDrop.role, uciOrDrop.key)) ctrl.sendNewPiece(uciOrDrop.role, uciOrDrop.key); +} + +function renderAcpl({ ctrl, moveStyle }: NvuiContext): LooseVNodes { + const analysis = ctrl.data.analysis; + if (!analysis || ctrl.retro) return undefined; + const analysisGlyphs = ['?!', '?', '??']; + const analysisNodes = ctrl.mainline.filter(n => n.glyphs?.find(g => analysisGlyphs.includes(g.symbol))); + const res: Array = []; + ['white', 'black'].forEach((color: Color) => { + res.push(hl('h3', `${color} player: ${analysis[color].acpl} ${i18n.site.averageCentipawnLoss}`)); + res.push( + hl( + 'select', + { + hook: bind( + 'change', + e => ctrl.jumpToMain(parseInt((e.target as HTMLSelectElement).value)), + ctrl.redraw, + ), + }, + analysisNodes + .filter(n => (n.ply % 2 === 1) === (color === 'white')) + .map(node => + hl( + 'option', + { attrs: { value: node.ply, selected: node.ply === ctrl.node.ply } }, + [ + plyToTurn(node.ply), + renderSan(node.san!, node.uci, moveStyle.get()), + renderComments(node, moveStyle.get()), + ].join(' '), + ), + ), + ), + ); + }); + return res; +} + +const requestAnalysisBtn = ({ ctrl, notify, analysisInProgress }: NvuiContext) => { + if (ctrl.ongoing || ctrl.synthetic || ctrl.hasFullComputerAnalysis()) return; + return analysisInProgress() + ? hl('p', 'Server-side analysis in progress') + : hl( + 'button.request-analysis', + clickHook(() => + xhrText(`/${ctrl.data.game.id}/request-analysis`, { method: 'post' }).then( + () => { + analysisInProgress(true); + notify.set('Server-side analysis in progress'); + }, + () => notify.set('Cannot run server-side analysis'), + ), + ), + i18n.site.requestAComputerAnalysis, + ); +}; + +function currentLineIndex(ctrl: AnalyseCtrl): { i: number; of: number } { + if (ctrl.path === treePath.root) return { i: 1, of: 1 }; + const prevNode = ctrl.tree.nodeAtPath(treePath.init(ctrl.path)); + return { + i: prevNode.children.findIndex(node => node.id === ctrl.node.id), + of: prevNode.children.length, + }; +} + +function renderLineIndex(ctrl: AnalyseCtrl): string { + const { i, of } = currentLineIndex(ctrl); + return of > 1 ? `, line ${i + 1} of ${of} ,` : ''; +} + +function renderCurrentNode({ ctrl, moveStyle }: NvuiContext): string { + const node = ctrl.node; + if (!node.san || !node.uci) return 'Initial position'; + return [ + plyToTurn(node.ply), + renderSan(node.san, node.uci, moveStyle.get()), + renderLineIndex(ctrl), + !ctrl.retro && renderComments(node, moveStyle.get()), + ] + .filter(x => x) + .join(' ') + .trim(); +} + +const renderPlayer = (ctrl: AnalyseCtrl, player: Player): LooseVNodes => + player.ai ? i18n.site.aiNameLevelAiLevel('Stockfish', player.ai) : userHtml(ctrl, player); + +function userHtml(ctrl: AnalyseCtrl, player: Player) { + const d = ctrl.data, + user = player.user, + perf = user ? user.perfs[d.game.perf] : null, + rating = player.rating ? player.rating : perf && perf.rating, + rd = player.ratingDiff, + ratingDiff = rd ? (rd > 0 ? '+' + rd : rd < 0 ? '−' + -rd : '') : ''; + const studyPlayers = ctrl.study && renderStudyPlayer(ctrl, player.color); + return user + ? hl('span', [ + hl( + 'a', + { attrs: { href: '/@/' + user.username } }, + user.title ? `${user.title} ${user.username}` : user.username, + ), + rating ? ` ${rating}` : ``, + ' ' + ratingDiff, + ]) + : studyPlayers || hl('span', i18n.site.anonymous); +} + +function renderStudyPlayer(ctrl: AnalyseCtrl, color: Color): VNode | undefined { + const player = ctrl.study?.currentChapter().players?.[color]; + const keys = [ + ['name', i18n.site.name], + ['title', 'title'], + ['rating', i18n.site.rating], + ['fed', 'fed'], + ['team', 'team'], + ] as const; + return ( + player && + hl( + 'span', + keys + .reduce< + string[] + >((strs, [key, i18n]) => (player[key] ? strs.concat(`${i18n}: ${key === 'fed' ? player[key].name : player[key]}`) : strs), []) + .join(' '), + ) + ); +} + +const playerByColor = (d: AnalyseData, color: Color): Player => + color === d.player.color ? d.player : d.opponent; + +const jumpNextLine = (ctrl: AnalyseCtrl) => jumpLine(ctrl, 1); +const jumpPrevLine = (ctrl: AnalyseCtrl) => jumpLine(ctrl, -1); + +function jumpLine(ctrl: AnalyseCtrl, delta: number) { + const { i, of } = currentLineIndex(ctrl); + if (of === 1) return; + const newI = (i + delta + of) % of; + const prevPath = treePath.init(ctrl.path); + const prevNode = ctrl.tree.nodeAtPath(prevPath); + const newPath = prevPath + prevNode.children[newI].id; + ctrl.userJumpIfCan(newPath); +} + +const redirectToSelectedHook = bind('change', (e: InputEvent) => { + const target = e.target as HTMLSelectElement; + const selectedOption = target.options[target.selectedIndex]; + const url = selectedOption.getAttribute('url'); + if (url) window.location.href = url; +}); + +function tourDetails({ ctrl, deps }: NvuiContext): VNode[] { + const ctx: RelayViewContext = { ...viewContext(ctrl, deps), allowVideo: false } as RelayViewContext; + const tour = ctx.relay.data.tour; + ctx.relay.redraw = ctrl.redraw; + + return [ + hl('h1', 'Tour details'), + hl('h2', 'Overview'), + hl('div', tourOverview(tour.info, tour.dates)), + hl('h2', 'Players'), + hl( + 'button.tournament-players', + clickHook(() => ctx.relay.tab('players'), ctrl.redraw), + 'Load player list', + ), + hl('div', ctx.relay.tab() === 'players' && playersView(ctx.relay.players, ctx.relay.data.tour)), + ]; +} + +function studyDetails(ctrl: AnalyseCtrl) { + const study = ctrl.study; + const relayGroups = study?.relay?.data.group; + const relayRounds = study?.relay?.data.rounds; + const tour = study?.relay?.data.tour; + const hash = window.location.hash; + return ( + study && + hl('div.study-details', [ + hl('h2', 'Study details'), + hl('span', `Title: ${study.data.name}. By: ${study.data.ownerId}`), + hl('br'), + relayGroups && + hl( + 'div.relay-groups', + hl('label', [ + 'Current group:', + hl( + 'select', + { + attrs: { autofocus: hash === '#group-select' }, + hook: redirectToSelectedHook, + }, + relayGroups.tours.map(t => + hl( + 'option', + { attrs: { selected: t.id == tour?.id, url: `/broadcast/-/${t.id}#group-select` } }, + t.name, + ), + ), + ), + ]), + ), + tour && + relayRounds && + hl( + 'div.relay-rounds', + hl('label', [ + 'Current round:', + hl( + 'select', + { + attrs: { autofocus: hash === '#round-select' }, + hook: redirectToSelectedHook, + }, + relayRounds.map(r => + hl( + 'option', + { + attrs: { + selected: r.id == study.data.id, + url: `/broadcast/${tour.slug}/${r.slug}/${r.id}#round-select`, + }, + }, + r.name, + ), + ), + ), + ]), + ), + hl('div.chapters', [ + hl('label', [ + 'Current chapter:', + hl( + 'select', + { + attrs: { id: 'chapter-select' }, + hook: bind('change', (e: InputEvent) => { + const target = e.target as HTMLSelectElement; + const selectedOption = target.options[target.selectedIndex]; + const chapterId = selectedOption.getAttribute('chapterId'); + study.setChapter(chapterId!); + }), + }, + study.chapters.list + .all() + .map((ch, i) => + hl( + 'option', + { attrs: { selected: ch.id === study.currentChapter().id, chapterId: ch.id } }, + `${i + 1}. ${ch.name}`, + ), + ), + ), + ]), + study.members.canContribute() + ? hl('div.buttons', [ + hl( + 'button.edit-chapter', + clickHook(() => study.chapters.editForm.toggle(study.currentChapter()), ctrl.redraw), + [ + 'Edit current chapter', + study.chapters.editForm.current() && chapterEditFormView(study.chapters.editForm), + ], + ), + hl( + 'button.create-chapter', + clickHook(() => study.chapters.newForm.toggle(), ctrl.redraw), + [ + 'Add new chapter', + study.chapters.newForm.isOpen() ? chapterNewFormView(study.chapters.newForm) : undefined, + ], + ), + ]) + : undefined, + ]), + ]) + ); +} + +const doAndRedraw = (ctrl: AnalyseCtrl, fn: (ctrl: AnalyseCtrl) => void): void => { + fn(ctrl); + ctrl.redraw(); +}; + +function jumpMoveOrLine(ctrl: AnalyseCtrl) { + return (e: KeyboardEvent) => { + if (e.key === 'A') doAndRedraw(ctrl, e.altKey ? jumpPrevLine : prev); + else if (e.key === 'D') doAndRedraw(ctrl, e.altKey ? jumpNextLine : next); + }; +} diff --git a/ui/lib/src/ceval/view/main.ts b/ui/lib/src/ceval/view/main.ts index b27c140e1ae6f..77f2aeb12c0cb 100644 --- a/ui/lib/src/ceval/view/main.ts +++ b/ui/lib/src/ceval/view/main.ts @@ -220,7 +220,7 @@ export function renderCeval(ctrl: ParentCtrl): VNode[] { const switchButton: VNode | false = !ctrl.mandatoryCeval?.() && - hl('div.switch', { attrs: { title: i18n.site.toggleLocalEvaluation + ' (L)' } }, [ + hl('div.switch', { attrs: { role: 'button', title: i18n.site.toggleLocalEvaluation + ' (L)' } }, [ hl('input#analyse-toggle-ceval.cmn-toggle.cmn-toggle--subtle', { attrs: { type: 'checkbox', checked: enabled, disabled: !ceval.analysable }, hook: onInsert((el: HTMLInputElement) => { @@ -232,11 +232,15 @@ export function renderCeval(ctrl: ParentCtrl): VNode[] { ]); const settingsGear = hl('button.settings-gear', { - attrs: { 'data-icon': licon.Gear, title: 'Engine settings' }, + attrs: { role: 'button', 'data-icon': licon.Gear, title: 'Engine settings' }, class: { active: ctrl.getCeval().showEnginePrefs() }, // must use ctrl.getCeval() rather than ceval here hook: bind( 'click', - () => ctrl.getCeval().showEnginePrefs.toggle(), // must use ctrl.getCeval() rather than ceval here + () => { + ctrl.getCeval().showEnginePrefs.toggle(); // must use ctrl.getCeval() rather than ceval here + if (ctrl.getCeval().showEnginePrefs()) + setTimeout(() => document.querySelector('.select-engine')?.focus()); // nvui + }, () => ctrl.getCeval().opts.redraw(), // must use ctrl.getCeval() rather than ceval here false, ), diff --git a/ui/lib/src/ceval/view/settings.ts b/ui/lib/src/ceval/view/settings.ts index efb145f4e7e59..0a8b7e303edc7 100644 --- a/ui/lib/src/ceval/view/settings.ts +++ b/ui/lib/src/ceval/view/settings.ts @@ -9,17 +9,7 @@ import { onClickAway } from '../../common'; import { clamp } from '../../algo'; import { confirm } from '../../view/dialogs'; -const allSearchTicks: [number, string][] = [ - [4000, '4s'], - [6000, '6s'], - [8000, '8s'], - [10000, '10s'], - [12000, '12s'], - [15000, '15s'], - [20000, '20s'], - [30000, '30s'], - [Number.POSITIVE_INFINITY, '∞'], -]; +const allSearchTicks = [4, 6, 8, 10, 12, 15, 20, 30, Number.POSITIVE_INFINITY]; const formatHashSize = (v: number): string => (v < 1000 ? v + 'MB' : Math.round(v / 1024) + 'GB'); @@ -28,7 +18,7 @@ export function renderCevalSettings(ctrl: ParentCtrl): VNode | null { minThreads = ceval.engines.active?.minThreads ?? 1, maxThreads = ceval.maxThreads, engCtrl = ctrl.getCeval().engines, - searchTicks = allSearchTicks.filter(x => x[0] <= ceval.engines.maxMovetime); + searchTicks = allSearchTicks.filter(x => x * 1000 <= ceval.engines.maxMovetime); let observer: ResizeObserver; @@ -45,7 +35,7 @@ export function renderCevalSettings(ctrl: ParentCtrl): VNode | null { function searchTick() { const millis = ceval.storedMovetime(); return clamp( - allSearchTicks.findIndex(([tickMs]) => tickMs >= millis), + allSearchTicks.findIndex(tickSecs => tickSecs * 1000 >= millis), { min: 0, max: searchTicks.length - 1 }, ); } @@ -69,15 +59,24 @@ export function renderCevalSettings(ctrl: ParentCtrl): VNode | null { !ceval.customSearch && (id => { return hl('div.setting', { attrs: { title: 'Set time to evaluate fresh positions' } }, [ - hl('label', 'Search time'), + hl('label', { attrs: { for: id } }, 'Search time'), hl('input#' + id, { - attrs: { type: 'range', min: 0, max: searchTicks.length - 1, step: 1 }, + attrs: { + type: 'range', + min: 0, + max: searchTicks.length - 1, + step: 1, + 'aria-valuetext': i18n.site.nbSeconds(searchTicks[searchTick()]), + }, hook: rangeConfig(searchTick, n => { - ceval.storedMovetime(searchTicks[n][0]); + ceval.storedMovetime(searchTicks[n] * 1000); ctrl.restartCeval?.(); }), }), - hl('div.range_value', searchTicks[searchTick()][1]), + hl( + 'div.range_value', + isFinite(searchTicks[searchTick()]) ? `${searchTicks[searchTick()]}s` : '∞', + ), ]); })('engine-search-ms'), !ceval.customSearch && @@ -160,6 +159,7 @@ export function renderCevalSettings(ctrl: ParentCtrl): VNode | null { max: Math.floor(Math.log2(engCtrl.active?.maxHash ?? 4)), step: 1, disabled: ceval.maxHash <= 16, + 'aria-valuetext': `${ceval.hashSize} megabytes`, }, hook: rangeConfig( () => Math.floor(Math.log2(ceval.hashSize)), diff --git a/ui/lib/src/nvui/chess.ts b/ui/lib/src/nvui/chess.ts index 17ae62e8dec13..25a26f70e6999 100644 --- a/ui/lib/src/nvui/chess.ts +++ b/ui/lib/src/nvui/chess.ts @@ -491,7 +491,12 @@ export function inputToMove(input: string, fen: string, chessground: CgApi): Uci return legalUcis.includes(uci) ? `${uci}${promotion}` : undefined; } -export function renderMainline(nodes: Tree.Node[], currentPath: Tree.Path, style: MoveStyle): VNodeChildren { +export function renderMainline( + nodes: Tree.Node[], + currentPath: Tree.Path, + style: MoveStyle, + withComments = true, +): VNodeChildren { const res: VNodeChildren = []; let path: Tree.Path = ''; nodes.forEach(node => { @@ -502,7 +507,7 @@ export function renderMainline(nodes: Tree.Node[], currentPath: Tree.Path, style renderSan(node.san, node.uci, style), ]; res.push(h('move', { attrs: { p: path }, class: { active: path === currentPath } }, content)); - res.push(renderComments(node, style)); + if (withComments) res.push(renderComments(node, style)); res.push(', '); if (node.ply % 2 === 0) res.push(h('br')); }); diff --git a/ui/lib/src/nvui/notify.ts b/ui/lib/src/nvui/notify.ts index 0332416bca625..179aec77b831f 100644 --- a/ui/lib/src/nvui/notify.ts +++ b/ui/lib/src/nvui/notify.ts @@ -1,5 +1,6 @@ import { h, type VNode } from 'snabbdom'; import { requestIdleCallback } from '../common'; +import { isApple } from '../device'; type Notification = { text: string; @@ -22,6 +23,16 @@ export class Notify { currentText = (): string => this.notification && this.notification.date.getTime() > Date.now() - 3000 ? this.notification.text : ''; - render = (): VNode => - h('div.notify', { attrs: { 'aria-live': 'assertive', 'aria-atomic': 'true' } }, this.currentText()); + render = (): VNode => { + return liveText(this.currentText(), 'assertive', 'div.notify'); + }; +} + +export function liveText(text: string, live: 'assertive' | 'polite' = 'polite', sel: string = 'p'): VNode { + const liveAction = (vnode: VNode) => setTimeout(() => (vnode.elm!.textContent = text), 50); + return h(sel, { + key: text, + attrs: isApple() ? { role: 'alert' } : { 'aria-live': live, 'aria-atomic': 'true' }, + hook: { insert: liveAction }, + }); } diff --git a/ui/lib/src/snabbdom.ts b/ui/lib/src/snabbdom.ts index 421ba0b1d4372..e687284cddd48 100644 --- a/ui/lib/src/snabbdom.ts +++ b/ui/lib/src/snabbdom.ts @@ -2,12 +2,13 @@ import { type VNode, type VNodeData, type VNodeChildElement, + type VNodeChildren, type Hooks, type Attrs, h as snabH, } from 'snabbdom'; -export type { Attrs, VNode }; +export type { Attrs, VNode, VNodeChildren }; export type MaybeVNode = VNode | string | null | undefined; export type MaybeVNodes = MaybeVNode[]; @@ -53,7 +54,7 @@ export const dataIcon = (icon: string): Attrs => ({ export const iconTag = (icon: string): VNode => snabH('i', { attrs: dataIcon(icon) }); -export type LooseVNode = VNode | string | number | undefined | null | boolean; +export type LooseVNode = VNodeChildElement | boolean; export type LooseVNodes = LooseVNode | LooseVNodes[]; // '' may be falsy but it's a valid VNode