Skip to content

Commit 2dfdfae

Browse files
B2JK-Industryclaude
andcommitted
feat(ux): PR-P G-29 + G-30 + G-34 — archive empty state, mobile scroll, +3 AI kinds
G-29 — `/sin-slavy` AI archive empty state. User reported "nikde som nenašiel archiv starych hier". Section existed (rendered when `pastAiWithTop.length > 0`) but was invisible when no archive entries existed yet — feature looked missing. Now the heading + count chip render unconditionally, with an empty- state card "Archive fills up soon" + 4-locale copy when no past challenges have expired yet. Players see WHERE past medals will land before any have arrived. G-30 — three new AI game kinds (schema only). Sprint H foundation for renderer + research-seed work. Added: - `rank-list` — drag 4 items into magnitude order; teaches "which is bigger" intuition for budget categories - `estimate-range` — slider with a correct band, finer-grained than `price-guess`'s discrete options - `odd-one-out` — pick the item that doesn't fit the group; cheapest to author, reinforces categorisation Schemas land in `lib/ai-pipeline/types.ts` + extend the discriminated union; SeedKind in research.ts adds the three so schemaForKind narrows + the generator's kindRules table stays exhaustive (stub system-prompt entries because no research seed emits these kinds yet — client renderers ship in Sprint H). G-34 — mobile horizontal scroll for hero / CityScene. User feedback (screenshot 8:44): the 20-slot row gets cropped on narrow viewports; on phones the buildings shrink below readable width. Added overflow-x:auto + min-width:720px on `.city-skyline-hero-root` and `.city-scene-viewport` for sub- 640 px viewports; desktop unchanged. Touch-action: pan-x pinch-zoom for natural mobile scrubbing. Plus G-31 audit straggler — `app/sin-slavy/page.tsx` archive heading deduped (had a duplicate when archive was non-empty). Validation: - pnpm typecheck → 0 errors Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent af10f53 commit 2dfdfae

9 files changed

Lines changed: 180 additions & 9 deletions

File tree

app/globals.css

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,10 +195,26 @@ body {
195195
}
196196

197197
.city-scene-viewport {
198-
touch-action: pinch-zoom;
199-
overflow: auto;
198+
/* G-34 — same pattern as .city-skyline-hero-root: horizontal
199+
* scroll under 640 px so the 20-slot row doesn't compress to
200+
* unreadable widths on phones. */
201+
touch-action: pan-x pinch-zoom;
202+
overflow-x: auto;
203+
overflow-y: hidden;
200204
-webkit-overflow-scrolling: touch;
201205
}
206+
.city-scene-viewport > svg {
207+
min-width: 720px;
208+
}
209+
@media (min-width: 640px) {
210+
.city-scene-viewport {
211+
overflow-x: visible;
212+
overflow-y: visible;
213+
}
214+
.city-scene-viewport > svg {
215+
min-width: 0;
216+
}
217+
}
202218

