Website: 16-bit pixel-art rebuild with a live Three.js office, downloads from Gitea v1.5.1
CI / Typecheck, lint, tests, e2e (push) Failing after 3m51s

- Title screen over a real-time voxel office rendered at low resolution and quantized to a rotating 32-colour palette with Bayer dithering; day part and season re-light the scene and every surface.
- 16-bit UI kit: name-plate dialogs, menu cursor, inventory, XP bar, badges, NPC speech, GAME OVER with CONTINUE, hand-placed 12x12 icons, Fusion Pixel font (Latin subset preloaded).
- Sections as a game: quest log (onboarding), character select (AI CEO / AI Co-Founder), inventory (features), party roster (providers), live download XP, save point (all v1.5.1 builds), FAQ as NPC dialog, credits.
- Downloads and source point to the Gitea release; the download function only redirects to git.zacharyzhang.com.
- Tailwind and the old components removed; unit tests for platform/downloads, rewritten e2e suite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-09 23:37:23 -04:00
1 parent e374bc1e8e
commit 2d7af224dd
112 files changed
+5692 -4223

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
import type { ReactNode } from "react";
import styles from "./pixel.module.css";
export type BadgeTone = "default" | "accent" | "mint" | "danger";
/** A small notched tag, like a status chip on a game card. */
export function Badge({ children, tone = "default" }: { children: ReactNode; tone?: BadgeTone }) {
return <span className={styles.badge} data-tone={tone}>{children}</span>;
}
+26
View File
@@ -0,0 +1,26 @@
import { useId, type ReactNode } from "react";
import styles from "./pixel.module.css";
export type Tone = "default" | "accent" | "danger";
/** A framed pixel window; a section's title is its heading, worn as a name-plate tab on the top edge. */
export function Dialog({ title, children, tone = "default", as: Tag = "section" }: {
title?: ReactNode;
children: ReactNode;
tone?: Tone;
as?: "section" | "div";
}) {
const titleId = useId();
const Plate = Tag === "section" ? "h2" : "div";
return (
<Tag
className={`${styles.frame} ${styles.dialog}`}
data-tone={tone}
data-titled={title ? "" : undefined}
aria-labelledby={title && Tag === "section" ? titleId : undefined}
>
{title ? <Plate id={titleId} className={styles.plate}>{title}</Plate> : null}
{children}
</Tag>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { useId, type ReactNode } from "react";
import { Menu } from "./menu-list";
import styles from "./pixel.module.css";
/** A failure as a game-over screen; CONTINUE? offers YES and NO, and both continue. */
export function GameOver({ title = "GAME OVER", message, onContinue, continueHref }: {
title?: string;
message: ReactNode;
onContinue?: () => void;
continueHref?: string;
}) {
const titleId = useId();
const href = continueHref ?? (onContinue ? undefined : "/");
return (
<section className={`${styles.frame} ${styles.gameOver}`} data-tone="danger" aria-labelledby={titleId}>
<h1 id={titleId} className={styles.gameOverTitle}>{title}</h1>
<div className={styles.gameOverMessage}>{message}</div>
<div className={styles.gameOverPrompt}>
<span>CONTINUE?</span>
<Menu
label="Continue?"
orientation="horizontal"
items={[
{ label: "YES", href, onSelect: onContinue },
{ label: "NO", hint: "(the hero continues anyway)", href, onSelect: onContinue },
]}
/>
</div>
</section>
);
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
import type { CSSProperties } from "react";
import type { DayPart, Season } from "../../lib/palette";
import { Icon, type IconName } from "./icon";
import { usePaletteClock } from "./palette-context";
import styles from "./pixel.module.css";
const PARTS: DayPart[] = ["dawn", "day", "dusk", "night"];
const PART_ICON: Record<DayPart, IconName> = { dawn: "sunrise", day: "sun", dusk: "sunset", night: "moon" };
const SEASON_ICON: Record<Season, IconName> = { spring: "blossom", summer: "icecream", autumn: "maple", winter: "snowflake" };
/** The HUD: today's light and season plus the live palette; clicking skips to the next part of the day. */
export function Hud() {
const { dayPart, season, colours, setOverride } = usePaletteClock();
const next = PARTS[(PARTS.indexOf(dayPart) + 1) % PARTS.length]!;
return (
<button
type="button"
className={`${styles.frame} ${styles.hud}`}
onClick={() => setOverride(next, season)}
aria-label={`Light: ${dayPart}, ${season}. Switch to ${next}`}
>
<span className={styles.hudGlyphs}>
<Icon name={PART_ICON[dayPart]} size={2} />
<Icon name={SEASON_ICON[season]} size={2} />
</span>
<span className={styles.hudText}>
<span>{dayPart.toUpperCase()}</span>
<span className={styles.swatches} aria-hidden="true">
{colours.map((_, index) => (
<i key={index} style={{ "--swatch": `var(--p${index})` } as CSSProperties} />
))}
</span>
</span>
</button>
);
}
+33
View File
@@ -0,0 +1,33 @@
import type { CSSProperties } from "react";
import { PIXEL_ICONS } from "../../lib/pixel-icons";
import styles from "./pixel.module.css";
export type IconName = keyof typeof PIXEL_ICONS;
/** Each row's lit runs become one path, so an icon is a single crisp shape. */
function bitmapPath(bitmap: string): string {
return bitmap.split(" ").flatMap((row, y) =>
[...row.matchAll(/#+/g)].map((run) => `M${run.index} ${y}h${run[0].length}v1h-${run[0].length}z`),
).join("");
}
const PATHS = Object.fromEntries(
Object.entries(PIXEL_ICONS).map(([name, bitmap]) => [name, bitmapPath(bitmap)]),
) as Record<IconName, string>;
/** A hand-placed 12×12 icon at a whole-pixel scale; labelled icons are images, unlabelled ones are decoration. */
export function Icon({ name, size = 1, label }: { name: IconName; size?: 1 | 2 | 3 | 4; label?: string }) {
return (
<svg
viewBox="0 0 12 12"
className={styles.icon}
style={{ "--icon-scale": size } as CSSProperties}
fill="currentColor"
shapeRendering="crispEdges"
focusable="false"
{...(label ? { role: "img", "aria-label": label } : { "aria-hidden": true })}
>
<path d={PATHS[name]} />
</svg>
);
}
+12
View File
@@ -0,0 +1,12 @@
export { PaletteClock } from "./palette-clock";
export { usePaletteClock, type PaletteClockValue } from "./palette-context";
export { Dialog, type Tone } from "./dialog";
export { NpcSay } from "./npc-say";
export { MenuList, type MenuItem } from "./menu-list";
export { PixelButton } from "./pixel-button";
export { XpBar } from "./xp-bar";
export { Badge, type BadgeTone } from "./badge";
export { Inventory, type InventoryItem } from "./inventory";
export { Icon, type IconName } from "./icon";
export { GameOver } from "./game-over";
export { Hud } from "./hud";
+69
View File
@@ -0,0 +1,69 @@
"use client";
import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import { Icon, type IconName } from "./icon";
import styles from "./pixel.module.css";
export type InventoryItem = { icon: IconName; name: string; description: ReactNode };
/** An RPG item menu: a grid of slots, arrow keys move the selection, the box below describes the chosen item. */
export function Inventory({ items }: { items: InventoryItem[] }) {
const [selected, setSelected] = useState(0);
const refs = useRef<(HTMLLIElement | null)[]>([]);
const infoId = useId();
const item = items[selected]!;
const select = (index: number) => {
const next = Math.max(0, Math.min(items.length - 1, index));
setSelected(next);
refs.current[next]?.focus();
};
const columns = () => {
const tops = refs.current.map((slot) => slot?.offsetTop);
return tops.filter((top) => top === tops[0]).length;
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "ArrowRight") select(selected + 1);
else if (event.key === "ArrowLeft") select(selected - 1);
else if (event.key === "ArrowDown") select(selected + columns());
else if (event.key === "ArrowUp") select(selected - columns());
else if (event.key === "Home") select(0);
else if (event.key === "End") select(items.length - 1);
else return;
event.preventDefault();
};
return (
<div className={styles.inventory}>
<ul role="listbox" aria-label="Inventory" className={styles.slots} onKeyDown={onKeyDown}>
{items.map((entry, index) => (
<li
key={entry.name}
ref={(slot) => {
refs.current[index] = slot;
}}
role="option"
aria-selected={index === selected}
aria-describedby={index === selected ? infoId : undefined}
tabIndex={index === selected ? 0 : -1}
className={styles.slot}
onClick={() => select(index)}
onFocus={() => setSelected(index)}
>
<Icon name={entry.icon} size={3} />
<span className={styles.visuallyHidden}>{entry.name}</span>
</li>
))}
</ul>
<div id={infoId} className={`${styles.frame} ${styles.itemInfo}`} aria-live="polite">
<p className={styles.itemName}>
<Icon name={item.icon} />
{item.name}
</p>
<p>{item.description}</p>
</div>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
"use client";
import { useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import styles from "./pixel.module.css";
export type MenuItem = { label: ReactNode; href?: string; onSelect?: () => void; hint?: ReactNode };
/** A game menu: one tab stop, arrow keys move the blinking ▶ cursor, Enter picks. */
export function Menu({ items, label, orientation }: {
items: MenuItem[];
label: string;
orientation: "vertical" | "horizontal";
}) {
const [active, setActive] = useState(0);
const refs = useRef<(HTMLElement | null)[]>([]);
const moveTo = (index: number) => {
const next = (index + items.length) % items.length;
setActive(next);
refs.current[next]?.focus();
};
const onKeyDown = (event: KeyboardEvent) => {
const vertical = orientation === "vertical";
if (event.key === (vertical ? "ArrowDown" : "ArrowRight")) moveTo(active + 1);
else if (event.key === (vertical ? "ArrowUp" : "ArrowLeft")) moveTo(active - 1);
else if (event.key === "Home") moveTo(0);
else if (event.key === "End") moveTo(items.length - 1);
else return;
event.preventDefault();
};
return (
<ul role="menu" aria-label={label} aria-orientation={orientation} className={styles.menu} data-orientation={orientation} onKeyDown={onKeyDown}>
{items.map((item, index) => {
const shared = {
role: "menuitem",
tabIndex: index === active ? 0 : -1,
className: styles.menuItem,
"data-active": index === active ? "" : undefined,
ref: (element: HTMLElement | null) => {
refs.current[index] = element;
},
onFocus: () => setActive(index),
onMouseEnter: () => setActive(index),
onClick: item.onSelect,
};
const body = (
<>
<span>{item.label}</span>
{item.hint ? <span className={styles.menuHint}>{item.hint}</span> : null}
</>
);
return (
<li role="none" key={index}>
{item.href ? <a {...shared} href={item.href}>{body}</a> : <button type="button" {...shared}>{body}</button>}
</li>
);
})}
</ul>
);
}
export function MenuList({ items, label }: { items: MenuItem[]; label: string }) {
return <Menu items={items} label={label} orientation="vertical" />;
}
+75
View File
@@ -0,0 +1,75 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Icon, type IconName } from "./icon";
import styles from "./pixel.module.css";
const CHAR_MS = 28;
/** A character's speech box: lines type out, ▼ blinks when one is done, a click or Enter skips ahead. */
export function NpcSay({ speaker, portrait = "bot", lines, typewriter = true }: {
speaker: string;
portrait?: IconName;
lines: string[];
typewriter?: boolean;
}) {
const boxRef = useRef<HTMLElement>(null);
const [line, setLine] = useState(0);
const [shown, setShown] = useState(0);
const [reduced, setReduced] = useState(false);
const [onScreen, setOnScreen] = useState(false);
const text = lines[line] ?? "";
const count = typewriter && !reduced ? Math.min(shown, text.length) : text.length;
const done = count === text.length;
const last = line === lines.length - 1;
useEffect(() => {
if (!typewriter) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return setReduced(true);
const observer = new IntersectionObserver(([entry]) => {
if (!entry?.isIntersecting) return;
setOnScreen(true);
observer.disconnect();
});
observer.observe(boxRef.current!);
return () => observer.disconnect();
}, [typewriter]);
useEffect(() => {
if (!onScreen || done) return;
const timer = window.setTimeout(() => setShown((value) => value + 1), CHAR_MS);
return () => window.clearTimeout(timer);
}, [onScreen, done, shown]);
const advance = () => {
if (!done) return setShown(text.length);
setLine(last ? 0 : line + 1);
setShown(0);
};
return (
<figure ref={boxRef} className={`${styles.frame} ${styles.npc}`} onClick={advance}>
<figcaption className={styles.plate}>{speaker}</figcaption>
<div className={styles.portrait} aria-hidden="true">
<Icon name={portrait} size={3} />
</div>
<blockquote className={styles.npcText}>
<p className={styles.visuallyHidden} aria-live="polite">{text}</p>
<p aria-hidden="true">
{text.slice(0, count)}
<span className={styles.ghost}>{text.slice(count)}</span>
</p>
</blockquote>
<button
type="button"
className={styles.advance}
data-done={done ? "" : undefined}
aria-label={!done ? "Show the whole line" : last ? "Hear it again" : "Next line"}
onClick={(event) => {
event.stopPropagation();
advance();
}}
/>
</figure>
);
}
+20
View File
@@ -0,0 +1,20 @@
import type { DayPart, Season } from "../../lib/palette";
/** Self-contained (no outer references) so the boot script can inline it with Function#toString. */
export function overrideFrom(search: string): { part: DayPart | null; season: Season | null } {
const query = new URLSearchParams(search);
const part = query.get("time");
const season = query.get("season");
return {
part: part === "dawn" || part === "day" || part === "dusk" || part === "night" ? part : null,
season: season === "spring" || season === "summer" || season === "autumn" || season === "winter" ? season : null,
};
}
/** Self-contained for the same reason: writes the palette variables and day-part attributes onto <html>. */
export function applyPalette(vars: Record<string, string>, part: DayPart, season: Season): void {
const root = document.documentElement;
for (const name in vars) root.style.setProperty(name, vars[name]!);
root.dataset.daypart = part;
root.dataset.season = season;
}
@@ -0,0 +1,23 @@
import type { ReactNode } from "react";
import { dayPartAt, paletteFor, seasonAt, type DayPart, type Season } from "../../lib/palette";
import { applyPalette, overrideFrom } from "./palette-boot";
import { PaletteClockProvider } from "./palette-context";
const PARTS: DayPart[] = ["dawn", "day", "dusk", "night"];
const SEASONS: Season[] = ["spring", "summer", "autumn", "winter"];
const TABLE = Object.fromEntries(
PARTS.flatMap((part) => SEASONS.map((season) => [`${part}-${season}`, paletteFor(part, season)])),
);
/** Runs before first paint so night visitors never see a flash of the day palette; also snaps --px to whole device pixels. */
const BOOT = `(function(){var t=${JSON.stringify(TABLE)},d=new Date(),o=(${overrideFrom})(location.search),p=o.part||(${dayPartAt})(d),s=o.season||(${seasonAt})(d),v={};t[p+"-"+s].forEach(function(c,i){v["--p"+i]=c});(${applyPalette})(v,p,s);var r=window.devicePixelRatio||1,e=document.documentElement;e.style.setProperty("--px",String(Math.max(1,Math.round(r))/r));e.dataset.js=""})()`;
/** Rotates the 32-colour palette with the visitor's clock (or ?time=&season=) and shares it via usePaletteClock. */
export function PaletteClock({ children }: { children: ReactNode }) {
return (
<>
<script dangerouslySetInnerHTML={{ __html: BOOT }} />
<PaletteClockProvider>{children}</PaletteClockProvider>
</>
);
}
@@ -0,0 +1,50 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { dayPartAt, paletteCssVars, paletteFor, seasonAt, type DayPart, type Season } from "../../lib/palette";
import { applyPalette, overrideFrom } from "./palette-boot";
type Override = { part: DayPart | null; season: Season | null };
export type PaletteClockValue = {
dayPart: DayPart;
season: Season;
colours: string[];
setOverride: (part: DayPart | null, season: Season | null) => void;
};
const PaletteClockContext = createContext<PaletteClockValue | null>(null);
const TICK_MS = 60_000;
export function PaletteClockProvider({ children }: { children: ReactNode }) {
const [now, setNow] = useState<Date | null>(null);
const [override, setOverrideState] = useState<Override>({ part: null, season: null });
useEffect(() => {
setOverrideState(overrideFrom(window.location.search));
setNow(new Date());
const timer = window.setInterval(() => setNow(new Date()), TICK_MS);
return () => window.clearInterval(timer);
}, []);
const dayPart = override.part ?? (now ? dayPartAt(now) : "day");
const season = override.season ?? (now ? seasonAt(now) : "summer");
const colours = useMemo(() => paletteFor(dayPart, season), [dayPart, season]);
useEffect(() => {
if (now) applyPalette(paletteCssVars(colours), dayPart, season);
}, [now, colours, dayPart, season]);
const setOverride = useCallback((part: DayPart | null, nextSeason: Season | null) => {
setOverrideState({ part, season: nextSeason });
}, []);
const value = useMemo(() => ({ dayPart, season, colours, setOverride }), [dayPart, season, colours, setOverride]);
return <PaletteClockContext.Provider value={value}>{children}</PaletteClockContext.Provider>;
}
export function usePaletteClock(): PaletteClockValue {
const value = useContext(PaletteClockContext);
if (!value) throw new Error("usePaletteClock must be used inside <PaletteClock>");
return value;
}
+23
View File
@@ -0,0 +1,23 @@
import type { MouseEventHandler, ReactNode } from "react";
import { Icon, type IconName } from "./icon";
import styles from "./pixel.module.css";
/** A chunky button that sinks into its base when pressed; with href it is a link that looks the part. */
export function PixelButton({ children, href, onClick, variant = "primary", icon }: {
children: ReactNode;
href?: string;
onClick?: MouseEventHandler<HTMLElement>;
variant?: "primary" | "ghost";
icon?: IconName;
}) {
const content = (
<>
{icon ? <Icon name={icon} /> : null}
<span>{children}</span>
</>
);
if (href) {
return <a className={styles.button} data-variant={variant} href={href} onClick={onClick}>{content}</a>;
}
return <button type="button" className={styles.button} data-variant={variant} onClick={onClick}>{content}</button>;
}
+427
View File
@@ -0,0 +1,427 @@
/* All sizes are whole art pixels (--u = 2 screen px at 1×); every colour is a palette role from app/globals.css. */
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.icon {
display: inline-block;
flex-shrink: 0;
width: calc(12px * var(--px) * var(--icon-scale));
height: calc(12px * var(--px) * var(--icon-scale));
vertical-align: calc(-2px * var(--px));
}
/* Window frame: notched outline, a full inner rim, a bottom-right inner shade and a hard drop shadow. */
.frame {
--frame-line: var(--ui-line);
--frame-rim: var(--ui-hi);
position: relative;
color: var(--ui-text);
background: var(--ui-panel);
box-shadow:
0 calc(-1 * var(--u)) 0 0 var(--frame-line),
0 var(--u) 0 0 var(--frame-line),
calc(-1 * var(--u)) 0 0 0 var(--frame-line),
var(--u) 0 0 0 var(--frame-line),
inset 0 0 0 var(--u) var(--frame-rim),
inset calc(-2 * var(--u)) calc(-2 * var(--u)) 0 0 var(--ui-lo),
calc(2 * var(--u)) calc(2 * var(--u)) 0 0 var(--ui-drop);
}
.frame[data-tone="accent"] { --frame-rim: var(--ink-gold); }
.frame[data-tone="danger"] { --frame-rim: var(--ink-accent); }
.plate {
--plate-bg: var(--ui-line);
--plate-fg: var(--ui-on-line);
position: absolute;
top: calc(-6 * var(--u));
left: calc(4 * var(--u));
margin: 0;
padding: var(--u) calc(4 * var(--u));
font-size: var(--fs-1);
line-height: calc(var(--fs-1) * 1.5);
color: var(--plate-fg);
background: var(--plate-bg);
box-shadow:
0 calc(-1 * var(--u)) 0 0 var(--frame-line),
0 var(--u) 0 0 var(--frame-line),
calc(-1 * var(--u)) 0 0 0 var(--frame-line),
var(--u) 0 0 0 var(--frame-line);
}
[data-tone="accent"] > .plate { --plate-bg: var(--ink-gold); --plate-fg: var(--ink-void); }
[data-tone="danger"] > .plate { --plate-bg: var(--ink-accent-dark); --plate-fg: var(--ink-star); }
.dialog {
margin: var(--u);
padding: calc(6 * var(--u));
}
.dialog[data-titled] {
margin-top: calc(7 * var(--u));
padding-top: calc(9 * var(--u));
}
/* Button: a face on a base; hover lifts the face one art pixel, :active sinks it flush with the base. */
.button {
--face: var(--ink-gold);
--face-text: var(--ink-void);
--face-hi: var(--ink-star);
--base: var(--ink-accent-dark);
--d: calc(2 * var(--u));
display: inline-flex;
align-items: center;
justify-content: center;
gap: calc(3 * var(--u));
min-height: calc(18 * var(--u));
margin: var(--u) var(--u) calc(3 * var(--u));
padding: calc(2 * var(--u)) calc(6 * var(--u));
border: 0;
border-radius: 0;
font-size: var(--fs-1);
line-height: 1.5;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
color: var(--face-text);
background: var(--face);
box-shadow:
inset 0 var(--u) 0 0 var(--face-hi),
0 var(--d) 0 0 var(--base),
0 calc(-1 * var(--u)) 0 0 var(--ui-line),
calc(-1 * var(--u)) 0 0 0 var(--ui-line),
var(--u) 0 0 0 var(--ui-line),
calc(-1 * var(--u)) var(--d) 0 0 var(--ui-line),
var(--u) var(--d) 0 0 var(--ui-line),
0 calc(var(--d) + var(--u)) 0 0 var(--ui-line);
}
.button[data-variant="ghost"] {
--face: var(--ui-panel);
--face-text: var(--ui-text);
--face-hi: var(--ui-hi);
--base: var(--ui-lo);
}
.button:hover {
--d: calc(3 * var(--u));
transform: translateY(calc(-1 * var(--u)));
}
.button:active {
--d: 0px;
transform: translateY(calc(2 * var(--u)));
}
.badge {
--badge-bg: var(--ui-line);
--badge-fg: var(--ui-on-line);
display: inline-flex;
align-items: center;
gap: var(--u);
padding: 0 calc(3 * var(--u));
font-size: var(--fs-1);
line-height: calc(var(--fs-1) * 1.5);
white-space: nowrap;
color: var(--badge-fg);
background: var(--badge-bg);
clip-path: polygon(
var(--u) 0, calc(100% - var(--u)) 0, calc(100% - var(--u)) var(--u), 100% var(--u),
100% calc(100% - var(--u)), calc(100% - var(--u)) calc(100% - var(--u)), calc(100% - var(--u)) 100%, var(--u) 100%,
var(--u) calc(100% - var(--u)), 0 calc(100% - var(--u)), 0 var(--u), var(--u) var(--u)
);
}
.badge[data-tone="accent"] { --badge-bg: var(--ink-gold); --badge-fg: var(--ink-void); }
.badge[data-tone="mint"] { --badge-bg: var(--ink-mint); --badge-fg: var(--ink-void); }
.badge[data-tone="danger"] { --badge-bg: var(--ink-accent-dark); --badge-fg: var(--ink-star); }
/* Menu: the ▶ cursor rests on the current item and blinks while the menu is in use. */
.menu {
display: grid;
gap: var(--u);
margin: 0;
padding: 0;
list-style: none;
}
.menu[data-orientation="horizontal"] {
display: flex;
flex-wrap: wrap;
gap: calc(4 * var(--u));
}
.menuItem {
position: relative;
display: flex;
align-items: baseline;
gap: calc(4 * var(--u));
width: 100%;
padding: var(--u) calc(2 * var(--u)) var(--u) calc(7 * var(--u));
border: 0;
background: none;
font: inherit;
text-align: left;
text-decoration: none;
color: inherit;
cursor: pointer;
}
.menuItem::before {
content: "";
position: absolute;
left: calc(2 * var(--u));
top: calc(50% - 2.5 * var(--u));
width: calc(3 * var(--u));
height: calc(5 * var(--u));
background: currentColor;
clip-path: polygon(
0 0, var(--u) 0, var(--u) var(--u), calc(2 * var(--u)) var(--u), calc(2 * var(--u)) calc(2 * var(--u)),
calc(3 * var(--u)) calc(2 * var(--u)), calc(3 * var(--u)) calc(3 * var(--u)), calc(2 * var(--u)) calc(3 * var(--u)),
calc(2 * var(--u)) calc(4 * var(--u)), var(--u) calc(4 * var(--u)), var(--u) calc(5 * var(--u)), 0 calc(5 * var(--u))
);
visibility: hidden;
}
.menuItem[data-active]::before { visibility: visible; }
.menu:is(:hover, :focus-within) .menuItem[data-active]::before {
animation: blink 0.8s steps(1, end) infinite;
}
.menuItem:focus-visible { outline-offset: 0; }
.menuHint {
margin-left: auto;
color: var(--ui-muted);
}
.menu[data-orientation="horizontal"] .menuItem { width: auto; }
.menu[data-orientation="horizontal"] .menuHint { margin-left: 0; opacity: 0; }
.menu[data-orientation="horizontal"] .menuItem[data-active] .menuHint { opacity: 1; }
/* NPC speech box: portrait on the left, name plate on the edge, ▼ bobbing once a line has finished. */
.npc {
display: grid;
grid-template-columns: auto 1fr;
gap: calc(6 * var(--u));
align-items: start;
margin: calc(7 * var(--u)) var(--u) var(--u);
padding: calc(9 * var(--u)) calc(6 * var(--u)) calc(8 * var(--u));
cursor: pointer;
}
.portrait {
display: grid;
place-items: center;
width: calc(24 * var(--u));
height: calc(24 * var(--u));
margin: var(--u);
color: var(--ui-text);
background: var(--ink-sky-2);
box-shadow:
0 calc(-1 * var(--u)) 0 0 var(--ui-line),
0 var(--u) 0 0 var(--ui-line),
calc(-1 * var(--u)) 0 0 0 var(--ui-line),
var(--u) 0 0 0 var(--ui-line),
inset 0 calc(-2 * var(--u)) 0 0 var(--ink-sky-1);
}
.npcText {
margin: 0;
min-height: calc(var(--fs-1) * 1.5 * 2);
}
.npcText p { margin: 0; }
:global(:root[data-js]) .ghost { visibility: hidden; }
.advance {
position: absolute;
right: calc(3 * var(--u));
bottom: calc(2 * var(--u));
padding: calc(2 * var(--u));
border: 0;
background: none;
color: var(--ui-muted);
cursor: pointer;
}
.advance::before {
content: "";
display: block;
width: calc(5 * var(--u));
height: calc(3 * var(--u));
background: currentColor;
clip-path: polygon(
0 0, calc(5 * var(--u)) 0, calc(5 * var(--u)) var(--u), calc(4 * var(--u)) var(--u), calc(4 * var(--u)) calc(2 * var(--u)),
calc(3 * var(--u)) calc(2 * var(--u)), calc(3 * var(--u)) calc(3 * var(--u)), calc(2 * var(--u)) calc(3 * var(--u)),
calc(2 * var(--u)) calc(2 * var(--u)), var(--u) calc(2 * var(--u)), var(--u) var(--u), 0 var(--u)
);
}
.advance[data-done] { color: var(--ui-text); }
.advance[data-done]::before { animation: bob 0.7s steps(1, end) infinite; }
/* Inventory: sunken slots in a tray, corner brackets on the chosen one, its description below. */
.inventory {
display: grid;
gap: calc(6 * var(--u));
}
.slots {
display: grid;
grid-template-columns: repeat(auto-fill, calc(24 * var(--u)));
gap: calc(4 * var(--u));
margin: var(--u);
padding: calc(5 * var(--u));
list-style: none;
background: var(--ui-panel);
box-shadow:
0 calc(-1 * var(--u)) 0 0 var(--ui-line),
0 var(--u) 0 0 var(--ui-line),
calc(-1 * var(--u)) 0 0 0 var(--ui-line),
var(--u) 0 0 0 var(--ui-line);
}
.slot {
position: relative;
display: grid;
place-items: center;
width: calc(24 * var(--u));
height: calc(24 * var(--u));
color: var(--ui-text);
background: var(--ui-ground);
box-shadow:
inset var(--u) var(--u) 0 0 var(--ui-lo),
inset calc(-1 * var(--u)) calc(-1 * var(--u)) 0 0 var(--ui-hi);
cursor: pointer;
}
.slot:focus-visible { outline: none; }
.slot[aria-selected="true"]::after {
--c: var(--ui-select);
--l: calc(4 * var(--u));
content: "";
position: absolute;
inset: calc(-2 * var(--u));
pointer-events: none;
background:
linear-gradient(var(--c) 0 0) 0 0 / var(--l) var(--u),
linear-gradient(var(--c) 0 0) 0 0 / var(--u) var(--l),
linear-gradient(var(--c) 0 0) 100% 0 / var(--l) var(--u),
linear-gradient(var(--c) 0 0) 100% 0 / var(--u) var(--l),
linear-gradient(var(--c) 0 0) 0 100% / var(--l) var(--u),
linear-gradient(var(--c) 0 0) 0 100% / var(--u) var(--l),
linear-gradient(var(--c) 0 0) 100% 100% / var(--l) var(--u),
linear-gradient(var(--c) 0 0) 100% 100% / var(--u) var(--l);
background-repeat: no-repeat;
}
.slot[aria-selected="true"]:focus-visible::after {
--c: var(--ui-focus);
animation: pulse 0.8s steps(1, end) infinite;
}
.itemInfo {
margin: var(--u);
padding: calc(6 * var(--u));
min-height: calc(var(--fs-1) * 1.5 * 3 + 12 * var(--u));
}
.itemInfo p { margin: 0; }
.itemName {
display: flex;
align-items: center;
gap: calc(3 * var(--u));
margin-bottom: calc(2 * var(--u)) !important;
color: var(--ui-muted);
}
/* XP bar: twenty blocks that light one after another the first time the bar is seen. */
.xp {
display: grid;
gap: calc(2 * var(--u));
width: max-content;
max-width: 100%;
}
.xpHead {
display: flex;
justify-content: space-between;
gap: calc(6 * var(--u));
}
.xpNumbers { color: var(--ui-muted); }
.xpTrack {
display: grid;
grid-template-columns: repeat(20, calc(4 * var(--u)));
gap: var(--u);
height: calc(7 * var(--u));
margin: 0 var(--u);
padding: var(--u);
background: var(--ink-void);
box-shadow:
0 calc(-1 * var(--u)) 0 0 var(--ink-void),
0 var(--u) 0 0 var(--ink-void);
}
.xpSegment { background: var(--ui-xp-off); }
.xpSegment[data-on] {
background: var(--ui-xp);
box-shadow: inset 0 var(--u) 0 0 var(--ink-screen);
animation: xp-tick 1ms steps(1, end) both paused;
animation-delay: calc(var(--i) * 60ms);
}
.xp[data-playing] .xpSegment[data-on] { animation-play-state: running; }
:global(:root:not([data-js])) .xpSegment { animation: none; }
/* Game over: the banner blinks three times, then CONTINUE? waits. */
.gameOver {
display: grid;
justify-items: center;
gap: calc(8 * var(--u));
margin: var(--u);
padding: calc(12 * var(--u)) calc(8 * var(--u)) calc(10 * var(--u));
text-align: center;
}
.gameOverTitle {
margin: 0;
font-size: var(--fs-4);
line-height: 1;
color: var(--ui-danger);
text-shadow: calc(2 * var(--u)) calc(2 * var(--u)) 0 var(--ui-line);
animation: blink 0.5s steps(1, end) 3;
}
.gameOverMessage { max-width: 48ch; }
.gameOverPrompt {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: calc(2 * var(--u)) calc(6 * var(--u));
}
/* HUD: inline where the page places it on phones, pinned to the top-right corner from 600px up. */
.hud {
display: flex;
align-items: center;
gap: calc(4 * var(--u));
padding: calc(3 * var(--u)) calc(5 * var(--u)) calc(4 * var(--u)) calc(4 * var(--u));
border: 0;
cursor: pointer;
}
.hud:active { transform: translateY(var(--u)); }
@media (min-width: 600px) {
.hud {
position: fixed;
top: calc(6 * var(--u));
right: calc(6 * var(--u));
z-index: 50;
}
}
.hudGlyphs {
display: flex;
gap: calc(2 * var(--u));
}
.hudText {
display: grid;
gap: var(--u);
text-align: left;
}
.swatches {
display: grid;
grid-template-columns: repeat(16, var(--u));
grid-auto-rows: var(--u);
box-shadow: 0 0 0 var(--u) var(--ui-line);
}
.swatches i { background: var(--swatch); }
@keyframes blink { 50% { visibility: hidden; } }
@keyframes bob { 50% { transform: translateY(var(--u)); } }
@keyframes pulse { 50% { inset: calc(-1 * var(--u)); } }
@keyframes xp-tick { from { background: var(--ui-xp-off); box-shadow: none; } }
@media (prefers-reduced-motion: reduce) {
:global(:root[data-js]) .ghost { visibility: visible; }
}
+51
View File
@@ -0,0 +1,51 @@
"use client";
import { useEffect, useId, useRef, useState, type CSSProperties } from "react";
import styles from "./pixel.module.css";
const SEGMENTS = 20;
/** A segmented XP bar that ticks up one block at a time the first time it scrolls into view. */
export function XpBar({ value, max, label }: { value: number; max: number; label: string }) {
const ref = useRef<HTMLDivElement>(null);
const [playing, setPlaying] = useState(false);
const labelId = useId();
const current = Math.max(0, Math.min(max, value));
const lit = Math.round((current / max) * SEGMENTS);
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
if (!entry?.isIntersecting) return;
setPlaying(true);
observer.disconnect();
});
observer.observe(ref.current!);
return () => observer.disconnect();
}, []);
return (
<div ref={ref} className={styles.xp} data-playing={playing ? "" : undefined}>
<div className={styles.xpHead}>
<span id={labelId}>{label}</span>
<span className={styles.xpNumbers}>{current}/{max}</span>
</div>
<div
role="progressbar"
aria-labelledby={labelId}
aria-valuemin={0}
aria-valuemax={max}
aria-valuenow={current}
className={styles.xpTrack}
>
{Array.from({ length: SEGMENTS }, (_, index) => (
<span
key={index}
className={styles.xpSegment}
data-on={index < lit ? "" : undefined}
style={{ "--i": index } as CSSProperties}
/>
))}
</div>
</div>
);
}