10 Commits

Author SHA1 Message Date
Lukas
b39e4923e1 Remove demo combatants and allow empty encounters
All checks were successful
CI / check (push) Successful in 45s
CI / build-image (push) Successful in 28s
Empty encounters are now valid (INV-1 updated). New sessions start
with zero combatants instead of pre-populated Aria/Brak/Cael.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:24:26 +01:00
Lukas
369feb3cc8 Add deploy step to CI workflow to auto-restart container on tag push
All checks were successful
CI / check (push) Successful in 43s
CI / build-image (push) Successful in 17s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:19:34 +01:00
Lukas
51bdb799ae Skip lifecycle scripts in Docker build to avoid missing git
All checks were successful
CI / check (push) Successful in 47s
CI / build-image (push) Successful in 29s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:06:49 +01:00
Lukas
1baddad939 Remove pnpm cache from setup-node to fix Gitea Actions timeout
Some checks failed
CI / check (push) Successful in 47s
CI / build-image (push) Failing after 18s
2026-03-12 09:52:16 +01:00
Lukas
e701e4dd70 Run build-image job on host to access Docker CLI
Some checks failed
CI / check (push) Failing after 14m13s
CI / build-image (push) Has been cancelled
The node:22 container doesn't have Docker installed. Running
on the host label executes directly on the VPS where Docker
is available.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:18:51 +01:00
Lukas
e2b0e7d5ee Exclude .pnpm-store from Biome and add .dockerignore
Some checks failed
CI / check (push) Successful in 10m21s
CI / build-image (push) Failing after 4s
The CI runner's pnpm store lands inside the workspace, causing
Biome to lint/format hundreds of store index JSON files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 00:46:38 +01:00
Lukas
635e9c0705 Add Dockerfile, nginx config, and Gitea Actions CI workflow
Some checks failed
CI / check (push) Failing after 6m20s
CI / build-image (push) Has been skipped
Multi-stage Docker build produces an Nginx container serving the
static SPA. The CI workflow runs pnpm check on every push and
builds/pushes a Docker image on semver tags.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 00:32:12 +01:00
Lukas
582a42e62d Change add creature placeholder to "+ Add combatants"
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:50:09 +01:00
Lukas
fc43f440aa Toggle pin off when clicking pin on already-pinned creature
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:49:00 +01:00
Lukas
1cf30b3622 Add swipe-to-dismiss gesture for mobile stat block drawer
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:48:48 +01:00
12 changed files with 188 additions and 30 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
node_modules
.pnpm-store
dist
coverage
.git
.claude
.specify
specs
docs

