diff --git a/src/daily/__tests__/schedule.test.ts b/src/daily/__tests__/schedule.test.ts
index 8d70e73..6c71726 100644
--- a/src/daily/__tests__/schedule.test.ts
+++ b/src/daily/__tests__/schedule.test.ts
@@ -74,3 +74,15 @@ test("National Egg Day builds a 27-card deck of Fried Egg cards", () => {
expect(deck.features.length).toEqual(3);
expect(Object.keys(deck.cards).length).toEqual(27);
});
+
+test("National Ice Cream Day is scheduled for July 18th and recurs annually", () => {
+ expect(getPuzzleForDate("2026-07-18").name).toEqual("National Ice Cream Day");
+ expect(getPuzzleForDate("2027-07-18").name).toEqual("National Ice Cream Day");
+ expect(getPuzzleForDate("2025-07-18").name).not.toEqual("National Ice Cream Day");
+});
+
+test("National Ice Cream Day builds a 27-card deck of Sundae Glass cards", () => {
+ const deck = DAILY_PUZZLE_SCHEDULE["2026-07-18"].createDeck();
+ expect(deck.features.length).toEqual(3);
+ expect(Object.keys(deck.cards).length).toEqual(27);
+});
diff --git a/src/daily/schedule.ts b/src/daily/schedule.ts
index 80d19a0..4f8a535 100644
--- a/src/daily/schedule.ts
+++ b/src/daily/schedule.ts
@@ -72,6 +72,19 @@ export const DAILY_PUZZLE_SCHEDULE: DailyPuzzleSchedule = {
{ idPrefix: "daily-egg" }
),
},
+ "2026-07-18": {
+ name: "National Ice Cream Day",
+ createDeck: () =>
+ new GeometricDeckGenerator(
+ {
+ numbers: [1, 2, 3],
+ scoops: [1, 2, 3],
+ colors: ["Pink", "Brown", "Mint"],
+ },
+ { shapes: "Ice Cream - Sundae Glass", topping: "Cherry", sauce: "Chocolate" },
+ { idPrefix: "daily-icecream" }
+ ),
+ },
};
/** The deck used whenever no scheduled puzzle matches the date. */
diff --git a/src/deckBuilder/CardSvg.tsx b/src/deckBuilder/CardSvg.tsx
index d2af6a5..5dfea37 100644
--- a/src/deckBuilder/CardSvg.tsx
+++ b/src/deckBuilder/CardSvg.tsx
@@ -52,6 +52,33 @@ const resolveYolks = (
return supported.includes(yolks as 1 | 2 | 3) ? yolks : supported[0];
};
+const resolveScoops = (
+ supported: ShapeFeatureSupport["scoops"],
+ scoops: number
+): number => {
+ if (supported === false || supported === undefined) return 1;
+ if (supported === true) return scoops;
+ return supported.includes(scoops as 0 | 1 | 2 | 3 | 4) ? scoops : supported[0];
+};
+
+const resolveTopping = (
+ supported: ShapeFeatureSupport["topping"],
+ topping: CardData["topping"]
+): CardData["topping"] => {
+ if (supported === false || supported === undefined) return "None";
+ if (supported === true) return topping;
+ return supported.includes(topping) ? topping : "None";
+};
+
+const resolveSauce = (
+ supported: ShapeFeatureSupport["sauce"],
+ sauce: CardData["sauce"]
+): CardData["sauce"] => {
+ if (supported === false || supported === undefined) return "None";
+ if (supported === true) return sauce;
+ return supported.includes(sauce) ? sauce : "None";
+};
+
interface Props {
card: CardData;
/** Document-unique id, used to namespace this card's SVG defs. */
@@ -73,6 +100,9 @@ export const CardSvg = ({ card, cardId }: Props) => {
const filterName: FilterName = supports.filters === false ? "none" : card.filters;
const rotation = resolveRotation(supports.rotations, card.rotations);
const yolks = resolveYolks(supports.yolks, card.yolks);
+ const scoops = resolveScoops(supports.scoops, card.scoops);
+ const topping = resolveTopping(supports.topping, card.topping);
+ const sauce = resolveSauce(supports.sauce, card.sauce);
let patternName: PatternName = supports.patterns === false ? "solid" : card.patterns;
if (PATTERN_DEFS[patternName].colorsUsed > colorCount) {
patternName = "solid";
@@ -98,7 +128,14 @@ export const CardSvg = ({ card, cardId }: Props) => {
rotation ? `rotate(${rotation}, ${rotationCenter.x}, ${rotationCenter.y})` : undefined
}
>
-
+
);
diff --git a/src/deckBuilder/__tests__/cardSvg.test.tsx b/src/deckBuilder/__tests__/cardSvg.test.tsx
index 38965bb..26dfd55 100644
--- a/src/deckBuilder/__tests__/cardSvg.test.tsx
+++ b/src/deckBuilder/__tests__/cardSvg.test.tsx
@@ -36,6 +36,9 @@ const CARD: CardData = {
filters: "shadow",
patterns: "striped",
yolks: 1,
+ scoops: 1,
+ topping: "None",
+ sauce: "None",
};
const renderCard = (card: CardData) =>
diff --git a/src/deckBuilder/__tests__/deckRules.test.ts b/src/deckBuilder/__tests__/deckRules.test.ts
index c979c6d..876fa90 100644
--- a/src/deckBuilder/__tests__/deckRules.test.ts
+++ b/src/deckBuilder/__tests__/deckRules.test.ts
@@ -42,8 +42,8 @@ const DECK: GeneratedDeckMetaData = {
numbers: [9, 3, 4],
};
-test("yolks is the only shape-only feature today", () => {
- expect(SHAPE_ONLY_FEATURES).toEqual(["yolks"]);
+test("yolks, scoops, topping, and sauce are the shape-only features today", () => {
+ expect(SHAPE_ONLY_FEATURES).toEqual(["yolks", "scoops", "topping", "sauce"]);
});
test("a shape owning no internal feature backs a single card", () => {
diff --git a/src/deckBuilder/__tests__/registry.test.ts b/src/deckBuilder/__tests__/registry.test.ts
index 5789407..efea388 100644
--- a/src/deckBuilder/__tests__/registry.test.ts
+++ b/src/deckBuilder/__tests__/registry.test.ts
@@ -18,6 +18,11 @@ const LEGACY_SHAPE_NAMES = [
"Tracks - Wolf",
"Tracks - Frog",
"Fried Egg",
+ "Ice Cream - Waffle Cone",
+ "Ice Cream - Sugar Cone",
+ "Ice Cream - Sundae Cup",
+ "Ice Cream - Sundae Glass",
+ "Ice Cream - Banana Split",
];
test("shape registry keeps the legacy shape names", () => {
diff --git a/src/deckBuilder/features/index.ts b/src/deckBuilder/features/index.ts
index ff725b7..8367010 100644
--- a/src/deckBuilder/features/index.ts
+++ b/src/deckBuilder/features/index.ts
@@ -7,6 +7,15 @@ import { PATTERN_NAMES, PatternName } from "./patterns";
export const NUMBERS = [1, 2, 3, 4, 5, 6, 7, 8, 9] as const;
export const ROTATIONS: readonly Rotation[] = [0, 90, 180, 270];
export const YOLKS = [1, 2, 3] as const;
+export const SCOOPS = [0, 1, 2, 3, 4] as const;
+export const TOPPINGS = [
+ "Chocolate Chips",
+ "Cherry",
+ "Rainbow Sprinkles",
+ "Twisty Pretzel",
+ "None",
+] as const;
+export const SAUCES = ["Chocolate", "Strawberry", "Toffee", "Pistachio", "None"] as const;
/**
* The features a generated deck can vary, mapped to their option value type.
@@ -22,6 +31,9 @@ interface FeatureOptionMap {
filters: FilterName;
patterns: PatternName;
yolks: (typeof YOLKS)[number];
+ scoops: (typeof SCOOPS)[number];
+ topping: (typeof TOPPINGS)[number];
+ sauce: (typeof SAUCES)[number];
}
export type FeatureName = keyof FeatureOptionMap;
@@ -57,6 +69,9 @@ export const FEATURES: { readonly [F in FeatureName]: FeatureConfig } = {
filters: { label: "Filter", options: FILTER_NAMES },
patterns: { label: "Pattern", options: PATTERN_NAMES },
yolks: { label: "Yolks", options: YOLKS, requiresShapeSupport: true },
+ scoops: { label: "Scoops", options: SCOOPS, requiresShapeSupport: true },
+ topping: { label: "Topping", options: TOPPINGS, requiresShapeSupport: true },
+ sauce: { label: "Sauce", options: SAUCES, requiresShapeSupport: true },
};
export const FEATURE_NAMES = Object.keys(FEATURES) as FeatureName[];
@@ -77,6 +92,9 @@ export const DEFAULT_CARD: CardData = {
filters: "none",
patterns: "solid",
yolks: 1,
+ scoops: 2,
+ topping: "None",
+ sauce: "None",
};
export function getFeatureOptions(feature: F): readonly FeatureValue[] {
diff --git a/src/deckBuilder/shapes/IceCream.tsx b/src/deckBuilder/shapes/IceCream.tsx
new file mode 100644
index 0000000..91cfe09
--- /dev/null
+++ b/src/deckBuilder/shapes/IceCream.tsx
@@ -0,0 +1,303 @@
+import * as React from "react";
+import { ShapeDefinition, ShapeFeatureSupport, ShapeProps } from "../types";
+
+/** Shared support declaration for every ice cream shape. */
+const ICE_CREAM_SUPPORTS: ShapeFeatureSupport = {
+ colors: 2,
+ patterns: false,
+ rotations: false,
+ scoops: [0, 1, 2, 3, 4],
+ topping: ["Chocolate Chips", "Cherry", "Rainbow Sprinkles", "Twisty Pretzel", "None"],
+ sauce: ["Chocolate", "Strawberry", "Toffee", "Pistachio", "None"],
+};
+
+const OUTLINE = {
+ fill: "none",
+ stroke: "#000000",
+ strokeWidth: 6,
+ strokeLinecap: "round" as const,
+ strokeLinejoin: "round" as const,
+};
+
+interface Scoop {
+ cx: number;
+ cy: number;
+ r: number;
+}
+
+/**
+ * `[dyFromMouth, radius]` per scoop, bottom-to-top. Fewer scoops are drawn
+ * larger; each scoop overlaps the one below it enough to read as a stack.
+ * Typed `number[][]` rather than tuple types — the pinned
+ * @typescript-eslint/parser crashes on tuple-array types and breaks the
+ * production build, which `yarn test` never sees. See commit d8701ac.
+ */
+const SCOOP_OFFSETS: Record = {
+ 0: [],
+ 1: [[-16, 24]],
+ 2: [[-14, 20], [-34, 17]],
+ 3: [[-13, 17], [-30, 15], [-46, 13]],
+ 4: [[-12, 15], [-27, 13], [-41, 12], [-54, 11]],
+};
+
+const scoopCircles = (scoops: number, mouthY: number): Scoop[] =>
+ (SCOOP_OFFSETS[scoops] || []).map(([dy, r]) => ({ cx: 60, cy: mouthY + dy, r }));
+
+const renderScoopFills = (stack: Scoop[], fill: string) => (
+ <>
+ {stack.map((s, i) => (
+
+ ))}
+ >
+);
+
+const renderScoopOutlines = (stack: Scoop[]) => (
+ <>
+ {stack.map((s, i) => (
+
+ ))}
+ >
+);
+
+/** Sauce is intrinsic to the flavor, not the card's color feature — fixed literal colors. */
+const SAUCE_COLORS: Record = {
+ Chocolate: "#5C3317",
+ Strawberry: "#F4436C",
+ Toffee: "#A9702A",
+ Pistachio: "#8DA047",
+};
+
+/** A drizzle squiggle scaled to the top scoop's own radius. */
+const drizzlePath = (top: Scoop): string => {
+ const { cx, cy, r } = top;
+ const at = (dx: number, dy: number) => `${cx + dx * r},${cy + dy * r}`;
+ return `M${at(-0.65, -0.2)} Q${at(-0.25, -1.0)} ${at(0.05, -0.35)} Q${at(0.35, 0.15)} ${at(
+ 0.65,
+ -0.15
+ )}`;
+};
+
+const renderSauce = (sauce: ShapeProps["sauce"], top: Scoop | undefined): JSX.Element | null => {
+ if (!top || !sauce || sauce === "None") return null;
+ const color = SAUCE_COLORS[sauce];
+ if (!color) return null;
+ return ;
+};
+
+/** Toppings are intrinsic to the flavor too — fixed literal colors, not the card's colors. */
+const CHIP_COLOR = "#3B1F0E";
+const CHERRY_COLOR = "#C1121F";
+const CHERRY_STEM_COLOR = "#2E7D32";
+const SPRINKLE_COLORS = ["#e6194B", "#3cb44b", "#4363d8", "#f58231"];
+const PRETZEL_COLOR = "#B08461";
+
+const CHIP_OFFSETS = [[0, -5], [5, -1], [-5, -1], [3, 4], [-3, 4]];
+const SPRINKLE_OFFSETS = [
+ [-4, -4, -20],
+ [2, -6, 10],
+ [6, -2, 35],
+ [-6, 0, -35],
+ [0, 2, 0],
+ [4, 3, 20],
+];
+
+const renderTopping = (topping: ShapeProps["topping"], top: Scoop | undefined): JSX.Element | null => {
+ if (!top || !topping || topping === "None") return null;
+ const { cx, cy } = top;
+
+ if (topping === "Chocolate Chips") {
+ return (
+
+ {CHIP_OFFSETS.map(([dx, dy], i) => (
+
+ ))}
+
+ );
+ }
+ if (topping === "Cherry") {
+ return (
+
+
+
+
+ );
+ }
+ if (topping === "Rainbow Sprinkles") {
+ return (
+
+ {SPRINKLE_OFFSETS.map(([dx, dy, rot], i) => (
+
+ ))}
+
+ );
+ }
+ // Twisty Pretzel: two small stroked loops, the same "colored accent stroke"
+ // treatment as the sauce drizzle — a closed filled loop collapses into a
+ // blob under the 6-unit outline stroke at this scale.
+ return (
+
+
+
+
+ );
+};
+
+interface ContainerGeometry {
+ mouthY: number;
+ fill: string;
+}
+
+/**
+ * A container drawn as a single closed path, no interior texture: at 26
+ * device pixels a 6-unit-wide outline stroke swallows any thin interior
+ * line (crosshatch, seam), so cones are told apart purely by proportion
+ * (wide/shallow vs narrow/steep) — silhouette-first, per iconography.md.
+ */
+const buildComponent = ({ mouthY, fill: containerFill }: ContainerGeometry) => {
+ const Component = ({
+ colors,
+ fill,
+ scoops = 2,
+ topping = "None",
+ sauce = "None",
+ }: ShapeProps) => {
+ const stack = scoopCircles(scoops, mouthY);
+ const top = stack[stack.length - 1];
+ return (
+ <>
+
+
+ {renderScoopFills(stack, fill)}
+
+
+
+ {renderScoopOutlines(stack)}
+
+ {renderSauce(sauce, top)}
+ {renderTopping(topping, top)}
+ >
+ );
+ };
+ return Component;
+};
+
+const WaffleConeComponent = buildComponent({
+ mouthY: 74,
+ fill: "M18,74 L102,74 L60,112 Z",
+});
+
+const SugarConeComponent = buildComponent({
+ mouthY: 74,
+ fill: "M46,74 L74,74 L60,114 Z",
+});
+
+const SundaeCupComponent = ({ colors, fill, scoops = 2, topping = "None", sauce = "None" }: ShapeProps) => {
+ const mouthY = 74;
+ const stack = scoopCircles(scoops, mouthY);
+ const top = stack[stack.length - 1];
+ const cupPath = "M18,74 L102,74 L84,110 L36,110 Z";
+ return (
+ <>
+
+
+ {renderScoopFills(stack, fill)}
+
+
+
+
+ {renderScoopOutlines(stack)}
+
+ {renderSauce(sauce, top)}
+ {renderTopping(topping, top)}
+ >
+ );
+};
+
+const SundaeGlassComponent = ({ colors, fill, scoops = 2, topping = "None", sauce = "None" }: ShapeProps) => {
+ const mouthY = 74;
+ const stack = scoopCircles(scoops, mouthY);
+ const top = stack[stack.length - 1];
+ const bowlPath = "M18,74 Q24,100 50,100 L70,100 Q96,100 102,74 Z";
+ return (
+ <>
+
+
+
+
+ {renderScoopFills(stack, fill)}
+
+
+
+
+
+
+ {renderScoopOutlines(stack)}
+
+ {renderSauce(sauce, top)}
+ {renderTopping(topping, top)}
+ >
+ );
+};
+
+const BananaSplitComponent = ({ colors, fill, scoops = 2, topping = "None", sauce = "None" }: ShapeProps) => {
+ const mouthY = 82;
+ const stack = scoopCircles(scoops, mouthY);
+ const top = stack[stack.length - 1];
+ const leftBanana = "M8,100 Q16,64 48,82 Q30,90 8,100 Z";
+ const rightBanana = "M112,100 Q104,64 72,82 Q90,90 112,100 Z";
+ return (
+ <>
+
+
+
+
+ {renderScoopFills(stack, fill)}
+
+
+
+
+
+ {renderScoopOutlines(stack)}
+
+ {renderSauce(sauce, top)}
+ {renderTopping(topping, top)}
+ >
+ );
+};
+
+export const IceCreamWaffleCone: ShapeDefinition = {
+ Component: WaffleConeComponent,
+ supports: ICE_CREAM_SUPPORTS,
+};
+
+export const IceCreamSugarCone: ShapeDefinition = {
+ Component: SugarConeComponent,
+ supports: ICE_CREAM_SUPPORTS,
+};
+
+export const IceCreamSundaeCup: ShapeDefinition = {
+ Component: SundaeCupComponent,
+ supports: ICE_CREAM_SUPPORTS,
+};
+
+export const IceCreamSundaeGlass: ShapeDefinition = {
+ Component: SundaeGlassComponent,
+ supports: ICE_CREAM_SUPPORTS,
+};
+
+export const IceCreamBananaSplit: ShapeDefinition = {
+ Component: BananaSplitComponent,
+ supports: ICE_CREAM_SUPPORTS,
+};
diff --git a/src/deckBuilder/shapes/index.ts b/src/deckBuilder/shapes/index.ts
index f27c6b2..bf2a4e3 100644
--- a/src/deckBuilder/shapes/index.ts
+++ b/src/deckBuilder/shapes/index.ts
@@ -1,6 +1,13 @@
import { ShapeDefinition } from "../types";
import { CircleQuarter, CircleSemi, CircleThreeQuarter } from "./Circles";
import { FriedEgg } from "./FriedEgg";
+import {
+ IceCreamBananaSplit,
+ IceCreamSugarCone,
+ IceCreamSundaeCup,
+ IceCreamSundaeGlass,
+ IceCreamWaffleCone,
+} from "./IceCream";
import { TetrisJBlock, TetrisLBlock, TetrisSBlock, TetrisTBlock } from "./Tetris";
import { TracksDeer, TracksFrog, TracksWolf } from "./Tracks";
import { Triangle } from "./Triangle";
@@ -28,6 +35,11 @@ export const SHAPE_REGISTRY = defineShapes({
"Tracks - Wolf": TracksWolf,
"Tracks - Frog": TracksFrog,
"Fried Egg": FriedEgg,
+ "Ice Cream - Waffle Cone": IceCreamWaffleCone,
+ "Ice Cream - Sugar Cone": IceCreamSugarCone,
+ "Ice Cream - Sundae Cup": IceCreamSundaeCup,
+ "Ice Cream - Sundae Glass": IceCreamSundaeGlass,
+ "Ice Cream - Banana Split": IceCreamBananaSplit,
});
export type ShapeName = keyof typeof SHAPE_REGISTRY;
diff --git a/src/deckBuilder/types.ts b/src/deckBuilder/types.ts
index 7141253..31943ed 100644
--- a/src/deckBuilder/types.ts
+++ b/src/deckBuilder/types.ts
@@ -30,6 +30,12 @@ export interface ShapeProps {
fill: string;
/** Yolk count for shapes that declare `supports.yolks`. */
yolks?: 1 | 2 | 3;
+ /** Scoop count for shapes that declare `supports.scoops`. */
+ scoops?: 0 | 1 | 2 | 3 | 4;
+ /** Topping for shapes that declare `supports.topping`. */
+ topping?: "Chocolate Chips" | "Cherry" | "Rainbow Sprinkles" | "Twisty Pretzel" | "None";
+ /** Sauce for shapes that declare `supports.sauce`. */
+ sauce?: "Chocolate" | "Strawberry" | "Toffee" | "Pistachio" | "None";
}
/**
@@ -61,6 +67,25 @@ export interface ShapeFeatureSupport {
* neutral yolk count, so unsupported always falls back to 1.
*/
yolks?: boolean | readonly (1 | 2 | 3)[];
+ /**
+ * true = all scoop counts, false/undefined = none (always 1), or the exact
+ * subset of counts the shape supports. No universal neutral scoop count
+ * (0 is a real, meaningful state, not a "don't care"), so unsupported
+ * always falls back to 1, same principle as yolks.
+ */
+ scoops?: boolean | readonly (0 | 1 | 2 | 3 | 4)[];
+ /**
+ * true = all toppings, false/undefined = none (always "None"), or the
+ * exact subset the shape supports. "None" is the universal neutral value,
+ * so unsupported falls back to "None".
+ */
+ topping?: boolean | readonly ("Chocolate Chips" | "Cherry" | "Rainbow Sprinkles" | "Twisty Pretzel" | "None")[];
+ /**
+ * true = all sauces, false/undefined = none (always "None"), or the exact
+ * subset the shape supports. "None" is the universal neutral value, so
+ * unsupported falls back to "None".
+ */
+ sauce?: boolean | readonly ("Chocolate" | "Strawberry" | "Toffee" | "Pistachio" | "None")[];
}
/** A registered shape: a React component plus the features it supports. */