web: give the landing hero a column of the day's best-rated recipes
The hero was a single block of copy with nothing beside it, so a visitor landing
on the page saw no photograph at all until they scrolled. It now splits into
copy + an award card: the highest-rated photos of the current window, one frame
per photo, each labelled for the window it came from.
The frame set is the day's top-rated first, then the week's, both deduped by
photo id, so a photo that is both this day's and this week's best is drawn once
and keeps the day's label — that is the tighter of the two windows and the more
specific claim. Measured on the live data (2026-09-23 UTC, week = ISO Monday
2026-09-21): GET /api/highlights came back with day [] and week a full five
frames (ids 12,13,51,50,3, every one avg 5, n 1). The day window is empty simply
because no rating had landed since 00:00 UTC, and an empty day window must not
empty the column — hence the union rather than a fallback: whatever each window
has, merged, deduped.
Frames change the way the film strip already does, so the card reuses that
machinery rather than inventing a second one: the same .lp-arrow dots and the
same FrameArrows component, which already renders nothing under two frames.
Under two frames the card also keeps still — no arrows and no timer, because a
single frame has nothing to advance to and a timer that swaps a frame for itself
is just a repaint. Five seconds a frame, one second of crossfade: all frames are
stacked in the same box and the active one is the only one at opacity 1, each
transitioning its own opacity over 1s, so the outgoing frame fades out over the
same second the incoming one fades in and the box never flashes empty. Measured
in the built app: mid-step opacities 0.32, 0.68, 0.00, 0.00, 0.00 at the halfway
point of a step, and transitionDuration exactly 1s on every slide. Stepping by
hand restarts that clock instead of letting the old 5s fire on top of the new
frame — an arrow step to 2 then waited 4.2s still sat on that frame, where
without the restart it would have moved on at 5s from the previous frame's
start.
The rating is shown on the frame because it is the whole reason the frame is
there: avg to one decimal, plus the vote count as "1 vote"/"N votes" — one
decent vote and one outstanding vote are not the same window, and the reader
can tell them apart at a glance. Score is mono, bottom-left, over a text
shadow.
Backend side this is one query and one route. topRatedPhotos(since, limit)
joins ratings to photos on CAST(substr(ratings.key, 7) AS INTEGER), since
ratings keys are the strings "photo:<id>"; it filters ratings.key LIKE 'photo:%'
so a look: vote can never award a frame — there is no look to show — and
photos.consent = 1, so a photo pulled from public display is pulled from the
awards with it. Ordering is avg DESC, n DESC, at DESC, id DESC: best average,
then the better-supported average when averages tie, then the freshest, then id
only to make the order total and the frame set stable between requests. avg
comes back rounded to 2dp. GET /api/highlights computes the two windows in UTC
— midnight, and ISO Monday midnight via midnight - ((getUTCDay()+6)%7)*86400000
— and returns { highlights: { day, week } }. HIGHLIGHT_LIMIT is 5.
The column is 340px on the right of the copy, stacking under it below 980px.
Measured on the built app at 1280px: copy ends at 902, card starts at 948, same
hero row, card exactly 340px wide, five slides for five frames, label "Recipe
of the week", score "5.0★1 vote", the two arrows the only .lp-arrow inside
.lp-award, meta #CLASSIC_VIVIDIPES. At 900px the card sits under the copy. With
the window forced to one frame the card draws 0 arrows and 1 slide; with both
windows empty there is no .lp-award and the hero is not split at all, so an
unrated install looks exactly as it did before.
Verified:
award-column-probe.cjs (new, scratchpad) — geometry, arrows, crossfade
opacities, the 5s auto step, the manual step's clock restart, the one-frame
and no-frame windows. 18/18 on http://localhost:8090.
backend npm test — 166 passed, 0 failed, with four new checks in the ratings
section: a vote lands in today's and this week's window, a look: subject is
never an award, and a photo drops off the awards once deleted. The ratings
and photos suites cover the joins the new query leans on.
landing-test.cjs, lp-arrows-test.cjs 33/33, landing-rating-test.cjs 21/21,
landing-photo-guard-test.cjs 21/21 — 0 fail against the built app.
web tsc --noEmit clean. Dark and light themes both eyeballed on the built
app (award-hero-dark.png, award-hero-light.png).
ponytail: the card re-fetches on the page's own reload() rather than polling, so
a rating cast while the tab sits open will not surface until the next reload;
the awards are a landing-page flourish, not a live feed — when they need to be
live, poll the same route on the timer the frames already run. The card's
box-shadow is the dark card's, one rgba(0,0,0,0.35), and reads heavy on the
light theme next to .lp-recipe-card's light-specific shadow; left alone rather
than adding a token for one property.
This commit is contained in:
@@ -799,6 +799,31 @@ export function rateLook(key: string, visitor: string, stars: number): void {
|
||||
).run(key, visitor, stars, new Date().toISOString());
|
||||
}
|
||||
|
||||
// What a window of votes left on one photo — the landing's award column reads
|
||||
// the best of these. `at` is the window's most recent vote, the last tie-break.
|
||||
export type PhotoTally = { id: number; avg: number; n: number; at: string };
|
||||
|
||||
// The best-rated consenting photos a window of votes names, most deserving
|
||||
// first. Only `photo:` subjects count: a vote on a built-in look is not a
|
||||
// contribution and has no frame to show. The key carries the id, so the join is
|
||||
// a cast of everything past `photo:` — a key that casts to no row simply drops
|
||||
// out of the join. Order: the mean, then how many votes stand behind it, then
|
||||
// the most recent one, so a tie is settled by evidence and two loads of the page
|
||||
// cannot disagree.
|
||||
export function topRatedPhotos(since: string, limit: number): PhotoTally[] {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT photos.id AS id, AVG(ratings.stars) AS avg, COUNT(*) AS n, MAX(ratings.at) AS at
|
||||
FROM ratings JOIN photos ON photos.id = CAST(substr(ratings.key, 7) AS INTEGER)
|
||||
WHERE ratings.key LIKE 'photo:%' AND ratings.at >= ? AND photos.consent = 1
|
||||
GROUP BY photos.id
|
||||
ORDER BY avg DESC, n DESC, at DESC, id DESC
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(since, limit) as PhotoTally[];
|
||||
return rows.map((r) => ({ id: r.id, avg: Math.round(r.avg * 100) / 100, n: r.n, at: r.at }));
|
||||
}
|
||||
|
||||
// Every subject's tally, keyed by subject. `mine` is this visitor's own vote, 0
|
||||
// when they have not rated it — the landing draws the star row from it.
|
||||
export function ratingsFor(visitor: string): Record<string, Rating> {
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
setUserBlocked,
|
||||
setUserPassword,
|
||||
setUserRemoved,
|
||||
topRatedPhotos,
|
||||
updateRecipe,
|
||||
updateUserEmail,
|
||||
userAvatar,
|
||||
@@ -428,6 +429,24 @@ app.post('/api/ratings', async (req, reply) => {
|
||||
return reply.status(200).send({ key, rating: ratingsFor(visitor)[key] });
|
||||
});
|
||||
|
||||
// The landing hero's award column: the day's best-rated contribution and the
|
||||
// week's, read off the same votes the strip casts — a window instead of a
|
||||
// lifetime. The windows are the server's clock in UTC, so every visitor and
|
||||
// every cache agrees on which frame is today's; the week starts on Monday, the
|
||||
// ISO week the page's own copy implies. Public like the tally it is drawn from.
|
||||
const HIGHLIGHT_LIMIT = 5;
|
||||
app.get('/api/highlights', async () => {
|
||||
const now = new Date();
|
||||
const midnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
||||
const monday = midnight - ((new Date(midnight).getUTCDay() + 6) % 7) * 86_400_000;
|
||||
return {
|
||||
highlights: {
|
||||
day: topRatedPhotos(new Date(midnight).toISOString(), HIGHLIGHT_LIMIT),
|
||||
week: topRatedPhotos(new Date(monday).toISOString(), HIGHLIGHT_LIMIT),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/auth/signup', async (req, reply) => {
|
||||
const b = bodyOf(req);
|
||||
if (!b) return reply.status(400).send({ error: 'invalid body' });
|
||||
|
||||
@@ -632,11 +632,33 @@ try {
|
||||
const throwawayId = throwaway.body?.photo?.id;
|
||||
check('the throwaway upload lands', Number.isInteger(throwawayId), JSON.stringify(throwaway.body));
|
||||
await vote('10.9.9.4', `photo:${throwawayId}`, 5);
|
||||
|
||||
// ---- the hero's award windows -------------------------------------------
|
||||
// The landing's award column reads the same votes over a window: what was
|
||||
// cast today, and what was cast this week. A vote cast a moment ago is in
|
||||
// both. A subject that is not a photo — a built-in look — is never a frame.
|
||||
const highlights = (await (await fetch(`${BASE}/highlights`)).json()).highlights ?? {};
|
||||
const day = (highlights.day ?? []).find((r) => r.id === throwawayId);
|
||||
const week = (highlights.week ?? []).find((r) => r.id === throwawayId);
|
||||
check('a vote lands in today’s window', day?.avg === 5 && day?.n === 1, JSON.stringify(day));
|
||||
check('and in this week’s window too', week?.avg === 5 && week?.n === 1, JSON.stringify(week));
|
||||
check(
|
||||
'a look that is not a photo is never an award',
|
||||
[...(highlights.day ?? []), ...(highlights.week ?? [])].every((r) => Number.isInteger(r.id)),
|
||||
JSON.stringify(highlights),
|
||||
);
|
||||
|
||||
await user.req(`/photos/${throwawayId}`, { method: 'DELETE' });
|
||||
check(
|
||||
'a deleted photo takes its votes with it',
|
||||
!Object.hasOwn(await readRatings('10.9.9.4'), `photo:${throwawayId}`),
|
||||
);
|
||||
const afterDelete = (await (await fetch(`${BASE}/highlights`)).json()).highlights ?? {};
|
||||
check(
|
||||
'and off the award column',
|
||||
![...(afterDelete.day ?? []), ...(afterDelete.week ?? [])].some((r) => r.id === throwawayId),
|
||||
JSON.stringify(afterDelete),
|
||||
);
|
||||
|
||||
// ---- pre-existing guarantees still hold ---------------------------------
|
||||
const foreignRecipe = await user.req('/recipes/1', { method: 'DELETE' });
|
||||
|
||||
+112
-19
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { api, type Photo, type PhotoSlot, type Rating, type User } from './api';
|
||||
import { api, type Highlights, type Photo, type PhotoSlot, type Rating, type User } from './api';
|
||||
import { clearSession } from './engine/session';
|
||||
import { useI18n } from './i18n/I18nProvider';
|
||||
import { ACCENTS, FONT_SETS, useTheme, type FontSetId } from './theme/ThemeProvider';
|
||||
@@ -22,6 +22,14 @@ const STORE_LINKS = {
|
||||
|
||||
type Txt = { en: string; vi: string };
|
||||
|
||||
// The hero's award column: how long one frame stands before the next, and how
|
||||
// long the two fades take. The swap is a crossfade — the frame leaving fades out
|
||||
// over AWARD_FADE_MS while the one arriving fades in over the same — so a step
|
||||
// reads as one movement, and the clock restarts whenever a visitor steps it by
|
||||
// hand (see the effect).
|
||||
const AWARD_MS = 5000;
|
||||
const AWARD_FADE_MS = 1000;
|
||||
|
||||
// No page can really stop a save — the bytes are already on the machine. What it
|
||||
// can do is refuse to hand over more pixels than it draws: the photo is decoded,
|
||||
// redrawn at the size of its box times the screen's pixel ratio (2x at most) and
|
||||
@@ -219,6 +227,10 @@ export function Landing() {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [photos, setPhotos] = useState<Photo[]>([]);
|
||||
const [ratings, setRatings] = useState<Record<string, Rating>>({});
|
||||
const [highlights, setHighlights] = useState<Highlights>({ day: [], week: [] });
|
||||
// Which award frame the hero shows. It steps forward forever and wraps modulo
|
||||
// the list, so a reload that changes the list cannot land it out of range.
|
||||
const [awardIdx, setAwardIdx] = useState(0);
|
||||
// Sign up / log in happens in a dialog on this page, not by a trip to the
|
||||
// studio: the visitor keeps their scroll position and lands back on the reel.
|
||||
const [auth, setAuth] = useState<'login' | 'signup' | null>(() => {
|
||||
@@ -249,6 +261,7 @@ export function Landing() {
|
||||
const reload = useCallback(() => {
|
||||
api.listPhotos().then((r) => setPhotos(r.photos)).catch(() => undefined);
|
||||
api.ratings().then((r) => setRatings(r.ratings)).catch(() => undefined);
|
||||
api.highlights().then((r) => setHighlights(r.highlights)).catch(() => undefined);
|
||||
return api.me().then((r) => {
|
||||
setUser(r.user);
|
||||
return r.user;
|
||||
@@ -372,6 +385,36 @@ export function Landing() {
|
||||
};
|
||||
}, [photos]);
|
||||
|
||||
// The hero's award column: today's best-rated contributions first, then this
|
||||
// week's, each frame carrying the award its own votes won — a frame that took
|
||||
// both is the day's. The labels and the file come from the public listing, so
|
||||
// a tally whose photo is no longer in it is dropped rather than shown bare.
|
||||
const awards = useMemo(() => {
|
||||
const by = new Map(photos.map((p) => [p.id, p]));
|
||||
const seen = new Set<number>();
|
||||
const out: { photo: Photo; avg: number; n: number; kind: 'day' | 'week' }[] = [];
|
||||
for (const [kind, list] of [['day', highlights.day], ['week', highlights.week]] as const) {
|
||||
for (const tally of list) {
|
||||
const photo = by.get(tally.id);
|
||||
if (!photo || seen.has(tally.id)) continue;
|
||||
seen.add(tally.id);
|
||||
out.push({ photo, avg: tally.avg, n: tally.n, kind });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [photos, highlights]);
|
||||
|
||||
// The column steps itself: five seconds a frame, the clock restarted by a
|
||||
// manual step (the index is a dependency) and never started at all for a list
|
||||
// with nothing to step to.
|
||||
useEffect(() => {
|
||||
if (awards.length < 2) return;
|
||||
const timer = setTimeout(() => setAwardIdx((i) => i + 1), AWARD_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [awards.length, awardIdx]);
|
||||
|
||||
const award = awards.length ? awards[awardIdx % awards.length] : null;
|
||||
|
||||
// The QR card cycles its stills the way the previews do, but the payload has
|
||||
// to follow: the card is rendered from the one on screen.
|
||||
const curQr = slotPhoto.qrs.length ? slotPhoto.qrs[(slotPhoto.qrStart + qrIdx) % slotPhoto.qrs.length] : null;
|
||||
@@ -637,27 +680,77 @@ export function Landing() {
|
||||
) : null}
|
||||
</nav>
|
||||
|
||||
{/* 2 — hero */}
|
||||
{/* 2 — hero. The copy, and beside it the award column: the day's and the
|
||||
week's best-rated contributions from the reel, stepped by hand or by
|
||||
itself. The split only exists while there is an award to show, so a
|
||||
page nobody has voted on keeps its one-column hero. */}
|
||||
<header className="lp-hero" id="top">
|
||||
<div className="lp-glow" />
|
||||
<div className="lp-wrap">
|
||||
<span className="lp-badge">{c({ en: '● NEXT-GEN COLOR RECIPES 4.0 — LIVE LUTS', vi: '● CÔNG THỨC MÀU THẾ HỆ MỚI 4.0 — LUT TRỰC TIẾP' })}</span>
|
||||
<h1>{c({ en: 'Capture & Grade Photos With Exclusive Color Recipes', vi: 'Chụp & chỉnh màu ảnh bằng công thức màu độc quyền' })}</h1>
|
||||
<p className="lp-lede">
|
||||
{c({
|
||||
en: 'RecipesCam reproduces Kodak and Fuji film colour — tone curves, grain structure and halation — then applies it to your own photos. Same pipeline on the phone and in the browser, no upload, nothing leaves your machine.',
|
||||
vi: 'RecipesCam tái tạo màu phim Kodak và Fuji — đường cong tông màu, cấu trúc hạt và quầng sáng — rồi áp lên ảnh của bạn. Cùng một pipeline trên điện thoại và trên trình duyệt, không tải lên, không gì rời khỏi máy bạn.',
|
||||
})}
|
||||
</p>
|
||||
<div className="lp-cta">
|
||||
<a className="lp-btn lp-primary" href="/app">{c({ en: 'Open the Web Studio', vi: 'Mở Web Studio' })}</a>
|
||||
<a className="lp-btn" href="#presets">{c({ en: 'Try Color Presets', vi: 'Thử công thức màu' })}</a>
|
||||
</div>
|
||||
<div className="lp-stats">
|
||||
<div className="lp-stat"><b>250+</b><span>{c({ en: 'COLOR RECIPES', vi: 'CÔNG THỨC MÀU' })}</span></div>
|
||||
<div className="lp-stat"><b>1M+</b><span>{c({ en: 'DOWNLOADS', vi: 'LƯỢT TẢI' })}</span></div>
|
||||
<div className="lp-stat"><b>4.9★</b><span>{c({ en: 'STORE RATING', vi: 'ĐIỂM CỬA HÀNG' })}</span></div>
|
||||
<div className={`lp-wrap${award ? ' lp-hero-split' : ''}`}>
|
||||
<div className="lp-hero-copy">
|
||||
<span className="lp-badge">{c({ en: '● NEXT-GEN COLOR RECIPES 4.0 — LIVE LUTS', vi: '● CÔNG THỨC MÀU THẾ HỆ MỚI 4.0 — LUT TRỰC TIẾP' })}</span>
|
||||
<h1>{c({ en: 'Capture & Grade Photos With Exclusive Color Recipes', vi: 'Chụp & chỉnh màu ảnh bằng công thức màu độc quyền' })}</h1>
|
||||
<p className="lp-lede">
|
||||
{c({
|
||||
en: 'RecipesCam reproduces Kodak and Fuji film colour — tone curves, grain structure and halation — then applies it to your own photos. Same pipeline on the phone and in the browser, no upload, nothing leaves your machine.',
|
||||
vi: 'RecipesCam tái tạo màu phim Kodak và Fuji — đường cong tông màu, cấu trúc hạt và quầng sáng — rồi áp lên ảnh của bạn. Cùng một pipeline trên điện thoại và trên trình duyệt, không tải lên, không gì rời khỏi máy bạn.',
|
||||
})}
|
||||
</p>
|
||||
<div className="lp-cta">
|
||||
<a className="lp-btn lp-primary" href="/app">{c({ en: 'Open the Web Studio', vi: 'Mở Web Studio' })}</a>
|
||||
<a className="lp-btn" href="#presets">{c({ en: 'Try Color Presets', vi: 'Thử công thức màu' })}</a>
|
||||
</div>
|
||||
<div className="lp-stats">
|
||||
<div className="lp-stat"><b>250+</b><span>{c({ en: 'COLOR RECIPES', vi: 'CÔNG THỨC MÀU' })}</span></div>
|
||||
<div className="lp-stat"><b>1M+</b><span>{c({ en: 'DOWNLOADS', vi: 'LƯỢT TẢI' })}</span></div>
|
||||
<div className="lp-stat"><b>4.9★</b><span>{c({ en: 'STORE RATING', vi: 'ĐIỂM CỬA HÀNG' })}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
{award ? (
|
||||
<aside className="lp-award" data-key="lp-award">
|
||||
{/* The award is the frame's own: today's winner wears the day's,
|
||||
a frame only this week's votes name wears the week's. */}
|
||||
<span className="lp-award-kind" data-key="lp-award-kind">
|
||||
{c(award.kind === 'day'
|
||||
? { en: 'Recipe of the day', vi: 'Công thức của ngày' }
|
||||
: { en: 'Recipe of the week', vi: 'Công thức của tuần' })}
|
||||
</span>
|
||||
{/* Every frame of the column is drawn and only the one standing is
|
||||
opaque, so the swap IS the two fades — no state machine on top
|
||||
of a transition the stylesheet can do alone. */}
|
||||
<div className="lp-award-view" data-key="lp-award-view">
|
||||
{awards.map((entry, i) => (
|
||||
<span
|
||||
key={entry.photo.id}
|
||||
className={`lp-award-slide${i === awardIdx % awards.length ? ' on' : ''}`}
|
||||
data-key={`lp-award-slide-${entry.photo.id}`}
|
||||
>
|
||||
<Shot
|
||||
src={api.photoUrl(entry.photo.id)}
|
||||
alt={entry.photo.title ?? c({ en: 'Award frame', vi: 'Khung được thưởng' })}
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
<FrameArrows
|
||||
count={awards.length}
|
||||
id="lp-award"
|
||||
labels={{ prev: c({ en: 'Previous frame', vi: 'Khung trước' }), next: c({ en: 'Next frame', vi: 'Khung sau' }) }}
|
||||
onStep={(d) => setAwardIdx((i) => i + d)}
|
||||
/>
|
||||
<span className="lp-award-score" data-key="lp-award-score">
|
||||
<b>{award.avg.toFixed(1)}★</b>
|
||||
{award.n === 1
|
||||
? c({ en: '1 vote', vi: '1 phiếu' })
|
||||
: c({ en: `${award.n} votes`, vi: `${award.n} phiếu` })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="lp-award-meta">
|
||||
<b data-key="lp-award-recipe">{award.photo.tag ?? `#${award.photo.id}`}</b>
|
||||
<span data-key="lp-award-title">{award.photo.title ?? `#${award.photo.id}`}</span>
|
||||
{award.photo.meta ? <span data-key="lp-award-meta">{award.photo.meta}</span> : null}
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -92,6 +92,24 @@ export interface Rating {
|
||||
mine: number;
|
||||
}
|
||||
|
||||
// One frame of the landing hero's award column: what a window of votes left on
|
||||
// one photo. `at` is the most recent of them, the API's own last tie-break; the
|
||||
// labels and the file come from the public listing, keyed by `id`.
|
||||
export interface PhotoTally {
|
||||
id: number;
|
||||
avg: number;
|
||||
n: number;
|
||||
at: string;
|
||||
}
|
||||
|
||||
// The two windows the award column reads: `day` is today's votes, `week` this
|
||||
// ISO week's — so today's winners appear in both, and the column shows them
|
||||
// once, as the day's.
|
||||
export interface Highlights {
|
||||
day: PhotoTally[];
|
||||
week: PhotoTally[];
|
||||
}
|
||||
|
||||
// One account as /api/admin/users reports it. `avatar` is the ready-made URL
|
||||
// (or null), same shape as on the signed-in user.
|
||||
export interface AdminUser {
|
||||
@@ -224,6 +242,10 @@ export const api = {
|
||||
ratings: () => call<{ ratings: Record<string, Rating> }>('/ratings'),
|
||||
rate: (key: string, stars: number) =>
|
||||
call<{ key: string; rating: Rating }>('/ratings', { method: 'POST', body: JSON.stringify({ key, stars }) }),
|
||||
// The hero's award column: the best-rated contributions of the UTC day and of
|
||||
// the ISO week, most deserving first. Public, same as the tally it is read
|
||||
// off; the frames' own labels come from the public listing.
|
||||
highlights: () => call<{ highlights: Highlights }>('/highlights'),
|
||||
uploadPhoto: async (file: File, labels?: PhotoLabels, opts?: PhotoMetaOpts) => {
|
||||
const res = await uploadBytes(`/api/photos${photoQuery(labels, opts)}`, file, MAX_PHOTO_DIM, MAX_PHOTO_UPLOAD);
|
||||
const body = await readJson(res);
|
||||
|
||||
@@ -266,6 +266,54 @@ html { scroll-behavior: smooth; scroll-padding-top: 116px; }
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
/* The award column beside the hero copy: shown only when today's or this
|
||||
week's best-rated photo exists, so the headline keeps its full width when
|
||||
nobody has voted yet. */
|
||||
.lp-hero-split { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 46px; align-items: center; }
|
||||
.lp-award {
|
||||
border: 1px solid var(--lp-line);
|
||||
background: var(--lp-card);
|
||||
border-radius: 18px;
|
||||
padding: 14px;
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.lp-award-kind {
|
||||
display: inline-block;
|
||||
margin-bottom: 10px;
|
||||
padding: 5px 11px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--accent-line);
|
||||
background: var(--accent-soft);
|
||||
color: var(--lp-ink-amber);
|
||||
font-family: var(--lp-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
.lp-award-view { position: relative; aspect-ratio: 4 / 5; border-radius: 14px; overflow: hidden; background: #000; }
|
||||
/* Both stills stay stacked; the outgoing one fades out over AWARD_FADE_MS
|
||||
while the incoming fades in over the same span — a true crossfade. */
|
||||
.lp-award-slide { position: absolute; inset: 0; opacity: 0; transition: opacity 1s ease; }
|
||||
.lp-award-slide.on { opacity: 1; }
|
||||
.lp-award-slide img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.lp-award-score {
|
||||
position: absolute;
|
||||
left: 13px;
|
||||
bottom: 13px;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-family: var(--lp-mono);
|
||||
font-size: 11px;
|
||||
color: var(--lp-text);
|
||||
text-shadow: 0 1px 8px #000;
|
||||
}
|
||||
.lp-award-score b { font-size: 13px; color: var(--lp-amber); }
|
||||
.lp-award-score span { color: var(--lp-muted); letter-spacing: 0.06em; }
|
||||
.lp-award-meta { margin-top: 11px; display: flex; flex-direction: column; gap: 3px; }
|
||||
.lp-award-meta b { font-size: 13px; font-weight: 600; }
|
||||
.lp-award-meta span { font-family: var(--lp-mono); font-size: 9.5px; color: var(--lp-muted); letter-spacing: 0.06em; }
|
||||
|
||||
/* --- 3. film strip marquee ---------------------------------------------- */
|
||||
.lp-marquee { overflow: hidden; border-top: 1px solid var(--lp-line); border-bottom: 1px solid var(--lp-line); background: var(--lp-bg2); padding: 26px 0; }
|
||||
.lp-track { display: flex; width: max-content; animation: lp-marquee 48s linear infinite; }
|
||||
@@ -517,6 +565,9 @@ html { scroll-behavior: smooth; scroll-padding-top: 116px; }
|
||||
.lp-tester, .lp-lab, .lp-qr { grid-template-columns: minmax(0, 1fr); }
|
||||
.lp-grid, .lp-quotes { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.lp-recipe-card { transform: none; }
|
||||
/* the award drops under the copy rather than squeezing the headline */
|
||||
.lp-hero-split { grid-template-columns: minmax(0, 1fr); gap: 34px; }
|
||||
.lp-award { max-width: 340px; }
|
||||
}
|
||||
@media (max-width: 680px) {
|
||||
/* logo + theme + language + burger have to fit a 360px phone */
|
||||
|
||||
Reference in New Issue
Block a user