| name | DevFest.cz 2026 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| description | Noir, but loud, in the darkroom. Photographs are tilted paper prints on a flat black table, each with one tape and a numbered edge; the facts beside them are straight paper slips; a page spends one red grease-pencil mark. Poster-scale Bebas section heads on space, two controls, three voices (Bebas speaks, Special Elite tells, mono records figures), no arrow glyphs. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| colors |
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| typography |
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| rounded |
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| spacing |
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| components |
|
Design system for the DevFest.cz 2026 site. Every value was read out of the
codebase and carries its source as file:line. Rationale lives here, inline
with the rules; CLAUDE.md only points here.
| Label | Meaning |
|---|---|
| [MUST] | binding. A PR that breaks it should be sent back. Either enforced by npm run a11y, or a decision the redesign exists to protect |
| [CURRENT] | the state of the code today. Not a rule; changing it is a normal design decision |
Where the code contradicts itself, see "Open points" at the end. Line numbers are against the commit this document ships in; if one drifts, the token name is the durable reference.
src/layouts/BaseLayout.scss |
all tokens (:root, lines 2–176), resets, global primitives, the darkroom primitives (.paper, .paper-photo, .paper-edge, .tape, .grease). Injected is:global, so every .astro page and .tsx island uses the class names directly |
src/styles/_type.scss |
the type recipes as mixins, one per voice — note(), action(), cta-label(), mono(), display(), row-title(), prose(), hairline-link(), paper-action(). A CSS Module that cannot compose a global class includes the mixin instead |
src/styles/_logos.scss |
partner-mark recipe — partner-logo() (original colours; .logo--dark on a paper card) |
src/components/Sheet.module.scss, SpeakerTile.module.scss |
shared chrome composed by more than one island (the detail sheets; the speaker print and its grid, on / and /speakers) |
astro.config.mjs:72–106 |
the three brand faces, self-hosted via the Astro Fonts API |
src/components/*.module.scss |
co-located CSS Modules for React islands (import s from './X.module.scss') |
src/components/*.astro + *.scss |
static components with a sibling stylesheet |
src/pages/*.scss |
page-scoped styles, one file per page |
[MUST] No Tailwind, no CSS-in-JS, no utility framework. SCSS + CSS custom
properties only. There is no tailwind.config, no theme object, and no runtime
style library in package.json.
The rules below trace to the DevFest.cz 2026 Brand Manual v1.0
(public/press-kit/DevFest.cz_2026_Brand_Manual.pdf). A compliance pass
brought the code in line; where a rule cites a page, it comes from here.
| Page | Rule here |
|---|---|
| p. 03–07 | official logo files only; white mono on red; 180px digital minimum, never shrink (change the layout); no recolour, glow or overlay |
| p. 08 | three colours; greys only as white or black at an opacity |
| p. 09 | red marks details; large red only as title bars, darkened red grounds, primary CTAs; never running text. The site spends the allowance on buttons only: no red ground |
| p. 10 | Bebas uppercase at line-height 0.9–1.0, tracking 0; Special Elite sentence case; JetBrains Mono 400/500 at 0.1–0.2em |
| p. 11 | CTA labels white on red; speaker names in Special Elite; one red mark per list. The manual sets buttons in mono; the site sets every action in Bebas since the three-voices pass (see Typography), a recorded divergence |
| p. 13 | photography B&W, high contrast |
| p. 15 | partner logos keep their original colours |
The palette follows the DevFest.cz 2026 Brand Manual v1.0
(public/press-kit/DevFest.cz_2026_Brand_Manual.pdf, p. 08–09): three colours,
no more. Noir Black #000000, Paper White #FFFFFF, Evidence Red #F11000.
Greys exist only as white or black at an opacity (manual p. 08): every neutral
token below is an rgba() of white, never a tinted grey or a solid near-black
hex (BaseLayout.scss:3–5).
All tokens are declared in BaseLayout.scss :root.
| Token | Value | Use |
|---|---|---|
--color-bg |
#000000 |
page ground |
--color-text |
#FFFFFF |
ink |
--color-grey |
rgba(255,255,255,0.55) |
muted meta |
--color-accent |
#F11000 |
the accent — CTA fill, the grease stroke, a margin mark, red words |
--color-accent-hot |
#F11000 |
focus rings, live status; same red, kept as a role |
--fs-cta |
1.5rem (24px) |
the CTA label on red, white Bebas: the 24px regular-weight large-text line |
There is no red ground token and no ink-on-red token: red is a button, a stroke or a word, and the button's white label is measured below.
Surfaces, white at an opacity over the black ground: --panel-lit 0.04
and --panel-lit-2 0.055, the "Every talk" search field's plate
(Sessions.module.scss). [MUST] A plate that must be opaque (a fixed bar
over content) layers it over --color-bg (the sheet ground is
--color-bg itself, Sheet.module.scss); paper is already opaque enough to sit over content
(the cookie slip, CookieBanner.scss). Canvas-drawn plates (speaker / attending card
monograms) fill black first, then the panel (SpeakerCard.tsx:47–52,
AttendingCard.tsx:41–46), or they export see-through. The footer ground is
--color-bg (Footer.scss:10).
The darkroom (BaseLayout.scss:152–165). Photographs and the facts that
travel with them are paper on the black table. [MUST] Paper is white at
an opacity, never a cream hex (the manual has no cream): --paper
rgba(255,255,255,0.94), --tape 0.82. Ink on paper is black:
--paper-ink #000000, --paper-ink-dim rgba(0,0,0,0.66) (5.3:1 on
--paper, the floor for small mono edge labels and notes on a print).
[MUST] Paper is flat. One even tone, no gradient, no curl: a print
sits on the table by the tight --paper-shadow (0 1.5rem 1.5rem -1rem rgba(0,0,0,0.85), 0 2px 6px rgba(0,0,0,0.7)), a drop pulled in by its
negative spread so it stays under its own print and never falls across a
neighbour as a grey band, plus one contact shadow. It is a static property
of the paper, never a hover state. [MUST] One shadow for all paper.
Every print, slip, card, clipping and form takes --paper-shadow; no
paper carries a shadow of its own. A card set a gap apart takes it as is
(the /contact index cards through .paper, the clippings in
press.scss); a grid of close-set slips draws the same token on each
slip's ::before under the isolated grid, so no neighbour's drop lands on
paper (Agenda.module.scss, Sessions.module.scss,
Workshops.module.scss). The share page's no-script slip
(Share.astro) loads no site stylesheet and writes the token's value out.
[MUST] Only photographs tilt. A .paper print that holds a
photograph tilts; a slip, a card, a form, a clipping or a cookie banner
sits straight, the speaker sheet's talk slips, the invite page's quote
slip and the share page's no-script slip included. The one exception is the home ticket slips, set down like
tickets on the table (Tickets.module.scss). Tilts are --tilt-1
0.8deg, --tilt-2 1.4deg, --tilt-3 2.2deg; nothing tilts past
--tilt-3, and under 720px a .paper clamps to ±--tilt-1
(BaseLayout.scss, @media (max-width: 720px) after .grease). Callers
vary the sign and take fractions of a step so neighbours never match.
Hairlines: --rule rgba(255,255,255,0.13), --rule-strong 0.22, --rule-red rgba(241,16,0,0.55),
--field-border rgba(255,255,255,0.4).
[MUST] Rules only where they carry structure. Space and paper part the
page, not lines: a section head sits on space (.head-rule draws no rule,
BaseLayout.scss:362–387), a print or a slip is set apart by its own
edge and shadow, and the footer opens on space (Footer.scss). A line
is drawn only in data tables (privacy policy), as a form control's
boundary (on paper a field is a 2px --paper-ink underline), as an
in-prose link's underline (the hero facts line's venue link,
index.scss), as a progress spine (the card builder's steps), as the
dashed --field-border outline of a place still empty (the agenda's open
slot) and as the short --rule-strong mark
between the two link groups in the desktop bar (.bar-sep, Menu.scss).
There is no ruled list: the sheets and the NextStep cards are paper.
Never a divider floating between
sections, a rule under a section head, a line above the hero's facts line,
a ruled readout (the invoice total), a rule down an agenda room column (the
gap between slips parts the rooms), a box drawn round a partner mark, or an
edge down a block's left side.
Ink ramp: --ink-strong 0.85, --ink 0.78, --ink-soft 0.7,
--ink-meta 0.62, --ink-dim 0.6, --ink-muted 0.55, --ink-faint
0.5 (all rgba(255,255,255,…)). Named steps only; a one-off alpha stays a
raw literal.
[MUST] The ground is flat black. No spotlight pool, no haze, no
gradient under a band: .band--lit and the sheet ground are plain
--color-bg (BaseLayout.scss, Sheet.module.scss). Atmosphere left:
--ink-monogram 0.46 and --ink-monogram-sm 0.66. Every photograph
is a .paper print. No glow token, no vignette, no grain.
[MUST] No glow anywhere. Hover and live states are flat colour changes: an inverted fill for buttons, a border or ink change for rows and fields. The paper shadow is the paper's, at rest and under the pointer alike.
[MUST] Red is evidence, not mood. Per the manual it marks details (a dot,
1–2 words in a headline, the year, CTA words, a grease-pencil stroke) and,
as a large area, only primary CTA buttons. No red
ground: every Closer, the /partners CTA and the newsletter sit on the
black ground (Closer.astro). Red is a verb: one word, one button, one
state at a time. No red washes behind photographs, no red running text, no
other accent hue, no red-to-other-hue gradients.
[MUST] Red fills: the header's Tickets plus at most one per screen.
The header Tickets rides on every page; under it a screen carries one red
fill at most. On / the hero's "Get tickets" and the live ticket slip's
Buy are on different screens; the newsletter submit and the cookie
Accept are black fills on their paper (NewsletterForm.module.scss,
CookieBanner.scss), and /speakers ends on a plain .action "Get
tickets" in its EndLine (speakers.astro). A spent wave's grease cross
is a mark, not a fill, and does not count.
[MUST] The grease pencil is one decorative red mark per page.
.grease (BaseLayout.scss) is an aria-hidden SVG stroke in
--color-accent, 3px, non-scaling, drawn over what it marks. A page spends
it once: frame 12A ringed on the home contact sheet (index.astro), the
entrance print ringed on /venue (venue.astro), the general inbox
underlined on /contact (contact.astro). The cross over a sold-out
ticket wave (Tickets.tsx) is functional, a state, and does not count
against the page's one mark. It is never a fill and never text.
[MUST] One red mark per list. A list spends red once: the invoice step
numbers are mono figures in --ink-dim (.invoice-step-no,
invoice.scss); on the ticket slips the live wave's red Buy button is the
mark and its state ("On sale.") is a note in --paper-ink-dim
(Tickets.module.scss). A spent wave's
grease cross is allowed beside it: it records a closed wave, not a call.
Use of #F11000 |
Allowed? |
|---|---|
| a fill (a CTA background, the live dot, a margin mark) | yes |
| a full-width band or a section ground | no — closers and forms sit on black |
| display type, highlighted words, the year | yes |
a rule / keyline (--rule-red) |
yes |
| a focus indicator | yes — 4.8:1 on black |
| body copy, lore, paragraphs, inline prose links | no — use --color-text; a prose link is white on the red hairline |
[MUST] An error is never red prose. Every form sits on paper, where an error is paper ink behind a red margin mark (see Forms); there is no error-text token.
[MUST] --field-border is the boundary of an interactive form control
(WCAG 1.4.11, ≥3:1). Structural hairlines (see above) stay at --rule.
[MUST] Measured ink on flat #F11000 (a red button) — hierarchy on
red comes from SIZE, never from dimming:
| ink | ratio | verdict |
|---|---|---|
#000000 |
4.82:1 | anything, incl. small notes, mono figures and body copy |
#000000 @ 85% |
~4.2:1 | control boundaries only (1.4.11) |
#FFFFFF |
4.36:1 | large text only (≥18.66px bold / 24px regular): Bebas headlines, the cta-label |
[MUST] Red on paper is a mark or large type, never small text.
#F11000 on --paper measures about 3.8:1: enough for a grease stroke, a
dot and Bebas at card scale (the speaker name reached, --fs-card-title),
not for a note. The agenda's "Live now" stays in paper ink with a red dot
in front of it for this reason (.flag, Agenda.module.scss); on the
black table the same word is red (.band .flagLive). The reach state on
paper is an ink underline, a red dot or a red highlighter under ink
(black on #F11000, 4.82:1), never red text: a hovered agenda slip's
title takes a flat red highlighter under its ink (.titleText,
Agenda.module.scss; a mark under the pointer, not a red fill), the open FAQ
question underlines and its marker goes to full ink (faq.scss), a FAQ
answer link, the partnerships and /contact addresses thicken their ink
underline (partners.scss, contact.scss), a clipping's "Read article"
underlines in ink, every text action on paper takes paper-action() (see
Component states), and a form field's
error is paper ink behind a red margin mark (InvoiceForm.module.scss,
Workshops.module.scss).
[MUST] A label on a red button is white Bebas at --fs-cta. Manual
p. 11 sets CTA labels white on red; 4.36:1 clears AA only as large text, and
Bebas has no bold, so the size carries it: --fs-cta is 1.5rem = 24px,
the regular-weight large-text line. The cta-label mixin is
action(var(--fs-cta)) (_type.scss:32–37: Bebas caps, tracking 0,
line-height 1). Never shrink it. Callers: .btn-primary
(BaseLayout.scss), the header Tickets (.header-cta, Menu.scss), and
the newsletter button and the cookie Accept (white on a black fill); the
cookie Decline takes its size (CookieBanner.scss). The live ticket
slip's Buy sets larger, at --fs-card-title (Tickets.module.scss).
[MUST] There is one theme, and it is dark. <meta name="color-scheme" content="dark"> (BaseLayout.astro:219) and theme-color #000000 declared
identically for both prefers-color-scheme branches (BaseLayout.astro:216–218)
— the site does not respond to the OS preference. There is no
prefers-color-scheme rule anywhere in src/**/*.scss.
The only light/dark fork is the favicon: favicon-light.webp /
favicon-dark.webp (BaseLayout.astro:260–261), which serves the browser
chrome, not the page.
Consequence [MUST]: never introduce a prefers-color-scheme block for a
single component. A light theme is a whole-system decision, not a local one; the
measured ratios above assume the dark ground.
Three faces, self-hosted through the Astro Fonts API (astro.config.mjs:72–106).
| Token | Face | Weights | Line | Role |
|---|---|---|---|---|
--font-bebas-neue |
Bebas Neue | 400 | 73–80 | speaks: every headline at --fs-h3 and above (hero, section and subpage titles, nav destinations, ticket wave names, speaker names on the prints) and every action: text actions, nav, the header Tickets, buttons, filters, the CTA label |
--font-special-elite |
Special Elite | 400 | 89–105 | tells: body, lede and long-form reading copy, and every label made of words: section notes, roles, eyebrows, captions, tape labels, form labels, status words, nav meta, the footer. Never a headline |
--font-jetbrains-mono |
JetBrains Mono | 400, 500 | 81–88 | records: figures only; prices, times, indices, frame numbers, counts, codes, the footer folio. 500 for the large figures on paper (the NextStep index, the invoice estimate, the card builder's step numbers) |
- [MUST] Only these three families (
BaseLayout.scss:16–18). - [MUST] Reference the injected variable, never a literal family name —
the resolved family is a build-time hash (
BaseLayout.scss:16–18). Keep a generic fallback:var(--font-jetbrains-mono), monospace. - [MUST] Go through the recipe, not the raw declaration.
_type.scsshasnote($size),action($size),cta-label,mono($size, $track, $case),display($size),row-title($size),prose($size, $lh),hairline-link($color)andpaper-action; a stylesheet that writesfont-family: var(--font-…)by hand is re-implementing one of them. - [MUST] The three voices (
_type.scss:6–9). Bebas speaks: headlines and actions (display(),row-title(),action(),cta-label). Special Elite tells: prose and every label made of words (prose(),note()): the head-rule note, eyebrows, roles, captions, tape labels, form labels, error and pending lines, status words, the ticket state and deadline, agenda room notes, the hosts label, nav meta, the footer. [MUST] JetBrains Mono records figures only (mono()): prices, times, frame and sheet numbers on a print's edge, counts, codes, the footer folio. A paper edge is figures only (frame, sheet number, time, year): "30 / 10 / 26" on the speaker sheet's print, "2019" on the/contactpress print. A word on an edge is a note (.paper-edge .edge-word,BaseLayout.scss: "On the back" on the home event card, the venue's name on the/venueentrance print, a clipping's outlet on/contact), and so are the agenda flags ("Live now", "Next", "Now") and the words on a slip's edge (track and length, room, "Time TBA"). Mono never sets a word-label; a tracked mono caption was the site's "AI" tell. A word in mono is a defect, a figure in Special Elite is one too (the ticket alt price "Company funded 1 452 Kč" is a note because it is a sentence; the folio "Case no. DF-2026" is mono because it is a code). - [MUST]
note()is sentence case, tracking 0, line-height 1.45 (_type.scss:14–20); the typewriter does not shout.action()is Bebas caps at--fs-action, tracking 0, line-height 1 (_type.scss:24–30). Neither takes atext-transformorletter-spacingfrom the caller. - Two recorded exceptions to mono for figures only. (1) The press
clipping's excerpt (the scraped standfirst) sets in mono (
press.scss): Special Elite ships Latin only and a Czech excerpt is whole sentences of ř, ě and č. The clipping's headline is Bebas and its outlet a note on the edge (contact.astro). (2) Speaker names on the prints and slips stay Bebas (SpeakerTile, the agenda.who, the session.names), where manual p. 11 sets them in Special Elite: on a print the name is the headline. - [MUST] Roles do not mix. Special Elite is wide; Bebas is condensed and is what makes the poster scale fit the column.
- [MUST] Czech letters Special Elite lacks fall to JetBrains Mono. Google
ships Special Elite in Latin only; its Fonts API entry sets
fallbacks: ['var(--font-jetbrains-mono)', 'monospace']withoptimizedFallbacks: false(astro.config.mjs), so--font-special-eliteresolves to Special Elite → JetBrains Mono (latin-ext loaded). The default metric fallback was Arial at 117%, which printed "Libčice" as "LibČice". Never add asubsetsentry or a fourth face to fix a glyph. - [MUST] No synthesised bold or italic.
body { font-synthesis: none }. Every face ships one weight (JetBrains Mono loads 400 and 500; 500 sets the large figures on paper: theNextStepindex, the invoice estimate and the card builder's step numbers; the ticket price and the/venuecard numbers are plainmono()at 400, tracking 0) and no italic; emphasis in prose is ink —strong/bstep up to--color-text— never weight or slant. Don't writefont-style: italic.
The ramp [MUST]. Every font-size goes through one of these steps; add a
step here before using a new value. [CURRENT] ~35 call sites still bypass
it — see Open points.
Poster scale (Bebas), BaseLayout.scss:61–79:
| Token | Value | Line | Use |
|---|---|---|---|
--fs-subpage |
clamp(5rem, 13vw, 12.5rem) |
70 | the subpage <h1> in SubpageHero — one per page |
--fs-hero |
clamp(4.5rem, 10.5vw, 9.25rem) |
62 | the home statement (index.scss:55–62): /'s one display element, above every section head at every width |
--fs-section |
clamp(4rem, 9vw, 8rem) |
69 | the section head (.head-rule-title): The eleventh DevFest.cz, Tickets, Speakers, Past editions, Partners on /; "Every talk" on /agenda; "The route" on /venue; "In the news" on /contact. And every secondary poster title: "Newsletter", the /venue address and "The entrance", "Become a partner", "Press kit", and every Closer title (Closer.scss) |
--fs-display |
clamp(3rem, 9.5vw, 9rem) |
61 | the speaker sheet's name; the menu panel's primary links under 901px; the invite title; the live ticket slip's name and price on a phone |
--fs-h2 |
clamp(2.25rem, min(6vw, 9svh), 5.5rem) |
66 | the FAQ categories; the session sheet's title; the /thank-you date; the /invoice steps head; the live ticket slip's name and price; the menu's primary links ≥901px |
--fs-h3 |
clamp(1.8rem, 3.4vw, 2.6rem) |
67 | a ticket slip's price; /team names; the menu's secondary links |
--fs-head |
clamp(2.1rem, 3.4vw, 3rem) |
68 | a compact head on paper: the NextStep titles and index, the /contact index-card titles, the /venue card titles and numbers, a clipping's headline, the partnerships contact's name, the legal h2, the workshop slip title, the invoice step numbers and estimate |
--fs-card-title |
1.95rem |
71 | the speaker name on a print, the live slip's Buy label |
--fs-row-sm |
clamp(1.7rem, min(4vw, 6svh), 3.4rem); 1.7rem ≤600px |
78 | a ticket slip's wave name |
[MUST] One display element per page, and it outranks the section
heads. On a subpage that is the SubpageHero title (--fs-subpage); on
/ the statement (--fs-hero, floor 4.5rem, cap 9.25rem = 148px), above
--fs-section (floor 4rem, cap 8rem) at every width. Every other poster
title is a section step: subpage / hero > section > h2 > h3. --fs-h2 and
the row steps carry an svh term so a short desktop screen brings them
down (BaseLayout.scss:63–79).
Text scale, BaseLayout.scss:83–95:
| Token | Value | Line | Use |
|---|---|---|---|
--fs-label-xs |
0.6875rem (11px) |
83 | micro mono figure: every .paper-edge, the agenda and session slip edges, the FAQ question numbers, avatar counts, kit file sizes, the footer folio |
--fs-label-sm |
0.74rem |
84 | mono figure: the agenda time gutter ≤1100px, counts |
--fs-label |
0.78rem |
85 | mono figure: the ticket slip's date stamp and company figure, the FAQ index numbers, agenda break times |
--fs-label-lg |
0.84rem |
86 | mono figure: the agenda time gutter and the lounge hours, the session sheet's time, the invite meta line |
--fs-note-sm |
0.85rem |
87 | Special Elite note: roles, captions, tape labels, row metadata (the ticket alt price and edge; the speaker role and "With …"; agenda room notes; a form label on paper) |
--fs-note |
0.95rem |
88 | Special Elite note: .eyebrow, a speaker print's talk, the hosts label, a partner tier label, a press-kit variant label, the lounge line, nav meta, the FAQ index label, the "Every talk" search query |
--fs-action |
1.3rem |
89 | Bebas action: the text action (.action / .btn-ghost), nav toggle, desktop bar links, footer links, Sheet close, speaker social links, the session filters, "Clear", the agenda room names and the session sheet's room (.room) |
--fs-ui |
0.95rem |
90 | small UI prose, help text |
--fs-body |
1.05rem |
91 | long-form reading copy; an agenda slip's title |
--fs-body-lg |
1.15rem |
92 | the .head-rule note, subpage ledes, footnotes, a session slip's title |
--fs-lede |
clamp(1.2rem, 1.6vw, 1.5rem) |
93 | section ledes, status prose, the EndLine, a FAQ question, a typed field on paper |
--fs-title-sm |
1.45rem |
94 | small titles, a talk slip's title on the speaker sheet, a clipping's headline ≤720px, speaker print names ≤720px (SpeakerTile.module.scss) |
--fs-monogram |
3.4rem |
95 | initials in an empty photo well |
[MUST] Mono figures never set under 11px. --fs-label-xs is the floor;
there is no smaller mono step. [MUST] A note never sets under
--fs-note-sm (13.6px): Special Elite's texture breaks up under 13px
(_type.scss:11–13).
[MUST] Bebas sets at line-height 0.9–1.0, tracking 0, uppercase (manual
p. 10). Display type uses --lh-display (0.9, line 99) and
--track-display (0, line 100), never a per-file line-height;
row-title() and action() set line-height: 1, letter-spacing: 0
(_type.scss:24–30, 75–82). No Bebas rule carries a letter-spacing of
its own (the desktop bar and footer links set at 0). No text-shadow on
type on the black ground. [CURRENT] Two exceptions sit on a photograph:
the home statement (line-height: 0.86, text-shadow: 0 2px 30px rgba(0,0,0,0.6), index.scss) and the subtitle line set over the home
event print (index.scss, .event-subtitle);
both are legibility devices for type on a photograph, not a style for type
on black (see Open points).
[MUST] JetBrains Mono tracks at 0.1em, 0.15em or 0.2em (manual
p. 10: 0.1–0.2em). A figure has no case to force: .paper-edge and the
footer folio keep uppercase at 0.12–0.16em, which
only reaches a code ("DF-2026") since their words left for the note voice;
the slip edges set their times without it. The ticket price and the
/venue card numbers set at tracking 0.
[MUST] Phones take their own list step and tracking. At ≤600px the
:root phone block (last in :root, so it wins over the base steps) sets
the row steps above to their phone values and --track-scale: 0.72; the
mono() mixin multiplies its tracking by --track-scale and floors the
result at 0.1em (max(0.1em, …), _type.scss:49–51), so a 0.15em figure
sets at 0.108em on a phone and a 0.1em one stays at 0.1em; notes and actions
carry no tracking and do not move. --fs-h2 and --fs-display keep their floors: the section head and the page title stay poster scale while
the lists under them come down.
[MUST] Body text caps at 65–75ch. Cap the container, don't shrink the
type. Mono never tracks under 0.1em.
[MUST] A hyphenated word in a Bebas title never breaks at its
hyphen. Bebas caps leave "FULL-" hanging at a line end, so the workshop
slip title holds each hyphenated word in one white-space: nowrap span
(unbrokenHyphens, Workshops.tsx; .nobreak,
Workshops.module.scss).
[MUST] Sheet prose sets ragged at every width. The speaker bio, the
talk slips under it and the session abstract are text-align: left; hyphens: manual (SpeakerDetail.module.scss, .bio, .slipDesc;
SessionDetail.module.scss); justified, Special Elite opened rivers down
the whole bio.
| Token | Value | Line | Use |
|---|---|---|---|
--maxw |
90rem (1440px) |
22 | the content column (.u-container, .band-inner) |
--gutter |
min(clamp(1.5rem, 5vw, 4.5rem), 6.25vw) |
29 | page gutter; also the negative inset for full-bleed row fields |
--section-y-tight |
clamp(3.75rem, 6vw, 6.5rem) |
32 | a cut |
--section-y |
clamp(6rem, 10vw, 11rem) |
33 | the normal beat |
--side-col / --side-gap |
24rem / clamp(1.5rem, 4vw, 4rem) |
22–23 | the right column of every head and its gap: SubpageHero's note and every .head-rule note or action start at one x on desktop |
--radius |
2px |
57 | [MUST] sharp corners — never pill-shaped |
--focus-gap-tight / --focus-gap / --focus-gap-lg |
2px / 3px / 4px |
140–142 | focus-ring standoff, see Accessibility |
[MUST] Large screens scale, they do not widen. Up to 1920px the root is
the browser default. Past it html grows the root linearly
(100% + (100vw - 1920px) / 120, capped at 200%), so a 2560px viewport (4K at
150%) renders the page at 1.33x and 3840px (4K at 100%) at 2x: the same
composition as 1920px, not a 1440px column adrift in black. It only works
because layout caps and grid minimums are in rem; a new max-width,
minmax() floor or image cap goes in rem, and a px value is for phone
floors and physical sizes (the 44px tap target, hairlines) only.
[MUST] Two section densities: --section-y the beat between
sections, --section-y-tight the last band's close (BaseLayout.scss).
[MUST] .anchor-target cancels a section's opening air with a negative
scroll-margin-top reading --section-air (BaseLayout.scss:468–470), the
same variable the padding is built from. --header-h is the single source
for the bar height. src/lib/anchor.ts keeps a jump landed while islands
grow the page; its click handler must not check event.defaultPrevented
and the hold must be armed before the landing (see the file). npm run anchors measures every landing.
[MUST] Max-width media queries, mobile last. No named breakpoints; use a value from the histogram.
[CURRENT] Actual media-query histogram over src/** (occurrences):
| Query | Count | Where |
|---|---|---|
max-width: 720px |
29 | the darkroom's phone step: prints go one or two across and stop tilting past --tilt-1 (BaseLayout.scss); the speaker grid goes two across (SpeakerTile.module.scss); the teaser trims; ticket slips stack; the agenda becomes the list; the FAQ index becomes a row of links; the subpage hero and every .head-rule stack; Sessions, Sheet, NextStep, the newsletter card, the /venue, /contact, /team, /partners, press and legal grids |
max-width: 1100px |
16 | tablet: the /speakers grid goes three across; the home event grid, the /venue, /contact, press and /partners CTA grids go one column; the agenda tightens; /team three across; the FAQ grid 4fr / 8fr; the calendar stub folds (thank-you.scss) |
max-width: 600px |
8 | the phone type steps in :root; the menu bar tightens (Menu.scss); sheets and forms |
max-width: 900px |
5 | NextStepList one column, the card builder, HeroBackground, BaseLayout.scss |
max-width: 860px |
4 | the speaker sheet stacks, the invite page |
max-width: 1240px / 1000px |
1 / 1 | four NextStep cards go two across (NextStepList.astro); Workshops.module.scss |
max-width: 500px |
1 | the newsletter form stacks |
max-width: 45em / 23.75em |
2 / 1 | the footer goes two columns, then one (Footer.scss) |
max-width: 700px |
1 | thank-you.scss |
max-width: 380px |
1 | narrowest phone (BaseLayout.scss header padding) |
max-width: 379.98px |
1 | the header Tickets leaves the bar (Menu.scss) |
[CURRENT] min-width queries exist: 1100px ×2 (the inline desktop
nav, Menu.scss; the press-kit page, press/downloads.scss), 901px ×2
(the menu panel's poster-scale link steps and its print, the latter also
min-height: 621px, Menu.scss), 601px ×1 (invite/member.scss),
640px ×1 (press/downloads.scss), and the 1921px root scale ×3 — see
Open points.
[CURRENT] The nav goes inline at 1100px. At ≥1100px the bar carries
the destinations inline (.bar-nav, Menu.scss:169–236) and the menu
toggle is hidden; under it every destination lives behind the one toggle.
[CURRENT] Two JS-only breakpoints: header auto-hide 760px
(Menu.astro:283) and the agenda's switch from the timetable to the list,
720px (NARROW, Agenda.tsx:50), which matches the stylesheet step.
[MUST] A new fixed/overlay element joins one of these layers.
| z | Element | Source |
|---|---|---|
10060 |
a sheet stacked on another sheet (speaker opened from a session) | SpeakerDetail.module.scss:7 (--sheet-z) |
10050 |
detail sheet (default --sheet-z) |
Sheet.module.scss:18 |
10001 |
fixed site header | Menu.scss:11 |
10001 |
.skip-link |
BaseLayout.scss:289 |
10000 |
cookie banner | CookieBanner.scss:20 |
10000 |
.status-bar-cover (iOS safe-area paint) |
BaseLayout.scss:930 |
0–4 |
in-component ordering only (the header mark at 2 over the open menu panel at 1, Menu.scss; on a print, the tape at 2 and the grease stroke at 3, BaseLayout.scss:440–454; on the home hero, the shade 1 and the type 3, index.scss) |
local modules |
-1 |
a close-set slip's paper shadow, on its ::before under the isolated grid |
Agenda.module.scss, Sessions.module.scss, Workshops.module.scss |
[MUST] No grain, no vignette overlay. No page, scene or print carries
film grain or a vignette layer: the home hero has a shade
(.hero-shade), the invite portrait is a paper print, subpages open on
type.
[MUST] A sheet is portalled to document.body (Sheet.tsx:88–117)
— any positioned ancestor with a z-index traps it under the header.
[MUST] A grid whose slips paint a z-index: -1 layer needs
isolation: isolate (Agenda.module.scss, Sessions.module.scss,
Workshops.module.scss), or the layer paints behind the section ground.
[MUST] Decorative overlays are pointer-events: none and stay inside
their scene, under its content.
[CURRENT] There are no duration or easing tokens — every value is written at its call site. The de-facto scale, by frequency:
| Duration | Where |
|---|---|
0.2s (dominant) |
the standard control transition: buttons, inputs, links; the text action's colour to --color-accent-hot (BaseLayout.scss:695, 727; _type.scss:105) |
0.18s |
the desktop bar links' colour (Menu.scss:193) |
0.3s |
the cookie slip's fade (CookieBanner.scss) |
0.32s |
the sheet entry (Sheet.module.scss) |
0.6s |
a B&W speaker print developing to colour (SpeakerTile.module.scss:53) |
0.7s |
the /team colour bleed from the cursor (team.scss:115) |
1.3s / 1.6s infinite |
loading pulse and skeleton shimmer while data loads (DataState.module.scss:41, 55; the two blank ticket slips, Tickets.module.scss:259–264) |
| Easing | Where |
|---|---|
ease |
the default for control transitions |
cubic-bezier(0.16, 1, 0.3, 1) |
the house "arrive" curve — sheet entry (3 uses) |
cubic-bezier(0.22, 1, 0.36, 1) |
the portrait view transition |
linear |
shimmer, where any easing would read as a stutter |
ease-in-out |
loading pulses |
Rules:
- [MUST] Every animation and transition has a
prefers-reduced-motion: reduceopt-out, includingscroll-behavior(BaseLayout.scss:229–233), controls (BaseLayout.scss), sheet (Sheet.module.scss:162), the speaker print and name (SpeakerTile.module.scss:296–301), the ticket skeleton (Tickets.module.scss:282–291). - [MUST] No scroll reveal, no per-section entrance. Sections are simply
there. The
.revealfade-and-rise on every band was removed: the same entrance seventeen times spent the page's motion before anything happened. No page-entry animation either: the hero lands still (the stagedfadeInUpon the hero and the tickets band is gone). Everything moves only to show a change (a sheet opening, a row's field). Anything.js-gated never hides content without that gate. - [MUST] One signature motion: a B&W photograph develops to colour.
The speaker print develops (
filtertononeover0.6s) while its link or button is focused, or hovered where a pointer hovers (.tile:focus-visible .imgand.tile:hover .imgunder@media (hover: hover),SpeakerTile.module.scss:69–87); on/teamthe colour bleeds from the cursor; the session sheet's speaker prints develop the same way (SessionDetail.module.scss); on/invite/<member>the portrait develops once on arrival (.is-lit,invite/member.scss). Every other print (the home event print, the contact sheet and its enlargement, the/venueentrance, the menu panel's print) stays B&W. With the portrait view transition into a sheet, it is the site's only decorative motion. The hero plate is still, and a print's tilt is set, never animated. - [MUST] One thing moves on its own: the hero countdown. It ticks by
the second in the facts line and drops the seconds under reduced motion,
so nothing ticks there (maintainer decision, 2026-10-19). No ticker band,
no topic rotor, no marquee: the topics are billed still under the hero
statement. The agenda's
live dot is static: the "Live now" word carries the state. Nothing
scrolls, nothing marquees: the contact sheet is still. A text action's
hover is a colour change to
--color-accent-hot, nothing slides: nothing lifts under the pointer, and no partner mark dims. - [MUST] Animate only
opacityandtransform.
[MUST] Hover paints, focus rings. A hover treatment never also fires
on :focus: focus returns to a sheet's opener after it closes, and a
painted state left behind reads as stuck. [MUST] Hover painting sits
under @media (hover: hover): on touch a tap leaves it painted.
[MUST] Two controls site-wide, and no third. Every action is either a
red fill or a Bebas text action; both set in the action voice.
No arrow glyph anywhere. The → after a link and the ↗ off-site
marker are deleted everywhere (a.btn-primary::after, .action::after,
the header Tickets, the speaker teaser's arrow column, the /partners
contact, the invoice and consent links, the press "Read article", the
footer's .ext): a glyph renders differently on every system, and a
destination is told by the label. A link and a button in the same control
look the same. A boxed secondary (the old outlined ghost), an underlined
standalone link or a second filled colour is a third control and does not
ship.
.btn-primary (BaseLayout.scss:669–706):
| State | Treatment |
|---|---|
| rest | --color-accent fill, white --color-text label via cta-label (Bebas --fs-cta 24px, tracking 0; see Color), 1px accent border, --radius, 1.25rem min(2.8rem, 14vw) padding (the hero's .hero-cta 1rem 2.5rem; the live ticket slip's Buy 1.1rem min(4rem, 12vw) at --fs-card-title) |
| hover | fill and border → --color-text, label → --color-bg. No lift, no glow, no shadow |
| focus-visible | 2px solid var(--color-accent-hot) at --focus-gap |
| disabled | not styled globally — see the form buttons below |
.btn-ghost / .action (BaseLayout.scss:709–744): the second control.
Bebas --fs-action caps via action() (tracking 0, line-height 1) in
--color-text, no box, no fill, no underline, no glyph, border-radius: 0; padding 0.75rem 0 (vertical only, so the inline edge stays on the
column) with min-height: 2.75rem for the 44px target. Hover and focus
turn the label --color-accent-hot over 0.2s: the red the site keeps
for a live state, and nothing moves. .btn-ghost is kept as the class
name its older callers use (Closer, InviteCta, DataState, the cookie
Decline, the ticket slip's "Company pays", the card builders); .action
is the same thing by its new name: the home "All partners" and "Become a
partner", the teaser's "All N speakers", the ticket head's "Request a
company invoice", the /venue map links, the press-kit back link, the
EndLine links and SubpageHero's way-across link take the class
directly. On paper a text action takes paper-action() (_type.scss):
--paper-ink, no line at rest, a 2px ink underline at a 4px offset on
hover (under (hover: hover)) and on focus; red text misses 4.5:1 on
paper, so the line is the reach state. One treatment for every paper
card: the ticket slip's "Company pays" (Tickets.module.scss), the
partnerships card's "Email Filip" at --fs-cta (partners.scss), the
/venue route actions (venue.scss), a NextStep card's .step-go
(NextStep.scss), the /thank-you calendar link (thank-you.scss), the
workshop slips' and sign-in's text actions (Workshops.module.scss) and
the card builders' (AttendingCard.module.scss).
A filter is a Bebas word, red when on. The agenda room filter
(action(--fs-cta), Agenda.module.scss:27–50) and the session filters
(action(--fs-action), Sessions.module.scss) are words in --ink-soft,
white under the pointer, --color-accent-hot when aria-pressed. No box,
no chip.
The red fills (see Color: the header plus one per screen). The
header Tickets is a red button at every width (.header-cta, Menu.scss:
cta-label on --color-accent, --radius, padding: 0.3rem 1.1rem 0.1rem, min-height: var(--header-tap), inverted on hover), hidden under
380px and first in the menu; it is dropped on /invite/*. Under it, each
screen spends at most one: the home hero's "Get tickets" (.hero-cta),
the live slip's "Buy"; a closer's primary; the brand-manual download on
/contact and /press/downloads; the partnership deck; the invoice and
workshop sign-in submits; a sheet's "Get tickets" (Sheet.tsx); the card
builders' download; the /thank-you "Download .ics". A workshop slip's
"Register" / "Join the waitlist" is a black fill on its paper
(.slipActionFill). The toggle beside the
header Tickets is Bebas with no box (.nav-toggle, Menu.scss; on the
home hero's first screen its border is transparent).
The same voice sets every in-island action: the Sheet's Close
(Sheet.module.scss), the session filters' "Clear", the speaker sheet's
social links (SpeakerDetail.module.scss, in --ink-soft, no underline),
the card builders' choose and link buttons. The newsletter button and the
cookie Accept are black fills on their paper: white cta-label on
--paper-ink; on hover the newsletter button inverts to the card's
--paper with an ink label and border; focus draws a --paper-ink ring.
Neither is red: the screen's red belongs to the ticket.
Live state is a dot and a word. On an agenda slip "Live now" stays in
--paper-ink behind a static 0.5rem red dot, and "Next" in
--paper-ink-dim, both notes (.flag, Agenda.module.scss);
on a break line across the table the word itself is red (.band .flagLive). The start time in the gutter turns --color-accent-hot while
something at it is live (.tickLive); in the phone list the group's time
carries a red "Now HH:MM" note (.groupNow). No pulse, no ring, no
shadow.
A slip is the control. An agenda slip and a session slip are each one
<button> that opens the sheet: no lift under the pointer; a hovered
agenda slip's title takes the red highlighter (see Color), the paper
itself never fills; focus draws the ring outside the paper at --focus-gap-lg
(Agenda.module.scss, Sessions.module.scss).
A FAQ question is a row; only the open one is paper. A closed
<details> is a straight row on the black table in white ink, set apart
by space alone; the open one becomes a --paper slip with the paper
shadow, its ink switching to --paper-ink (--faq-ink, faq.scss). The
<summary> carries no red block (a red block under a toggle stayed lit
after a tap). Its reach state is an underline under the question and its
drawn "+" marker going to full ink, under (hover: hover); open, the "+"
turns 45deg into a "×". Keyboard focus draws an inset red ring
(outline-offset: -4px) plus the same underline (faq.scss). Red text on
paper misses 4.5:1.
[MUST] Red is spent once per list, not once per row. Resting labels are muted typewriter notes; step numbers, frame numbers and separators are muted too (see Color). Exception: the live wave's Buy button.
[MUST] An open <details> opens on its own slip — no panel, no wash,
no feather inside the paper, only the marker turned to "×" and the answer
under its question. The answer starts under the question text, not under
its number (.faq-a, faq.scss), so nothing shifts when it opens.
[MUST] No opacity-based "inactive" state on content — status is a word,
not a fade. [CURRENT] A spent ticket slip's paper is a shade darker
(filter: brightness(0.93), Tickets.module.scss) under its
state word and grease cross.
[CURRENT] Disabled controls: the newsletter submit (disabled until
consent) is an outlined slot in --paper-ink-dim on the card (5.3:1), not
a dimmed red; the invoice submit dims to opacity: 0.55 only while in
flight ([aria-disabled='true'], InvoiceForm.module.scss);
/partners marks a not-yet-live deck with aria-disabled="true" on a
<span> drawn as a dashed paper-ink slot (.partners-cta-soon,
partners.scss). See Open points.
Loading / empty / error [CURRENT]: skeleton (two blank shimmering slips
in the live layout, Tickets.module.scss, so nothing reflows on
load) or the DataState.tsx loading pulse, then content or the shared
DataState.tsx ErrorState. [MUST] Every data-backed page ships a
.fallback-note (BaseLayout.scss:554) — except /, see Open points.
Every form is straight paper on the black table: NewsletterForm (native
POST to SmartEmailing), InvoiceForm (Firebase callable), the workshop
sign-in (Workshops.tsx) and the card builders' step sheet
(AttendingCard.module.scss, shared by /speaker-card). [MUST] A form
is not a print: it sits straight, untaped.
- [MUST] A field is a typed line, no box. Special Elite
--fs-ledeon a 2px--paper-inkunderline that turns--color-accenton focus and onaria-invalid="true", with the keyboard ring at--focus-gap(InvoiceForm.module.scss,Workshops.module.scss,NewsletterForm.module.scss). Neveroutline: nonewithout the ring. On the black ground a control keeps the--field-borderboundary (≥3:1): the "Every talk" search field, its query typed as anote(--fs-note)(Sessions.module.scss,.searchInput). - [MUST] Minimum control height
2.75rem. - [MUST] An error is paper ink behind a red margin mark. A per-field
message (
note(--fs-note-sm)) and the form'sdata-tone="error"status line (prose(--fs-ui)) set in--paper-inkbeside a 4px--color-accentbar (InvoiceForm.module.scss,.error,.message;Workshops.module.scss,.error): red is the mark, never the words (#F11000 on paper is under the small-text floor). - [MUST] Field labels, pending and help lines are notes:
note(--fs-note-sm)in--paper-ink-dimfor the label, going to--paper-inkwhile its field has focus (InvoiceForm.module.scss). A label is a word, so it is never mono. - [MUST] A submit button is never
disabledfor a missing input. Validation runs on blur and first submit; a failed submit focuses the first bad field (InvoiceForm.tsx,validate()).aria-disabledcovers only the in-flight submit; button states key off[aria-disabled='true']. - [MUST] Field components live at module scope (
TextField,InvoiceForm.tsx) — declared in a render body they remount on every keystroke. - [MUST] Status messages are announced:
aria-live="polite"on the message region,role="status"on success,aria-describedbyto help text; an invalid field carriesaria-invalid(InvoiceForm.tsx). - [CURRENT] Validation is native (
required+ type/pattern,.honeypot) plus the invoice form's own rules. - The newsletter form is a straight paper card on the table
(
NewsletterForm.module.scss):--paper,--paper-ink, the paper shadow, a typed edge ("DevFest.cz" as a note, a mono figure) across its top. Its label anote(--fs-note-sm)in--paper-ink-dim. The black submit sits beside the field and stacks under it ≤500px. - [MUST] The consent checkbox is drawn, not native
(
NewsletterForm.module.scss):appearance: none, a 2px border in the card's ink (--box-ink: var(--paper-ink)), filled with that ink and a cut-out tick (--box-tick: var(--paper)) when checked;--radiuscorners, focus ring at--focus-gap. The UA box read as disabled. - Form pages.
/invoiceruns two plain bands: "How it works" as a numbered list on black (mono--fs-headnumbers, three across), then the paper form on an8fr / 4frgrid with itsEndLinebeside it (one column ≤1100px,invoice.scss)./workshopssets the sign-in as a sticky paper form on a5fr / 7frgrid beside the workshop slips (one column, max44rem, ≤1000px;Workshops.module.scss). The card builders (/attending,/speaker-card) put the straight paper step sheet (max47.5rem) on 7 of 12 columns and the live preview beside it on 5, mounted as a tilted print, the canvas its photograph (AttendingCard.module.scss,.cardWrap).
- [MUST] A photograph is a paper print.
.paper(BaseLayout.scss) is the white border (--paper,0.65remon three sides, open at the foot for the edge), the paper shadow and a--tilt;.paper-photois the black well inside it, its<img>covering the well ingrayscale(1) contrast(1.1);.paper-edgeis the margin under the photograph: mono figures (a frame or sheet number, a date or a time) at--fs-label-xsin--paper-ink-dim, pushed apart, a word on it a note (.edge-word). Every photograph on the site is one, the speaker sheet's and the session sheet's speakers included. - [MUST] One tape per print, top-left, never truncated. A
.tapeon a print (.paper > .tape) overlaps its top edge attop: -0.85rem; left: 1rem(z 2) with a typewritten label, one line atwidth: max-content. A label that does not fit gets a shorter text (SpeakerTile'stapeShorton a phone) or a smaller tape, never an ellipsis; as a last resort it wraps inside the print. A host's print carries no tape (the "Hosts" label over their row says it). - [MUST] A group label is plain, never tape. A partner tier, the home
partner rows, the hosts, an agenda room, a press-kit variant: a
note(--fs-note)in--ink-meta(or a Bebas--fs-actionroom name on the agenda), no tape (partners.scss,.tier-label;index.scss,.partners-label;SpeakerTile.module.scss,.hostsLabel;press.scss). Tape is for prints. The session sheet's room sits on its slip as a plain Bebas label (.room,SessionDetail.module.scss). - [MUST] The portrait well is 4:5: every speaker print
(
SpeakerTile.module.scss), the speaker sheet's print, the session sheet's speaker prints, every/teammugshot, the invite portrait, the partnerships contact's mug. - [CURRENT] Other ratios in use:
2.39 / 1the home event print (4 / 3≤720px,index.scss);3 / 2the contact-sheet frames, the home enlargement and the/contactpress print (index.scss,press.scss);4 / 3the/venueentrance print;3 / 4the menu panel's print (Menu.scss); press-kit previews16 / 9(press/downloads.scss). The home hero has no ratio:#heroismin-height: clamp(45rem, 62vw, 56.25rem)(47.5rem≤720px) and the photograph fills its right-hand 74% (index.scss). - [MUST] Portraits crop
object-fit: cover, the speaker print atobject-position: 50% 0%so the heads line up along a row (SpeakerTile.module.scss),/teamat50% 28%(team.scss), the speaker sheet's print and the session slip avatarscenter 22%(SpeakerDetail.module.scss,Sessions.module.scss), the session sheet's speaker prints50% 25%. - [MUST] The DevFest.cz logo is the official file, never a redraw.
src/assets/logo.pngispublic/press-kit/DF26_logo_white_and_red_dot.png(3000×600, clear space built into the artboard, 2026 badge included); share cards on a red band usesrc/assets/logo-mono-white.png(DF26_logo_white.png), the manual's only version on red. Both mono files are pure#FFFFFF, alpha as supplied. Never tint, recolour, crop the badge or retype the wordmark (manual p. 03–07). - [MUST] Nothing dims or covers the logo. No opacity change on hover or
focus: keyboard focus draws the ring around the link (
Menu.scss). No darkening overlay over it: the invite OG card composites the logo layer after the vignette, so it lands at full white (og/invite/[member].jpg.ts). - [MUST] Logo minimum: 180px wide for the logo itself (manual, digital) — 188–190px for the artboard (header 38px tall = 190px). Never typeset the name as a second wordmark. The official mark shows twice: in the header and as the footer sign-off at poster scale (see Footer anatomy).
- [MUST] The header logo never shrinks.
.brand-logoismax-width: none;.brandismin-width: max-contentin a flex bar that puts the space between the mark and the actions (Menu.scss). The bar gives way instead: ≤600px a tighter inset (min(var(--gutter), 3.2vw)),0.5rem/0.375remgaps, a square toggle and a narrower Tickets button; under 380px the header Tickets is hidden and stays the first menu line. That is the only way to keep the full logo and an AA-sized CTA label in one row at 320 / 360px. - [MUST] The standalone symbol is white or dark only (favicons, app icons); never red.
- [MUST] Partner and press logos are
object-fit: contain— never cropped (_logos.scss,press/downloads.scss). - [MUST] Partner marks keep their original colours (brand manual
p. 15: the one exception to the three-colour rule). Never recolour,
flatten to white, tint or dim a partner logo, not even under the
pointer; a linked mark shows its focus ring and nothing else. One recipe
for the home wall and the
/partnerswall:partner-logo()insrc/styles/_logos.scss.LogoMonoinsrc/lib/partners.tsflags masters made for a light ground (dark: Dotěkománie, White Whale Media), which sit on a paper card; a master that is its own tile (tile) is shown as supplied and on/partnerstakes the paper shadow; every other master is shown as supplied. - [MUST] The partner wall draws no tiles of its own. No cell, no
border, no lit plate: open marks on the black table. The one paper is the
dark-ink card: on
/partnersit is a straight--paperchip,0.75rem 1.1remof padding, the paper shadow (partners.scss). Each tier is a9remlabel column beside its marks (the label over them ≤1100px): theh2is the plain tier label, the tier name plus ansr-only"partners" (partners.astro,.tier-label). The marks sit in one left-set flex row wrapping when it must (.wall), except Diamond two to a row and Gold three to a row on a grid so the strong sizes line up into columns (≤720px Diamond one column, Gold a wrapped row). The tier order is the ladder, then Community, then Media, at every width. - [CURRENT] Rank is the tier's box; marks in a tier carry equal ink.
Each tier has a reference box (
CELLS,partners.ts, px: Platinum 600×300, Diamond 330×190, Gold 260×150, Silver 210×120, Community, Travel and Media 180×100).partnerLogo()fits each mark into a box of the same area shaped to its ratio (opticalBox(), capped so no mark outgrows the box) and hands the width to the stylesheet as--logo-w; thepartner-logomixin setswidth: calc(var(--logo-w) * var(--logo-scale, 1)); height: auto(_logos.scss). No height per tier./partnerssets each tier larger than its box throughTIER_SCALE(partners.astro: Platinum and Diamond 1.5, Gold and Silver 1.28, Community, Travel and Media 1.2), one size per tier, passed topartnerLogo()so the srcset is cut for the rendered size. Narrower screens step whole tiers down through--logo-scale: Diamond and Gold 0.86 ≤1100px; ≤720px Diamond 0.6, Gold 0.75, Silver 0.7, Community, Travel and Media 0.8 (partners.scss).platedis a per-partner flag, not a tier inversion. - [CURRENT] The home partner wall (
index.astro,index.scss): the.head-rule"Partners" with the two text actions "All partners" and "Become a partner" in the note's place, then one row per tier under a plain label (.partners-label,note(--fs-note)in--ink-meta), top tier first: Platinum and Diamond side by side on a5fr / 7frgrid, Gold and the rest across the page; Silver and Community share a row under one label ("Silver and community", or "Community"), each mark still sized by its own tier; Travel takes the last row; Media on/partnersonly. The wall uses the reference boxes as they are (STRIP_SCALE = 1); each tier's--logo-scalesets the rank on this page (Platinum 0.85, Diamond 0.9, Gold and Silver 1, Community and Travel 1.1; 0.55 to 0.85 ≤720px). - [MUST] A missing photograph falls back to initials at
--ink-monogram/--ink-monogram-sm(4.21:1 / 7.71:1,BaseLayout.scss). - [MUST] Photography is B&W at high contrast (manual p. 13).
[CURRENT] Every
.paper-photorests atgrayscale(1) contrast(1.1)(BaseLayout.scss), the speaker sheet's and the session sheet's prints included; the speaker prints atcontrast(1.15)(SpeakerTile.module.scss); the home event print darker atbrightness(0.7)under its subtitle and the contact-sheet frames atcontrast(1.15) brightness(0.9)(index.scss); the session slip avatars atcontrast(1.1); the hero plate atcontrast(1.12)(see Open points). Only the speaker prints (on the grid and in the session sheet),/teamand the invite portrait develop to colour. - [MUST] Every
<img>below the fold isloading="lazy" decoding="async"with intrinsicwidth/height; above the foldeager. Local assets use Astro<Image>withlayout: 'constrained',responsiveStyles: true(astro.config.mjs). - [MUST] Decorative images take
alt=""(the contact-sheet frames,index.astro; the menu panel's print and the/contactpress print, botharia-hidden;press/downloads.astro). Where a print repeats what a frame already shows, the print carries the description (the home enlargement,index.astro). - [CURRENT] Imagery is
.webp; OG card.jpg1200×630 (public/og-image.jpg: "30 OCT 2026" with only "2026" red, the official white red-dot logo, a white CTA label on red). [MUST] The detective key art,/hero-detective.webp, is the home hero's alone (HeroBackground,index.astro:width="74%", full opacity,focus="38% 42%";#hero .hero-photore-masks it to fade in from the left and takesgrayscale(1) contrast(1.12) brightness(0.95),index.scss; full width over the top of the hero ≤720px).HeroBackgroundhas no other caller. [MUST] A subpage opens on type.SubpageHerotakes no photograph: every subpage opens on its title and lede./venueshows its one print, the entrance, in a band;/contactits 2019 press print beside the clippings; the lineup's portraits and the agenda's slips start right under the title. Beyond the hero,/carries three photographic blocks: the event print of the mill (uhelny-mlyn-chimney), the speaker prints in the teaser, and the "Past editions" contact sheet of eight frames with one enlarged (STRIPS,index.astro, frompublic/gallery/). The menu panel carries one print of the venue (.nav-print, see Menu).
- [MUST] Astro for layout and static UI; React islands only for behaviour
(
HeroOffer,NewsletterForm,Speakers,SpeakersTeaser,Sessions,Agenda,Tickets,InvoiceForm). - [CURRENT]
client:loadby default;NewsletterForm(index.astro) and the invite closer'sInviteCtauseclient:visible.Ticketstakesclient:load(index.astro): the hero's "Get tickets" jumps straight to it. [MUST] An island that renders nothing until data resolves takesclient:load— a zero-height placeholder never intersects (SpeakersTeaser,index.astro). - [MUST] Styling location: islands import a co-located CSS Module; Astro
components use a sibling
.scssor global primitives; pages usesrc/pages/<page>.scss. - [MUST] Global primitives are used by class name, never copied.
- [MUST] Anything on more than one page is a component or a primitive.
- [MUST] A measured value is decided once, as a token.
Primitives, all in BaseLayout.scss:
| Class | Line | What it is |
|---|---|---|
.u-container |
347 | centred column at --maxw with the page gutter |
.page-stack |
545 | every page's <main>; its last .band closes on --section-y-tight (624) and the footer adds no opening air |
.band (+ --lit 605) / .band-inner |
589 / 628 | a section's ground, flat black; --lit is the same flat black, kept as the class of the form, legal and reference subpages (/agenda, /faq among them). There is no --accent variant. [MUST] One gap between two sections: a band followed by a closer hands its bottom air to the closer (.band:has(+ .closer-band), 617), and consecutive bands of one page do the same (/agenda, /invoice), so two paddings never stack |
.eyebrow |
355 | plain typewriter note (note(), --ink-muted) — no decoration, no trailing hairline, never uppercased; the head of a NextStepList, a Closer eyebrow |
.head-rule (+ .head-rule-title, .head-rule-note) |
335 | the section head: a <header> (or <div>) on SubpageHero's grid, minmax(0, 1fr) minmax(0, var(--side-col)) with --side-gap between, aligned to the foot (align-items: end): the Bebas --fs-section title (display()) left and, in the right column, at most one note set as prose(--fs-body-lg, 1.6) in --ink-soft (max 34ch) or the section's text actions, so every head's note starts at the hero note's x; a head with no note spans both columns (> :only-child); one column ≤720px; no rule, space closes it (margin-bottom: clamp(2.5rem, 5vw, 4.5rem)). The class keeps its old name |
.paper |
393 | a paper print: --paper border (0.65rem 0.65rem 0), --paper-ink, --paper-shadow, rotate(var(--tilt, 0deg)); ≤720px the tilt clamps to ±--tilt-1. Also the class of a straight paper card, slip or form that carries no photograph (the /venue and /contact index cards, the NextStep cards, the partnerships card, the invoice and workshop forms, the /thank-you stub); those set no --tilt |
.paper-photo |
403 | the black photograph well inside a print; its <img> covers it in grayscale(1) contrast(1.1); the caller sets the ratio |
.paper-edge |
418 | the print's bottom margin: two items pushed apart, mono(--fs-label-xs, 0.12em, uppercase) in --paper-ink-dim; a word on it is an .edge-word note |
.tape (+ .paper > .tape 447) |
436 | a strip of tape: --tape ground, note(--fs-note-sm) in --paper-ink, 0.35rem 0.85rem 0.3rem, a 1px contact shadow, rotate(var(--tape-tilt, -1.5deg)). Only on a print: one per print, over the top edge at the top-left (top: -0.85rem; left: 1rem, z 2), one line at width: max-content, never cut with an ellipsis (see Images) |
.grease |
461 | the red grease pencil: an absolutely placed, aria-hidden, pointer-events: none SVG at z 3; its path is a 3px --color-accent stroke, round caps and joins, non-scaling-stroke. The caller sets the box. One decorative mark per page (see Color) |
.display (+ .red 494) |
484 | poster headline; .red only on the home hero statement (see Structural rules) |
.fallback-note |
565 | the no-JS / endpoint-down prose |
.anchor-target |
581 | a jump lands on the section head |
.closer family |
636 | the closing statement (see Closer.astro), on the black ground |
.btn-primary |
693 | the red fill; white Bebas cta-label, no glyph |
.btn-ghost / .action |
733 | the text action: Bebas --fs-action caps, no box, no underline, no glyph; red on hover (see Component states) |
paper-action() |
_type.scss:118 |
the text action on paper, a mixin: --paper-ink, no line at rest, a 2px ink underline at 4px offset on hover and focus (see Component states) |
hairline-link() |
_type.scss:99 |
the in-prose link, a mixin only (the old .hairline-link class is gone): plain underline in --rule-red at 3px offset, white, lit to --color-accent-hot on hover/focus (SC 1.4.1: colour alone does not mark it). [MUST] A link inside running text on the black table takes it as is (privacy-policy.scss, the ticket footnote, the forms' consent lines, the hero facts line's venue link, the press-kit footnote, the FAQ and /team inbox addresses); never a red-text variant, red is not running text. On paper an in-prose link is a 1px --paper-ink underline (the FAQ answers, the NextStep notes). A standalone link is an .action, never underlined |
.scene |
690 | position: relative for the home hero (#hero) |
.skip-link / .sr-only |
290 / 333 | see Accessibility; the skip link is set in the action voice |
.status-bar-cover |
954 | iOS safe-area paint |
Shared components: SubpageHero.astro (takes titleHtml, lede,
seoHeading, titleId and an optional link), HeroBackground.astro
(the home hero's plate: a focus crop, a src plate, default the
detective key art, an opt-in width / size, the two opacities),
Closer.astro (takes an extra class; every closer renders on black),
EndLine.astro,
Menu.astro, Footer.astro, CookieBanner.astro, NextStep.astro,
NextStepList.astro, DataState.tsx, SpeakerPhoto.tsx, SpeakerTile.tsx
(the speaker print and billLineup()), and Sheet.module.scss (the shared
detail-view chrome for SessionDetail / SpeakerDetail).
-
SubpageHero.astro— every subpage opens on type (SubpageHero.scss): the title Bebas--fs-subpage,text-wrap: balance, left; beside it the lede,prose(--fs-body-lg, 1.6)in--ink-soft(max34ch), and the optional way-across.actionunder it, on aminmax(0, 1fr) / minmax(0, var(--side-col))grid with--side-gapbetween, aligned to the foot, so the note sits on the title's last line. [MUST] One column for every note:--side-col(24rem) and--side-gapare shared with.head-rule, so every section head's note on the page starts at the hero note's x. It carries no photograph. ≤720px the title and the lede stack. Its padding is the only clearance under the fixed header; its bottom padding (clamp(3.5rem, 7vw, 6.5rem)) is the pause before the first band, which opens with no top padding of its own (.subpage-hero + .band,BaseLayout.scss). Compact on purpose: the first content row shows without scrolling. -
EndLine.astro— the last line of a page that asks for no decision, in place of aCloser: one Special Elite--fs-ledeline in--ink-softand one.actionat--fs-cta(Bebas caps, no glyph, no underline) to the useful next step, plus an optional second one (also), on one baseline, wrapping under on a phone;6remof air above it, inside the last band's inner column (EndLine.astro,EndLine.scss). Where the next step is an address (/faq,/team) the action is the address itself, typed: anote(--fs-lede)on the in-prose underline (faq.scss,team.scss). Ends/speakers("Full agenda" and "Get tickets"),/attending,/invoice,/thank-you,/newsletter-subscription-thank-you,/contact,/press/downloads,/faq,/team. -
NextStepList.astro— the cards and their head: a typewriter.eyebrowh2(label,labelIdfor the section'saria-labelledby), then a<ul>, or<ol>whenordered(a real sequence), ofNextStepcards laid straight on the table, stretched to one height: three across, four when there are four (two ≤1240px), one column (max36rem) ≤900px, air between them, never a rule (NextStepList.astro). Never a poster head: the card titles under it are the larger type./404"Try one of these",/thank-you"Before you go",/newsletter-subscription-thank-you"While you wait". -
NextStep.astro— one thing to do, a straight paper index card (.paper,NextStep.scss): an optional mono--fs-headindex figure at 500 in paper ink (index,01, visual only — the<ol>carries the order), the errand as a Bebas--fs-headtitle, the note asprose(--fs-body)in paper ink, and its actions at the card's foot: ghost by default as.step-go(Bebas--fs-ctathroughpaper-action(): no line at rest, a 2px ink underline on hover and focus), or the red fill; the default slot, for media, sits in the same foot (.step-foot). [MUST] Across a row the cards line up: each card spans three row tracks of the list (four with an index) throughgrid-template-rows: subgrid, so every note and every foot starts at one height however many lines a title takes; without subgrid the foot drops to the card's bottom edge (margin-top: auto,NextStep.scss). -
DataState.tsx(ErrorState/EmptyState) — left-set; an empty state always offers somewhere to go. -
SpeakerPhoto.tsx— no URL or a failed load both land on the monogram; caller passes classes for the shape. -
Speaker print (
SpeakerTile.tsx,SpeakerTile.module.scss) — every speaker is a paper print set down on the table, the same print on/and on/speakers, in running order (billLineup(): timed talks by start, then room; then untimed talks; then the hosts). The whole print is the control (.paperon the<a>or<button>): one tape at its top-left naming the room ("Time to be announced"; on a phone the short label, "Time TBA"), the 4:5 B&W portrait cropped from the top centre so the heads line up, then the caption on the paper: the name Bebas--fs-card-title(row-title(), line-height 0.92) in--paper-ink, the role (cleanTagline()) as anote(--fs-note-sm)in--paper-ink-dim, wrapping, never cut, the talk title as anote(--fs-note), "With …" for co-speakers, and the edge pinned to the print's foot: "NN / NN" running number and the CET start. A row's prints are one height: the caption takes the slack (.cell,.caption). A host's print carries no tape and no time. Tilts cycle--tilt-2,-tilt-1,--tilt-1,-tilt-2so neighbours never match (TILTS). Reached, the name turns--color-accent(Bebas at card scale, large enough on paper) and the portrait develops; focus draws the ring at--focus-gap-lg. [MUST] Every print is the same size, the opening keynote (the first timed talk of the day) included: only its tape says "Opening keynote, ". On a phone two to a row, names at--fs-title-sm. The grid (.grid) is four columns (/speakers: three ≤1100px; the home teaser stays four), two ≤720px,grid-auto-flow: row dense, gapclamp(3.5rem, 5vw, 4.5rem)×clamp(1.5rem, 2.8vw, 2.5rem)with1remof top padding for the first row's tapes. -
Speakers teaser (
SpeakersTeaser, home) — a different random handful of the lineup on every visit (pickTeaser), shown in running order with each print's number from the full lineup. Head: the.head-rule"Speakers" with the note "N confirmed. Times in CET." (the count a mono figure); then eight prints (TEASER_SIZE), two whole rows of four; each an<a>to the speaker. ≤720px the prints from the seventh on are hidden: three rows of two. Under the grid one.actionat--fs-cta, "All N speakers". No rotation while the page is open. The teaser opens on--section-yand closes on none, like every home section; nothing renders until the lineup resolves (SpeakersTeaser.tsx). -
Speakers lineup (
Speakers,/speakers) — every speaker with a talk as a print on the same grid, each a<button>that opens the speaker sheet; the pressed print's photograph carries the view transition and morphs into the sheet's (usePortraitMorph;SpeakerLineup,Speakers.tsx). The first four prints load eagerly. The hosts follow in the same grid and the same prints under a plain "Hosts" label (.hostsLabel,note(--fs-note)in--ink-meta), so the speakers above fill whole rows and the running number stays continuous. The page ends on itsEndLine("Every talk by time and room.", the.actions "Full agenda" and "Get tickets"; the header Tickets is the page's red). No closer. No placeholder print for names still to come. -
Speaker sheet (
SpeakerDetail) — a print and a reading column. Left, the speaker as a large.paperprint, sticky while the bio scrolls (max30rem;min(20rem, 82%)once the sheet stacks ≤860px): tilted-tilt-1, the room on its tape, a 4:5.paper-photothat receives the portrait morph, the edge "30 / 10 / 26" and the start time. Right, the reading column: the name at--fs-display, the tagline as a note, the links, the bio (prose(--fs-body-lg, 1.75), max62ch, ragged), then the talks as paper slips (the title Bebas--fs-title-sm, the abstract ragged), straight. No kicker. Links are named Bebas--fs-actiontext in--ink-soft, no underline, lit to--color-accent-hot: the network's name for a known kind ("LinkedIn", "GitHub"), the bare domain for a website; no icons. The accessible name says who, what, the host and "opens in a new tab" (SpeakerDetail.tsx,SpeakerDetail.module.scss). -
Session sheet (
SessionDetail) — the talk as one big flat paper slip (max58rem,SessionDetail.module.scss): the room as a plain Bebas--fs-actionlabel (.room, no tape), the time a mono--fs-label-lgfigure and the tags a note on its edge, the titledisplay(--fs-h2), the abstract a ragged reading column. Beside it (under it ≤1100px) the speakers as small 4:5.paperprints, tilted in turn, each a<button>that opens the speaker sheet on top and develops to colour when reached. Under both, the sheet's red "Get tickets" (SheetTickets,Sheet.tsx). -
Sessions (
Sessions, "Every talk") — every talk as a straight paper slip on the table, two to a row (one ≤720px), on a2.25rem × 1.75remgap. Flat paper over black with the shadow on a::beforeunder the isolated grid, like the agenda slips. A slip is one<button>that opens the session sheet: its edge (time and room left, track right, in--paper-ink-dim: the time a mono figure, the words notes), the titleprose(--fs-body-lg, 1.3), three lines of the abstract asprose(--fs-note-sm)in--paper-ink-dim, and at its foot the speakers' avatars (paper-ringed,grayscale(1) contrast(1.1), initials under a lazy or failed photo,CardAvatars) beside their names in Bebas--fs-action. An unscheduled talk shows "Time TBA" (plus the room if it has one) where the time goes. On a phone the slips are compact and drop the abstract, and before any search or filter the section is one line inviting the search, the field and the track filter; the slips come with the first query or chip (.invite,Sessions.module.scss). The filters are Bebas words (see Component states). -
Agenda (
Agenda) — the day as slips on the table (Agenda.tsx,Agenda.module.scss). Over it, a bar: the room filter ("All rooms" and each room, Bebas--fs-cta, red when pressed) left and "All times Prague (CET)" as a note right. A room open all day (the lounge,LONG_RUN_MIN180 minutes) stays out of the live and coming-up state. Desktop (>720px): every room, four in practice, is a column: a faint lane (.lane,--panel-lit) from its name to the day's end, under a name pinned below the bar while the day scrolls (.head, sticky at--header-h,--panel-litover--color-bg;.headLabel, Bebas--fs-action, no tape; a standing note under a name where a room needs one, "Workshops only", "Open all day" for the lounge); a mono--fs-label-lggutter names each start time. The lounge's lane holds one block from open to close (AllDayBlock,.allDay,--panel-lit-2): mono hours, the title in Bebas, "Drop in any time" as a note; a<button>that opens its sheet when it has a description, its title red under the pointer. The lounge's lane is always the last column, and a break line stops before the first room still busy at that time, so it never runs under the lounge or a workshop. The grid is not to scale: one row per moment something starts or ends, each as tall as what sits in it, so a lunch never outweighs the talks around it (AgendaGrid). Every talk is a straight.slipfrom its start line to its end line, one sheet of paper over its whole span (a workshop included): flat paper over black, no radius,0.875rem 1rem 1rem, the paper shadow on a::beforeunder the isolated grid; its edge carries the time range (mono--fs-label-xs) and the track and length (a note), both in--paper-ink-dim, then the titleprose(--fs-body, 1.3)and the speakers in Bebas--fs-action, then the live or next flag (see Component states). A break or lunch is a quiet typewriter line across the rooms, mono time and title in--ink-soft; the evening after the last talk is louder (--fs-lede, white) and names its room. A speakerless "TBA"/"TBD" slot is "Slot open": a dashed--field-borderoutline with its time and a note, not a control (.open). One room picked, the grid narrows to44rem. Phone (≤720px, JSNARROW): the same day as one compact list grouped by start time, the lounge one line above it (the room in Bebas, "<title>, open all day , drop in any time" as anote(--fs-note)in--ink-soft, the hours mono;LoungeLine,.lounge): a mono--fs-body-lgtime head per group (with a red "Now HH:MM" while live), then each slip with its room named on its edge (time and room on the first line, track and length on their own); a break alone at a moment is a quiet line with no head (AgendaList). Abstracts, tags and photos live only in "Every talk" below. On a phone the "Every talk" head drops its note (the list invites the search itself,agenda.astro). -
Venue screens (
TvScreen.tsx,TvScreen.module.scss, unlisted/tv) — the darkroom at sign scale, read across a hall. Recorded exceptions: (1) every size is a multiple of--u(1/100 of the screen's long side), not a--fs-*step, because the ramp tops out at laptop sizes; (2) the speaker print restates.paper/.paper-photo/.paper-edgein--ufor the same reason (tokens unchanged:--paper,--paper-shadow,--tilt-*,--paper-ink-dim); (3) talk titles are Bebas on paper too, where the agenda slip sets them inprose(): the condensed face fits a title in two lines across the room; (4) a 1px drift every six minutes keeps a still frame off the panel (burn-in), off under reduced motion; (5) the credits band turns a partner page every 8 s on its own, since a band of every partner cannot fit one screen. Otherwise the site's rules: flat black, no rules or cards on the table; word labels are notes (never under1.6u), times mono and a figure inside a note a mono.fig; the speaker portraits are tilted prints with frame number and start time on the edge, Up next and the board's Now column straight slips; "Live now" is a red word and dot on black, an ink word and red dot on paper; the live progress is a spine, white on black, ink on paper. The one grease mark rings the countdown to a talk inside ten minutes (the hero's while it is the next talk, else Up next's) or underlines "That's a wrap." at the day's end. A shared session held in another room (the kick-off, on the Speakeasy screen) names that room in a note,In Moonshine, and the hero title steps down a size to give the note its line. The credits band keeps every partner mark in its colours, equal-area sized from the Gold box (logoBox,partners.ts), adarkmaster on a white card; a new partner page fades in (opacity, none under reduced motion). No split-flap, sweep or pulse. -
Menu (
Menu.astro,Menu.scss) — the bar is the mark left and the actions right (display: flex; justify-content: space-between). At ≥1100px the destinations sit in the bar (.bar-nav): Speakers, Agenda, Venue, FAQ in--color-text, a short--rule-strongmark, then Partners, Team, Press and Contact in--ink-soft, every link Bebasaction()at tracking 0, the current page in--color-accent-hotwitharia-current, red on hover; then the red Tickets button; the toggle is hidden. Under 1100px the toggle and the Tickets button stay and every destination lives in the full-screen panel. The header mark link is a full 44px tap target and stays over the open panel (z 2). Open, the toggle label reads "Close". The open panel is one column of links: the five primary destinations (Tickets, Speakers, Agenda, Venue, FAQ), then the second tier, Partners, Team, Press (/contact#press) and Contact. The primary links set at--fs-display(--fs-h2≥901px,--fs-h3on a short screen) and the second tier at--fs-h3. Beside the links, where the panel has room (≥901px wide and ≥621px tall), one print of the venue (.nav-print):.paper, tilted--tilt-2, taped with the venue's name, a 3:4 photograph, the edge "30 / 10 / 26" and the doors time,aria-hidden. -
Cookie banner (
CookieBanner.astro,CookieBanner.scss) — a small straight paper slip at the foot of the table: fixed bottom right on a wide screen (min(28rem, …), clear of the hero's ticket button), across the foot of a phone;--paper,--paper-ink, the paper shadow, no tilt, no edge drawn round it. Accept is the black fill, Decline a paper-ink text action; no red, the screen keeps its red for the ticket. It fades in and out (opacity and a1.5remdrop over0.3s, none under reduced motion).
Structural rules [MUST]: no decorated eyebrow; red text is flat; the
red word and the closing full stop belong to the home hero statement
(and the invite title). Subpage hero titles carry neither (see the title
list under Voice); every SubpageHero title is migrated. Section heads and every Closer title
are plain white, because a red final word on ~30 headlines made none of them
the loud one. On / the sections open on a .head-rule at poster scale:
"The eleventh DevFest.cz", "Get the app", "Tickets",
"Speakers", "Past editions", "Partners"; "Newsletter" is a .display h2
on its own card's column. Space under the head, never a rule. No numbered
eyebrow, no stamp. Headings are literal: no puns.
[MUST] A head note carries a fact, never a label of the section's own
contents. The note exists only where it tells the visitor something the
section does not already show: Speakers ("N confirmed. Times in CET."),
Past editions ("Ten editions since 2012."), Tickets (the invoice route:
"Need to pay by bank transfer?" over the .action "Request a company
invoice", Tickets.tsx; "Opens with the first wave" before the
first wave), "Every talk" (how to search), "The route" (the train and the
walk), "In the news" (where the press kit is), "Get the app" (what the app does,
which the badges don't say). Partners takes its two text
actions in the note's place; The eleventh DevFest.cz opens on the title
alone. A note that only names what follows is filler and is left off.
Section titles say what the section is; a full stop belongs to a statement,
not a label. Section heads left-set; detail views are full-bleed sheets.
Each page ends on its one next step. Closer.astro ends a page that
asks for a decision, on the black ground, its title at --fs-section:
/agenda, /workshops, /venue, /invite/<member>. A page that asks
for none ends on an EndLine (list above): /speakers ("Full agenda" and
"Get tickets", no closer), /contact after its press kit, /team on
"Want to help next year?" and the inbox address. /partners ends on its
"Become a partner" block (the title beside the partnerships card), /404
on its NextStepList, and /privacy-policy, /code-of-conduct and
/delete-account on their content.
The footer is the open sitemap [MUST]. Under 1100px the menu hides
every destination behind one toggle, and the desktop bar leaves out the
company invoice, the code of conduct and "I'm attending", so Footer.astro
lists every page in two groups, Attend and About, with the menu's
labels. A new route is added to both.
Footer anatomy: the logo → five columns → the folio line, parted by
space, never a rule (Footer.scss). The footer adds no opening air of its
own: the page's last band closes on --section-y-tight and that is the
only space above the sign-off (.page-stack > .band:last-child,
BaseLayout.scss). [MUST] The footer signs off with the official logo
file, never the name typeset: src/assets/logo.png through Astro
Image, min(40rem, 80vw) wide and pulled left by the file's own
transparent margin (83 of 3000 px) so the mark starts on the text column,
alt "DevFest.cz 2026", a link home with the focus ring and no hover
treatment (.footer-logo-link, Footer.astro). The columns: the brand
(the tagline and the inbox link), Attend, About, Event (Date, Place
as a map link, Format) and Organizer (the GUG.cz line and the social
marks, one row at every width, each glyph at its target's left edge so the
first lines up with the column), on a 1.4fr + four 1fr grid; two
columns with the brand across the top ≤45em, one ≤23.75em. Column titles and the Event terms are note(--fs-note-sm) in
--ink-meta, sentence case, no tracking; the links are Bebas action() in
white at tracking 0, red on hover. The bottom line: © 2026 GUG.cz, z.s. left, the folio Case no. DF-2026 centre, "Open source" right as an underlined note, no off-site
glyph. The folio is the case-file mark the manual prints on every page and
the footer's one mono figure: a <small class="folio" aria-hidden="true">
at mono(--fs-label-xs, 0.16em, uppercase) (Footer.astro,
Footer.scss); decorative, so it stays out of the accessibility
tree. Ground --color-bg.
[MUST] A subpage is: SubpageHero → .band sections → Closer where
the page asks for a decision, otherwise an EndLine inside the last band →
Footer. No Ticker anywhere (it was the same five topics under every
hero, then under the home hero only; now no page renders it). The first
band after the hero opens with no top padding (.subpage-hero + .band,
BaseLayout.scss): the hero's bottom padding is the pause, and neither
draws a line between them. Between two sections there is one gap, never
two paddings stacked (.band:has(+ .closer-band); see the primitives). The legal pages
(/privacy-policy, /code-of-conduct, /delete-account) run this shell
too, with a footer; / and /partners are out of scope.
---
import BaseLayout from '../layouts/BaseLayout.astro';
import SubpageHero from '../components/SubpageHero.astro';
import Closer from '../components/Closer.astro';
import EndLine from '../components/EndLine.astro';
---
<BaseLayout title="…" description="…">
<main class="page-stack">
<SubpageHero
titleHtml={'…'}
lede="…"
seoHeading="…"
titleId="…"
<!-- link={{ href: '#talks', label: 'Browse all talks' }} for a way across -->
/>
<section class="band band--lit" aria-label="…">
<div class="band-inner">
<!-- The hero names the first band, so its head is sr-only. -->
<h2 class="sr-only">…</h2>
<!-- a component grid, paper cards or a reading column -->
<!-- A page that asks for no decision ends here: -->
<EndLine text="…" href="…" label="…" />
</div>
</section>
<!-- A page that asks for a decision ends on a Closer instead (on black): -->
<Closer titleHtml={'…'} note="…" actions={[…]} titleId="…" />
</main>
</BaseLayout>Decision criteria, picked per page:
- Type first. Every subpage opens on type alone; the detective art is
the home hero's. A page's photographs live in its bands:
speakersandagenda(prints and slips right under the title),venue(the entrance),contact(the 2019 press print),team(the crew prints). - Legal pages. Inside the band a
.readinggrid,3fr / 9fr(4fr / 8fr≤1100px, one column ≤720px): a sticky contents index on the left (a note label, mono numbers and Bebas--fs-actionlinks, red on hover) and the.policyreading column (max68ch,prose(--fs-body, 1.8),h2row-title(--fs-head),h3anote(--fs-body-lg)) (privacy-policy.scss, shared by all three). Data tables are never ruled: each row is an entry whose cells print their column head fromdata-labelas a note, a6.5remlabel column at every width, one column ≤720px./code-of-conductadds a straight paper card for the listeners with paper-ink actions. .band--lit. The class of the form, legal and reference pages (/agenda,/faq,/workshops, the thank-you pages, the card builders,/404,/press/downloads, the legal pages). It is the same flat black as.band: there is no spotlight. The darkroom pages (/speakers,/venue,/invoice) run plain.bands, and/partners,/contactand/teamtheir own black sections.- No red band. The accent band variant and its ground token are gone.
A closer, the
/partnersCTA and the newsletter sit on black; the red on such a screen is the button. - The first band's head. [MUST] When the hero already names what the
band holds (
speakers,agenda,contact,team), the band opens straight on its content with ansr-onlyh2(or none:/team's section is anaria-label). A second poster headline under the hero ("Who's talking" under "Speakers") says the same thing twice. A visible head is for a band the hero does not name: the FAQ categories, "Every talk", "The route", "In the news". ANextStepfield is headed by itsNextStepListtypewriter label, not a poster head. - A way across goes in the hero as
SubpageHero link, an.actionunder the lede (SubpageHero.astro:41–45), not in a band head:/agenda"Browse all talks" (#talks;/sessionsnow redirects there),/faq"Get in touch" (mailto:). .head-ruleis the one section head. A poster name and, at most, one fact or the section's actions in the right column, the column the hero's note takes.- Eyebrows on a
Closer. Only when the eyebrow carries a fact the title does not (invite: "The list closes"). A mood label ("Be there", "Join us") above the closing title is filler; leave it off. /thank-you,/newsletter-subscription-thank-you,/404runSubpageHero→.band--litwith aNextStepList→EndLine(not/404)./thank-youopens the band on the calendar as a straight paper stub (.event-stub.paper,thank-you.scss): "Put it in your calendar" as a note, the datedisplay(--fs-h2)fromEVENT.dateLabel, a Time / Place list with note terms and mono hours, then the red "Download .ics" and "Google Calendar" as a paper-ink underlined action; three columns, two ≤1100px with the actions across, one ≤700px. Then "Before you go", share and an@media printblock./app(app.astro,app.scss) — a type-only hero ("Get the app"), then the App Store and Google Play badges straight under it on black (the venue QR codes land here, so a phone sees them on the first screen), the visitor's own store first with a red "Your phone" note, and the web app as an.actionunder them. The badges are one component,StoreBadges.astro(StoreBadges.scss), shared with the home page's "Get the app" band. The store badges are the one control the site does not draw: Apple's and Google's artwork, unaltered, at one height (their guidelines forbid recolouring). A store without a link yet shows its name and "Coming soon", never a dimmed badge. Then "What's inside" as four straight paper index cards (the/venueroute card: mono number, Bebas--fs-headtitle, paper-ink prose; four across, two ≤1100px, one ≤600px) and anEndLineto the agenda./venue(venue.astro,venue.scss) — a type-only hero ("Venue"), three plain.bands, then the closer. The address, type only: a note "Address", the street at--fs-section, the post code (mono) and town as a lede, the doors and keynote hours, and the two.actions "Open in maps" and "Station to venue". The route: the.head-rule"The route" with the train and the walk as its note, then two straight index cards in an ordered list (display: contents, so the<ol>keeps the order for AT) side by side at one height (one column ≤720px). A step card carries a large mono number (mono(--fs-head), tracking 0), the Bebas--fs-headtitle,prose(--fs-body)in paper ink withstrongplace names on a faint highlighter, a<dl>of facts (Get off, Ride / Walk, Distance), and, on the walk, an.actionin paper ink that underlines in ink on hover and focus (.action.venue-go). The entrance: the page's one print, the doors, a 4:3.papertilted--tilt-1, taped "The black metal building", its edge the venue's name as a note and the doors time in mono, ringed in grease (the page's one mark), beside "The entrance" at--fs-sectionand one line on a7fr / 5frgrid. The closer's title sets at--fs-section./faq(faq.astro,faq.scss) — the category index pinned on the left (position: stickyunder the header): a "Categories" note, then a mono number and a Bebas--fs-ctaname per category, red on hover and focus (on black); under it "Write to us" and the inbox address as a typed note on the in-prose underline. The questions sit on the right, on a3fr / 9frgrid (4fr / 8fr≤1100px). ≤720px the index becomes a wrapped row of Bebas--fs-actionlinks above the questions and the inbox line drops. Each category is a Bebas--fs-h2head; under it every question is a<details>row on the black table, one width, one left edge, straight: "Q nn" as a mono figure, the question asprose(--fs-lede)in white, a drawn "+" marker in--ink-dim. Only the open question is paper: a--paperslip with the paper shadow and a little air above and below, its ink--paper-ink, the "+" turned to "×", the answerprose(--fs-body)under the question text (max62ch). The first question opens on arrival. The page ends on itsEndLine./partners(partners.astro,partners.scss) — a type-only hero, then one wall per tier under its plain label (see Images & media), then "Become a partner": the.displaytitle at--fs-sectionleft and the partnerships card right on a5fr / 7frgrid (one column ≤1100px). The card is a straight.paperwith "2026" on its edge, the contact's 4:5 mug, a--fs-ledeline, the name in Bebas--fs-head, the role as a note, the address as an underlined link (a thicker ink line on hover), then the red "See partnership deck" and the paper-ink.action"Email Filip"./contact(contact.astro,contact.scss,press.scss) — a type-only hero, then the three inboxes as straight typed index cards in one row of equal cards (two columns ≤1100px with the general card across, one ≤720px): each a.paperwith "0n" on its edge, a Bebas--fs-headtitle, a line and the address; the general inbox's address is underlined in grease, the page's one mark. These cards and the clippings take--paper-shadowlike all paper. In the news: the.head-rulewith the press-kit note, then the clippings as straight paper slips cut square beside a past-edition print taped "DevFest.cz 2019" (3:2,--tilt-2, its edge "2019"); each slip is one link: the outlet (a note) and the date (mono) on its edge, the headline Bebas--fs-head(--fs-title-sm≤720px), the excerpt in mono (Czech diacritics; the recorded exception in Typography), "Read article" in Bebas, underlined in ink when the slip is reached; focus rings outside the slip. Press kit: the.displaytitle at--fs-section, a note, the brand manual as the red download (its format and size inside the button), and beside them one plain label per logo variant with its formats as Bebas download actions and mono sizes, two columns (one ≤720px). The page ends on itsEndLine("Need a format we have not published?", "Write to us")./team(team.astro,team.scss) — type-only hero, then every member as a print on a four-column grid (three ≤1100px, two ≤720px), tilts repeating every four and never past--tilt-2: the 4:5 B&W mugshot with the colour bleeding from the cursor, the name Bebas--fs-h3, the role as a note, links as Bebas actions, the edge "NN" and "2026". No tape: hovering or focusing a card cross-fades the role into the member's codename in full paper ink (.crew-role[data-swap]); with no hover (a phone) both lines print, the codename under the role. It ends on itsEndLine"Want to help next year?" and the inbox address./press/downloads— under a plain note label per logo variant, one straight.papercard: the mark in a 16:9 well on the paper (on black for a white mark), its formats as Bebas download actions with mono sizes; one column, two ≥640px, four ≥1100px, separated by air. The brand manual is the page's red download, its format and size inside the button (press/downloads.scss). The back link is the.action"Back to In the news" to/contact#press(an.anchor-target); the page ends on the sameEndLineas/contact.- Event facts live in
src/lib/event.ts(doors 08:00, keynote 09:00, close 19:00, afterparty to 23:00,dateLabel/dateShort, venue, map,.ics); every page that states an hour or the date reads them from there. Keep in step with the.icsinpublic/and the Event JSON-LD inBaseLayout.astro(startDate09:00,doorTime08:00,endDate19:00,BaseLayout.astro:94–97). - The home page is prints on a black table (
index.astro,index.scss): the hero plate with the statement, the facts and the red fill set over it, then six sections on a poster.head-rule(The eleventh DevFest.cz, Get the app, Tickets, Speakers, Past editions, Partners) and the Newsletter card. No second hero, no numbered eyebrows, nothing that moves on its own. Each section is a.band .secand closes on space, no bottom padding (.sec,index.scss); the next head is the cut, so there is one section gap between two sections. The ticket slips and the speakers teaser set the same rhythm on their roots (--section-yabove, none below;Tickets.module.scss,SpeakersTeaser.module.scss). The page spends one decorative grease mark, frame 12A on the contact sheet. - The home hero (
#hero,index.astro,index.scss):min-height: clamp(45rem, 62vw, 56.25rem)(47.5rem≤720px), black, type at the foot.HeroBackgroundputs the detective art on the right 74%, fading in from the left;.hero-shadelifts the type off it (the bar's shade at the top, the left third to black under the statement, the foot to black so the page runs on). Set left over it, on the page column: the statement (h1.hero-statement.displayat--fs-hero, line-height 0.86, max6.2em, the red word and the full stop; see Typography for its text-shadow); the topics; the facts line; the page's red "Get tickets" (.btn-primary.hero-cta,padding: 1rem 2.5rem; full measure ≤720px). No stamp, no logo in the hero (the bar carries the mark), no rotor. - The topics (
.hero-topics,index.astro,index.scss): the fiveTOPICSnames as a list under the statement, Bebas at--fs-card-title, white, each but the last trailed by a/in--ink-faint(empty alt text, so it is not read out), so a wrapped line never opens on a slash. One line on a desktop, wrapping on a phone. Names only; the descriptions live on/agenda. - The facts line (
.hero-facts,index.astro,index.scss): typewriternote(--fs-body-lg)in--ink-strong, spaced by gap with no separators: the date as a mono<time>in white, the countdown (below), the venue as a maps link on the in-prose underline, andHeroOffer— the wave on sale ("Last chance" for the final wave), "· until " when it has one, and "from " linked to#tickets, at the line's one size (note(1em)), the price a mono figure a step down (mono(0.86em), like the line's other figures;HeroOffer.tsx,HeroOffer.module.scss). ≤720px the facts stack one per line. - "The eleventh DevFest.cz" (
index.astro,index.scss): the.head-ruletitle at--fs-section, no note, then on an8fr / 4frgrid (one column ≤1100px): a widescreen print of the mill (2.39:1, 4:3 ≤720px) taped "Uhelný Mlýn, Libčice nad Vltavou" (EVENT.venue), with the line "One day in a converted coal mill 25 minutes north of Prague, for 350 medior and senior developers from across CEE." set over the photograph like a subtitle, tilted-tilt-1, its edge "02 · 2.39 : 1 · 08:00"; and beside it the back of the print, a straight paper card (.event-card) with the edge "On the back" as a note and the facts as a<dl>: the term anote(--fs-note-sm)in--paper-ink-dim, the value anote(--fs-body)— Date (a<time>), Place, Doors (mono "08:00–20:00"), Audience (mono "350", "developers"). - "Get the app" (
index.astro,index.scss), right after the event: the.head-rulewith its note, the sameStoreBadgesas/app(the visitor's own store first), and the.action"What's in the app" to/app. No red fill: the hero's "Get tickets" stays the page's one. The footer's Attend column carries "Get the app" too. - The countdown (
Countdown.tsx,Countdown.module.scss): in the facts line right after the date, at the line's one size — "Keynote in" as the 0.72 qualifier, then the time left toEVENT.startas one mono figure (10d 14h 22m 05s), no boxes, no red. Ticks by the second; under reduced motion it drops the seconds and refreshes every 15s. The figure isaria-hidden, a static sr-only sentence names the keynote. Before hydration it holds its box (visibility: hidden); from the keynote toEVENT.endit reads "Live now", after that it is gone. - "Past editions" (
index.astro,index.scss), after the speakers teaser: the.head-rule"Past editions" with its note set beside the title, not across the page; then on a7fr / 5frgrid (one column ≤720px) the contact sheet and the enlargement. The contact sheet is a dark card (rgba(255,255,255,0.055), tilted-0.6deg, flat ≤720px) holding two strips of four frames on black with sprocket holes along both edges (radial-gradient), each frame a 3:2 B&W photograph under a mono frame number ("11A", "12" …, running on like a roll of film); the hall, frame 12A, is ringed in grease, the page's one mark; the sheet's foot reads "2015 · 2025". The sheet holds photographs, so it tilts like a print. Four across, two ≤720px. The frames are decorative (alt=""). The enlargement is that frame as a 3:2 paper print tilted--tilt-3, taped "The big hall", edge "12A · 2025", and it carries the description. Nothing scrolls, nothing develops. - The partner wall on
/— see Images & media. - The newsletter (
index.astro,index.scss): two equal columns (one ≤720px): left the.display"Newsletter" at--fs-section(never broken) over aprose(--fs-body-lg)lede in--ink(max30ch); right the form as a straight paper card (see Forms). - The bar over the home hero. The mark and the red Tickets stay in the
bar at every scroll position. At the top of the page the bar's ground is
the scrim only, and the toggle's border is transparent (
Menu.scss). - The ticket slips (
Tickets.tsx,Tickets.module.scss): the.head-rule"Tickets" with the invoice route as its note ("Need to pay by bank transfer?" over the.action"Request a company invoice" at--fs-cta): a quiet side door, never a second red button. Then one paper ticket slip per wave, the only paper without a photograph that tilts: tickets set down on the table. The wave on sale leads: with one on sale the slips sit on a grid (.slipsLed,minmax(0, 35rem) minmax(0, 28rem), spread to the head's edges), the live slip first and largest on the left spanning the rows, the other waves small in the right column stacked from the top; with none on sale they wrap in a flex row aligned to the foot. The big slip is tilted--tilt-1: the wave name Bebas--fs-h2, the price mono at--fs-h2, the other variant's price as one note line under it ("Company funded 1 452 Kč", the figure mono), the state as anote(--fs-body-lg)in--paper-ink-dim("On sale.", "The final wave, on sale.", and the deadline when ti.to set one), a perforation (a row of punched holes through to the table, bleeding to both edges), then the slip's foot: the VAT line and the mono event date stamp left, the redBuyat--fs-card-titleand the paper-ink "Company pays" right. A spent wave is a small slip (flex: 5 1 18rem, max34rem) tilted-tilt-3, its paper a shade darker, its price (mono--fs-h3) in--paper-ink-dim, perforated, its state ("Sold out.", "Ended.") as the note, and crossed out corner to corner by the grease pencil: a state, not the page's decorative mark. A wave still to come is a small plain slip tilted-tilt-1with its state. ≤720px the slips stack full width, the live one first, every wave name at--fs-h2and the live slip's name and price at--fs-display; the slips are not.paper, so they keep their tilt. Under the slips, while a later wave is still to come, the footnote (prose(--fs-body-lg)in--ink-meta, max70ch) links the newsletter. The skeleton is two blank shimmering slips in the led layout, big then small. - The ticket and invoice facts sit where the decision is. Each slip
prints its price once; another variant's price is one data line under
it ("Company funded 1 452 Kč", a note: it is a sentence with a figure in
it). The buttons read "Buy" / "Company pays"; their accessible names lead
with that visible label (WCAG 2.5.3). That is the whole of who-pays on a
slip: no admission line, no explainer under the buttons. Every price is
gross, so the tax line is said once, on the live slip's foot.
/invoicenames the rate it prices from (no "Estimated"), the 14-day due date (INVOICE_DUE_DAYS, mirrored inInvoiceForm.tsx), that the price holds once the invoice is issued, and that one code claims every ticket. ItsEndLineanswers invoice questions; it does not send the visitor to card checkout. - A
<details>list opens with its first item open (faq.astro). A section must not reuse the hero'saria-labelledby(landmark-unique). Closerground. Every closer renders on the black.band(Closer.astro), its title at--fs-section. ACloserends a page that asks for a decision (buy, register, come); a reference page ends on anEndLine(/faq: "Still stuck?" and amailto:link) or on its content (privacy-policy,code-of-conduct,delete-account)./faqcontent. The detective-theme question sits in Essentials (src/content/faq.json)./venuelinks. Both map links ("Open in maps", "Station to venue") are text actions, by the address and again on the walk card: two ways to the same place are not two decisions; the page's red is the closer's button.
[MUST] The darkroom's rules for a new surface. A photograph is a
.paper print with a .paper-edge; a fact that travels with it (a ticket,
a talk, an inbox, an open question) is a straight paper slip or card in the
same flat paper and ink. One tape per print, top-left, never cut; a group
label is plain type, never tape. A page spends one decorative grease mark.
Only prints tilt (the home ticket slips the one exception), by a fraction
or a step of --tilt-*, alternating so neighbours never match, and never
past --tilt-3. On a phone every print but the keynote is equal and barely
tilted. The ground is flat black, the paper flat, its shadow the tight
--paper-shadow. Type on paper is --paper-ink / --paper-ink-dim; red
on paper is a stroke, a dot or a button, not small text.
[MUST] No icon library. The inline <svg> elements are the
Footer.astro social marks, each aria-hidden="true" beside a visible text
link, the Sheet's close cross (Sheet.tsx), the "Every talk" search glass
(Sessions.tsx) and the card builders' drop marks (AttendingCard.tsx),
all drawn inline and aria-hidden, plus the grease strokes. Don't add an
icon package for a UI glyph. Speaker-sheet links are
named text, not network glyphs (SPEAKER_ICON_PATHS is deleted): three
identical chain icons told the visitor nothing.
[MUST] No arrow glyphs. The trailing → on links, the ↗ off-site
marker and the teaser's arrow column are deleted (see Component states): a
glyph renders differently on every system and the label already names the
destination. The Sheet's "Close" carries a drawn cross, not a text glyph.
No emoji in UI copy.
[CURRENT] Inferred from existing copy:
- Word-labels are typewriter notes: one to three words, sentence case, no
tracking, never uppercased (
note()setstext-transform: none). Bebas uppercases headlines and actions by CSS. Never type caps — screen readers read them as shouting. - Ledes run one to two sentences, no italic.
- Copy is English; Czech appears only in quoted press clippings. [MUST] no third mixed-language block without the same justification.
- Prices via
formatPrice(src/lib/tito.ts); dates viaIntl.DateTimeFormat.
[MUST] Dates, hours and spelling. Day-month-year: "30 October 2026" in
running copy (EVENT.dateLabel), "30 Oct 2026" in mono stamps and meta
lines (EVENT.dateShort), never "October 30". Hours are 24-hour HH:MM
("08:00", not "8:00" or "9 AM"), read from src/lib/event.ts. British
spelling. The schedule is "the agenda", never "programme" or "program".
[MUST] Noir wordplay is spent in a few earned places, not everywhere.
It lives in: the home hero statement, the /404 title "Nothing filed under
that", and at most one closer line per page ("Doors open 30 October.",
"See you on the 30th."). Every section title and page title says plainly
what it is ("The eleventh DevFest.cz", "Tickets", "Speakers", "Past
editions", "Partners", "Newsletter", "Agenda", "Every talk", "The route",
"The team", "FAQ", "In the news", "Press kit", "Become a partner");
a .head-rule note is a fact or absent ("N confirmed. Times in CET.",
"Ten editions since 2012."). The case dressing is the darkroom's own: the
frame numbers and dates on a print's edge and the footer's folio. No triads, no em-dash chains, no hype words.
[CURRENT] Subpage titles (SubpageHero titleHtml): /speakers
"Speakers", /partners "Partners", /team "The team", /faq "FAQ",
/agenda "Agenda", /venue "Venue", /contact
"Contact", /press/downloads "Logos & assets", /invoice "Buy on a
company invoice", /workshops "Save a seat at a workshop", /attending
"I'm attending", /speaker-card "I'm speaking", /thank-you "You're in",
/newsletter-subscription-thank-you "You're on the list", /404 "Nothing
filed under that", /privacy-policy "Privacy Policy", /code-of-conduct
"Code of Conduct", /delete-account "Delete your app account". Closers:
"Doors open 30 October." (/agenda), "No ticket yet?" (/workshops), "See you on the 30th."
(/venue), "Take the ticket." (/invite/<member>); the /partners CTA is
"Become a partner".
npm run a11y (scripts/a11y.mjs) builds with A11Y_MOCK=1 and runs axe
over 15 routes at desktop and 375×812, plus inside opened [role="dialog"]
sheets. Tags: wcag2a, wcag2aa, wcag21a, wcag21aa, wcag22aa.
[MUST] Zero violations. Run it before a PR touching markup or styles.
- [MUST] Contrast. Body 1.4.3; boundaries and focus rings ≥3:1 (1.4.11).
--field-borderfor control boundaries on black,--paper-inkon paper, an error in paper ink behind a red mark. On paper, small text is--paper-inkor--paper-ink-dim(5.3:1), never red (see Color). Don't dim ink on red; change size. - [MUST] Focus is always visible:
:focus-visible { outline: 2px solid var(--color-accent-hot) }. Gap by how much edge the control has:--focus-gap-tight(own boundary),--focus-gap(type on ground),--focus-gap-lg(alone in space) (BaseLayout.scss:138–142); a whole print, slip or card that is the control rings outside its paper at--focus-gap-lg. Commented exceptions: the FAQ question's inset ring (outline-offset: -4px,faq.scss), the newsletter submit's--paper-inkring on its card, and a partner logo's wider6pxgap on the home wall and/partners(index.scss,partners.scss). - [MUST] Semantics before ARIA. A row that IS a control is a real
<button>/<a>/<summary>. - [MUST]
.skip-linkis the first focusable element (BaseLayout.astro:332,BaseLayout.scss:289–321). `html { scroll-padding-top: calc(var(--header-h)- 1.5rem); scroll-padding-bottom: 8rem }
keeps targets clear of header and banner.#main-contenthastabindex="-1"soEnter` moves focus, not just the fragment; its ring alone is suppressed.
- 1.5rem); scroll-padding-bottom: 8rem }
- [MUST] The cookie banner is second in the DOM,
Escapehandler ondocument(CookieBanner.astro:89–93). - [MUST] Only the countdown moves on its own. It is
aria-hiddenbehind a static sentence, and under reduced motion it stops ticking. It has no pause control, so its seconds are a known gap against 2.2.2 that the maintainer accepted (2026-10-19). The other loops are the loading skeleton and pulse, both stopped under reduced motion. - [MUST] Header controls are 44px tap targets. The mark link and the
bar's controls and the desktop bar links take
min-height: var(--header-tap)(Menu.scss:83,149,189,267); the text actions takemin-height: 2.75rem(BaseLayout.scss:709–736). - [MUST] A white label on red is large text or it does not ship. The
cta-labelsize (Bebas 24px, regular large text) is the AA floor (see Color); where it does not fit, the control moves (the header Tickets into the menu under 380px), the label never shrinks. - [MUST]
.sr-onlyfor text in the a11y tree but off screen (BaseLayout.scss:332). - [MUST] A grease stroke is
aria-hidden, and so is a decorative print (the menu panel's print, the/contactpress print). [CURRENT] Most paper edges arearia-hiddentoo (the home prints, the/contactand/teamcards); the speaker print's tape and edge are read as part of its link or button. A label that names a group is real text (the/partnerstierh2, the hostsh3, the agenda room heads). - [MUST] Sheets return focus to the opening row (
src/lib/useReturnFocus.ts) and close on Esc. - [MUST] Reduced motion — see Motion.
- [MUST]
html { overflow-x: clip }, neverhidden—hiddenmakeshtmla scroll container on iOS Safari and breaksposition: fixedand safe-areaenv()(BaseLayout.scss:222–226).
Places the code contradicts itself or this document. Each needs a decision.
- No motion tokens.
0.2sat ~68 sites, the arrive curve at 8, and no rule for0.28svs0.3svs0.32s. Proposal:--dur-control,--dur-field,--ease-arrive. - No z-index scale. Literals across six files; cookie banner and
.status-bar-coverboth10000,.skip-linkand header both10001. - The in-flight dim contradicts the no-dimming rule. The invoice submit
drops to
opacity: 0.55while submitting (InvoiceForm.module.scss). The newsletter's disabled state now uses a dedicated colour pair instead. - Three ways to say "not available": disabled
<button>,aria-disabled<span>(partners.astro, the deck slot), and a spent ticket slip's state note under a grease cross. NoDone: the invoice fields carryaria-invalidinsrc.aria-invalidandaria-describedby(InvoiceForm.tsx) and the typed line turns red on it.Orphaned token.Done:--fs-title-compactis deleted.min-widthqueries against a max-width convention:1100px×2 (the inline nav,Menu.scss;press/downloads.scss),901px×2 (Menu.scss: the panel's link steps and its print),601px×1 (invite/member.scss),640px×1 (press/downloads.scss).- JS-only breakpoint: the header's
760pxauto-hide (Menu.astro:283) matches no stylesheet query. The agenda's switch is720pxin JS and CSS alike. Special Elite declares noResolved: Latin-Ext letters fall to JetBrains Mono via the Fonts APIsubsetsfallbacks(see Typography).- Split with
CLAUDE.md. Rules live here;CLAUDE.mdpoints here. Nothing enforces it. Resolved:Closerdefaulttone="raised"rendersband--raised, which has no CSS.Closer.astrohas notoneprop; every closer renders on the black.band.- The font-size ramp is [MUST] but broken at ~25 call sites:
literals in
Menu.scss(the short-viewport nav steps),AttendingCard.module.scss,invite/member.scss, relative sizes (.figat0.86emon/and/venue, the teaser count at1em), and inherited ones in the footer's bottom line. The same claim inBaseLayout.scss:82is equally untrue. Fold into the ramp or downgrade to [CURRENT]. /has no.fallback-notewhileagenda,speakersandworkshopsdo.file:linecitations drift. This pass re-cited the whole document against the uncommitted tree; token and class names stay the durable reference (/impeccable doctorreports drift as truth drift).Dead CSS left by the refresh.Resolved:.facts/.fact*,--fs-stat,.develop+print-fadeand.head-split--ruledare deleted.Speaker plates no longer develop to colour.Resolved: the print develops via[data-developed]while a row of its list is hovered or focused; the dead.framestates, crop brackets and.vignetteare deleted.One section head still carries a full stop: the final-wave title.Resolved: the ticket section has no poster head; the final wave is a note under the numbered eyebrow."Closer only where the page asks for a decision" is not applied yet.Resolved:EndLineends the reference pages,/contactends on its press kit;Closerstays onagenda,workshops,venue,invite.Glow remnants against "No glow anywhere".Resolved: the agenda live dot is static, the now line and live cell carry no shadow, the newsletter button has no shadow on either ground, and an open FAQ item is flat paper.- Partner mono modes are a filename map.
LOGO_MONO(src/lib/partners.ts:22–27) pickstile/dark/colourby master filename; it belongs in thepartnerscollection schema (content.config.ts) as a per-partner field. Resolved: the class is deleted; standalone links are.hairline-linkhas no markup caller..action(EndLine,SubpageHero, the home walls) and the mixin is the in-prose link only.- Dark-ink partner masters sit on a paper card. Dotěkománie and White
Whale Media only ship dark-ink artwork; light versions would let them
drop the card.
LogoMonois keyed by filename and belongs as a field in the partners schema. Canvas drift.Moot: the Pencil canvas left the repo in #473;docs/design.mdand.impeccable/design.jsonare the only design record (.claude/rules/design.md).Full-width flat red bands are not an approved large red area.Resolved: there is no red band. The accent band variant and its ground token are deleted (BaseLayout.scss:501–504); everyCloser, the/partnersCTA and the newsletter sit on black.- The attending card still draws a red band with a black radial of
its own (
src/lib/attending-card.ts), the one red ground left now the site has none. Decide whether the generated card follows the site. - Photo contrast is mixed. The prints rest at
contrast(1.1)(BaseLayout.scss, the sheets' prints included), the speaker prints and the contact-sheet frames at1.15, the hero plate at1.12(index.scss). Manual p. 13 asks for high contrast and also allows speaker portraits in colour on black. Done: both are deleted with.head-stackand.head-splithave no caller..head-titleand.head-note;.head-ruleis the one section head.- Two
text-shadows on photographs against the Bebas rule's "no text-shadow": the home statement (index.scss:61) and the subtitle set over the home event print (index.scss,.event-subtitle), legibility lifts for white type on a photograph with no scrim. Decide whether a shade or a darker grade replaces them, or state the exception in the rule. .btn-ghostis a stale name. It draws the text action but every caller still says "ghost", and the--field-bordercomment inBaseLayout.scssstill describes the outlined box it used to be. Rename the callers to.actionand fix the comment.The cookie buttons track a Bebas label.Done: theletter-spacingline is gone.One arrow glyph left.Done: the press-kit back link reads "Back to In the news".Desk.scssis orphaned.Desk.astrois deleted and nothing importssrc/components/Desk.scss. Delete the file.JetBrains Mono 700 is loaded with no caller.Done: the Fonts API entry loads 400 and 500.The header stamp is unrecorded elsewhere.Done: the stamp is gone; the bar is the mark, the inline links (≥1100px) and the Tickets button.More than one red fill on a page.Done: red fills are the header's Tickets plus at most one per screen (Color). The newsletter submit is a black fill, the/speakers"Get tickets" a plain.action; a spent wave's grease cross is a mark and stays.The darkroom bends the type rules.Done: paper edges are figures only, their words notes (.edge-word); the agenda and session flags and slip-edge words are notes; the hero stamp reads "01 · 30.10.2026"; the footer titles and terms are sentence-case notes and its links track 0; the ticket price and/venuecard numbers are plainmono(). Two recorded exceptions (Typography): the mono clipping excerpt, Bebas speaker names. The desktop bar links set at tracking 0.The footer typesets the name.Done: the sign-off is the official logo file atmin(40rem, 80vw)(Footer anatomy).Motion and dimming the rules forbid.Done: no slip lifts, no partner mark dims (Motion, Images & media).Red text on paper.Done: the reach state on paper is an ink underline or a red dot (Color).The section heads outrank the home statement.Done:--fs-sectionisclamp(4rem, 9vw, 8rem)and--fs-heroclamp(4.5rem, 10.5vw, 9.25rem); every secondary title moved to--fs-section, so a page has one display element (Typography).Two stray edges.Done: the lounge is one line above the agenda sheet and the/venueroute actions are.actions in paper ink.Tilt everywhere.Done: only photo prints tilt; slips, cards, forms, clippings, the FAQ and the cookie slip sit straight; the home ticket slips are the one exception (Color).Tapes as labels.Done: one tape per print, top-left, never cut with an ellipsis; group labels (partner tiers, the home partner rows, the hosts, agenda rooms, press-kit variants) are plain type (Images).A grease mark per section.Done: one decorative mark per page (frame 12A, the/venuedoor, the/contactinbox); the keynote corners, the "350" ring and the ride ring are gone; the sold-out cross is functional (Color).The spotlight and the long shadow.Done: the ground is flat black (no spotlight layer), the paper flat with the tight--paper-shadow, and one section gap everywhere (.band:has(+ .closer-band), the last band on--section-y-tight, no footer margin).Paper that still tilts without a photograph.Done: the speaker sheet's talk slips, the invite quote slip and the share page's no-script slip sit straight (Color).Dead primitives and tokens.Done:.print,.field/.field-row,.scene-vignetteand its grain,.head-split/.head-title/.head-note,.head-stack,.record-status,_controls.scsswithlit-field(), and every token with novar()caller (--lit,--print-mount,--wash,--wash-strong,--field-feather,--on-accent,--on-accent-ink,--on-accent-border,--color-cream,--color-error,--panel,--panel-2,--panel-hover,--rule-soft,--section-y-wide,--fs-row-figure,--fs-title-compact) are deleted..scenestays on#hero.Two words in mono.Done: a clipping's outlet is an.edge-wordnote (contact.astro) and the "Every talk" search types its query as anote(--fs-note)(Sessions.module.scss).Two kinds of paper shadow.Done: all paper takes--paper-shadow; the/contactcards and the clippings take it as is, close-set slips draw it on a::beforeunder their isolated grid (Color).The session sheet's room wearsDone: the room is a plain.tapeand undoes it..roomlabel (SessionDetail.module.scss).