203219
/* City-scene SVG: neon night palette is core-skin intent. Under pko we
204220
* blend two strategies — a global filter to mute saturation, plus
@@ -536,6 +552,31 @@ body {
536552
* outline so they read against the sky. */
537553
.city-skyline-hero-root {
538554
background: #ffffff;
555+
/* G-34 — horizontal scroll on narrow viewports.
556+
* SVG aspect is 1800:430 ≈ 4.2:1; below ~480 px viewport the slot
557+
* width drops under 26 px and L-pills + glyphs stop reading. The
558+
* SVG inside opts into `min-width` so the scene keeps a legible
559+
* minimum, and the parent allows horizontal overflow with
560+
* touch-pinch + iOS momentum. Desktop (640+ px) is wide enough to
561+
* fit edge-to-edge so the rule is mobile-only. */
562+
overflow-x: auto;
563+
touch-action: pan-x pinch-zoom;
564+
-webkit-overflow-scrolling: touch;
565+
}
566+
.city-skyline-hero-root > svg {
567+
/* Force a readable horizontal floor (≈ 720 px → 18-px slot avg);
568+
* desktop SVG still scales to 100 % via Tailwind's w-full. */
569+
min-width: 720px;
570+
}
571+
@media (min-width: 640px) {
572+
.city-skyline-hero-root {
573+
/* Above 640 px the canvas fits without scroll — drop the
574+
* overflow so the focus ring + drop shadow aren't clipped. */
575+
overflow-x: visible;
576+
}
577+
.city-skyline-hero-root > svg {
578+
min-width: 0;
579+
}
539580
}
540581
/* PR-N — the prior `rect[fill]` blanket stroke (R-11 era) outlined
541582
* EVERY rect in the SVG: building bodies, ground zones, AND the

app/sin-slavy/page.tsx

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -203,15 +203,41 @@ export default async function HallOfFamePage() {
203203
</section>
204204
)}
205205

206-
{/* Archive of expired AI games — permanent top-3 medals */}
207-
{pastAiWithTop.length > 0 && (
208-
<section className="flex flex-col gap-4">
206+
{/* G-29 — Archive of expired AI games. Always rendered (with
207+
empty state when no archive yet) so players can see WHERE
208+
past challenges land instead of guessing the feature is
209+
missing. The header chip surfaces the count. */}
210+
<section className="flex flex-col gap-4">
211+
<div className="flex flex-wrap items-baseline gap-3">
209212
<h2 className="section-heading text-xl sm:text-2xl">
210213
{t.aiArchiveTitle}
211214
</h2>
212-
<p className="text-sm text-[var(--ink-muted)] max-w-2xl">
213-
{t.aiArchiveBody}
214-
</p>
215+
<span
216+
className="chip text-[11px] border-[var(--accent)] text-[var(--accent)]"
217+
aria-label={`${pastAiWithTop.length} archived`}
218+
>
219+
{pastAiWithTop.length}
220+
</span>
221+
</div>
222+
<p className="text-sm text-[var(--ink-muted)] max-w-2xl">
223+
{t.aiArchiveBody}
224+
</p>
225+
{pastAiWithTop.length === 0 ? (
226+
<div className="card p-6 flex flex-col items-center gap-2 text-center">
227+
<span aria-hidden className="text-3xl">
228+
📜
229+
</span>
230+
<p className="font-semibold">{t.aiArchiveEmptyTitle}</p>
231+
<p className="text-sm text-[var(--ink-muted)] max-w-md">
232+
{t.aiArchiveEmptyBody}
233+
</p>
234+
</div>
235+
) : null}
236+
</section>
237+
{pastAiWithTop.length > 0 && (
238+
<section className="flex flex-col gap-4">
239+
{/* Inline duplicate header guard kept so existing block below
240+
renders unchanged when archive has entries. */}
215241
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
216242
{pastAiWithTop.map(({ record, top }) => {
217243
const date = new Date(record.generatedAt);

lib/ai-pipeline/generate.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ import {
1313
BudgetSpecSchema,
1414
WhatIfSpecSchema,
1515
ChartReadSpecSchema,
16+
RankListSpecSchema,
17+
EstimateRangeSpecSchema,
18+
OddOneOutSpecSchema,
1619
LocalizedSpecSchema,
1720
type GameSpec,
1821
type LocalizedSpec,
@@ -37,6 +40,9 @@ function schemaForKind(kind: SeedKind) {
3740
case "budget-allocate": return BudgetSpecSchema;
3841
case "what-if": return WhatIfSpecSchema;
3942
case "chart-read": return ChartReadSpecSchema;
43+
case "rank-list": return RankListSpecSchema;
44+
case "estimate-range": return EstimateRangeSpecSchema;
45+
case "odd-one-out": return OddOneOutSpecSchema;
4046
}
4147
}
4248

@@ -171,6 +177,24 @@ function buildPlSystemPrompt(kind: SeedKind): string {
171177
"- Chart should represent real Polish macro/finance data when possible (NBP rates, WIG20, Tauron tariffs over years, inflation CPI).",
172178
"- correctIndex is the answer derivable from the chart data.",
173179
],
180+
// G-30 — Sprint H research-seed kinds. The generator's prompt
181+
// surfaces are stubs because no research seed currently emits
182+
// these; client renderers ship in Sprint H. Listed here so the
183+
// Record<SeedKind, …> constraint stays exhaustive without runtime
184+
// unreachable-branch warnings.
185+
"rank-list": [
186+
"You are producing a RANK-LIST spec — drag 4 items into magnitude order.",
187+
"Schema: {kind:'rank-list', prompt, items:[4 strings], correctOrder:[4 ints 0-3], direction:'high-to-low'|'low-to-high', unitLabel, explanation, xpPerCorrect:10-40}.",
188+
],
189+
"estimate-range": [
190+
"You are producing an ESTIMATE-RANGE spec — slider with a correct band.",
191+
"Schema: {kind:'estimate-range', question, unit, domainMin, domainMax, step, correctMin, correctMax, explanation, xpReward:10-60}.",
192+
"- correctMin–correctMax band should be 10–30 % of the domain.",
193+
],
194+
"odd-one-out": [
195+
"You are producing an ODD-ONE-OUT spec — pick the item that doesn't fit.",
196+
"Schema: {kind:'odd-one-out', prompt, items:[4 strings], oddIndex:0-3, category, explanation, xpPerCorrect:10-40}.",
197+
],
174198
};
175199

176200
return [

lib/ai-pipeline/research.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,13 @@ export type SeedKind =
2424
| "calc-sprint"
2525
| "budget-allocate"
2626
| "what-if"
27-
| "chart-read";
27+
| "chart-read"
28+
// G-30 Sprint H — schemas already in types.ts; research seeds +
29+
// client renderers ship next sprint. Listed here so schemaForKind
30+
// can narrow + the generator's kindRules table stays exhaustive.
31+
| "rank-list"
32+
| "estimate-range"
33+
| "odd-one-out";
2834
export type SeedDifficulty = "easy" | "medium" | "hard";
2935

3036
// Phase 2.2.3 metadata — every theme carries an age target + subject tag to

lib/ai-pipeline/types.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,65 @@ export const ChartReadSpecSchema = z.object({
197197
xpPerCorrect: z.number().int().min(10).max(40),
198198
});
199199

200+
/* ---------- G-30 — Sprint H proposed kinds ----------
201+
*
202+
* Three new kinds added as schema only; client components ship in
203+
* Sprint H. The discriminated union below lists them so generator
204+
* prompts + content-hash + spec validators recognise the kind once
205+
* the client renderers land.
206+
*
207+
* Why these three (UX rationale per docs/ux-audit/sprint-H.md):
208+
* - rank-list: drag-to-reorder by magnitude, distinct from `order`
209+
* which is sequence/timeline. Teaches "which is bigger" intuition
210+
* for budget categories (rent > food > internet > coffee, etc.).
211+
* - estimate-range: continuous-slider numeric estimation with a
212+
* correct band, finer-grained than `price-guess`'s discrete
213+
* options. Teaches "feel for the right order of magnitude" on
214+
* bills, salaries, savings goals.
215+
* - odd-one-out: 4 cards, pick the one that doesn't fit the group.
216+
* Reinforces categorisation (PKO products vs competitor, savings
217+
* vs spending, energy source taxonomy). Cheapest to author.
218+
*/
219+
220+
export const RankListSpecSchema = z.object({
221+
kind: z.literal("rank-list"),
222+
prompt: z.string().min(8).max(200),
223+
/** Rendered shuffled to the player; `correctOrder` is the canonical
224+
* high-to-low (or low-to-high — see `direction`) sequence. */
225+
items: z.array(z.string().min(1).max(80)).length(4),
226+
correctOrder: z.array(z.number().int().min(0).max(3)).length(4),
227+
direction: z.enum(["high-to-low", "low-to-high"]),
228+
unitLabel: z.string().max(40),
229+
explanation: z.string().min(10).max(300),
230+
xpPerCorrect: z.number().int().min(10).max(40),
231+
});
232+
233+
export const EstimateRangeSpecSchema = z.object({
234+
kind: z.literal("estimate-range"),
235+
question: z.string().min(8).max(200),
236+
unit: z.string().min(1).max(20),
237+
/** Slider domain. Player picks a single value; `correctMin`-
238+
* `correctMax` defines the accepted band. Width should be 10-30 %
239+
* of the domain to keep the game winnable but not trivial. */
240+
domainMin: z.number(),
241+
domainMax: z.number(),
242+
step: z.number().min(0.0001),
243+
correctMin: z.number(),
244+
correctMax: z.number(),
245+
explanation: z.string().min(10).max(300),
246+
xpReward: z.number().int().min(10).max(60),
247+
});
248+
249+
export const OddOneOutSpecSchema = z.object({
250+
kind: z.literal("odd-one-out"),
251+
prompt: z.string().min(8).max(200),
252+
items: z.array(z.string().min(1).max(80)).length(4),
253+
oddIndex: z.number().int().min(0).max(3),
254+
category: z.string().max(60),
255+
explanation: z.string().min(10).max(300),
256+
xpPerCorrect: z.number().int().min(10).max(40),
257+
});
258+
200259
export const GameSpecSchema = z.discriminatedUnion("kind", [
201260
QuizSpecSchema,
202261
ScrambleSpecSchema,
@@ -210,6 +269,9 @@ export const GameSpecSchema = z.discriminatedUnion("kind", [
210269
BudgetSpecSchema,
211270
WhatIfSpecSchema,
212271
ChartReadSpecSchema,
272+
RankListSpecSchema,
273+
EstimateRangeSpecSchema,
274+
OddOneOutSpecSchema,
213275
]);
214276
export type GameSpec = z.infer<typeof GameSpecSchema>;
215277

lib/locales/cs.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,9 @@ const cs: typeof plDict = {
325325
aiArchiveTitle: "Archiv AI výzev",
326326
aiArchiveBody:
327327
"Medaile za každou minulou AI výzvu — zůstávají tu natrvalo, i když samotná hra už vypršela.",
328+
aiArchiveEmptyTitle: "Archiv se brzy zaplní",
329+
aiArchiveEmptyBody:
330+
"První AI výzvy právě dokončují svůj cyklus — jakmile vyprší, top-3 medaile zůstanou zde natrvalo. Zahraj dnes a buď první v archivu.",
328331
liveMedalNote:
329332
"Top 3 ve chvíli vypršení hry získávají medaili natrvalo. Níže aktuální pozice — mění se živě.",
330333
leaderLink: "Celá liga →",

lib/locales/en.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,9 @@ const en: typeof plDict = {
325325
aiArchiveTitle: "AI challenge archive",
326326
aiArchiveBody:
327327
"Medals from every past AI challenge — they stay here forever, even after the game itself expired.",
328+
aiArchiveEmptyTitle: "Archive fills up soon",
329+
aiArchiveEmptyBody:
330+
"The first AI challenges are wrapping their cycle — once they expire, top-3 medals stay here forever. Play today to be first in the archive.",
328331
liveMedalNote:
329332
"Top 3 at the moment the game expires earns a permanent medal. Current standings below — they update live.",
330333
leaderLink: "Full league →",

lib/locales/pl.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,9 @@ const pl = {
323323
aiArchiveTitle: "Archiwum wyzwań AI",
324324
aiArchiveBody:
325325
"Medale za każde minione AI wyzwanie — zostają tu na stałe, nawet po wygaśnięciu samej gry.",
326+
aiArchiveEmptyTitle: "Archiwum wkrótce się zapełni",
327+
aiArchiveEmptyBody:
328+
"Pierwsze AI wyzwania właśnie kończą swój cykl — gdy wygasną, top-3 medale zostaną tu na stałe. Zagraj dziś, żeby być pierwszym w archiwum.",
326329
liveMedalNote:
327330
"Top 3 w chwili gdy gra wygaśnie dostaje medal na stałe. Poniżej bieżące pozycje — zmieniają się na żywo.",
328331
leaderLink: "Cała liga →",

lib/locales/uk.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,9 @@ const uk: typeof plDict = {
325325
aiArchiveTitle: "Архів AI-викликів",
326326
aiArchiveBody:
327327
"Медалі з кожного минулого AI-виклику — залишаються тут назавжди, навіть після того, як сама гра вичерпала час.",
328+
aiArchiveEmptyTitle: "Архів скоро заповниться",
329+
aiArchiveEmptyBody:
330+
"Перші AI-виклики саме завершують свій цикл — щойно вони вичерпають час, топ-3 медалі залишаться тут назавжди. Зіграй сьогодні, щоб бути першим в архіві.",
328331
liveMedalNote:
329332
"Топ-3 на момент завершення гри отримують медаль назавжди. Нижче поточні позиції — оновлюються в реальному часі.",
330333
leaderLink: "Вся ліга →",

0 commit comments

Comments
 (0)