fix(02-06,G3): brighten tile outline and hover state — close dim-grid gap

Brightens OUTLINE_COLOR 0x4d4d52 → 0x5a5a60 and OUTLINE_HOVER
0x6e6e75 → 0x7a7a82, plus adds a subtle HOVER_FILL_ALPHA=0.06 fill bump
on the hit rectangle. Closes G3 first-impression UX gap from 2026-05-09
live UAT — the 4×4 grid now reads as legible interactive surfaces
against the #1a1a1a canvas background.

The hover state is pointer-driven steady-state (color + fill alpha
swap), not animation — reduced-motion-safe per Phase 8 ownership of
global motion preferences. Phase 3 watercolor deferral preserved: this
is color values + a fill alpha; no painted assets, no new sprites.

Constants OUTLINE_COLOR / OUTLINE_HOVER are exported so the test pins
the brightened values directly. drawTiles function signature unchanged —
Garden.ts continues to work without modification.

Vitest: 5 new cases green via Phaser-Scene-mock pattern (vi.mock('phaser')
short-circuits the Phaser 4 / happy-dom checkInverseAlpha boot crash;
the mocked scene's add.graphics / add.rectangle capture the call args).
npm run ci exits 0; 329/329 total green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 12:15:11 -04:00
parent c46fc75549
commit ab48c7ef30
2 changed files with 124 additions and 6 deletions
+106
View File
@@ -0,0 +1,106 @@
import { describe, it, expect, vi } from 'vitest';
/**
* G3 (gap closure 02-06) — assert tile-renderer uses the brightened
* outline colors and the hover fill bump.
*
* Phaser cannot import under happy-dom — its boot probe `checkInverseAlpha`
* calls `canvas.getContext('2d')` which returns null and the call into
* `context.fillStyle = '...'` then crashes (Plan 02-02 SUMMARY documents
* this). We mock the `phaser` module entirely so importing tile-renderer.ts
* does not pull the real Phaser bundle.
*
* The rest of the test mocks the Scene API surface that drawTiles uses.
*/
vi.mock('phaser', () => ({
default: {},
// No named exports needed — tile-renderer uses only Phaser types and
// the runtime call surface comes from the mocked scene argument below.
}));
const { drawTiles, OUTLINE_COLOR, OUTLINE_HOVER } = await import('./tile-renderer');
type Scene = Parameters<typeof drawTiles>[0];
describe('tile-renderer (Plan 02-06 G3 closure)', () => {
it('exports OUTLINE_COLOR=0x5a5a60 (brightened from 0x4d4d52)', () => {
expect(OUTLINE_COLOR).toBe(0x5a5a60);
});
it('exports OUTLINE_HOVER=0x7a7a82 (brightened from 0x6e6e75)', () => {
expect(OUTLINE_HOVER).toBe(0x7a7a82);
});
function makeMocks(): {
graphics: { clear: ReturnType<typeof vi.fn>; lineStyle: ReturnType<typeof vi.fn>; strokeRoundedRect: ReturnType<typeof vi.fn> };
rectangle: {
setInteractive: ReturnType<typeof vi.fn>;
on: ReturnType<typeof vi.fn>;
setData: ReturnType<typeof vi.fn>;
setFillStyle: ReturnType<typeof vi.fn>;
};
scene: Scene;
pointerOverHandlers: Array<() => void>;
} {
const graphics = {
clear: vi.fn(),
lineStyle: vi.fn(),
strokeRoundedRect: vi.fn(),
};
const pointerOverHandlers: Array<() => void> = [];
const rectangle = {
setInteractive: vi.fn().mockReturnThis(),
on: vi.fn().mockReturnThis(),
setData: vi.fn().mockReturnThis(),
setFillStyle: vi.fn().mockReturnThis(),
};
rectangle.on.mockImplementation((evt: string, fn: () => void) => {
if (evt === 'pointerover') pointerOverHandlers.push(fn);
return rectangle;
});
const scene = {
add: {
graphics: vi.fn(() => graphics),
rectangle: vi.fn(() => rectangle),
},
} as unknown as Scene;
return { graphics, rectangle, scene, pointerOverHandlers };
}
it('drawTiles creates 16 tile groups with outline graphics + hit rectangles', () => {
const { scene } = makeMocks();
const tiles = drawTiles(scene);
expect(tiles).toHaveLength(16);
expect((scene.add.graphics as ReturnType<typeof vi.fn>)).toHaveBeenCalledTimes(16);
expect((scene.add.rectangle as ReturnType<typeof vi.fn>)).toHaveBeenCalledTimes(16);
});
it('initial draw uses OUTLINE_COLOR (resting state)', () => {
const { graphics, scene } = makeMocks();
drawTiles(scene);
const calls = graphics.lineStyle.mock.calls;
expect(calls.length).toBeGreaterThan(0);
// Every initial call uses OUTLINE_COLOR; assert the first.
expect(calls[0][1]).toBe(OUTLINE_COLOR);
});
it('pointerover handler swaps to OUTLINE_HOVER and adds fill alpha bump', () => {
const { graphics, rectangle, scene, pointerOverHandlers } = makeMocks();
drawTiles(scene);
expect(pointerOverHandlers.length).toBeGreaterThan(0);
// Fire the first tile's pointerover handler.
pointerOverHandlers[0]!();
// After pointerover, the most recent lineStyle call uses OUTLINE_HOVER.
const lineStyleCalls = graphics.lineStyle.mock.calls;
const lastLineCall = lineStyleCalls[lineStyleCalls.length - 1];
expect(lastLineCall[1]).toBe(OUTLINE_HOVER);
// setFillStyle was called with the hover alpha bump (>0, ≤0.1).
const fillCalls = rectangle.setFillStyle.mock.calls;
const fillBumpCall = fillCalls.find((c) => c[1] && c[1] > 0);
expect(fillBumpCall).toBeDefined();
expect(fillBumpCall![1]).toBeGreaterThan(0);
expect(fillBumpCall![1]).toBeLessThanOrEqual(0.1); // sanity: subtle bump, not a flash
});
});
+18 -6
View File
@@ -3,13 +3,18 @@ import { GRID_SIZE } from '../../sim/garden/types';
import { tileTopLeftCanvas, GRID_LAYOUT } from './tile-coords';
/**
* Empty-tile look: faint outlined rounded rectangle with subtle hover.
* Empty-tile look: outlined rounded rectangle with subtle hover.
* Per CONTEXT D-06; Phase 3 paints the watercolor treatment over this
* primitive without changing the function signature.
*
* Plan 02-06 G3 — outline + hover values brightened so the 4×4 grid
* reads as legible interactive surfaces against the #1a1a1a canvas
* background. No painted assets (Phase 3 deferral preserved).
*/
const OUTLINE_COLOR = 0x4d4d52;
const OUTLINE_HOVER = 0x6e6e75;
export const OUTLINE_COLOR = 0x5a5a60; // ← Plan 02-06 G3 (was 0x4d4d52 — too dim against #1a1a1a)
export const OUTLINE_HOVER = 0x7a7a82; // ← Plan 02-06 G3 (was 0x6e6e75 — needed clearer contrast in resting vs hover)
const OUTLINE_ALPHA = 0.6;
const HOVER_FILL_ALPHA = 0.06; // ← Plan 02-06 G3 — slight fill on hover to reinforce the affordance (no animation noise, reduced-motion-safe)
export interface TileGameObjects {
/** Hit-area rectangle (interactive). */
@@ -34,11 +39,18 @@ export function drawTiles(scene: Phaser.Scene): TileGameObjects[] {
const g = scene.add.graphics();
drawOutline(g, tl.x, tl.y, OUTLINE_COLOR);
// Hit rectangle (transparent, interactive).
// Hit rectangle (interactive). Holds a faint hover fill to reinforce
// the click affordance — Plan 02-06 G3.
const hit = scene.add.rectangle(cx, cy, GRID_LAYOUT.tileSize, GRID_LAYOUT.tileSize, 0xffffff, 0);
hit.setInteractive({ useHandCursor: true });
hit.on('pointerover', () => drawOutline(g, tl.x, tl.y, OUTLINE_HOVER));
hit.on('pointerout', () => drawOutline(g, tl.x, tl.y, OUTLINE_COLOR));
hit.on('pointerover', () => {
drawOutline(g, tl.x, tl.y, OUTLINE_HOVER);
hit.setFillStyle(0xffffff, HOVER_FILL_ALPHA); // ← Plan 02-06 G3 — slight fill bump
});
hit.on('pointerout', () => {
drawOutline(g, tl.x, tl.y, OUTLINE_COLOR);
hit.setFillStyle(0xffffff, 0); // ← Plan 02-06 G3 — reset
});
// Tag the hit object with its index for handler dispatch.
hit.setData('tileIdx', i);