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("