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:
co-authored by
Claude Opus 5.5
parent
e9093d0fad
commit
1b825b44c4
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user