Components
Vacancy
One open position in a register of them. Not a card: a block of type standing on a hairline, with the facts of the job as a description list under it. It is the same drawing as the search result — literally the same rules, naming both — plus the one part a result has no use for.
Default
A mono line naming the team, a linked title, one or two sentences, and four facts. The register draws its own top edge and every entry closes with its own rule including the last, so the list is bounded at both ends.
-
Data Engineer (m/w/d)
Sie bauen die Strecken, auf denen Anlagendaten ankommen: Anbindung, Modellierung, Qualitätssicherung. Ohne dieses Fundament ist jede Auswertung darüber geraten.
- Standort
- Konstanz, hybrid
- Anstellung
- Festanstellung
- Umfang
- Voll- oder Teilzeit
- Start
- ab sofort
-
Werkstudent Frontend (m/w/d)
Sie bauen an der Oberfläche mit, auf der Betreiber ihre Anlagen lesen. HTML, CSS und so viel JavaScript wie nötig — bei uns ist das erfahrungsgemäß wenig.
- Standort
- Konstanz
- Anstellung
- Werkstudium
- Umfang
- 16–20 Std./Woche
- Start
- ab sofort
<ul class="cf-vacancies" role="list"> <li class="cf-vacancy"> <p class="cf-vacancy__meta"><span>Plattform</span></p> <h3 class="cf-vacancy__title"> <a class="cf-vacancy__link" href="/karriere/data-engineer">Data Engineer (m/w/d)</a> </h3> <p class="cf-vacancy__excerpt">Sie bauen die Strecken, auf denen…</p> <dl class="cf-vacancy__facts"> <div class="cf-vacancy__fact"> <dt class="cf-vacancy__term">Standort</dt> <dd class="cf-vacancy__value">Konstanz, hybrid</dd> </div> <div class="cf-vacancy__fact">…</div> </dl> </li> </ul>
.cf-vacancy… added to its selector list — the same
move .cf-prose table makes alongside .cf-table. A job
opening and a search hit are the same object at the level the drawing works on: a
mono line, a linked title, a couple of sentences on a hairline. Writing that twice
would be two things to keep in step, and things that have to be kept in step drift.
Only .cf-vacancy__facts is declared on its own.
The title picture
An opening names one picture that stands for it — bild: in its file,
the Titelbild property in the Stellen register — and the row is drawn
from it: .cf-vacancy--image on the entry, an <img>
with .cf-vacancy__image as its first child, and everything the picture
is not moved into a <div class="cf-vacancy__body"> beside it.
scripts/build-jobs.py writes all three from the same record the sync
filled, so the record, the file and the drawing cannot drift apart.
-
Data Engineer (m/w/d)
Sie bauen die Strecken, auf denen Anlagendaten ankommen: Anbindung, Modellierung, Qualitätssicherung.
- Standort
- Konstanz, hybrid
…
- Optional, and the layout does not reserve a hole for it. An opening without one is the row this register has always drawn, so a register where some rows carry a photograph reads as a register — a row of grey squares reads as a fault.
-
The square. The picture is drawn exactly square — a width from the
space scale and
aspect-ratio: 1 / 1withobject-fit: cover—96 px, and64 pxonce the row drops below 34 rem. Thewidthandheightattributes still carry the pixel size of the file that was downloaded, not of the box drawn. -
It is decoration, not content.
alt="", and it is not a link —.cf-vacancy__linkstays the title and only the title, so the row's markup and its reading order do not change when the picture appears. - Only on the register. The job page never draws it. It is an aid to scanning a list, and on a page describing one opening it would be a second claim on a page whose one lime moment is already taken.
The facts
Four name/value pairs — Standort, Anstellung, Umfang, Start —
which is what <dl> is for and what nothing else in HTML is. They
are the questions every candidate asks before reading a word of the description, so
they are answered on the register rather than one click further in.
The drawing is the contact list's: a mono term with its value under it. What changes is the axis. Four short facts laid down a column would be four rows under every opening and would make the register three times as long as its content; laid along the row they read as a strip, and they wrap onto a second line on a phone without any rule of their own.
Column gap is --space-8 against a row gap of --space-3,
and the asymmetry is the whole legibility of the block: a pair has to read as a pair
before the row reads as a row, so the air between two facts must beat the air inside
one. There are no rules between them — a line there would be neither an edge nor a
division nor a label rule, which is the test in
where a line may go. The
register's own hairline is the only line in the component.
Keep it to four. A fifth fact is a fact for the job page, and a register whose facts wrap to three lines has stopped being a register.
When nothing is open
A company that is not hiring is the normal state, not an error, so the register is
replaced rather than rendered empty — .cf-error--inline, at
200, exactly as the news overview does for a filter that matches
nothing. → Error & Empty State
0 Stellen
Gerade ist nichts ausgeschrieben.
Wir stellen ein, wenn Arbeit da ist, nicht auf Vorrat. Eine Initiativbewerbung lesen wir trotzdem — und antworten auch dann.
Note which button that is. On the page itself the initiative link is
.cf-btn--primary and is the screen's one lime moment; in the empty state
it drops to --outline, because .cf-error--inline is already
the loudest thing on an otherwise empty page and two claims on one screen is one too
many.
Anatomy
| Class | Element | Required | Does |
|---|---|---|---|
.cf-vacancies | ul | yes | The register. role="list" because list-style: none drops list semantics in Safari. Draws the top edge, or gives it up to a .cf-section-header--flush above it. |
.cf-vacancy | li | yes | One opening. Column stack, --space-6 of air above and below, its own bottom hairline, and --sheen-panel travelling across it on hover or focus-within. |
.cf-vacancy--image | li modifier | with picture | Rotation to a row: the square on the left, everything else in a .cf-vacancy__body beside it, --space-4 between the two. |
.cf-vacancy__image | img | with picture | The square. --space-24 across, aspect-ratio: 1/1, object-fit: cover, decorative alt="" — --space-16 below 34 rem. |
.cf-vacancy__body | div | with picture | Everything the picture is not. Only on an image row, so the column stack from .cf-vacancy moves in here rather than being written twice. |
.cf-vacancy__meta | p | no | Mono 11 px uppercase, --text-secondary. The team, and nothing that is already a fact below. A row of <span>s, so a second item needs no separator. |
.cf-vacancy__title | h3 | yes | The role. Display face, --text-h4. A heading level, not a class — pick the level the page's outline needs. |
.cf-vacancy__link | a | yes | The link is the title and only the title. It underlines on hover and on :focus-visible; the row lights under it either way. |
.cf-vacancy__excerpt | p | no | One or two sentences, capped at --measure-prose. What the job is, not what the company is. |
.cf-vacancy__facts | dl | no | The wrapping strip of pairs. --space-3 row gap, --space-8 column gap. |
.cf-vacancy__fact | div | with facts | One pair. The wrapper is required by <dl> if term and value are to stay together when the strip wraps. |
.cf-vacancy__term | dt | with facts | Mono 11 px uppercase, --text-secondary. The contact list's term at label scale. |
.cf-vacancy__value | dd | with facts | Body face, --text-md, full ink, --space-1 under its term. |
Accessibility
- The link text is the role title on its own, so it is unique and it makes sense read out of context — which is how a screen reader's link list presents it. No “mehr erfahren”, ever.
- The whole row is not a link. A stretched
::afterover the entry would make the excerpt and the facts unselectable, and the facts are the part a candidate copies. The row lights on:focus-withininstead, so keyboard and pointer get the same feedback. - A picture is decoration. When an opening has one it carries
alt=""and no link of its own — the title is still the only link, and what a reader who cannot see the picture loses is nothing that is not already in the block beside it. - The facts are a real
<dl>. Assistive technology announces the number of pairs and reads each value with its term attached; four loose spans read as eight unlabelled words. - Nothing is carried by colour. The mono term is distinguished by face, size, case and position as well as by ink.
- Contrast measured against CF-Grau, the dark end of the page wash, so these are the floors: title and value 13.5:1, mono meta and term
--text-secondary5.9:1, excerpt 5.9:1. The row hairline is a separator, not a UI contour — the structure is in the markup — so it is not load-bearing for the 3:1 floor. - The sheen is a
background-positiontransition on a gradient. Nothing moves, nothing reflows, and there is no reduced-motion exception to make because there is no motion to remove. - Focus rings come from
base.cssand are untouched here.
Before launch
- The count and the register come from one list. Karriere prints “4 offene Stellen” in the page header and then renders four entries. Both are the server's, out of the same query — the same standing the consent dialog's entry counts have.
-
Structured data belongs on the job page, not here. Google's job
posting documentation is explicit that
JobPostingmarkup goes on a page describing exactly one opening, one URL per opening, and that a careers landing page listing several must not carry it. So the register links out and stays plain HTML; the JSON-LD —title,description,datePosted,validThrough,jobLocation,hiringOrganization— goes on/karriere/<rolle>, and every field in it has to match what the reader sees on that page. Stellenanzeige is that page: it carries the block, the register does not, andscripts/check-job-posting.pyholds both halves of that sentence. -
Every posting needs an end date. An opening with no
validThroughstays live in search results after it is filled. The four entries on the pattern page are placeholder copy; one of the four — Data Engineer — now has its job page, and the other three are still addresses without a template. - The fifth and sixth fact live on the job page. The strip here holds four, and the two a candidate asks next — what it pays and how long the posting runs — are the ones the job page opens with. Vergütung and Bewerbungsschluss are the first two rows the register does not have.
-
The mailbox is a placeholder.
jobs@control-f.deis written on the page three times and has to exist, or the one lime moment on it is a dead end. -
The square comes from the register. The
Titelbildproperty of the Stellen database becomes thebild:line and the file underdesign-system/assets/img/jobs/on the next sync — same plate as a news picture, andscripts/check-content-images.pystates the numbers. It is drawn only here; an opening whose page exists but whose register row has no picture is not missing anything.
Rules
Do
- Put the register under a
.cf-section-header--flushand let one hairline do both jobs. - Answer the four facts every candidate asks before they ask them.
- Write the title so it stands alone in a list of links.
- Replace the whole register with
.cf-error--inlinewhen nothing is open. - Keep the excerpt to what the job is. The company is the section above.
- Draw the square only when the opening names one picture. An entry without one is the row the register has always drawn.
Don't
- Don't box it. This is a register, not a grid of cards — there is no plate and no corner.
- Don't add a rule between the facts, or down the side of an entry. The row hairline is the whole drawing.
- Don't spend lime on an entry. The page has one lime moment and it is the initiative button under the list.
- Don't stretch a link over the entry. It costs the reader the ability to select the facts.
- Don't put a fifth or sixth fact in the strip. That is content for the job page.
- Don't build a filter into the component. Narrowing a list is a server's work, the same as it is for the news overview.
- Don't link the picture or give it an alt. It is decoration for scanning a list; the title is the link and the block beside it carries the content.
- Don't draw a placeholder where a picture is missing. A row of grey squares reads as a fault, and this component does not reserve a hole.