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
Binary file modified public/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified src/assets/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
43 changes: 43 additions & 0 deletions src/components/Countdown.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,49 @@
line-height: 1;
}

// Compact — one line, unit letters as a suffix on the digits instead of a
// caption underneath: `069d 22h 43m 21s`. Used where the clock sits under a
// poster-scale statement and four captioned stubs would out-weigh it.
.countdownCompact {
gap: 0.55rem;

// Sized to sit beside the footing strip's DATE/PLACE values, not tower
// over them — the full four-stub clock is display-scale on its own line;
// this one shares a row with the rest of the meta.
.value {
font-size: clamp(1.3rem, 1.75vw, 1.7rem);
}
}

// Reserves the days digit's own width at its longest (three figures, the
// stretch above 99 days) so the 100 → 99 rollover doesn't nudge the suffix
// and everything after it — a printed leading zero would do the same job
// but read as part of the count.
.valueDays {
min-width: 3ch;
text-align: right;
}

.unitCompact {
flex-direction: row;
align-items: baseline;
gap: 0.06em;

.label {
font-family: var(--font-bebas-neue), sans-serif;
font-size: clamp(1rem, 1.4vw, 1.3rem);
letter-spacing: 0.02em;
text-transform: lowercase;
color: rgba(240, 237, 230, 0.5);
}
}

@media (max-width: 600px) {
.countdownCompact .value {
font-size: 1.3rem;
}
}

@media (max-width: 600px) {
.countdown {
gap: 0.35rem;
Expand Down
53 changes: 36 additions & 17 deletions src/components/Countdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,34 +14,53 @@ function calcTimeLeft(): TimeLeft {
const diff = TARGET - Date.now();

if (diff <= 0) {
return { days: '000', hours: '00', minutes: '00', seconds: '00' };
return { days: '0', hours: '00', minutes: '00', seconds: '00' };
}

return {
days: String(Math.floor(diff / (1000 * 60 * 60 * 24))).padStart(3, '0'),
days: String(Math.floor(diff / (1000 * 60 * 60 * 24))),
hours: String(Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))).padStart(2, '0'),
minutes: String(Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))).padStart(2, '0'),
seconds: String(Math.floor((diff % (1000 * 60)) / 1000)).padStart(2, '0'),
};
}

