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
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:
1 parent
e374bc1e8e
commit
2d7af224dd
112 files changed
+5692
-4223
No files matched your search
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user