feat(planner): desktop redesign — flip tiles, full-width grid, no right panel #54
@@ -2,6 +2,7 @@
|
|||||||
import EmptyDayTile from './EmptyDayTile.svelte';
|
import EmptyDayTile from './EmptyDayTile.svelte';
|
||||||
import { formatDayAbbr } from '$lib/planner/week';
|
import { formatDayAbbr } from '$lib/planner/week';
|
||||||
import type { Recipe, Slot, Suggestion } from '$lib/planner/types';
|
import type { Recipe, Slot, Suggestion } from '$lib/planner/types';
|
||||||
|
import { sanitizeForCssUrl } from '$lib/planner/DesktopDayTile.utils';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
slot,
|
slot,
|
||||||
@@ -67,7 +68,7 @@
|
|||||||
|
|
||||||
const gradientBackground = $derived((() => {
|
const gradientBackground = $derived((() => {
|
||||||
if (!slot.recipe) return 'var(--color-surface)';
|
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');
|
const proteinTag = slot.recipe.tags?.find((t) => t.tagType === 'protein');
|
||||||
if (proteinTag?.name) {
|
if (proteinTag?.name) {
|
||||||
return `var(--gradient-protein-${toCssKey(proteinTag.name)})`;
|
return `var(--gradient-protein-${toCssKey(proteinTag.name)})`;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest';
|
|||||||
import { render, screen } from '@testing-library/svelte';
|
import { render, screen } from '@testing-library/svelte';
|
||||||
import { userEvent } from '@testing-library/user-event';
|
import { userEvent } from '@testing-library/user-event';
|
||||||
import DesktopDayTile from './DesktopDayTile.svelte';
|
import DesktopDayTile from './DesktopDayTile.svelte';
|
||||||
|
import { sanitizeForCssUrl } from './DesktopDayTile.utils';
|
||||||
|
|
||||||
const filledSlot = {
|
const filledSlot = {
|
||||||
id: 's1',
|
id: 's1',
|
||||||
@@ -18,6 +19,29 @@ const filledSlot = {
|
|||||||
|
|
||||||
const emptySlot = { id: null, slotDate: '2026-04-14', recipe: null };
|
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('DesktopDayTile — filled slot', () => {
|
||||||
describe('front face', () => {
|
describe('front face', () => {
|
||||||
it('renders recipe name on front face', () => {
|
it('renders recipe name on front face', () => {
|
||||||
|
|||||||
8
frontend/src/lib/planner/DesktopDayTile.utils.ts
Normal file
8
frontend/src/lib/planner/DesktopDayTile.utils.ts
Normal 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, '');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user