Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 89 additions & 10 deletions e2e/branding.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,42 @@ const PHOTO = path.join(process.cwd(), 'public', 'user.jpg');
// The sampled wedge (bottom 80 units of the ring, centred on 6 o'clock) is
// solid green on the flag, so any near-white pixel there is the label.
function labelPixels(bytes: Buffer): number {
return countPixels(
bytes,
(dx, dy, radius) =>
// The photo sits at a 7.5% inset, so the ring spans r 127.5 -> 150.
radius >= 126 && radius <= 150 && dy >= 100 && Math.abs(dx) <= 80,
(r, g, b) => r > 200 && g > 200 && b > 200,
);
}

// The name tag's red sticker sits over the bottom of the photo, inside the
// ring. The placeholder photo has no red, so red pixels there are the sticker.
function stickerPixels(bytes: Buffer): number {
return countPixels(
bytes,
(dx, dy, radius) => radius < 120 && dy > 50 && Math.abs(dx) < 70,
(r, g, b) => r > 170 && g < 70 && b < 80,
);
}

function countPixels(
bytes: Buffer,
inRegion: (dx: number, dy: number, radius: number) => boolean,
matches: (r: number, g: number, b: number) => boolean,
): number {
const image = PNG.sync.read(bytes);
// Normalise to the 300-unit frame the ring is drawn in, so the check holds
// whatever devicePixelRatio the browser rasterised at.
// Normalise to the 300-unit frame the overlays are drawn in, so the check
// holds whatever devicePixelRatio the browser rasterised at.
const unit = 150 / (image.width / 2);
let count = 0;
for (let y = 0; y < image.height; y++) {
for (let x = 0; x < image.width; x++) {
const dx = (x - image.width / 2) * unit;
const dy = (y - image.height / 2) * unit;
const radius = Math.hypot(dx, dy);
// The photo sits at a 7.5% inset, so the ring spans r 127.5 -> 150.
if (radius < 126 || radius > 150 || dy < 100 || Math.abs(dx) > 80) {
continue;
}
if (!inRegion(dx, dy, Math.hypot(dx, dy))) continue;
const { r, g, b, a } = pixelAt(image, x, y);
if (r > 200 && g > 200 && b > 200 && a > 200) count++;
if (a > 200 && matches(r, g, b)) count++;
}
}
return count;
Expand Down Expand Up @@ -61,12 +81,71 @@ test.describe('Short URL on the frame', () => {
test('rasterises into the downloaded picture once switched on', async ({
page,
}) => {
await page.getByRole('checkbox').check();
await page.getByRole('checkbox', { name: /ppm\.t4p\.al/ }).check();
expect(labelPixels(await downloadPicture(page))).toBeGreaterThan(50);
});

test('is off by default, leaving the frame untouched', async ({ page }) => {
await expect(page.getByRole('checkbox')).not.toBeChecked();
await expect(
page.getByRole('checkbox', { name: /ppm\.t4p\.al/ }),
).not.toBeChecked();
expect(labelPixels(await downloadPicture(page))).toBe(0);
});
});

test.describe('Co-brand link', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/gaza-status', (route) =>
route.fulfill({ json: { summary: 'Test status summary' } }),
);
// Which code unlocks which brand is a server secret; the page only needs
// the answer.
await page.route('**/api/brand?code=*', (route) =>
new URL(route.request().url()).searchParams.get('code') === 'members'
? route.fulfill({
json: { brand: 't4p-member', text: { link: 'join at t4p.al/e2e' } },
})
: route.fulfill({ status: 404 }),
);
});

test('turns the name tag on by default and replaces the short URL', async ({
page,
}) => {
await page.goto('/?b=members');
await page.setInputFiles('#fileInput', PHOTO);
await expect(
page.getByRole('checkbox', { name: /name tag/ }),
).toBeChecked();
// Text from the secret overrides the brand's default.
await expect(page.locator('textPath').first()).toHaveText(
'join at t4p.al/e2e',
);
await expect(
page.getByRole('checkbox', { name: /ppm\.t4p\.al/ }),
).toHaveCount(0);
const png = await downloadPicture(page);
expect(stickerPixels(png)).toBeGreaterThan(500);
expect(labelPixels(png)).toBeGreaterThan(50);
});

test('can be switched off', async ({ page }) => {
await page.goto('/?b=members');
await page.setInputFiles('#fileInput', PHOTO);
await page.getByRole('checkbox', { name: /name tag/ }).uncheck();
expect(stickerPixels(await downloadPicture(page))).toBe(0);
});

test('is not offered without a valid link', async ({ page }) => {
for (const url of ['/', '/?b=nope']) {
await page.goto(url);
await page.setInputFiles('#fileInput', PHOTO);
await expect(
page.getByRole('checkbox', { name: /ppm\.t4p\.al/ }),
).toBeVisible();
await expect(
page.getByRole('checkbox', { name: /name tag/ }),
).toHaveCount(0);
}
});
});
25 changes: 25 additions & 0 deletions src/app/api/brand/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { NextResponse, type NextRequest } from 'next/server';

import { resolveBrand } from '@/lib/brands';
import { LIMITS, rateLimit } from '@/lib/rate-limit';

export const runtime = 'edge';