49
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,49 @@
name: CI
on:
push:
branches: [main]
tags: ["*"]
pull_request:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: pnpm install --frozen-lockfile
- run: pnpm check
build-image:
if: startsWith(github.ref, 'refs/tags/')
needs: check
runs-on: host
steps:
- uses: actions/checkout@v4
- name: Log in to Gitea registry
run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.bahamut.nitrix.one -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
- name: Build and push
run: |
IMAGE=git.bahamut.nitrix.one/dostulata/initiative
TAG=${GITHUB_REF#refs/tags/}
docker build -t $IMAGE:$TAG -t $IMAGE:latest .
docker push $IMAGE:$TAG
docker push $IMAGE:latest
- name: Deploy
run: |
IMAGE=git.bahamut.nitrix.one/dostulata/initiative
TAG=${GITHUB_REF#refs/tags/}
docker stop initiative || true
docker rm initiative || true
docker run -d --name initiative --restart unless-stopped -p 8080:80 $IMAGE:$TAG

19
Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM node:22-slim AS build
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY packages/domain/package.json packages/domain/
COPY packages/application/package.json packages/application/
COPY apps/web/package.json apps/web/
RUN pnpm install --frozen-lockfile --ignore-scripts
COPY . .
RUN pnpm --filter web build
FROM nginx:alpine
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

View File

@@ -150,7 +150,9 @@ export function App() {
const handlePin = useCallback(() => { const handlePin = useCallback(() => {
if (selectedCreatureId) { if (selectedCreatureId) {
setPinnedCreatureId(selectedCreatureId); setPinnedCreatureId((prev) =>
prev === selectedCreatureId ? null : selectedCreatureId,
);
} }
}, [selectedCreatureId]); }, [selectedCreatureId]);

View File

@@ -226,7 +226,7 @@ export function ActionBar({
value={nameInput} value={nameInput}
onChange={(e) => handleNameChange(e.target.value)} onChange={(e) => handleNameChange(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="Search creatures to add..." placeholder="+ Add combatants"
className="max-w-xs" className="max-w-xs"
/> />
{suggestions.length > 0 && ( {suggestions.length > 0 && (

View File

@@ -4,6 +4,7 @@ import type { ReactNode } from "react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getSourceDisplayName } from "../adapters/bestiary-index-adapter.js"; import { getSourceDisplayName } from "../adapters/bestiary-index-adapter.js";
import type { BulkImportState } from "../hooks/use-bulk-import.js"; import type { BulkImportState } from "../hooks/use-bulk-import.js";
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
import { BulkImportPrompt } from "./bulk-import-prompt.js"; import { BulkImportPrompt } from "./bulk-import-prompt.js";
import { SourceFetchPrompt } from "./source-fetch-prompt.js"; import { SourceFetchPrompt } from "./source-fetch-prompt.js";
import { StatBlock } from "./stat-block.js"; import { StatBlock } from "./stat-block.js";
@@ -176,6 +177,8 @@ function MobileDrawer({
onDismiss: () => void; onDismiss: () => void;
children: ReactNode; children: ReactNode;
}) { }) {
const { offsetX, isSwiping, handlers } = useSwipeToDismiss(onDismiss);
return ( return (
<div className="fixed inset-0 z-50"> <div className="fixed inset-0 z-50">
<button <button
@@ -184,7 +187,13 @@ function MobileDrawer({
onClick={onDismiss} onClick={onDismiss}
aria-label="Close stat block" aria-label="Close stat block"
/> />
<div className="absolute top-0 right-0 bottom-0 w-[85%] max-w-md animate-slide-in-right border-l border-border bg-card shadow-xl"> <div
className={`absolute top-0 right-0 bottom-0 w-[85%] max-w-md border-l border-border bg-card shadow-xl ${isSwiping ? "" : "animate-slide-in-right"}`}
style={
isSwiping ? { transform: `translateX(${offsetX}px)` } : undefined
}
{...handlers}
>
<div className="flex items-center justify-between border-b border-border px-4 py-2"> <div className="flex items-center justify-between border-b border-border px-4 py-2">
<button <button
type="button" type="button"

View File

@@ -22,7 +22,6 @@ import type {
} from "@initiative/domain"; } from "@initiative/domain";
import { import {
combatantId, combatantId,
createEncounter,
isDomainError, isDomainError,
creatureId as makeCreatureId, creatureId as makeCreatureId,
resolveCreatureName, resolveCreatureName,
@@ -33,24 +32,16 @@ import {
saveEncounter, saveEncounter,
} from "../persistence/encounter-storage.js"; } from "../persistence/encounter-storage.js";
function createDemoEncounter(): Encounter { const EMPTY_ENCOUNTER: Encounter = {
const result = createEncounter([ combatants: [],
{ id: combatantId("1"), name: "Aria" }, activeIndex: 0,
{ id: combatantId("2"), name: "Brak" }, roundNumber: 1,
{ id: combatantId("3"), name: "Cael" }, };
]);
if (isDomainError(result)) {
throw new Error(`Failed to create demo encounter: ${result.message}`);
}
return result;
}
function initializeEncounter(): Encounter { function initializeEncounter(): Encounter {
const stored = loadEncounter(); const stored = loadEncounter();
if (stored !== null) return stored; if (stored !== null) return stored;
return createDemoEncounter(); return EMPTY_ENCOUNTER;
} }
function deriveNextId(encounter: Encounter): number { function deriveNextId(encounter: Encounter): number {

View File

@@ -0,0 +1,72 @@
import { useCallback, useRef, useState } from "react";
const DISMISS_THRESHOLD = 0.35;
const VELOCITY_THRESHOLD = 0.5;
interface SwipeState {
offsetX: number;
isSwiping: boolean;
}
export function useSwipeToDismiss(onDismiss: () => void) {
const [swipe, setSwipe] = useState<SwipeState>({
offsetX: 0,
isSwiping: false,
});
const startX = useRef(0);
const startY = useRef(0);
const startTime = useRef(0);
const panelWidth = useRef(0);
const directionLocked = useRef<"horizontal" | "vertical" | null>(null);
const onTouchStart = useCallback((e: React.TouchEvent) => {
const touch = e.touches[0];
startX.current = touch.clientX;
startY.current = touch.clientY;
startTime.current = Date.now();
directionLocked.current = null;
const el = e.currentTarget as HTMLElement;
panelWidth.current = el.getBoundingClientRect().width;
}, []);
const onTouchMove = useCallback((e: React.TouchEvent) => {
const touch = e.touches[0];
const dx = touch.clientX - startX.current;
const dy = touch.clientY - startY.current;
if (!directionLocked.current) {
if (Math.abs(dx) < 10 && Math.abs(dy) < 10) return;
directionLocked.current =
Math.abs(dx) > Math.abs(dy) ? "horizontal" : "vertical";
}
if (directionLocked.current === "vertical") return;
const clampedX = Math.max(0, dx);
setSwipe({ offsetX: clampedX, isSwiping: true });
}, []);
const onTouchEnd = useCallback(() => {
if (directionLocked.current !== "horizontal") {
setSwipe({ offsetX: 0, isSwiping: false });
return;
}
const elapsed = (Date.now() - startTime.current) / 1000;
const velocity = swipe.offsetX / elapsed / panelWidth.current;
const ratio =
panelWidth.current > 0 ? swipe.offsetX / panelWidth.current : 0;
if (ratio > DISMISS_THRESHOLD || velocity > VELOCITY_THRESHOLD) {
onDismiss();
}
setSwipe({ offsetX: 0, isSwiping: false });
}, [swipe.offsetX, onDismiss]);
return {
offsetX: swipe.offsetX,
isSwiping: swipe.isSwiping,
handlers: { onTouchStart, onTouchMove, onTouchEnd },
};
}

View File

@@ -7,7 +7,8 @@
"!.claude/**", "!.claude/**",
"!.specify/**", "!.specify/**",
"!specs/**", "!specs/**",
"!coverage/**" "!coverage/**",
"!.pnpm-store/**"
] ]
}, },
"assist": { "assist": {

9
nginx.conf Normal file
View File

@@ -0,0 +1,9 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -169,9 +169,9 @@ describe("advanceTurn", () => {
}); });
describe("invariants", () => { describe("invariants", () => {
it("INV-1: createEncounter rejects empty combatant list", () => { it("INV-1: createEncounter accepts empty combatant list", () => {
const result = createEncounter([]); const result = createEncounter([]);
expect(isDomainError(result)).toBe(true); expect(isDomainError(result)).toBe(false);
}); });
it("INV-2: activeIndex always in bounds across all scenarios", () => { it("INV-2: activeIndex always in bounds across all scenarios", () => {

View File

@@ -38,8 +38,8 @@ function domainError(code: string, message: string): DomainError {
/** /**
* Creates a valid Encounter, enforcing INV-1, INV-2, INV-3. * Creates a valid Encounter, enforcing INV-1, INV-2, INV-3.
* - INV-1: At least one combatant required. * - INV-1: An encounter MAY have zero combatants.
* - INV-2: activeIndex defaults to 0 (always in bounds). * - INV-2: activeIndex defaults to 0 (always in bounds when combatants exist).
* - INV-3: roundNumber defaults to 1 (positive integer). * - INV-3: roundNumber defaults to 1 (positive integer).
*/ */
export function createEncounter( export function createEncounter(
@@ -47,13 +47,10 @@ export function createEncounter(
activeIndex = 0, activeIndex = 0,
roundNumber = 1, roundNumber = 1,
): Encounter | DomainError { ): Encounter | DomainError {
if (combatants.length === 0) { if (
return domainError( combatants.length > 0 &&
"invalid-encounter", (activeIndex < 0 || activeIndex >= combatants.length)
"An encounter must have at least one combatant", ) {
);
}
if (activeIndex < 0 || activeIndex >= combatants.length) {
return domainError( return domainError(
"invalid-encounter", "invalid-encounter",
`activeIndex ${activeIndex} out of bounds for ${combatants.length} combatants`, `activeIndex ${activeIndex} out of bounds for ${combatants.length} combatants`,