feat(planner): desktop redesign — flip tiles, full-width grid, no right panel #54

Merged
marcel merged 30 commits from feat/issue-52-planner-flip-tiles into master 2026-04-10 15:44:39 +02:00
3 changed files with 34 additions and 1 deletions
Showing only changes of commit 4c87d9c134 - Show all commits

View File

@@ -2,6 +2,7 @@
import EmptyDayTile from './EmptyDayTile.svelte';
import { formatDayAbbr } from '$lib/planner/week';
import type { Recipe, Slot, Suggestion } from '$lib/planner/types';
import { sanitizeForCssUrl } from '$lib/planner/DesktopDayTile.utils';
let {
slot,
@@ -67,7 +68,7 @@
const gradientBackground = $derived((() => {
if (!slot.recipe) return 'var(--color-surface)';
if (slot.recipe.heroImageUrl) return `url(${slot.recipe.heroImageUrl})`;
if (slot.recipe.heroImageUrl) return `url("${sanitizeForCssUrl(slot.recipe.heroImageUrl)}")`;
const proteinTag = slot.recipe.tags?.find((t) => t.tagType === 'protein');
if (proteinTag?.name) {
return `var(--gradient-protein-${toCssKey(proteinTag.name)})`;

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { userEvent } from '@testing-library/user-event';
import DesktopDayTile from './DesktopDayTile.svelte';
import { sanitizeForCssUrl } from './DesktopDayTile.utils';
const filledSlot = {
id: 's1',
@@ -18,6 +19,29 @@ const filledSlot = {
const emptySlot = { id: null, slotDate: '2026-04-14', recipe: null };
describe('sanitizeForCssUrl', () => {
it('strips parentheses that could break out of url() context', () => {
expect(sanitizeForCssUrl('x);}body{display:none}/*')).not.toContain(')');
});
it('strips single quotes', () => {
expect(sanitizeForCssUrl("data:image/png;base64,abc'def")).not.toContain("'");
});
it('strips double quotes', () => {
expect(sanitizeForCssUrl('data:image/png;base64,abc"def')).not.toContain('"');
});
it('strips backslashes', () => {
expect(sanitizeForCssUrl('data:image/png;base64,abc\\def')).not.toContain('\\');
});
it('preserves a safe data URI unchanged', () => {
const safe = 'data:image/png;base64,abc123+/==';
expect(sanitizeForCssUrl(safe)).toBe(safe);
});
});
describe('DesktopDayTile — filled slot', () => {
describe('front face', () => {
it('renders recipe name on front face', () => {

View File

@@ -0,0 +1,8 @@
/**
* Strips characters that could break out of a CSS url() context or inject
* CSS into an inline style attribute. Safe data URIs (base64) are unaffected
* as they contain only A-Z, a-z, 0-9, +, /, = and the data: prefix.
*/
export function sanitizeForCssUrl(url: string): string {
return url.replace(/['"()\\]/g, '');
}