const UNITS: { key: keyof TimeLeft; label: string }[] = [
{ key: 'days', label: 'Days' },
{ key: 'hours', label: 'Hrs' },
{ key: 'minutes', label: 'Min' },
{ key: 'seconds', label: 'Sec' },
const UNITS: { key: keyof TimeLeft; label: string; suffix: string }[] = [
{ key: 'days', label: 'Days', suffix: 'd' },
{ key: 'hours', label: 'Hrs', suffix: 'h' },
{ key: 'minutes', label: 'Min', suffix: 'm' },
{ key: 'seconds', label: 'Sec', suffix: 's' },
];

const INITIAL_TIME: TimeLeft = { days: '000', hours: '00', minutes: '00', seconds: '00' };
const INITIAL_TIME: TimeLeft = { days: '0', hours: '00', minutes: '00', seconds: '00' };

export default function Countdown() {
interface Props {
/**
* `069d 22h 43m 21s` on one line instead of four labelled stubs separated
* by colons. The suffix is real rendered text on the unit, not a stylesheet
* `::after` — a generated-content suffix reads fine visually but is
* invisible to anything that inspects the DOM text.
*/
compact?: boolean;
/**
* Drop the seconds unit — `54D 10H 34M`, one rhythm. Seconds ticking next
* to the hero's own rotating topic is a second, unrelated clock in the
* same shot. Without seconds the display only needs to update once a
* minute; the 15s interval is just slack so a stale minute never survives
* more than that long, not a tick visitors are meant to notice.
*/
showSeconds?: boolean;
}

export default function Countdown({ compact = false, showSeconds = true }: Props) {
const [time, setTime] = useState<TimeLeft>(INITIAL_TIME);
const units = showSeconds ? UNITS : UNITS.filter((u) => u.key !== 'seconds');

useEffect(() => {
setTime(calcTimeLeft());
const id = setInterval(() => setTime(calcTimeLeft()), 1000);
const id = setInterval(() => setTime(calcTimeLeft()), showSeconds ? 1000 : 15000);
return () => clearInterval(id);
}, []);
}, [showSeconds]);

// The clock is decorative — screen readers get the static "doors open"
// sentence instead. A per-second aria-label would spam AT without adding
Expand All @@ -54,13 +73,13 @@ export default function Countdown() {
return (
<>
<span className={s.srOnly}>Doors open on 30 October 2026 at 9:00 AM Central European Time.</span>
<div className={s.countdown} aria-hidden="true">
{UNITS.map(({ key, label }, i) => (
<div className={`${s.countdown} ${compact ? s.countdownCompact : ''}`} aria-hidden="true">
{units.map(({ key, label, suffix }, i) => (
<Fragment key={key}>
{i > 0 && <span className={s.sep}>:</span>}
<div className={s.unit}>
<span className={s.value}>{time[key]}</span>
<span className={s.label}>{label}</span>
{!compact && i > 0 && <span className={s.sep}>:</span>}
<div className={`${s.unit} ${compact ? s.unitCompact : ''}`}>
<span className={`${s.value} ${compact && key === 'days' ? s.valueDays : ''}`}>{time[key]}</span>
<span className={s.label}>{compact ? suffix : label}</span>
</div>
</Fragment>
))}
Expand Down
4 changes: 2 additions & 2 deletions src/components/Footer.astro
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ const SITEMAP = [
alt="DevFest.cz 2026"
class="footer-logo"
loading="lazy"
widths={[260, 520]}
sizes="(max-width: 500px) 60vw, 260px"
widths={[204, 408]}
sizes="(max-width: 500px) 60vw, 204px"
/>
</a>
<p class="brand-tagline">
Expand Down
2 changes: 1 addition & 1 deletion src/components/Footer.scss
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@

.footer-logo {
display: block;
width: clamp(190px, 18vw, 260px);
width: clamp(150px, 18vw, 204px);
height: auto;
}

Expand Down
26 changes: 24 additions & 2 deletions src/components/HeroBackground.astro
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,43 @@ interface Props {
*/
focus?: string;
mobileFocus?: string;
/**
* Opt-in `background-size` for the phone crop, e.g. `auto 130%`. The plate
* is a square and the phone frame is 9:19.5, so a bare `cover` at some
* focus points still leaves the subject too small to read at arm's length
* — `mobileFocus` alone repositions the crop, this scales it. Left unset,
* the phone crop stays `cover` like every other hero.
*/
mobileZoom?: string;
/** Opt-in desktop `width` for the plate. Defaults to the shipped 60%. */
width?: string;
/** Opt-in desktop `background-size`. Left unset, the desktop crop stays `cover`. */
size?: string;
/**
* Shortens the left-to-right dissolve so the plate reaches full opacity by
* 62% of its own width instead of 92% — a fade that stays transparent past
* its own midpoint turns most of a paid-for photograph into flat black.
*/
tightMask?: boolean;
}

const {
photoOpacity = 0.85,
mobilePhotoOpacity = 0.55,
focus = 'center',
mobileFocus = '38% center',
mobileZoom,
width,
size,
tightMask = false,
} = Astro.props;
---

<div class="hero-bg" aria-hidden="true"></div>
<div
class="hero-photo"
class={`hero-photo${tightMask ? ' hero-photo--tight-mask' : ''}`}
aria-hidden="true"
style={`--hero-photo-opacity:${photoOpacity};--hero-photo-opacity-mobile:${mobilePhotoOpacity};--hero-photo-pos:${focus};--hero-photo-pos-mobile:${mobileFocus};`}
style={`--hero-photo-opacity:${photoOpacity};--hero-photo-opacity-mobile:${mobilePhotoOpacity};--hero-photo-pos:${focus};--hero-photo-pos-mobile:${mobileFocus};${mobileZoom ? `--hero-photo-size-mobile:${mobileZoom};` : ''}${width ? `--hero-photo-width:${width};` : ''}${size ? `--hero-photo-size:${size};` : ''}`}
></div>

<style lang="scss">
Expand Down
34 changes: 30 additions & 4 deletions src/components/HeroBackground.scss
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
.hero-bg {
position: absolute;
inset: 0;
// One wash, and it answers to the frame: the lamp in the plate sits
// upper right, so that's the only light source here. A second bloom in
// the lower left answered to nothing in the shot and read as
// decoration rather than lighting.
background:
// distant neon — a red light source glowing in the alley.
radial-gradient(ellipse 36% 50% at 12% 78%, rgba(204, 0, 0, 0.16) 0%, transparent 62%),
radial-gradient(ellipse 30% 40% at 70% 18%, rgba(204, 0, 0, 0.07) 0%, transparent 60%),
#050505;
z-index: 0;
Expand All @@ -14,9 +16,9 @@
top: 0;
bottom: 0;
right: 0;
width: 60%;
width: var(--hero-photo-width, 60%);
background-image: url('/hero-detective.webp');
background-size: cover;
background-size: var(--hero-photo-size, cover);
background-position: var(--hero-photo-pos, center);
// Cinematic grade — push the detective plate to high-contrast B&W noir.
filter: grayscale(1) contrast(1.12) brightness(0.84);
Expand Down Expand Up @@ -44,8 +46,32 @@
pointer-events: none;
z-index: 1;

// A shorter dissolve for a frame that widened the plate: the shipped
// gradient is still transparent at half its own width, which on a wider
// plate turns most of a paid-for photograph into flat black. This one
// clears the type column and gets out of the way by 62%.
&.hero-photo--tight-mask {
mask-image: linear-gradient(
to right,
transparent 0%,
rgba(0, 0, 0, 0.1) 16%,
rgba(0, 0, 0, 0.34) 30%,
rgba(0, 0, 0, 0.72) 46%,
#000 62%
);
-webkit-mask-image: linear-gradient(
to right,
transparent 0%,
rgba(0, 0, 0, 0.1) 16%,
rgba(0, 0, 0, 0.34) 30%,
rgba(0, 0, 0, 0.72) 46%,
#000 62%
);
}

@media (max-width: 900px) {
width: 100%;
background-size: var(--hero-photo-size-mobile, cover);
background-position: var(--hero-photo-pos-mobile, 38% center);
opacity: var(--hero-photo-opacity-mobile, 0.55);
mask-image: linear-gradient(
Expand Down
8 changes: 4 additions & 4 deletions src/components/Menu.astro
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,15 @@ const isCurrent = (href: string) => {
<header id="site-header" class="site-header" data-state="top" data-home={isHome ? 'true' : 'false'}>
<a href="/" class="brand" aria-label="DevFest.cz 2026 — home">
<!-- Header sits above the fold on every page, so it loads eagerly; the
intrinsic width/height come from the source (5:1), and `widths` caps
the srcset at the 210px CSS maximum up to 2× DPR. -->
intrinsic width/height come from the source (4.2:1), and `widths` caps
the srcset at the 176px CSS maximum up to 2× DPR. -->
<Image
src={logo}
alt="DevFest.cz 2026"
class="brand-logo"
loading="eager"
widths={[210, 420]}
sizes="210px"
widths={[165, 330]}
sizes="176px"
/>
</a>

Expand Down
20 changes: 20 additions & 0 deletions src/components/Menu.scss
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,26 @@
border-color: var(--color-accent-hot);
}

// The home hero carries its own "Get tickets" button, in view whenever the
// bar is still transparent — a second route to the same place inside one
// screen. It comes back the moment the hero has scrolled away.
#site-header[data-home='true'][data-state='top'] .header-cta {
display: none;
}

// The toggle is the only bordered box left on the hero's first screen once
// the CTA above hands the accent to the hero's own button — a boxed
// control beside an unboxed one reads as two systems. Border comes back on
// hover/focus (below) and once the bar goes solid.
#site-header[data-home='true'][data-state='top'] .nav-toggle {
border-color: transparent;
}

#site-header[data-home='true'][data-state='top'] .nav-toggle:hover,
#site-header[data-home='true'][data-state='top'] .nav-toggle:focus-visible {
border-color: var(--color-text);
}

.nav {
display: flex;
align-items: center;
Expand Down
15 changes: 6 additions & 9 deletions src/lib/ticker.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { TOPICS } from './topics';

/**
* The running band's copy.
*
Expand All @@ -8,13 +10,8 @@
*
* `/partners` is the deliberate exception: that page is about partnering, not
* about the programme, so it keeps its own list (see `partners.astro`).
*
* Same five subjects as the hero's topic set (`TOPICS` in `topics.ts`), not a
* second list — no product names here either.
*/
export const EVENT_TOPICS = [
'AI & Machine Learning',
'Android & Kotlin',
'Web Technologies',
'Cloud & DevOps',
'Flutter & Dart',
'Security',
'Open Source',
] as const;
export const EVENT_TOPICS = TOPICS.map((t) => t.name);
21 changes: 21 additions & 0 deletions src/lib/topics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* The audited topic set shown under the hero statement and in the ticker.
*
* No product names. The set describes what the conference is about, and a
* subject outlives the vendor's naming — a track called after a specific
* model or OS dates the page the moment either is renamed, and reads as a
* sponsor list rather than a programme. The talks still name the products
* they are about; the hero and the ticker do not.
*
* Mobile and web are one entry, not two. Split, they read as two platform
* camps and the poster spends two of six lines on the same answer: what you
* ship to a person. Merged, accessibility stays in the description where it
* belongs — it is a property of the work, not a track beside it.
*/
export const TOPICS = [
{ n: '01', name: 'AI Agents', desc: 'Agents that touch real code and real systems.' },
{ n: '02', name: 'Generative UI', desc: 'LLMs as a product layer, not a demo.' },
{ n: '03', name: 'Cloud & Backends', desc: 'Where the AI actually runs — access, data, and the bill.' },
{ n: '04', name: 'Mobile & Web', desc: 'What you ship to a person — on every device, for every user.' },
{ n: '05', name: 'Humans in Tech', desc: 'Teams, trust, and the part that is not code.' },
] as const;
Loading