/**
* GET /api/brand?code=<code>
*
* What a co-branding link unlocks (see `@/lib/brands`): `{ brand, text }`, or
* 404 for an unknown or retired code. Rate limited so codes can't be guessed
* by brute force. Not cached, so retiring a code takes effect at once.
*/
export async function GET(request: NextRequest) {
const limited = rateLimit(request, LIMITS.brand);
if (limited) return limited;
const link = resolveBrand(request.nextUrl.searchParams.get('code'));
return link
? NextResponse.json(link, { headers: { 'Cache-Control': 'no-store' } })
: new NextResponse(null, {
status: 404,
headers: { 'Cache-Control': 'no-store' },
});
}
34 changes: 34 additions & 0 deletions src/app/brands.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { ComponentType } from 'react';

import MemberTag from './member-tag';

/**
* Co-branded overlays, keyed by the brand id `BRAND_LINKS` maps link codes
* to (see `@/lib/brands`). Each one replaces the ring's short URL.
*
* `text` holds the defaults; the secret can override any key without a code
* change. `label` and `hint` are the checkbox shown to people who came through
* the link; every other key is the overlay's own.
*/
export type BrandText = { label: string; hint: string } & Record<
string,
string
>;

export interface Brand {
Overlay: ComponentType<{ text: BrandText }>;
text: BrandText;
}

export const BRANDS: Record<string, Brand> = {
't4p-member': {
Overlay: MemberTag,
text: {
label: 'Add the “Hello, I’m a T4P member” name tag',
hint: 'With a join link along the bottom of the frame.',
// TODO: placeholder until the t4p.al/j redirect to the membership page
// exists. Around 25 characters fit on the arc.
link: 'join at t4p.al/j',
},
},
};
8 changes: 8 additions & 0 deletions src/app/member-tag-marker.ts

Large diffs are not rendered by default.

98 changes: 98 additions & 0 deletions src/app/member-tag.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
'use client';
import { useId } from 'react';

import type { BrandText } from './brands';
import { MARKER_TEXT_PATH } from './member-tag-marker';

/*
* Membership-launch name tag: a red "HELLO, I'M A / T4P member" sticker over
* the bottom of the photo, with the join link curved along the flag ring.
* Like BrandingRing it's a pure overlay on the node the download rasterises,
* and it takes the ring's bottom arc, so the two are never shown together.
*
* Geometry, in the same 300-unit viewBox as BrandingRing (photo inset 7.5%,
* ring r 127.5 to r 150):
* - the sticker is 85% size, tilted 3deg, low enough to leave the face clear
* and high enough to stay off the link;
* - the link's arc is turned 12deg anticlockwise from 6 o'clock, which moves
* the start of "join" away from the sticker's lower-left corner;
* - baseline at r 143 keeps the "j" descenders inside the circle.
*/
const SIZE = 300;
const CENTER = SIZE / 2;
const BASELINE_RADIUS = 143;
const ARC_TURN = 12;

const at = (deg: number) => {
const rad = (deg * Math.PI) / 180;
return `${CENTER + BASELINE_RADIUS * Math.cos(rad)} ${CENTER + BASELINE_RADIUS * Math.sin(rad)}`;
};

export default function MemberTag({ text }: { text: BrandText }) {
// Rendered on the page and the story card at once; keep the ids apart.
const uid = useId().replace(/[^a-zA-Z0-9]/g, '');
const arcId = `ppm-member-arc-${uid}`;
const shadowId = `ppm-member-shadow-${uid}`;

// Bottom half-circle, left to right, rotated anticlockwise by ARC_TURN.
const arc =
`M ${at(180 - ARC_TURN)} ` +
`A ${BASELINE_RADIUS} ${BASELINE_RADIUS} 0 0 0 ${at(-ARC_TURN)}`;

return (
<svg
viewBox={`0 0 ${SIZE} ${SIZE}`}
style={{
position: 'absolute',
left: 0,
top: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
}}
>
<defs>
<path id={arcId} d={arc} fill="none" />
<filter id={shadowId} x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="2" stdDeviation="2.5" floodOpacity="0.35" />
</filter>
</defs>
<text
fill="#ffffff"
stroke="rgba(0, 0, 0, 0.35)"
strokeWidth={2}
paintOrder="stroke"
fontFamily="inherit"
fontSize={16}
fontWeight={700}
letterSpacing={1.5}
textAnchor="middle"
>
<textPath href={`#${arcId}`} xlinkHref={`#${arcId}`} startOffset="50%">
{text.link}
</textPath>
</text>
<g
transform="translate(150 231) scale(0.85) rotate(-3) translate(-150 -231)"
filter={`url(#${shadowId})`}
>
<rect x={72} y={196} width={156} height={72} rx={9} fill="#ce1126" />
<rect x={77} y={218} width={146} height={44} rx={3} fill="#ffffff" />
<text
x={150}
y={212}
textAnchor="middle"
fill="#ffffff"
fontFamily="inherit"
fontSize={11.5}
fontWeight={800}
letterSpacing={1.6}
>
HELLO, I&rsquo;M A
</text>
{/* "T4P member", handwritten; outlines, not text (see the import). */}
<path d={MARKER_TEXT_PATH} fill="#111111" />
</g>
</svg>
);
}
Loading
Loading