feat(ui): Erudit blank tiles carry the star (✻) mark #109

Merged
developer merged 4 commits from feature/erudit-blank-star into development 2026-06-22 11:06:22 +00:00
8 changed files with 84 additions and 13 deletions
+4 -1
View File
@@ -109,7 +109,10 @@ dismisses as soon as the lobby is ready. The pure layout and timing live in `lib
## Tiles & board
- **Tiles**: the letter sits in the **top-left** corner (offset a touch more than the
value), the point value bottom-right; blanks show no value.
value), the point value bottom-right; blanks show no value. In **Erudit** the blank is the
"звёздочка" (star) chip: an unplaced blank shows the star (`✻`, U+273B) centred on the rack
tile, and a placed blank carries it in the value corner; the Scrabble variants leave the
blank unmarked (`usesStarBlank` in `lib/variants.ts`).
- **Board zoom** (`Board.svelte`): a two-state zoom (full 15×15 ↔ ~9 cells) by **growing
the board's width** inside a fixed-size viewport (a real layout change → native scroll
that works consistently across browsers; no `transform`, which broke scrolling
+18 -6
View File
@@ -1,12 +1,14 @@
<script lang="ts">
// A best-move word drawn as a row of game tiles, mirroring the board's placed-tile
// look (letter top-left, point value bottom-right) at a small fixed size. A blank tile
// shows its letter but no value, exactly as on the board. Letters are upper-cased for
// display. The tile values ride on each tile, so this renders without the variant's
// alphabet table (which the statistics screen has not cached).
import type { BestMoveTile } from '../lib/model';
// shows its letter but no value, exactly as on the board; in Erudit it also carries the
// blank's star (✻) in the value corner. Letters are upper-cased for display. The tile
// values ride on each tile, so this needs only the variant id (for the star) — not the
// variant's alphabet table, which the statistics screen has not cached.
import type { BestMoveTile, Variant } from '../lib/model';
import { usesStarBlank, BLANK_STAR } from '../lib/variants';
let { word }: { word: BestMoveTile[] } = $props();
let { word, variant }: { word: BestMoveTile[]; variant: Variant } = $props();
const label = $derived(word.map((t) => t.letter).join('').toUpperCase());
</script>
@@ -15,7 +17,11 @@
{#each word as tile, i (i)}
<span class="tile" class:blank={tile.blank} aria-hidden="true">
<span class="letter">{tile.letter.toUpperCase()}</span>
{#if !tile.blank}<span class="val">{tile.value}</span>{/if}
{#if !tile.blank}
<span class="val">{tile.value}</span>
{:else if usesStarBlank(variant)}
<span class="val blankmark">{BLANK_STAR}</span>
{/if}
</span>
{/each}
</span>
@@ -50,4 +56,10 @@
font-size: 7px;
font-weight: 600;
}
/* A placed Erudit blank ("звёздочка") shows its star where the (absent) point value sits,
its ink kept on the value digits' line (mirrors the board tile). */
.blankmark {
font-size: 8px;
bottom: 0;
}
</style>
+12 -1
View File
@@ -4,6 +4,7 @@
import type { Premium } from '../lib/premiums';
import { valueForLetter } from '../lib/alphabet';
import type { Variant } from '../lib/model';
import { usesStarBlank, BLANK_STAR } from '../lib/variants';
import { bonusLabel, type BoardLabelMode } from '../lib/boardlabels';
import type { Locale } from '../lib/i18n/catalog';
@@ -254,7 +255,11 @@
>
{#if letter}
<span class="letter">{letter}</span>
{#if !blank}<span class="val">{valueForLetter(variant, letter)}</span>{/if}
{#if !blank}
<span class="val">{valueForLetter(variant, letter)}</span>
{:else if usesStarBlank(variant)}
<span class="val blankmark">{BLANK_STAR}</span>
{/if}
{:else if r === centre.row && c === centre.col}
<span class="star"></span>
{:else if bl?.kind === 'single'}
@@ -410,6 +415,12 @@
font-size: 2.4cqw;
font-weight: 600;
}
/* A placed Erudit blank ("звёздочка") shows its star where the (absent) point value sits,
its ink centred on the same line as a neighbouring tile's value digit. */
.blankmark {
font-size: 2.8cqw;
bottom: 0;
}
.star {
position: absolute;
inset: 0;
+2 -2
View File
@@ -17,7 +17,7 @@
import { badgeKind } from '../lib/unread';
import { historyGrid } from '../lib/history';
import { centre, premiumGrid } from '../lib/premiums';
import { variantNameKey } from '../lib/variants';
import { variantNameKey, usesStarBlank, BLANK_STAR } from '../lib/variants';
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
import { hintsLeft } from '../lib/hints';
import { shareOrDownloadGcg } from '../lib/share';
@@ -1293,7 +1293,7 @@
{#if drag}
<div class="ghost" class:touch={drag.touch} style="left:{drag.x}px; top:{drag.y}px">
<span>{drag.blank ? '' : drag.letter}</span>
<span>{drag.blank ? (usesStarBlank(variant) ? BLANK_STAR : '') : drag.letter}</span>
</div>
{/if}
+18 -2
View File
@@ -3,6 +3,7 @@
import { BLANK } from '../lib/placement';
import { valueForLetter } from '../lib/alphabet';
import type { Variant } from '../lib/model';
import { usesStarBlank, BLANK_STAR } from '../lib/variants';
let {
slots,
@@ -66,8 +67,12 @@
animate:hop={shuffling}
onpointerdown={(e) => ondown(e, slot.index)}
>
<span class="letter">{slot.letter === BLANK ? '' : slot.letter}</span>
{#if slot.letter !== BLANK}<span class="val">{valueForLetter(variant, slot.letter)}</span>{/if}
{#if slot.letter === BLANK}
{#if usesStarBlank(variant)}<span class="star">{BLANK_STAR}</span>{/if}
{:else}
<span class="letter">{slot.letter}</span>
<span class="val">{valueForLetter(variant, slot.letter)}</span>
{/if}
</button>
{/each}
</div>
@@ -133,4 +138,15 @@
font-size: 0.7rem;
font-weight: 600;
}
/* Erudit's blank ("звёздочка") shows its star horizontally centred on the otherwise empty
tile face; the top offset centres its ink against the neighbouring letters' block, nudged
up a pixel to sit right by eye (it is slightly larger than them so it reads). */
.star {
position: absolute;
top: calc(0.5% - 1px);
left: 0;
right: 0;
text-align: center;
font-size: 1.7rem;
}
</style>
+15
View File
@@ -5,6 +5,8 @@ import {
availableVariants,
supportsMultipleWordsToggle,
multipleWordsForRequest,
usesStarBlank,
BLANK_STAR,
} from './variants';
describe('ALL_VARIANTS', () => {
@@ -53,3 +55,16 @@ describe('multipleWordsForRequest', () => {
expect(multipleWordsForRequest('scrabble_en', true)).toBe(true);
});
});
describe('usesStarBlank', () => {
it('marks the blank with a star for Erudit only', () => {
expect(usesStarBlank('erudit_ru')).toBe(true);
expect(usesStarBlank('scrabble_ru')).toBe(false);
expect(usesStarBlank('scrabble_en')).toBe(false);
});
it('BLANK_STAR is the heavy teardrop-spoked asterisk (U+273B)', () => {
expect(BLANK_STAR).toBe('✻');
expect(BLANK_STAR.codePointAt(0)).toBe(0x273b);
});
});
+14
View File
@@ -48,6 +48,20 @@ export const VARIANT_FLAG: Record<Variant, string> = {
// ru -> Russian + Эрудит.
export const VARIANT_LANGUAGE: Record<Variant, 'en' | 'ru'> = { scrabble_en: 'en', scrabble_ru: 'ru', erudit_ru: 'ru' };
// BLANK_STAR is the glyph drawn on an Эрудит blank tile: the variant's blank is the
// "звёздочка" (star) chip, so it carries a star rather than a bare face. U+273B HEAVY
// TEARDROP-SPOKED ASTERISK.
export const BLANK_STAR = '✻';
// usesStarBlank reports whether a variant marks its blank tiles with BLANK_STAR. Only
// Эрудит does: an empty rack blank shows the star centred, and a placed blank carries it
// in the value corner (the corner is free — a blank has no point value). The Scrabble
// variants leave the blank unmarked (an empty rack face; a placed blank shown by its
// designated letter alone).
export function usesStarBlank(v: Variant): boolean {
return v === 'erudit_ru';
}
// availableVariants gates ALL_VARIANTS by the player's variant preferences (the set
// they enabled in Settings). An empty or absent set is ungated (returns every variant)
// — a safety fallback; a real profile always carries at least one preference.
+1 -1
View File
@@ -73,7 +73,7 @@
{#each bestMoves as bm (bm.variant)}
<span class="variant">{t(variantNameKey(bm.variant))}</span>
<span class="score">{bm.score}</span>
<span class="wordcell"><WordTiles word={bm.word} /></span>
<span class="wordcell"><WordTiles word={bm.word} variant={bm.variant} /></span>
{/each}
</div>
</div>