feat(design): designauswahl mit klemmbaustein-look als standard

Sechs Designs (Klemmbaustein, Warm, Nachtblau, Bonbon, Bauklotz,
Schlicht) als Tokens je data-design, gewaehlt unter Konto und pro Geraet
im Cookie gespeichert. Dazu Noppen, Steinkontur, Platzhalter fuer Sets
ohne Bild, neues Logo, lokal gebuendelte Schriften und eine Startseite
mit Steinreihe und naechstem Termin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
unknown
2026-10-25 15:01:33 +01:00
co-authored by Claude Opus 5.5
parent e9093d0fad
commit 1b825b44c4
26 changed files with 1343 additions and 259 deletions
+48
View File
@@ -0,0 +1,48 @@
/**
* The progress row on the dashboard: a fixed number of slots, filled with one
* brick per share of the catalog that is already in the collection. Each
* brick takes the colour of a collector, in proportion to what they own.
*/
export type BrickShare = { key: string; count: number };
/**
* Returns `slots` entries: a collector key for a filled slot, `null` for an
* empty one. Filled slots come first, grouped by collector in input order.
*/
export function brickRow(
ownedSets: number,
totalSets: number,
shares: BrickShare[],
slots = 10,
): Array<string | null> {
const empty = Array<string | null>(slots).fill(null);
if (totalSets <= 0 || ownedSets <= 0) return empty;
// Never round a non-empty collection down to an empty row, and only show a
// full row when everything is really there.
const ratio = Math.min(ownedSets, totalSets) / totalSets;
let filled = Math.round(ratio * slots);
filled = Math.max(1, filled);
if (ratio < 1) filled = Math.min(filled, slots - 1);
const positive = shares.filter((share) => share.count > 0);
const shareTotal = positive.reduce((sum, share) => sum + share.count, 0);
if (shareTotal === 0) return [...Array<string | null>(filled).fill(''), ...empty].slice(0, slots);
// Largest remainder: floor first, then hand out the rest by biggest fraction.
const exact = positive.map((share) => (share.count / shareTotal) * filled);
const counts = exact.map(Math.floor);
let rest = filled - counts.reduce((sum, value) => sum + value, 0);
const order = exact
.map((value, index) => ({ index, fraction: value - Math.floor(value) }))
.sort((a, b) => b.fraction - a.fraction || a.index - b.index);
for (const { index } of order) {
if (rest === 0) break;
counts[index]! += 1;
rest -= 1;
}
const row = positive.flatMap((share, index) => Array<string>(counts[index]!).fill(share.key));
return [...row, ...empty].slice(0, slots);
}
+110
View File
@@ -0,0 +1,110 @@
/**
* The selectable looks of the app.
*
* A design is nothing but a set of CSS tokens under `[data-design=...]` in
* `src/app/designs.css`; this module only knows the names. The choice lives in
* a cookie, so it is per device and also applies to pages without a session
* (login, public wishlist).
*/
export const DESIGNS = ['klemmbaustein', 'warm', 'nacht', 'bonbon', 'bauklotz', 'schlicht'] as const;
export type Design = (typeof DESIGNS)[number];
export const DEFAULT_DESIGN: Design = 'klemmbaustein';
export const DESIGN_COOKIE = 'bricktrack-design';
export type DesignInfo = {
label: string;
description: string;
/** Dark designs also set the `.dark` class so `dark:` variants apply. */
dark: boolean;
/** Browser chrome colour (address bar, PWA title bar). */
themeColor: string;
/** Four colours for the preview swatch on the account page. */
swatch: [string, string, string, string];
};
export const DESIGN_INFO: Record<Design, DesignInfo> = {
klemmbaustein: {
label: 'Klemmbaustein',
description: 'Noppen, Konturen und kräftige Steinfarben.',
dark: false,
themeColor: '#fff6e5',
swatch: ['#fff6e5', '#d63a30', '#ffc93c', '#2f6bea'],
},
warm: {
label: 'Warm',
description: 'Ruhiger Sandton mit Ziegelrot.',
dark: false,
themeColor: '#f6f3ec',
swatch: ['#f6f3ec', '#c8381f', '#fbe3a6', '#1d1b17'],
},
nacht: {
label: 'Nachtblau',
description: 'Dunkel mit gelbem Akzent, schont abends die Augen.',
dark: true,
themeColor: '#0e1422',
swatch: ['#0e1422', '#ffc93c', '#182136', '#60a5fa'],
},
bonbon: {
label: 'Bonbon',
description: 'Weiches Lila mit Pastellfarben.',
dark: false,
themeColor: '#f4f0ff',
swatch: ['#f4f0ff', '#6d4aff', '#d3f5e7', '#ffe1d1'],
},
bauklotz: {
label: 'Bauklotz',
description: 'Primärfarben und dicke schwarze Konturen.',
dark: false,
themeColor: '#ffd500',
swatch: ['#fffbef', '#1e5eff', '#ffd500', '#e63b2e'],
},
schlicht: {
label: 'Schlicht',
description: 'Neutrales Grau, wie bis Version 0.3.',
dark: false,
themeColor: '#ffffff',
swatch: ['#ffffff', '#171717', '#f5f5f5', '#a3a3a3'],
},
};
export function isDesign(value: unknown): value is Design {
return typeof value === 'string' && (DESIGNS as readonly string[]).includes(value);
}
/** Cookie value to design; anything unknown falls back to the default. */
export function resolveDesign(value: string | null | undefined): Design {
return isDesign(value) ? value : DEFAULT_DESIGN;
}
/** Number of placeholder tones every design defines (`--tone-1` ...). */
export const TONE_COUNT = 5;
/** The manufacturers named in the brief get a fixed, recognisable tone. */
const KNOWN_TONES: Record<string, number> = {
lego: 1,
playmobil: 2,
cobi: 3,
bluebrixx: 4,
megabloks: 5,
};
/**
* Stable tone for a set without an image, derived from its manufacturer, so
* all Lego placeholders share a colour and Playmobil gets another one.
* Returns 1 ... TONE_COUNT.
*/
export function toneIndex(manufacturerName: string): number {
const key = manufacturerName.toLowerCase().replace(/[^a-z0-9]/g, '');
const known = KNOWN_TONES[key];
if (known) return known;
let hash = 0;
for (const char of key) {
hash = (hash * 31 + char.codePointAt(0)!) >>> 0;
}
return (hash % TONE_COUNT) + 1;
}