diff --git a/README.md b/README.md index d9fc3af..b9c1c5a 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,11 @@ photography existed: | `engineer-glass.jpg` | Method section | https://www.magnific.com/app/creation/3zdScUIREY | | `sector-5g.jpg` | Sectors, tab 1 | https://www.magnific.com/app/creation/s7ZQP52l8e | | `sector-substation.jpg` | Sectors, tab 2 | https://www.magnific.com/app/creation/yi0r27vPW9 | -| `sector-datacentre.jpg` | Sectors, tab 3 | https://www.magnific.com/app/creation/eIHAvetdqL | +| `sector-datacentre.jpg` | Sectors, tab 4 | https://www.magnific.com/app/creation/eIHAvetdqL | + +`sector-ot.jpg` (Sectors, tab 3, operational technology) is a real photograph from the Freepik +stock catalogue via Magnific, "Workers in control room" (free licence, Canon EOS 5D Mark III, 2017), +downloaded on 7 October 2026 and scaled to 2304 px. They are placeholders in the sense of the brief: swap them for the company's own documentary photography when it exists, keeping the same file names and the `img-doc` treatment. The grid diff --git a/src/assets/images/sector-ot.jpg b/src/assets/images/sector-ot.jpg new file mode 100644 index 0000000..9ab7ed9 Binary files /dev/null and b/src/assets/images/sector-ot.jpg differ diff --git a/src/components/home/Sectors.astro b/src/components/home/Sectors.astro index 647783e..077cc73 100644 --- a/src/components/home/Sectors.astro +++ b/src/components/home/Sectors.astro @@ -2,12 +2,13 @@ import { Picture } from 'astro:assets'; import fiveG from '../../assets/images/sector-5g.jpg'; import substation from '../../assets/images/sector-substation.jpg'; +import controlRoom from '../../assets/images/sector-ot.jpg'; import datacentre from '../../assets/images/sector-datacentre.jpg'; import { t, type Lang } from '../../i18n/ui'; interface Props { lang: Lang } const { lang } = Astro.props; const ui = t(lang); -const images = [fiveG, substation, datacentre]; +const images = [fiveG, substation, controlRoom, datacentre]; const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], id: `sector-${i + 1}` })); ---
@@ -36,8 +37,8 @@ const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], id alt={item.imageAlt} formats={['avif']} fallbackFormat="webp" - widths={[480, 768, 1024, 1440]} - sizes="(min-width: 64rem) 50vw, 100vw" + widths={[480, 768, 1024, 1440, 1920]} + sizes="(min-width: 64rem) 60vw, 100vw" loading="lazy" decoding="async" class="sectors__img img-doc" @@ -56,8 +57,8 @@ const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], id alt="" formats={['avif']} fallbackFormat="webp" - widths={[480, 768, 1024, 1440]} - sizes="(min-width: 64rem) 50vw, 100vw" + widths={[480, 768, 1024, 1440, 1920]} + sizes="(min-width: 64rem) 60vw, 100vw" loading="lazy" decoding="async" class="sectors__img img-doc" @@ -81,11 +82,34 @@ const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], id flex-direction: column; gap: var(--space-8); } - .sectors__tabs { margin-top: auto; } - .sectors__tab + .sectors__tab { margin-top: var(--space-5); } + /* The tabs are rows between hairlines. The selected row's rule turns ink: that is the marker. */ + .sectors__tabs { + margin-top: auto; + border-bottom: var(--hairline) solid var(--line); + } + .sectors__tab { + position: relative; + border-top: var(--hairline) solid var(--line); + } + .sectors__tab::before { + content: ''; + position: absolute; + left: 0; + right: 0; + top: calc(-1 * var(--hairline)); + height: var(--hairline); + background: var(--ink); + transform: scaleX(0); + transform-origin: left; + transition: transform var(--dur-slow) var(--ease); + } + .sectors__tab:has([aria-selected='true'])::before { transform: none; } .sectors__heading { font-size: inherit; font-weight: inherit; } + /* The whole row is the button, so the target is as wide as the column. */ .sectors__button { display: block; + width: 100%; + padding-block: var(--space-5) var(--space-3); text-align: left; font-size: var(--step-h3); line-height: var(--leading-h3); @@ -96,7 +120,11 @@ const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], id } .sectors__button[aria-selected='false'] { color: var(--ink-muted); } .sectors__button[aria-selected='false']:hover { color: var(--ink); } - .sectors__panel-inner { display: grid; gap: var(--space-5); padding-top: var(--space-3); } + .sectors__panel-inner { display: grid; gap: var(--space-5); } + /* The button keeps a small step below its title, so an open body sits close under it and the + focus ring never crosses the text. The rest of the row padding is the panel's margin: below + the body when open, in place of it when closed, so every row is symmetric. */ + .sectors__panel { margin-bottom: var(--space-5); } .sectors__panel p { max-width: 28em; } :global(.sectors__picture) { display: block; width: 100%; } .sectors__img, :global(.sectors__img) { width: 100%; height: auto; aspect-ratio: 4 / 3; object-fit: cover; } @@ -110,17 +138,27 @@ const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], id display: grid; grid-template-rows: 0fr; overflow: hidden; - transition: grid-template-rows var(--dur-slow) var(--ease); + transition: + grid-template-rows var(--dur-slow) var(--ease), + margin var(--dur-slow) var(--ease); } :global(html.js) .sectors__panel-inner { min-height: 0; } :global(html.js) .sectors__panel[data-open] { grid-template-rows: 1fr; } + :global(html.js) .sectors__panel:not([data-open]) { margin-bottom: calc(var(--space-5) - var(--space-3)); } @media (min-width: 64rem) { .sectors__text { grid-column: 1 / span 5; } + /* The image runs past the container to the right edge of the window: the page gutter, plus + half of whatever the window is wider than the container. The section clips, so a classic + scrollbar (counted in 100vw) cannot cause a horizontal scroll. */ + .sectors { overflow-x: clip; } .sectors__media { grid-column: 7 / span 6; display: grid; + margin-right: calc(-1 * (max(var(--gutter), var(--safe-right)) + max(0px, (100vw - var(--container)) / 2))); } + /* Near square, but never taller than a screenful on very wide windows. */ + .sectors__media :global(.sectors__img) { aspect-ratio: 5 / 4; max-height: 45rem; } .sectors__media :global(.sectors__picture--stage) { grid-area: 1 / 1; opacity: 0; @@ -132,7 +170,7 @@ const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], id } @media (prefers-reduced-motion: reduce) { - .sectors__panel, .sectors__media :global(.sectors__picture--stage) { transition: none; } + .sectors__panel, .sectors__tab::before, .sectors__media :global(.sectors__picture--stage) { transition: none; } } diff --git a/src/i18n/ui.ts b/src/i18n/ui.ts index 172fbcb..95e1d3f 100644 --- a/src/i18n/ui.ts +++ b/src/i18n/ui.ts @@ -82,10 +82,11 @@ const en = { ], }, sectors: { - title: 'Who we work with', + title: 'Who we work with.', items: [ { title: 'Private 5G and campus networks', body: 'Software-defined networks with open interfaces and a multi-vendor supply chain. Our origin, and still the hardest case.', imageAlt: 'A private 5G antenna and radio cabinet on the parapet of a factory roof, cranes in the distance.' }, { title: 'Critical infrastructure', body: 'Control and network software where a breach stops production, damages equipment or endangers people.', imageAlt: 'An electrical substation seen straight from above: transformers, switchgear and a service road.' }, + { title: 'Operational technology (OT)', body: 'Controllers, SCADA and the engineering software that configures them. Systems built to run for decades, often on protocols without authentication, and rarely patched.', imageAlt: 'Three operators in hard hats at their consoles in a plant control room, facing a wall-sized diagram of the process.' }, { title: 'Cloud-native systems', body: 'Containers, service meshes and CI/CD pipelines that carry infrastructure workloads. Security that runs as code, alongside yours.', imageAlt: 'A cold aisle between server racks in a data centre, a technician far in the distance.' }, ], }, @@ -332,10 +333,10 @@ const en = { roles: { ceo: 'Founder and CEO', cto: 'Founder and CTO', - engineer: 'Software engineer', - bizdev: 'Business development', - studentF: 'Working student, development', - studentM: 'Working student, development', + engineer: 'Software Engineer', + bizdev: 'Business Development', + studentF: 'Mathematical Engineer', + studentM: 'Process Optimization & Automation', }, research: { title: ['Published', 'research.'], @@ -427,10 +428,11 @@ const de: typeof en = { ], }, sectors: { - title: 'Für wen wir arbeiten', + title: 'Für wen wir arbeiten.', items: [ { title: 'Private 5G- und Campusnetze', body: 'Softwaredefinierte Netze mit offenen Schnittstellen und einer Lieferkette aus vielen Herstellern. Unser Ursprung, und noch immer der schwerste Fall.', imageAlt: 'Eine private 5G-Antenne mit Funkschrank auf der Attika eines Fabrikdachs, Kräne im Hintergrund.' }, { title: 'Kritische Infrastruktur', body: 'Steuerungs- und Netzsoftware, bei der ein Einbruch die Produktion stoppt, Anlagen beschädigt oder Menschen gefährdet.', imageAlt: 'Ein Umspannwerk senkrecht von oben: Transformatoren, Schaltanlagen und eine Zufahrt.' }, + { title: 'Operational Technology (OT)', body: 'Steuerungen, SCADA und die Engineering-Software, mit der sie konfiguriert werden. Anlagen, die Jahrzehnte laufen, oft über Protokolle ohne Authentisierung, und selten Updates erhalten.', imageAlt: 'Drei Bediener mit Schutzhelm an ihren Pulten in der Leitwarte eines Werks, vor einem wandgroßen Schema der Anlage.' }, { title: 'Cloud-native Systeme', body: 'Container, Service Meshes und CI/CD-Pipelines, die Infrastruktur tragen. Sicherheit als Code, neben Ihrem.', imageAlt: 'Ein Kaltgang zwischen Serverschränken in einem Rechenzentrum, ein Techniker weit hinten.' }, ], }, @@ -679,8 +681,8 @@ const de: typeof en = { cto: 'Gründer und CTO', engineer: 'Software Engineer', bizdev: 'Business Development', - studentF: 'Werkstudentin, Entwicklung', - studentM: 'Werkstudent, Entwicklung', + studentF: 'Mathematical Engineer', + studentM: 'Process Optimization & Automation', }, research: { title: ['Veröffentlichte', 'Forschung.'],