diff --git a/Services/Website/README.md b/Services/Website/README.md index ecb3da1..3f8d700 100644 --- a/Services/Website/README.md +++ b/Services/Website/README.md @@ -51,6 +51,13 @@ HealthController (GET /health → liveness, GET /health The router is created with `.autoGenerateHeadEndpoints`, so each of those `GET` routes gets a `HEAD` sibling for free. +### Design foundation +The screens follow the **CCN marketplace** design canvas: +- **Tokens and primitives** live in `Resources/Static/css/shared.css`, written phone-first with breakpoints at 600px and 1024px; the dark theme only repoints the tokens. +- **No inline styles**: the `Content-Security-Policy` blocks them, so markup carries classes only. +- **Shared frame**: `SiteHeader`, `SiteFooter`, and the `SiteLayout` a page wraps its `content` in, under `Sources/Library/Internal/Components`. +- **Paths**: `String.Route` lists every screen's path; screens not built yet answer with the not-found page. + ### Page metadata Each page conforms to `Infrastructure`'s `Page` protocol and supplies only its `title`, `content`, `stylesheets`, and `scripts`; the protocol assembles the document around them and renders the head in a fixed order: the viewport declaration, the `analytics` origin preconnect hint, the `summary`, the `canonicalURL` link, the `socialCard` tags, the `structuredData` script, the `analytics` tracker script, then the page `metadata` and the stylesheet links. The body is the content followed by the script tags. @@ -68,7 +75,7 @@ What the pages *do* ship is in `Page+Defaults` (`Sources/Library/Internal/Extens `ogLocale` renders Open Graph's `language_TERRITORY` form by looking the page's `lang` up in the `ogLocales` map, which ships one pairing: `en` → `en_US`. A site serving a language in a territory of its own repoints or extends the map (`en_NL`, `nl_NL`, …); a language with no entry stays a bare code, which scrapers also accept. Nothing reads it until a page supplies a `socialCard`. -`preloadedFonts` is empty until the site ships fonts. Listing one emits `` at `/font/.woff2`, deliberately unversioned: a preload URL must match the stylesheet's `@font-face` source exactly, or the browser fetches the font twice. List only the faces the stylesheets actually render — a subset gated by a `unicode-range` no page reaches would add a download that never otherwise happens. +`preloadedFonts` lists the three self-hosted faces under `Resources/Static/font`: Archivo, Oswald, and Anton. Listing one emits `` at `/font/.woff2`, deliberately unversioned: a preload URL must match the stylesheet's `@font-face` source exactly, or the browser fetches the font twice. List only the faces the stylesheets actually render — a subset gated by a `unicode-range` no page reaches would add a download that never otherwise happens. ### Language editions The `WebsiteLibrary` String Catalog is the single source of truth for the languages the site serves: add a localization and it appears, with no code change. `Language` (`Sources/Library/Internal/Types`) reads that list and derives each language's URLs from it. @@ -373,7 +380,7 @@ All icons are renditions of the star mark in `icon.svg`, the canonical source | `icon-192.png`, `icon-512.png` | 192/512 | `site.webmanifest` install icons and splash screens | None (no platform mechanism); full artwork, light rendering, on a transparent background. | | `apple-touch-icon.png` | 180×180 | iOS home-screen bookmarks | None (fetched once, outside any page context); full artwork, deliberately opaque — iOS fills transparent regions with black. | -The icon and manifest links come from the shared `Page+Defaults` extension, so every page carries them, alongside two `theme-color` metas: `#fafafa` unqualified, then `#0c0710` qualified with `(prefers-color-scheme: dark)`. +The icon and manifest links come from the shared `Page+Defaults` extension, so every page carries them, alongside two `theme-color` metas matching the site header, which stays dark in both themes: `#191713` unqualified, then `#0f0e0b` qualified with `(prefers-color-scheme: dark)`. > **Note:** a browser applies the *first* `theme-color` whose media query matches, so the unqualified light value currently wins on both themes. Put the dark, media-qualified meta first in `Page+Defaults` if the dark value should take effect. diff --git a/Services/Website/Resources/Static/css/index.css b/Services/Website/Resources/Static/css/index.css index e69de29..643ea7c 100644 --- a/Services/Website/Resources/Static/css/index.css +++ b/Services/Website/Resources/Static/css/index.css @@ -0,0 +1,23 @@ +/* CCN — landing page. */ + +.hero { + display: flex; + flex-direction: column; + gap: var(--space-3); + max-width: 640px; + padding: var(--space-1) 0 var(--space-2); +} + +.hero__actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + padding-top: var(--space-3); +} + +@media (min-width: 1024px) { + .hero { + gap: var(--space-5); + padding: var(--space-10) 0; + } +} diff --git a/Services/Website/Resources/Static/css/not-found.css b/Services/Website/Resources/Static/css/not-found.css index 5a95a8d..89d1dcd 100644 --- a/Services/Website/Resources/Static/css/not-found.css +++ b/Services/Website/Resources/Static/css/not-found.css @@ -1,43 +1,9 @@ -/* Styles for the not-found (404) error page. */ +/* CCN — not-found page. */ -* { - line-height: 1.2; - margin: 0; -} - -html { - color: #888; - display: table; - font-family: sans-serif; - height: 100%; - text-align: center; - width: 100%; -} - -body { - display: table-cell; - vertical-align: middle; - margin: 2em auto; -} - -h1 { - color: #555; - font-size: 2em; - font-weight: 400; -} - -p { - margin: 0 auto; - width: 280px; -} - -@media only screen and (max-width: 280px) { - body, p { - width: 95%; - } - - h1 { - font-size: 1.5em; - margin: 0 0 0.3em; - } +.not-found { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--space-4); + padding-top: var(--space-14); } diff --git a/Services/Website/Resources/Static/css/shared.css b/Services/Website/Resources/Static/css/shared.css index ba80dc8..c3a799e 100644 --- a/Services/Website/Resources/Static/css/shared.css +++ b/Services/Website/Resources/Static/css/shared.css @@ -1,70 +1,258 @@ -/*! HTML5 Boilerplate v9.0.1 | MIT License | https://html5boilerplate.com/ */ - -/* main.css 3.0.0 | MIT License | https://github.com/h5bp/main.css#readme */ /* - * What follows is the result of much research on cross-browser styling. - * Credit left inline and big thanks to Nicolas Gallagher, Jonathan Neal, - * Kroc Camen, and the H5BP dev community and team. + * CCN — shared stylesheet: design tokens, base styles, primitives, site header and footer. + * + * Tokens mirror the CCN design system; the dark theme only repoints them. + * The CSP blocks inline styles, so every rule lives in a stylesheet. + * Phone-first; layouts switch at 600px and 1024px. */ /* ========================================================================== - Base styles: opinionated defaults + Fonts — self-hosted latin subsets. URLs stay unversioned to match the preload links. ========================================================================== */ +@font-face { + font-family: "Anton"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("/font/anton-400.woff2") format("woff2"); +} + +@font-face { + font-family: "Archivo"; + font-style: normal; + font-weight: 400 700; + font-display: swap; + src: url("/font/archivo-variable.woff2") format("woff2"); +} + +@font-face { + font-family: "Oswald"; + font-style: normal; + font-weight: 500 700; + font-display: swap; + src: url("/font/oswald-variable.woff2") format("woff2"); +} + +/* ========================================================================== + Tokens + ========================================================================== */ + +:root { + color-scheme: light dark; + + /* Colour — light theme */ + --paper: #f5f3ee; + --card: #ffffff; + --tint: #edeae2; + --header: #191713; + --header-well: #24211b; + --ink: #191713; + --ink-soft: #3d3a33; + --muted: #56524a; + --faint: #a39d8f; + --placeholder: #8e8779; + --on-dark: #ffffff; + --on-dark-muted: #cfc9bc; + --on-dark-faint: #8e8779; + --on-dark-fill: #f5f3ee; + --on-dark-fill-text: #191713; + --line: #ddd8cd; + --rule: #c9c3b6; + --line-dark: #3a362e; + --accent: #b3271e; + --accent-hover: #8e1f18; + --gold: #e8b84b; + --good: #1f6f4a; + --good-bg: #e7f0ea; + --good-line: #bfd8c9; + --warn: #8a5a0a; + --warn-bg: #fbefd9; + --warn-line: #ead3a0; + --bad: #8e1f18; + --bad-bg: #f8e3e1; + --bad-line: #e9b8b3; + + /* Placeholder cover grounds */ + --cover-navy: #2b3a55; + --cover-wine: #5b2333; + --cover-forest: #1f3d33; + --cover-rust: #6b3a1f; + --cover-slate: #26363b; + --cover-plum: #43304f; + + /* Type families */ + --font-display: "Oswald", "Arial Narrow", "Helvetica Neue", sans-serif; + --font-body: "Archivo", "Helvetica Neue", Helvetica, Arial, sans-serif; + --font-lettering: "Anton", "Haettenschweiler", Impact, sans-serif; + + /* Spacing — a 4px grid */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-7: 28px; + --space-8: 32px; + --space-10: 40px; + --space-14: 56px; + + /* Page gutter: 16 / 32 / 56 */ + --gutter: 16px; + --content-max: 1328px; + + /* Radius */ + --radius-xs: 2px; + --radius-sm: 3px; + --radius-sheet: 12px; + --radius-pill: 999px; + + /* Shadow */ + --shadow-cover: 0 24px 48px rgba(25, 23, 19, 0.22); + --shadow-cover-sm: 0 18px 40px rgba(25, 23, 19, 0.28); + --shadow-sheet: 0 8px 24px rgba(25, 23, 19, 0.1); + + /* Control heights */ + --control-xs: 32px; + --control-sm: 36px; + --control-md: 44px; + --control-lg: 48px; + --control-xl: 52px; + --header-height: 56px; + + /* Card grid: 2 / 3 / 5 columns */ + --grid-columns: 2; + --grid-gap: 14px; + --card-cover: 258px; +} + +@media (min-width: 600px) { + :root { + --gutter: 32px; + --grid-columns: 3; + --grid-gap: 24px; + --card-cover: 330px; + } +} + +@media (min-width: 1024px) { + :root { + --gutter: 56px; + --header-height: 64px; + --grid-columns: 5; + --card-cover: 370px; + } +} + +@media (prefers-color-scheme: dark) { + :root { + --paper: #1c1a15; + --card: #24211b; + --tint: #2c2922; + --header: #0f0e0b; + --header-well: #1c1a15; + --ink: #f5f3ee; + --ink-soft: #d9d3c6; + --muted: #b5ae9f; + --faint: #7d776a; + --placeholder: #7d776a; + --on-dark: #f5f3ee; + --on-dark-muted: #b5ae9f; + --on-dark-faint: #7d776a; + --line: #3a362e; + --rule: #4a453c; + --accent: #d9453a; + --accent-hover: #b3271e; + --good: #7fc79a; + --good-bg: #1f3328; + --good-line: #2f5a41; + --warn: #e8b84b; + --warn-bg: #3a2e12; + --warn-line: #6a5320; + --bad: #f0a099; + --bad-bg: #3a1a17; + --bad-line: #6a2f2a; + } +} + +/* ========================================================================== + Base + ========================================================================== */ + +*, +*::before, +*::after { + box-sizing: border-box; +} + html { - color: #222; - font-size: 1em; - line-height: 1.4; + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; } -/* - * Remove text-shadow in selection highlight: - * https://twitter.com/miketaylr/status/12228805301 - * - * Customize the background color to match your design. - */ - -::-moz-selection { - background: #b3d4fc; - text-shadow: none; +body { + display: flex; + flex-direction: column; + min-height: 100vh; + min-height: 100dvh; + margin: 0; + background: var(--paper); + color: var(--ink); + font-family: var(--font-body); + font-size: 15px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; } -::selection { - background: #b3d4fc; - text-shadow: none; +main { + flex: 1 0 auto; } -/* - * A better looking default horizontal rule - */ +h1, +h2, +h3, +h4, +p, +ul, +ol, +dl, +dd, +figure { + margin: 0; +} -hr { - display: block; - height: 1px; - border: 0; - border-top: 1px solid #ccc; - margin: 1em 0; +ul, +ol { padding: 0; + list-style: none; } -/* - * Remove the gap between audio, canvas, iframes, - * images, videos and the bottom of their containers: - * https://github.com/h5bp/html5-boilerplate/issues/440 - */ - -audio, -canvas, -iframe, img, -svg, -video { +svg { vertical-align: middle; } -/* - * Remove default fieldset styles. - */ +img { + max-width: 100%; + height: auto; +} + +a { + color: var(--accent); +} + +a:hover { + color: var(--accent-hover); +} + +button, +input, +select, +textarea { + font: inherit; + color: inherit; +} fieldset { border: 0; @@ -72,115 +260,1100 @@ fieldset { padding: 0; } -/* - * Allow only vertical resizing of textareas. - */ - textarea { resize: vertical; } -/* ========================================================================== - Author's custom styles - ========================================================================== */ +hr { + height: 0; + margin: 0; + border: 0; + border-top: 1px solid var(--line); +} + +input::placeholder, +textarea::placeholder { + color: var(--placeholder); + opacity: 1; +} + +input[type="checkbox"], +input[type="radio"] { + width: 16px; + height: 16px; + margin: 0; + accent-color: var(--ink); +} + +::selection { + background: var(--gold); + color: #191713; + text-shadow: none; +} + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + } +} /* ========================================================================== - Helper classes + Layout ========================================================================== */ -/* - * Hide visually and from screen readers - */ +/* The centred content column. */ +.page { + width: 100%; + max-width: calc(var(--content-max) + 2 * var(--gutter)); + margin: 0 auto; + padding: var(--space-6) var(--gutter) var(--space-14); +} + +.stack { + display: flex; + flex-direction: column; + gap: var(--space-8); +} + +.card-grid { + display: grid; + grid-template-columns: repeat(var(--grid-columns), minmax(0, 1fr)); + gap: var(--grid-gap); +} + +.section-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-4); +} + +.skip-link { + position: absolute; + top: var(--space-2); + left: var(--space-2); + z-index: 10; + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-sm); + background: var(--on-dark-fill); + color: var(--on-dark-fill-text); + font-size: 13px; + font-weight: 600; + transform: translateY(-200%); +} + +.skip-link:focus { + color: var(--on-dark-fill-text); + transform: none; +} + +/* ========================================================================== + Type + ========================================================================== */ + +.type-hero, +.type-h1, +.type-h2, +.type-panel, +.type-price, +.type-stat { + font-family: var(--font-display); + font-weight: 600; + letter-spacing: 0; +} + +.type-hero, +.type-h1 { + font-size: 36px; + line-height: 1.02; + text-wrap: balance; +} + +.type-h2, +.type-panel { + font-size: 24px; + line-height: 1.1; +} + +.type-price { + font-size: 38px; + line-height: 1; +} + +.type-stat { + font-size: 36px; + line-height: 1; +} + +@media (min-width: 1024px) { + .type-hero { + font-size: 64px; + } + + .type-h1 { + font-size: 48px; + } + + .type-h2 { + font-size: 28px; + } + + .type-price { + font-size: 44px; + } +} + +.type-lead { + color: var(--ink-soft); + font-size: 15px; + line-height: 1.5; + text-wrap: pretty; +} + +@media (min-width: 1024px) { + .type-lead { + font-size: 17px; + } +} + +.type-body { + font-size: 15px; + line-height: 1.5; +} + +.type-body-sm { + font-size: 14px; + line-height: 1.5; +} + +.type-caption { + color: var(--muted); + font-size: 13px; + line-height: 1.45; +} + +.type-small { + color: var(--muted); + font-size: 12px; + line-height: 1.45; +} + +.type-label { + font-size: 13px; + font-weight: 600; + line-height: 1.2; +} + +.type-eyebrow { + color: var(--muted); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.12em; + line-height: 1.2; + text-transform: uppercase; +} + +.type-card-title { + font-size: 14px; + font-weight: 600; + line-height: 1.3; +} + +.type-price-card { + font-size: 18px; + font-weight: 600; + line-height: 1.2; +} + +/* A quiet, ink-coloured link. */ +.link-ink { + color: var(--ink); + font-size: 13px; + font-weight: 600; + text-decoration: underline; + text-underline-offset: 3px; +} + +.link-ink:hover { + color: var(--accent); +} + +/* ========================================================================== + Primitives + ========================================================================== */ + +/* Buttons, and links styled as buttons. */ +.button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + height: var(--control-md); + padding: 0 var(--space-5); + border: 1px solid var(--rule); + border-radius: var(--radius-sm); + background: var(--card); + color: var(--ink); + font-size: 14px; + font-weight: 600; + line-height: 1; + text-decoration: none; + white-space: nowrap; + cursor: pointer; +} + +.button:hover { + border-color: var(--ink); + color: var(--ink); +} + +.button--primary { + border-color: var(--accent); + background: var(--accent); + color: #ffffff; +} + +.button--primary:hover { + border-color: var(--accent-hover); + background: var(--accent-hover); + color: #ffffff; +} + +.button--dark { + border-color: var(--ink); + background: var(--ink); + color: var(--paper); +} + +.button--dark:hover { + background: var(--ink-soft); + color: var(--paper); +} + +.button--outline { + border-color: var(--ink); +} + +.button--sm { + height: var(--control-sm); + padding: 0 var(--space-4); + font-size: 13px; +} + +.button--lg { + height: var(--control-lg); + padding: 0 var(--space-6); + font-size: 15px; +} + +.button--block { + width: 100%; +} + +.button:disabled, +.button[aria-disabled="true"] { + border-color: var(--line); + background: var(--tint); + color: var(--muted); + cursor: not-allowed; +} + +/* Chips: grade, status and format markers. */ +.chip { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 3px 7px; + border: 1px solid var(--line); + border-radius: var(--radius-xs); + background: var(--tint); + color: var(--ink); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.04em; + line-height: 1.2; + white-space: nowrap; +} + +@media (min-width: 600px) { + .chip { + padding: var(--space-1) var(--space-2); + font-size: 11px; + } +} + +.chip--good { + border-color: var(--good-line); + background: var(--good-bg); + color: var(--good); +} + +.chip--warn { + border-color: var(--warn-line); + background: var(--warn-bg); + color: var(--warn); +} + +.chip--bad { + border-color: var(--bad-line); + background: var(--bad-bg); + color: var(--bad); +} + +/* Pills: filter chips and quick replies. */ +.pill { + display: inline-flex; + align-items: center; + height: 40px; + padding: 0 14px; + border: 1px solid var(--rule); + border-radius: var(--radius-pill); + background: var(--card); + color: var(--ink); + font-size: 13px; + font-weight: 500; + text-decoration: none; + white-space: nowrap; + cursor: pointer; +} + +.pill:hover { + border-color: var(--ink); + color: var(--ink); +} + +.pill[aria-current], +.pill[aria-pressed="true"] { + border-color: var(--header); + background: var(--header); + color: var(--on-dark); + font-weight: 600; +} + +@media (min-width: 1024px) { + .pill { + height: var(--control-xs); + } +} + +/* Form fields */ +.field { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.field__label { + font-size: 13px; + font-weight: 600; + line-height: 1.2; +} + +.field__hint { + color: var(--muted); + font-size: 12px; + line-height: 1.45; +} + +.input { + width: 100%; + height: var(--control-md); + padding: 0 var(--space-3); + border: 1px solid var(--rule); + border-radius: var(--radius-sm); + background: var(--card); + color: var(--ink); + font-size: 14px; +} + +textarea.input { + height: auto; + min-height: 96px; + padding: var(--space-3); + line-height: 1.5; +} + +.input:hover { + border-color: var(--ink); +} + +.input[aria-invalid="true"] { + border-color: var(--bad); +} + +/* Surfaces */ +.card { + padding: var(--space-4); + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: var(--card); +} + +@media (min-width: 1024px) { + .card { + padding: var(--space-6); + } +} + +/* Notices */ +.notice { + padding: var(--space-3) var(--space-4); + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: var(--tint); + color: var(--ink); + font-size: 13px; + line-height: 1.45; +} + +.notice--good { + border-color: var(--good-line); + background: var(--good-bg); + color: var(--good); +} + +.notice--warn { + border-color: var(--warn-line); + background: var(--warn-bg); + color: var(--warn); +} + +.notice--bad { + border-color: var(--bad-line); + background: var(--bad-bg); + color: var(--bad); +} + +/* Two-letter monograms */ +.monogram { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: var(--radius-sm); + background: var(--header); + color: var(--on-dark); + font-family: var(--font-lettering); + font-size: 15px; + letter-spacing: 0.04em; + line-height: 1; +} + +.icon { + flex-shrink: 0; +} + +/* ========================================================================== + Site header — one markup, rearranged per breakpoint through grid areas. + ========================================================================== */ + +.site-header { + flex-shrink: 0; + /* On phone, the search and pills below the dark bar sit on paper. */ + background: linear-gradient(var(--header) 0 var(--header-height), var(--paper) var(--header-height)); + color: var(--on-dark); +} + +.site-header :focus-visible { + outline-color: var(--gold); +} + +.site-header__inner { + display: grid; + grid-template-areas: + "brand actions" + "search search" + "nav nav"; + grid-template-columns: auto 1fr; + grid-template-rows: var(--header-height) auto auto; + align-items: center; + width: 100%; + max-width: calc(var(--content-max) + 2 * var(--gutter)); + margin: 0 auto; + padding: 0 var(--gutter); +} + +.site-header__brand { + grid-area: brand; + color: var(--on-dark); + font-family: var(--font-lettering); + font-size: 26px; + letter-spacing: 0.04em; + line-height: 1; + text-decoration: none; +} + +.site-header__brand:hover { + color: var(--on-dark); +} + +/* Search */ +.site-search { + grid-area: search; + display: flex; + align-items: center; + min-width: 0; + padding-top: var(--space-4); +} + +.site-search__input { + flex: 1 1 auto; + min-width: 0; + height: var(--control-lg); + padding: 0 14px; + border: 1px solid var(--rule); + border-right: 0; + border-radius: var(--radius-sm) 0 0 var(--radius-sm); + background: var(--card); + color: var(--ink); + font-size: 15px; + -webkit-appearance: none; + appearance: none; +} + +.site-search__input:focus-visible { + outline-color: var(--accent); + outline-offset: -2px; +} + +.site-search__submit { + display: flex; + align-items: center; + justify-content: center; + width: 52px; + height: var(--control-lg); + padding: 0; + border: 0; + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; + background: var(--header); + color: var(--on-dark); + cursor: pointer; +} + +.site-search__submit:focus-visible { + outline-color: var(--accent); +} + +/* Format navigation */ +.site-nav { + grid-area: nav; + display: flex; + align-items: center; + gap: var(--space-2); + margin: 0 calc(-1 * var(--gutter)); + padding: var(--space-5) var(--gutter) var(--space-1); + overflow-x: auto; + scrollbar-width: none; +} + +.site-nav::-webkit-scrollbar { + display: none; +} + +.site-nav__link { + display: inline-flex; + flex-shrink: 0; + align-items: center; + height: 40px; + padding: 0 14px; + border: 1px solid var(--rule); + border-radius: var(--radius-pill); + background: var(--card); + color: var(--ink); + font-size: 13px; + font-weight: 500; + text-decoration: none; + white-space: nowrap; +} + +.site-nav__link:hover { + border-color: var(--ink); + color: var(--ink); +} + +.site-nav__link[aria-current] { + border-color: var(--header); + background: var(--header); + color: var(--on-dark); + font-weight: 600; +} + +.site-nav__link:focus-visible { + outline-color: var(--accent); +} + +/* Tablet only. */ +.site-nav__link--extra { + display: none; +} + +/* Actions */ +.site-header__actions { + grid-area: actions; + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--space-1); +} + +.site-header__icon-link { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + color: var(--on-dark); +} + +.site-header__icon-link:hover { + color: var(--gold); +} + +.site-header__badge { + position: absolute; + top: 6px; + right: 6px; + display: flex; + align-items: center; + justify-content: center; + min-width: 16px; + height: 16px; + padding: 0 var(--space-1); + border-radius: 8px; + background: var(--accent); + color: #ffffff; + font-size: 10px; + font-weight: 700; + line-height: 1; +} + +.site-header__text-link { + color: var(--on-dark-muted); + font-size: 13px; + font-weight: 500; + text-decoration: none; +} + +.site-header__text-link:hover { + color: var(--on-dark); +} + +.site-header__text-link--strong { + color: var(--on-dark); + font-weight: 600; +} + +.site-header__sign-in { + display: inline-flex; + align-items: center; + gap: 7px; + height: 40px; + padding: 0 14px; + border-radius: var(--radius-sm); + background: var(--accent); + color: #ffffff; + font-size: 13px; + font-weight: 600; + text-decoration: none; + white-space: nowrap; +} + +.site-header__sign-in:hover { + background: var(--accent-hover); + color: #ffffff; +} + +.site-header__account { + display: inline-flex; + align-items: center; + gap: 10px; + height: 44px; + padding: 0 var(--space-2); + border-radius: var(--radius-sm); + color: var(--on-dark); + font-size: 13px; + font-weight: 600; + text-decoration: none; +} + +.site-header__account:hover { + color: var(--on-dark); +} + +.site-header__avatar { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: var(--radius-sm); + background: var(--on-dark-fill); + color: var(--on-dark-fill-text); + font-family: var(--font-lettering); + font-size: 12px; + letter-spacing: 0.04em; +} + +/* Breakpoint visibility helpers */ +.from-tablet, +.from-desktop { + display: none; +} + +@media (min-width: 600px) { + .site-header { + background: var(--header); + } + + .site-header__inner { + grid-template-areas: + "brand search actions" + "nav nav nav"; + grid-template-columns: auto minmax(0, 1fr) auto; + grid-template-rows: var(--header-height) 44px; + column-gap: var(--space-4); + } + + .site-search { + padding-top: 0; + } + + .site-search__input { + height: 40px; + border-color: var(--line-dark); + background: var(--header-well); + color: var(--on-dark); + font-size: 14px; + } + + .site-search__input:focus-visible, + .site-search__submit:focus-visible, + .site-nav__link:focus-visible { + outline-color: var(--gold); + } + + .site-search__submit { + width: 44px; + height: 40px; + background: var(--accent); + color: #ffffff; + } + + .site-search__submit:hover { + background: var(--accent-hover); + } + + .site-nav { + align-self: stretch; + gap: 22px; + padding: 0 var(--gutter); + border-top: 1px solid var(--line-dark); + } + + .site-nav__link, + .site-nav__link[aria-current] { + height: auto; + padding: 0; + border: 0; + border-radius: 0; + background: none; + } + + .site-nav__link { + color: var(--on-dark-muted); + } + + .site-nav__link:hover, + .site-nav__link[aria-current] { + color: var(--on-dark); + } + + .site-nav__link--extra { + display: inline-flex; + } + + /* Push the extra links to the end of the row. */ + .site-nav__link:not(.site-nav__link--extra) + .site-nav__link--extra { + margin-left: auto; + } + + .site-header__actions { + gap: var(--space-3); + } + + .site-header__sign-in { + height: 38px; + } + + .from-tablet { + display: revert; + } + + .phone-only { + display: none; + } +} + +@media (min-width: 1024px) { + .site-header__inner { + grid-template-areas: "brand nav search actions"; + grid-template-columns: auto auto minmax(0, 440px) 1fr; + grid-template-rows: var(--header-height); + column-gap: var(--space-7); + } + + .site-header__brand { + font-size: 28px; + } + + .site-search__input, + .site-search__submit { + height: 38px; + } + + .site-search__input { + font-size: 13px; + } + + .site-nav { + align-self: center; + margin: 0; + padding: 0; + border-top: 0; + overflow: visible; + } + + .site-nav__link--extra { + display: none; + } + + .site-header__actions { + gap: 18px; + } + + .site-header__icon-link { + width: 40px; + height: 40px; + } + + .site-header__badge { + top: 4px; + right: 4px; + } + + .site-header__sign-in { + gap: var(--space-2); + padding: 0 var(--space-4); + } + + .site-header__account { + height: 38px; + padding: 0 6px 0 var(--space-3); + } + + .from-desktop { + display: revert; + } + + .until-desktop { + display: none; + } +} + +/* ========================================================================== + Site footer + ========================================================================== */ + +.site-footer { + flex-shrink: 0; + background: var(--header); + color: var(--on-dark-muted); + font-size: 13px; +} + +.site-footer :focus-visible { + outline-color: var(--gold); +} + +.site-footer__inner { + display: flex; + flex-direction: column; + gap: var(--space-8); + width: 100%; + max-width: calc(var(--content-max) + 2 * var(--gutter)); + margin: 0 auto; + padding: var(--space-10) var(--gutter) var(--space-7); +} + +.site-footer__columns { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-8) var(--space-6); + align-items: start; +} + +.site-footer__about { + display: flex; + flex-direction: column; + grid-column: 1 / -1; + gap: 10px; + max-width: 420px; +} + +.site-footer__wordmark { + color: var(--on-dark); + font-family: var(--font-lettering); + font-size: 28px; + letter-spacing: 0.04em; + line-height: 1.2; +} + +.site-footer__column { + display: flex; + flex-direction: column; + gap: 10px; +} + +.site-footer__heading { + color: var(--on-dark); + font-family: inherit; + font-size: 13px; + font-weight: 600; + line-height: 1.5; +} + +.site-footer__links { + display: flex; + flex-direction: column; + gap: 10px; +} + +.site-footer a { + color: var(--on-dark-muted); + text-decoration: none; +} + +.site-footer a:hover { + color: var(--on-dark); + text-decoration: underline; + text-underline-offset: 3px; +} + +.site-footer__language { + align-items: flex-start; +} + +.language-toggle { + display: inline-flex; + overflow: hidden; + border: 1px solid var(--line-dark); + border-radius: var(--radius-sm); +} + +.language-toggle__option { + height: var(--control-sm); + padding: 0 14px; + border: 0; + background: transparent; + color: var(--on-dark-muted); + font-size: 12px; + font-weight: 600; + cursor: pointer; +} + +.language-toggle__option[aria-pressed="true"] { + background: var(--on-dark-fill); + color: var(--on-dark-fill-text); +} + +.language-toggle__option:focus-visible { + outline-offset: -2px; +} + +.site-footer__note { + color: var(--on-dark-faint); + font-size: 12px; +} + +.site-footer__legal { + display: flex; + flex-direction: column; + gap: var(--space-3); + padding-top: var(--space-4); + border-top: 1px solid var(--line-dark); + color: var(--on-dark-faint); + font-size: 12px; +} + +.site-footer__legal-links { + display: flex; + flex-shrink: 0; + gap: var(--space-4); +} + +@media (min-width: 600px) { + .site-footer__columns { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} + +@media (min-width: 1024px) { + .site-footer__columns { + grid-template-columns: 300px auto auto auto 1fr; + column-gap: 64px; + } + + .site-footer__about { + grid-column: auto; + } + + .site-footer__language { + justify-self: end; + } + + .site-footer__legal { + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: var(--space-8); + } +} + +/* ========================================================================== + Helpers + ========================================================================== */ .hidden, [hidden] { display: none !important; } -/* - * Hide only visually, but have it available for screen readers: - * https://snook.ca/archives/html_and_css/hiding-content-for-accessibility - * - * 1. For long content, line feeds are not interpreted as spaces and small width - * causes content to wrap 1 word per line: - * https://medium.com/@jessebeach/beware-smushed-off-screen-accessible-text-5952a4c2cbfe - */ - +/* Hidden visually, still read out. */ .visually-hidden { - border: 0; - clip: rect(0, 0, 0, 0); + position: absolute; + width: 1px; height: 1px; margin: -1px; - overflow: hidden; padding: 0; - position: absolute; + overflow: hidden; + border: 0; + clip-path: inset(50%); white-space: nowrap; - width: 1px; - /* 1 */ -} - -/* - * Extends the .visually-hidden class to allow the element - * to be focusable when navigated to via the keyboard: - * https://www.drupal.org/node/897638 - */ - -.visually-hidden.focusable:active, -.visually-hidden.focusable:focus { - clip: auto; - height: auto; - margin: 0; - overflow: visible; - position: static; - white-space: inherit; - width: auto; -} - -/* - * Hide visually and from screen readers, but maintain layout - */ - -.invisible { - visibility: hidden; -} - -/* - * Clearfix: contain floats - * - * The use of `table` rather than `block` is only necessary if using - * `::before` to contain the top-margins of child elements. - */ - -.clearfix::before, -.clearfix::after { - content: ""; - display: table; -} - -.clearfix::after { - clear: both; } /* ========================================================================== - EXAMPLE Media Queries for Responsive Design. - These examples override the primary ('mobile first') styles. - Modify as content requires. - ========================================================================== */ - -@media only screen and (min-width: 35em) { - /* Style adjustments for viewports that meet the condition */ -} - -@media print, - (min-resolution: 1.25dppx), - (min-resolution: 120dpi) { - /* Style adjustments for high resolution devices */ -} - -/* ========================================================================== - Print styles. - Inlined to avoid the additional HTTP request: - https://www.phpied.com/delay-loading-your-print-css/ + Print ========================================================================== */ @media print { @@ -188,59 +1361,29 @@ textarea { *::before, *::after { background: #fff !important; - color: #000 !important; - /* Black prints faster */ box-shadow: none !important; + color: #000 !important; text-shadow: none !important; } + .site-header, + .site-footer, + .skip-link { + display: none !important; + } + a, a:visited { text-decoration: underline; } - a[href]::after { - content: " (" attr(href) ")"; - } - - abbr[title]::after { - content: " (" attr(title) ")"; - } - - /* - * Don't show links that are fragment identifiers, - * or use the `javascript:` pseudo protocol - */ - a[href^="#"]::after, - a[href^="javascript:"]::after { - content: ""; - } - - pre { - white-space: pre-wrap !important; - } - - pre, - blockquote { - border: 1px solid #999; - page-break-inside: avoid; - } - tr, img { page-break-inside: avoid; } - p, - h2, - h3 { - orphans: 3; - widows: 3; - } - h2, h3 { page-break-after: avoid; } } - diff --git a/Services/Website/Resources/Static/font/README.txt b/Services/Website/Resources/Static/font/README.txt new file mode 100644 index 0000000..45ce2d6 --- /dev/null +++ b/Services/Website/Resources/Static/font/README.txt @@ -0,0 +1,7 @@ +CCN — self-hosted fonts (latin subsets, from Google Fonts; SIL Open Font License 1.1) + + anton-400.woff2 Anton 400 lettering + archivo-variable.woff2 Archivo 400-700 body + oswald-variable.woff2 Oswald 500-700 display + +Served unversioned: the preload links must match the `@font-face` URLs in `css/shared.css`. diff --git a/Services/Website/Resources/Static/font/anton-400.woff2 b/Services/Website/Resources/Static/font/anton-400.woff2 new file mode 100644 index 0000000..35e8f01 Binary files /dev/null and b/Services/Website/Resources/Static/font/anton-400.woff2 differ diff --git a/Services/Website/Resources/Static/font/archivo-variable.woff2 b/Services/Website/Resources/Static/font/archivo-variable.woff2 new file mode 100644 index 0000000..a31bef9 Binary files /dev/null and b/Services/Website/Resources/Static/font/archivo-variable.woff2 differ diff --git a/Services/Website/Resources/Static/font/oswald-variable.woff2 b/Services/Website/Resources/Static/font/oswald-variable.woff2 new file mode 100644 index 0000000..aa7ae2d Binary files /dev/null and b/Services/Website/Resources/Static/font/oswald-variable.woff2 differ diff --git a/Services/Website/Resources/Static/site.webmanifest b/Services/Website/Resources/Static/site.webmanifest index e5e517f..c396092 100644 --- a/Services/Website/Resources/Static/site.webmanifest +++ b/Services/Website/Resources/Static/site.webmanifest @@ -1,6 +1,6 @@ { - "short_name": "", - "name": "", + "short_name": "CCN", + "name": "CCN — Comics marketplace", "icons": [{ "src": "icon-192.png", "type": "image/png", @@ -11,6 +11,6 @@ "sizes": "512x512" }], "start_url": "/?utm_source=homescreen", - "background_color": "#fafafa", - "theme_color": "#fafafa" + "background_color": "#f5f3ee", + "theme_color": "#191713" } diff --git a/Services/Website/Sources/Library/Catalogs/Localizable.xcstrings b/Services/Website/Sources/Library/Catalogs/Localizable.xcstrings index 729eda6..a17c14d 100644 --- a/Services/Website/Sources/Library/Catalogs/Localizable.xcstrings +++ b/Services/Website/Sources/Library/Catalogs/Localizable.xcstrings @@ -1,13 +1,13 @@ { "sourceLanguage" : "en", "strings" : { - "index.greeting" : { + "index.heading" : { "comment" : "The landing page's greeting paragraph.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Hello world! This is HTML5 Boilerplate." + "value" : "Back issues, from shops you can vet first." } } } @@ -18,7 +18,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Index page" + "value" : "CCN — Comics marketplace" } } } @@ -51,7 +51,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Page Not Found" + "value" : "Page Not Found — CCN" } } } diff --git a/Services/Website/Sources/Library/Internal/Components/Icon.swift b/Services/Website/Sources/Library/Internal/Components/Icon.swift new file mode 100644 index 0000000..8882535 --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Components/Icon.swift @@ -0,0 +1,96 @@ +import Elementary + +/// A decorative inline SVG stroke icon, drawn in the current text colour. +struct Icon: HTML { + + // MARK: Properties + + /// The glyph the icon draws. + let glyph: Glyph + + /// The icon's rendered width and height, in pixels. + let size: Int + + // MARK: Initializers + + /// Creates an icon. + /// - Parameters: + /// - glyph: the glyph the icon draws. + /// - size: the icon's rendered width and height, in pixels; 20 by default. + init( + _ glyph: Glyph, + size: Int = 20 + ) { + self.glyph = glyph + self.size = size + } + + // MARK: Computed + + /// Rendered raw: the markup is static, never user input. + var body: some HTML { + HTMLRaw( + """ + + """ + ) + } + +} + +// MARK: - Enumerations + +extension Icon { + /// A glyph an ``Icon`` can draw. + enum Glyph { + /// A bell. + case bell + /// A bookmark. + case bookmark + /// A check mark. + case check + /// Filter lines. + case filter + /// A key. + case key + /// A speech bubble. + case message + /// A magnifying glass. + case search + /// A shopfront. + case shop + /// Sort arrows. + case sort + } +} + +extension Icon.Glyph { + + // MARK: Computed + + /// The shapes the glyph is drawn with. + var shapes: String { + switch self { + case .bell: #""# + case .bookmark: #""# + case .check: #""# + case .filter: #""# + case .key: #""# + case .message: #""# + case .search: #""# + case .shop: #""# + case .sort: #""# + } + } + + /// The glyph's stroke width; heavier for glyphs drawn small. + var strokeWidth: String { + switch self { + case .check: "3.5" + case .filter, .key, .search, .sort: "2.2" + default: "2" + } + } + +} diff --git a/Services/Website/Sources/Library/Internal/Components/SiteFooter.swift b/Services/Website/Sources/Library/Internal/Components/SiteFooter.swift new file mode 100644 index 0000000..1cde858 --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Components/SiteFooter.swift @@ -0,0 +1,154 @@ +import Elementary + +/// The site footer: the about blurb, the link columns, the language toggle, and the legal line. +struct SiteFooter: HTML { + + // MARK: Computed + + var body: some HTML { + footer(.class("site-footer")) { + div(.class("site-footer__inner")) { + div(.class("site-footer__columns")) { + div(.class("site-footer__about")) { + span(.class("site-footer__wordmark")) { + "CCN" + } + p { + "A marketplace for single-issue comics, manga, trade paperbacks and hardcovers, sold by independent shops in the Netherlands and across the EU." + } + } + for column in Self.columns { + nav( + .class("site-footer__column"), + .custom( + name: "aria-label", + value: column.title + ) + ) { + h2(.class("site-footer__heading")) { + column.title + } + ul(.class("site-footer__links")) { + for link in column.links { + li { + a(.href(link.path)) { + link.label + } + } + } + } + } + } + language + } + div(.class("site-footer__legal")) { + span { + "CCN B.V. · Amsterdam · KvK [NUMBER] · BTW-id [NUMBER] · Business sellers show their KvK number on their shop page; consumers have a 14-day right of withdrawal on purchases from business sellers." + } + span(.class("site-footer__legal-links")) { + a(.href(.Route.terms)) { + "Terms" + } + a(.href(.Route.privacy)) { + "Privacy" + } + a(.href(.Route.sellerRules)) { + "Seller rules" + } + } + } + } + } + } + +} + +// MARK: - Helpers + +private extension SiteFooter { + + // MARK: Type aliases + + /// A titled column of links. + typealias Column = (title: String, links: [(label: String, path: String)]) + + // MARK: Constants + + /// The link columns, in reading order. + static let columns: [Column] = [ + ( + title: "Browse", + links: Format.allCases.map { ($0.label, $0.path) } + [ + ("By series", .Route.series) + ] + ), + ( + title: "Sell", + links: [ + ("Open a shop", .Route.openShop), + ("Seller dashboard", .Route.sellerDashboard), + ("Grading guide", .Route.gradingGuide), + ("Trade at events", .Route.events), + ] + ), + ( + title: "Account", + links: [ + ("Sign in or join", .Route.signIn), + ("Want list & alerts", .Route.wants), + ("My collection", .Route.collection), + ("Messages", .Route.messages), + ("Passkeys & settings", .Route.account), + ("Lost your passkey?", .Route.recover), + ] + ), + ] + + // MARK: Computed + + /// The language toggle; display-only while the prototype serves English alone. + var language: some HTML { + div(.class("site-footer__column site-footer__language")) { + h2(.class("site-footer__heading")) { + "Language" + } + div( + .class("language-toggle"), + .custom( + name: "role", + value: "group" + ), + .custom( + name: "aria-label", + value: "Language" + ) + ) { + button( + .class("language-toggle__option"), + .type(.button), + .custom( + name: "aria-pressed", + value: "false" + ), + .disabled + ) { + "NL" + } + button( + .class("language-toggle__option"), + .type(.button), + .custom( + name: "aria-pressed", + value: "true" + ) + ) { + "EN" + } + } + span(.class("site-footer__note")) { + "Prices in € · ships to NL" + } + } + } + +} diff --git a/Services/Website/Sources/Library/Internal/Components/SiteHeader.swift b/Services/Website/Sources/Library/Internal/Components/SiteHeader.swift new file mode 100644 index 0000000..46187c0 --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Components/SiteHeader.swift @@ -0,0 +1,253 @@ +import Elementary + +/// The site header: the wordmark, the format navigation, the search field, and the viewer's actions. +/// +/// One markup serves all three layouts; the shared stylesheet rearranges it through grid areas. +struct SiteHeader: HTML { + + // MARK: Properties + + /// The format marked as current in the navigation, or `nil` for none. + let active: Format? + + /// The signed-in viewer, or `nil` to render the signed-out actions. + let viewer: Viewer? + + // MARK: Initializers + + /// Creates a site header. + /// - Parameters: + /// - active: the format marked as current, or `nil` (the default) for none. + /// - viewer: the signed-in viewer, or `nil` (the default) to render the signed-out actions. + init( + active: Format? = nil, + viewer: Viewer? = nil + ) { + self.active = active + self.viewer = viewer + } + + // MARK: Computed + + var body: some HTML { + header(.class("site-header")) { + div(.class("site-header__inner")) { + a( + .class("site-header__brand"), + .href(.Route.home) + ) { + "CCN" + } + navigation + search + div(.class("site-header__actions")) { + if let viewer { + actions(for: viewer) + } else { + guestActions + } + } + } + } + } + +} + +// MARK: - Helpers + +private extension SiteHeader { + + // MARK: Computed + + /// The signed-out actions: *Open a shop* and the passkey sign-in. + @HTMLBuilder + var guestActions: some HTML { + a( + .class("site-header__icon-link phone-only"), + .href(.Route.openShop), + .custom( + name: "aria-label", + value: "Open a shop" + ) + ) { + Icon(.shop, size: 22) + } + a( + .class("site-header__text-link from-desktop"), + .href(.Route.openShop) + ) { + "Open a shop" + } + a( + .class("site-header__sign-in"), + .href(.Route.signIn) + ) { + Icon(.key, size: 15) + span { + "Sign in" + span(.class("from-desktop")) { + " with passkey" + } + } + } + } + + /// The format links, plus the series and events links shown on tablet only. + var navigation: some HTML { + nav( + .class("site-nav"), + .custom( + name: "aria-label", + value: "Formats" + ) + ) { + for format in Format.allCases { + if format == active { + a( + .class("site-nav__link"), + .href(format.path), + .custom( + name: "aria-current", + value: "page" + ) + ) { + format.label + } + } else { + a( + .class("site-nav__link"), + .href(format.path) + ) { + format.label + } + } + } + a( + .class("site-nav__link site-nav__link--extra"), + .href(.Route.series) + ) { + "Series" + } + a( + .class("site-nav__link site-nav__link--extra"), + .href(.Route.events) + ) { + "Events" + } + } + } + + /// The search field, a plain `GET` form. + var search: some HTML { + form( + .class("site-search"), + .action(.Route.search), + .method(.get), + .custom( + name: "role", + value: "search" + ) + ) { + label( + .class("visually-hidden"), + .for("site-search-query") + ) { + "Search listings" + } + input( + .class("site-search__input"), + .id("site-search-query"), + .type(.search), + .name("q"), + .placeholder("Search title, issue, grade or shop") + ) + button( + .class("site-search__submit"), + .type(.submit), + .custom( + name: "aria-label", + value: "Search" + ) + ) { + Icon(.search, size: 16) + } + } + } + + // MARK: Methods + + /// The signed-in actions: want list, messages, shop, and account. + /// - Parameter viewer: the signed-in viewer. + @HTMLBuilder + func actions( + for viewer: Viewer + ) -> some HTML { + counter( + .bell, + label: "Want list", + count: viewer.newWantMatches, + unit: "new matches", + path: .Route.wants + ) + counter( + .message, + label: "Messages", + count: viewer.unreadMessages, + unit: "unread", + path: .Route.messages + ) + a( + .class("site-header__text-link site-header__text-link--strong from-desktop"), + .href(.Route.sellerDashboard) + ) { + "My shop" + } + a( + .class("site-header__account"), + .href(.Route.account), + .custom( + name: "aria-label", + value: "Account of \(viewer.displayName)" + ) + ) { + span(.class("from-desktop")) { + viewer.displayName + } + span(.class("site-header__avatar")) { + viewer.initials + } + } + } + + /// An icon link with a count badge, omitted at zero. + /// - Parameters: + /// - glyph: the glyph the link shows. + /// - label: the link's accessible name. + /// - count: the number the badge shows. + /// - unit: what the count measures. + /// - path: the path the link leads to. + func counter( + _ glyph: Icon.Glyph, + label: String, + count: Int, + unit: String, + path: String + ) -> some HTML { + a( + .class("site-header__icon-link"), + .href(path), + .custom( + name: "aria-label", + value: count > 0 ? "\(label), \(count) \(unit)" : label + ) + ) { + Icon(glyph) + if count > 0 { + span(.class("site-header__badge")) { + "\(count)" + } + } + } + } + +} diff --git a/Services/Website/Sources/Library/Internal/Components/SiteLayout.swift b/Services/Website/Sources/Library/Internal/Components/SiteLayout.swift new file mode 100644 index 0000000..e2707da --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Components/SiteLayout.swift @@ -0,0 +1,53 @@ +import Elementary + +/// The frame a page wraps its content in: a skip link, the ``SiteHeader``, the content inside `main`, and the ``SiteFooter``. +struct SiteLayout: HTML { + + // MARK: Properties + + /// The format marked as current in the header, or `nil` for none. + let active: Format? + + /// The screen's markup, rendered inside the `main` landmark. + let content: Content + + /// The signed-in viewer, or `nil` to render the signed-out header. + let viewer: Viewer? + + // MARK: Initializers + + /// Creates a site layout around a screen's content. + /// - Parameters: + /// - active: the format marked as current, or `nil` (the default) for none. + /// - viewer: the signed-in viewer, or `nil` (the default) to render the signed-out header. + /// - content: the screen's markup. + init( + active: Format? = nil, + viewer: Viewer? = nil, + @HTMLBuilder content: () -> Content + ) { + self.active = active + self.content = content() + self.viewer = viewer + } + + // MARK: Computed + + var body: some HTML { + a( + .class("skip-link"), + .href("#main") + ) { + "Skip to content" + } + SiteHeader( + active: active, + viewer: viewer + ) + main(.id("main")) { + content + } + SiteFooter() + } + +} diff --git a/Services/Website/Sources/Library/Internal/Enumerations/Format.swift b/Services/Website/Sources/Library/Internal/Enumerations/Format.swift new file mode 100644 index 0000000..b388dc3 --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Enumerations/Format.swift @@ -0,0 +1,34 @@ +/// A format listings are published in, declared in navigation order; the raw value is the search filter token. +enum Format: String, CaseIterable, Sendable { + /// Single-issue comics. + case singleIssues = "single-issues" + /// Manga volumes. + case manga + /// Trade paperbacks. + case tradePaperbacks = "trade-paperbacks" + /// Hardcovers. + case hardcovers +} + +// MARK: - Extensions + +extension Format { + + // MARK: Computed + + /// The format's name as shown in the navigation. + var label: String { + switch self { + case .singleIssues: "Single issues" + case .manga: "Manga" + case .tradePaperbacks: "Trade paperbacks" + case .hardcovers: "Hardcovers" + } + } + + /// The path of the search screen filtered to the format. + var path: String { + "\(String.Route.search)?format=\(rawValue)" + } + +} diff --git a/Services/Website/Sources/Library/Internal/Extensions/Page+Defaults.swift b/Services/Website/Sources/Library/Internal/Extensions/Page+Defaults.swift index 7ce8886..2eabd7e 100644 --- a/Services/Website/Sources/Library/Internal/Extensions/Page+Defaults.swift +++ b/Services/Website/Sources/Library/Internal/Extensions/Page+Defaults.swift @@ -11,9 +11,9 @@ extension Page { /// The fonts preloaded on every page: one per face the stylesheets actually render. /// /// Listed rather than derived from every WOFF2 in ``StaticFile/all``: a subset gated by a `unicode-range` no page reaches would add a - /// download that never otherwise happens. Empty until the site ships fonts of its own. + /// download that never otherwise happens. All three faces render on every page. static var preloadedFonts: [StaticFile] { - [] + [.fontArchivo, .fontOswald, .fontAnton] } /// The territories paired with the site's languages, in Open Graph's `language_TERRITORY` form. @@ -147,11 +147,11 @@ extension Page { ) meta( .name("theme-color"), - .content("#fafafa") + .content("#191713") ) meta( .name("theme-color"), - .content("#0c0710"), + .content("#0f0e0b"), .custom( name: "media", value: "(prefers-color-scheme: dark)" diff --git a/Services/Website/Sources/Library/Internal/Extensions/String+Routes.swift b/Services/Website/Sources/Library/Internal/Extensions/String+Routes.swift new file mode 100644 index 0000000..9d79f3b --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Extensions/String+Routes.swift @@ -0,0 +1,39 @@ +extension String { + /// A namespace for the paths of the site's screens. + /// + /// Screens not built yet are listed too, and answer with the not-found page until they are. + enum Route { + /// The account screen. + static let account = "/account" + /// The collection tracker. + static let collection = "/collection" + /// The events screen. + static let events = "/events" + /// The public grading guide. + static let gradingGuide = "/grading-guide" + /// The browse / home screen. + static let home = "/" + /// The message threads. + static let messages = "/messages" + /// The open-a-shop onboarding. + static let openShop = "/sell/open-a-shop" + /// The privacy statement. + static let privacy = "/privacy" + /// The passkey recovery screen. + static let recover = "/recover" + /// The search results screen. + static let search = "/search" + /// The seller dashboard. + static let sellerDashboard = "/sell" + /// The rules sellers agree to. + static let sellerRules = "/seller-rules" + /// The series index. + static let series = "/series" + /// The passkey sign-in screen. + static let signIn = "/sign-in" + /// The terms of service. + static let terms = "/terms" + /// The want list. + static let wants = "/wants" + } +} diff --git a/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift b/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift index ee61949..5485dcb 100644 --- a/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift @@ -82,9 +82,36 @@ extension IndexPage: Page { // MARK: Properties + /// The hero alone, until the listings exist. var content: some HTML { - p { - localize("index.greeting", locale: locale) + SiteLayout { + div(.class("page")) { + section(.class("hero")) { + p(.class("type-eyebrow")) { + "Single issues · Manga · Trades · Hardcovers" + } + h1(.class("type-hero")) { + localize("index.heading", locale: locale) + } + p(.class("type-lead")) { + "Search every listing by title, print and grade. Browse any seller's shop without an account — sign in with a passkey only when you're ready to buy, make an offer or message." + } + div(.class("hero__actions")) { + a( + .class("button button--primary button--lg"), + .href(Format.singleIssues.path) + ) { + "Browse single issues" + } + a( + .class("button button--outline button--lg"), + .href(.Route.series) + ) { + "Browse by series" + } + } + } + } } } diff --git a/Services/Website/Sources/Library/Internal/Pages/NotFoundPage.swift b/Services/Website/Sources/Library/Internal/Pages/NotFoundPage.swift index 902f827..cbc4a52 100644 --- a/Services/Website/Sources/Library/Internal/Pages/NotFoundPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/NotFoundPage.swift @@ -47,11 +47,21 @@ extension NotFoundPage: Page { // MARK: Properties var content: some HTML { - h1 { - localize("notFound.heading", locale: locale) - } - p { - localize("notFound.message", locale: locale) + SiteLayout { + div(.class("page not-found")) { + h1(.class("type-h1")) { + localize("notFound.heading", locale: locale) + } + p(.class("type-lead")) { + localize("notFound.message", locale: locale) + } + a( + .class("button button--dark"), + .href(.Route.home) + ) { + "Back to browsing" + } + } } } diff --git a/Services/Website/Sources/Library/Internal/Types/StaticFile.swift b/Services/Website/Sources/Library/Internal/Types/StaticFile.swift index 7925703..3612c37 100644 --- a/Services/Website/Sources/Library/Internal/Types/StaticFile.swift +++ b/Services/Website/Sources/Library/Internal/Types/StaticFile.swift @@ -51,6 +51,9 @@ extension StaticFile { static let all: [Self] = [ .appleTouchIcon, .favicon, + .fontAnton, + .fontArchivo, + .fontOswald, .icon, .icon192, .icon512, @@ -104,6 +107,15 @@ extension StaticFile { /// The `favicon.ico` icon. static let favicon = Self("favicon", as: .ico) + /// The `font/anton-400.woff2` lettering face. + static let fontAnton = Self("anton-400", as: .woff2) + + /// The `font/archivo-variable.woff2` body face (weights 400–700). + static let fontArchivo = Self("archivo-variable", as: .woff2) + + /// The `font/oswald-variable.woff2` display face (weights 500–700). + static let fontOswald = Self("oswald-variable", as: .woff2) + /// The `icon.svg` icon. static let icon = Self("icon", as: .svg) diff --git a/Services/Website/Sources/Library/Internal/Types/Viewer.swift b/Services/Website/Sources/Library/Internal/Types/Viewer.swift new file mode 100644 index 0000000..04fda7c --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Types/Viewer.swift @@ -0,0 +1,50 @@ +/// The signed-in person a page is rendered for. +struct Viewer: Sendable { + + // MARK: Properties + + /// The viewer's name. + let displayName: String + + /// The number of new listings matching the viewer's want list. + let newWantMatches: Int + + /// The number of messages the viewer has not read yet. + let unreadMessages: Int + + // MARK: Initializers + + /// Creates a viewer. + /// - Parameters: + /// - displayName: the viewer's name. + /// - newWantMatches: the number of new listings matching the viewer's want list; none by default. + /// - unreadMessages: the number of messages the viewer has not read yet; none by default. + init( + displayName: String, + newWantMatches: Int = 0, + unreadMessages: Int = 0 + ) { + self.displayName = displayName + self.newWantMatches = newWantMatches + self.unreadMessages = unreadMessages + } + +} + +// MARK: - Extensions + +extension Viewer { + + // MARK: Computed + + /// The uppercased initials of the first two words of the display name. + var initials: String { + displayName + .split(separator: " ") + .prefix(2) + .compactMap(\.first) + .map { String($0).uppercased() } + .joined() + } + +} diff --git a/Services/Website/Tests/App/AppTests.swift b/Services/Website/Tests/App/AppTests.swift index 61ca5f3..b4ba04f 100644 --- a/Services/Website/Tests/App/AppTests.swift +++ b/Services/Website/Tests/App/AppTests.swift @@ -53,7 +53,7 @@ struct AppTests { #expect(response.status == .ok) #expect(response.headers[.contentType] == "text/html; charset=utf-8") - #expect(body.contains("Hello world!")) + #expect(body.contains("Back issues, from shops you can vet first.")) } } } diff --git a/Services/Website/Tests/Library/Cases/Internal/Components/IconTests.swift b/Services/Website/Tests/Library/Cases/Internal/Components/IconTests.swift new file mode 100644 index 0000000..2982055 --- /dev/null +++ b/Services/Website/Tests/Library/Cases/Internal/Components/IconTests.swift @@ -0,0 +1,30 @@ +import Elementary +import Testing + +@testable import WebsiteLibrary + +@Suite( + "Icon component", + .tags(.component) +) +struct IconTests { + + // MARK: Functional tests + + @Test + func `renders a decorative icon at the given size`() { + let html = Icon(.search, size: 16).render() + + #expect(html.hasPrefix("\(title)")) + } + + for path in [String.Route.series, .Route.openShop, .Route.gradingGuide, .Route.signIn, .Route.recover, .Route.terms] { + #expect(html.contains(#"href="\#(path)""#)) + } + } + + @Test + func `escapes the ampersands in its labels`() { + #expect(SiteFooter().render().contains("Want list & alerts")) + } + + @Test + func `presses the one language the prototype serves`() { + let html = SiteFooter().render() + + #expect(html.components(separatedBy: #"aria-pressed="true""#).count - 1 == 1) + #expect(html.contains(#"aria-pressed="true">EN<"#)) + } + +} diff --git a/Services/Website/Tests/Library/Cases/Internal/Components/SiteHeaderTests.swift b/Services/Website/Tests/Library/Cases/Internal/Components/SiteHeaderTests.swift new file mode 100644 index 0000000..441abf1 --- /dev/null +++ b/Services/Website/Tests/Library/Cases/Internal/Components/SiteHeaderTests.swift @@ -0,0 +1,78 @@ +import Elementary +import Testing + +@testable import WebsiteLibrary + +@Suite( + "SiteHeader component", + .tags(.component) +) +struct SiteHeaderTests { + + // MARK: Functional tests + + @Test + func `renders the signed-out actions without a viewer`() { + let html = SiteHeader().render() + + #expect(html.contains(#"href="/sign-in""#)) + #expect(html.contains("Open a shop")) + #expect(!html.contains(#"href="/account""#)) + #expect(!html.contains(#"href="/messages""#)) + } + + @Test + func `renders the signed-in actions for a viewer`() { + let html = SiteHeader(viewer: .init( + displayName: "Mara Visser", + newWantMatches: 2, + unreadMessages: 3 + )).render() + + #expect(html.contains(#"aria-label="Want list, 2 new matches""#)) + #expect(html.contains(#"aria-label="Messages, 3 unread""#)) + #expect(html.contains(#"href="/sell""#)) + #expect(html.contains(">MV<")) + #expect(!html.contains(#"href="/sign-in""#)) + } + + @Test + func `omits a badge that counts nothing`() { + let html = SiteHeader(viewer: .init(displayName: "Mara")).render() + + #expect(html.contains(#"aria-label="Messages""#)) + #expect(!html.contains("site-header__badge")) + } + + @Test + func `links every format and marks none current by default`() { + let html = SiteHeader().render() + + for format in Format.allCases { + #expect(html.contains(#"href="\#(format.path)""#)) + } + + #expect(!html.contains("aria-current")) + } + + @Test(arguments: Format.allCases) + func `marks the active format alone as current`( + _ format: Format + ) { + let html = SiteHeader(active: format).render() + + #expect(html.components(separatedBy: #"aria-current="page""#).count - 1 == 1) + #expect(html.contains(#"href="\#(format.path)" aria-current="page""#)) + } + + @Test + func `searches through a plain GET form`() { + let html = SiteHeader().render() + + #expect(html.contains(#"action="/search""#)) + #expect(html.contains(#"method="get""#)) + #expect(html.contains(#"name="q""#)) + #expect(html.contains(#"for="site-search-query""#)) + } + +} diff --git a/Services/Website/Tests/Library/Cases/Internal/Enumerations/FormatTests.swift b/Services/Website/Tests/Library/Cases/Internal/Enumerations/FormatTests.swift new file mode 100644 index 0000000..617436e --- /dev/null +++ b/Services/Website/Tests/Library/Cases/Internal/Enumerations/FormatTests.swift @@ -0,0 +1,29 @@ +import Testing + +@testable import WebsiteLibrary + +@Suite( + "Format enumeration", + .tags(.enumeration) +) +struct FormatTests { + + // MARK: Computed tests + + @Test(arguments: zip( + Format.allCases, + [ + "/search?format=single-issues", + "/search?format=manga", + "/search?format=trade-paperbacks", + "/search?format=hardcovers", + ] + )) + func `path`( + of format: Format, + expects path: String + ) { + #expect(format.path == path) + } + +} diff --git a/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift b/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift index 5e49a81..705e5a0 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift @@ -27,16 +27,47 @@ struct IndexPageTests { #expect(html.contains("/apple-touch-icon.png")) #expect(html.contains("/site.webmanifest")) #expect(html.contains(#"media="(prefers-color-scheme: dark)""#)) - #expect(html.contains("Hello world!")) + #expect(html.contains("Back issues, from shops you can vet first.")) #expect(html.contains("/js/shared.js")) #expect(html.contains("/js/index.js")) } @Test - func `renders no font preloads until fonts are declared`() { - // The template ships no fonts, so a preload link here would point at a file the service does not serve. - #expect(IndexPage.preloadedFonts.isEmpty) - #expect(!IndexPage(locale: .init(identifier: "en")).render().contains(#"rel="preload""#)) + func `renders the site frame around its content`() { + let html = IndexPage( + locale: .init(identifier: "en") + ).render() + + #expect(html.contains(#"class="skip-link""#)) + #expect(html.contains(#"class="site-header""#)) + #expect(html.contains(#"
"#)) + #expect(html.contains(#"class="site-footer""#)) + } + + @Test + func `renders no inline styles`() { + // The Content-Security-Policy is `default-src 'self'`, which blocks `style` attributes and elements alike. + let html = IndexPage( + locale: .init(identifier: "en") + ).render() + + #expect(!html.contains("style=")) + #expect(!html.contains("