From 4fe6df76a828fc5526131003b593a1a8a0939673 Mon Sep 17 00:00:00 2001 From: Julian Kahnert Date: Thu, 10 Sep 2026 12:35:26 +0200 Subject: [PATCH] Fix contrast, reach and target sizes on the landing page The language toggle was hidden below 720 px, leaving no route to the German page on a phone. White on --coral-bright measured 3.19:1 and the dark mode's --rule 1.26:1 against a card, so buttons failed SC 1.4.3 and card edges were effectively gone. The filename in "Keep it." was cut off at the tags, the part the section exists to explain. New tokens split the roles the accent colour was carrying: --accent-fill keeps the darker coral under white text in both modes, --accent-on-sunk carries coral on the sunk surface, --ink-edge gives the dark blocks an edge on dark paper. --- Resources/css/styles.css | 145 ++++++++++++++++-- .../Localization/SiteStrings+English.swift | 10 +- .../Localization/SiteStrings+German.swift | 6 +- .../Localization/SiteStrings.swift | 3 + .../Theme/HomeContent.swift | 43 ++++-- .../Theme/PDFArchiverTheme.swift | 4 +- 6 files changed, 181 insertions(+), 30 deletions(-) diff --git a/Resources/css/styles.css b/Resources/css/styles.css index 3f430445..98b2fab2 100644 --- a/Resources/css/styles.css +++ b/Resources/css/styles.css @@ -39,6 +39,13 @@ --surface-sunk: var(--paper-sunk); --accent: var(--coral); + /* White on --coral-bright is 3.19:1, so the solid coral fills keep the darker coral in + both modes; --accent stays mode-dependent because it also paints text on paper. */ + --accent-fill: var(--coral); + /* --accent on --paper-sunk is 4.05:1 in the light mode, just under SC 1.4.3. */ + --accent-on-sunk: #b8394a; + /* The dark filename blocks stand free on light paper; on dark paper they need a line. */ + --ink-edge: transparent; --wrap: 72rem; --font-heading: Charter, Georgia, "Times New Roman", serif; @@ -53,8 +60,11 @@ --card: #1c2531; --text: #e7ebf0; --text-soft: #a3aebc; - --rule: #2c3644; + /* 2.83:1 against --card: below this the hairline that makes a card a card is gone. */ + --rule: #5a6a86; --accent: var(--coral-bright); + --accent-on-sunk: var(--coral-bright); + --ink-edge: var(--rule); } } @@ -116,6 +126,25 @@ pre { font-family: var(--font-mono); } +/* Off-screen until it takes focus, so the first Tab on any page skips the header. */ +.skip-link { + position: fixed; + top: -100%; + inset-inline-start: 1rem; + z-index: 40; + padding: 0.7rem 1.1rem; + border: 1px solid var(--rule); + border-radius: 0.7rem; + background: var(--card); + color: var(--text); + font-weight: 600; + text-decoration: none; +} + +.skip-link:focus { + top: 1rem; +} + /* ---------- Layout ---------- */ .wrap { @@ -154,6 +183,12 @@ pre { text-align: center; } +/* Both sections are jumped to from the navigation, past a header that stays on screen. */ +#features, +#download { + scroll-margin-top: 5rem; +} + /* `margin-block`, not `margin`: a blanket reset would cancel the `margin-inline: auto` this element inherits from `.wrap` and push the divider off-centre. */ .divider { @@ -170,7 +205,7 @@ pre { gap: 0.5rem; padding: 0.9rem 1.7rem; border-radius: 999px; - background: var(--accent); + background: var(--accent-fill); color: #fff; font-weight: 600; text-decoration: none; @@ -312,14 +347,14 @@ pre { display: inline-flex; align-items: center; line-height: 1; - background: var(--accent); + background: var(--accent-fill); color: #fff; padding: 0.55rem 1.2rem; border-radius: 999px; } .site-nav .nav-cta:hover { - background: var(--coral-bright); + background: color-mix(in srgb, var(--accent-fill) 85%, var(--ink)); color: #fff; } @@ -332,7 +367,12 @@ pre { font-size: 0.85rem; } +/* SC 2.5.8 asks for 24 x 24 CSS px; the bare 0.85rem label is about 17 x 22. */ .language-toggle a { + display: inline-flex; + align-items: center; + min-height: 24px; + padding-inline: 0.4rem; color: var(--text-soft); text-decoration: none; font-weight: 500; @@ -503,9 +543,43 @@ pre { animation-delay: 1.96s; } +/* On a phone the card sits below the fold, so the timed reveal is over before anyone sees + it. `.sig-line-wrap` scrolls horizontally and would capture an anonymous `view()`, so the + timeline is named on the card instead. */ +@supports (animation-timeline: view()) { + @media (max-width: 720px) { + .sig-card { + view-timeline-name: --sig-card; + } + + .sig-reveal { + animation-timeline: --sig-card; + animation-range: entry 10% entry 85%; + } + + .sig-label { + animation-timeline: --sig-card; + } + + .sig-date .sig-label { + animation-range: entry 35% entry 50%; + } + + .sig-desc .sig-label { + animation-range: entry 65% entry 80%; + } + + .sig-tags .sig-label { + animation-range: entry 80% entry 95%; + } + } +} + +/* The separators are what the whole section explains, so they follow the body text + rather than a fixed grey that only happens to work in one mode. */ .sig-sep, .sig-ext { - color: #b9c2cc; + color: var(--text-soft); align-self: flex-start; } @@ -538,7 +612,7 @@ pre { font-family: var(--font-mono); font-size: 0.95rem; font-weight: 700; - color: var(--accent); + color: var(--accent-on-sunk); background: var(--surface-sunk); border-radius: 50%; margin-bottom: 1.25rem; @@ -576,7 +650,7 @@ pre { padding: 0.3rem 0.7rem; border-radius: 999px; background: var(--surface-sunk); - color: var(--accent); + color: var(--accent-on-sunk); font-size: 0.84rem; } @@ -684,15 +758,23 @@ pre { margin: 2rem 0; padding: clamp(1.1rem, 3vw, 1.75rem); background: var(--ink); + border: 1px solid var(--ink-edge); border-radius: 1rem; +} + +.filename-line-wrap { + min-width: 0; overflow-x: auto; + container-type: inline-size; } .filename-line { display: flex; align-items: flex-start; font-family: var(--font-mono); - font-size: clamp(0.9rem, 2vw, 1.35rem); + /* Same measure as the hero: against the block's real width, so a phone shows the tags + instead of cutting the name off at the description. */ + font-size: clamp(0.5rem, calc(155cqw / var(--name-chars, 46)), 1.35rem); white-space: nowrap; } @@ -731,13 +813,33 @@ pre { } .filename-sep { - color: #566073; + color: #7c8598; +} + +/* The tree cannot shrink to fit — at 51 characters a phone would land near 8 px — so it + scrolls. iOS Safari shows no resting scrollbar, hence the fade: it ends in the block's own + background, invisible where nothing is cut off and a "continues" cue where something is. */ +.tree-wrap { + position: relative; +} + +.tree-wrap::after { + content: ""; + position: absolute; + inset-block: 1px; + inset-inline-end: 1px; + width: 2.5rem; + border-start-end-radius: 1rem; + border-end-end-radius: 1rem; + background: linear-gradient(to right, transparent, var(--ink)); + pointer-events: none; } .tree { margin: 0; padding: clamp(1rem, 2.5vw, 1.4rem); background: var(--ink); + border: 1px solid var(--ink-edge); border-radius: 1rem; color: #a2aebd; font-size: 0.85rem; @@ -775,7 +877,7 @@ pre { height: 34px; border-radius: 9px; background: var(--surface-sunk); - color: var(--accent); + color: var(--accent-on-sunk); display: flex; align-items: center; justify-content: center; @@ -829,7 +931,7 @@ pre { width: 36px; height: 36px; border-radius: 50%; - background: var(--accent); + background: var(--accent-fill); color: #fff; display: flex; align-items: center; @@ -1008,6 +1110,12 @@ pre { border-bottom: 1px solid var(--rule); } +.footer-links a, +.help-links a { + display: inline-block; + padding-block: 0.35rem; +} + .footer-links a { color: var(--text-soft); text-decoration: none; @@ -1022,9 +1130,22 @@ pre { } @media (max-width: 720px) { - .site-nav ul li:not(:has(.nav-cta)) { + /* Without the toggle there is no way to the other language: the footer carries none and + nothing redirects by Accept-Language. */ + .site-nav ul li:not(:has(.nav-cta)):not(.language-toggle) { display: none; } + + /* Toggle and pill now share the row with the wordmark, which needs 333 px. Below that + the nav drops to a second line rather than pushing the page into horizontal scroll. */ + .site-header-inner { + flex-wrap: wrap; + row-gap: 0.5rem; + } + + .site-nav .nav-cta { + padding-inline: 0.9rem; + } } @media (prefers-reduced-motion: reduce) { diff --git a/Sources/PDFArchiverWebsite/Localization/SiteStrings+English.swift b/Sources/PDFArchiverWebsite/Localization/SiteStrings+English.swift index f918748a..137d746c 100644 --- a/Sources/PDFArchiverWebsite/Localization/SiteStrings+English.swift +++ b/Sources/PDFArchiverWebsite/Localization/SiteStrings+English.swift @@ -1,5 +1,6 @@ extension SiteStrings { static let english = SiteStrings( + skipToContent: "Skip to content", siteTitle: "PDF\u{00A0}Archiver", homeTitle: "PDF\u{00A0}Archiver — scan and archive documents on Mac and iPhone", selectLanguage: "Select language", @@ -38,7 +39,7 @@ extension SiteStrings { title: "Tag\u{00A0}it.", lead: "Date, description, tags.", body: """ - Apple Intelligence suggests all three, on your device. On the Mac, ↹ moves \ + Apple Intelligence suggests all three, on your device. On the Mac, ⇥ moves \ between fields — check them, press ⌘S, and the document is renamed and moved to \ your archive. """ @@ -126,7 +127,8 @@ extension SiteStrings { ), Promise( title: "Open source.", - body: "The code is on GitHub, if you want to check." + body: "The code is on GitHub, if you want to check.", + repositoryLinkText: "GitHub" ), Promise( title: "Built since 2018.", @@ -144,7 +146,7 @@ extension SiteStrings { initials: "iF", source: "iFun", statement: """ - The Mac app uses a simple file name convention, which simply notes keywords, data \ + The Mac app uses a simple file name convention, which simply notes keywords, dates \ and descriptions in the file name of the documents and can therefore be used \ largely independently of the operating system, cloud provider and Mac or PC \ application. @@ -155,7 +157,7 @@ extension SiteStrings { source: "Sir Apfelot", statement: """ With the PDF\u{00A0}Archiver I have finally managed to bring some order and structure to \ - my PDF collection with documents, contracts and other documents that I have been \ + my PDF collection with receipts, contracts and other documents that I have been \ building since 2014. """ ), diff --git a/Sources/PDFArchiverWebsite/Localization/SiteStrings+German.swift b/Sources/PDFArchiverWebsite/Localization/SiteStrings+German.swift index 82401368..87fbbb74 100644 --- a/Sources/PDFArchiverWebsite/Localization/SiteStrings+German.swift +++ b/Sources/PDFArchiverWebsite/Localization/SiteStrings+German.swift @@ -1,5 +1,6 @@ extension SiteStrings { static let german = SiteStrings( + skipToContent: "Zum Inhalt springen", siteTitle: "PDF\u{00A0}Archiver", homeTitle: "PDF\u{00A0}Archiver — Dokumente scannen und archivieren auf Mac und iPhone", selectLanguage: "Sprache auswählen", @@ -39,7 +40,7 @@ extension SiteStrings { lead: "Datum, Beschreibung, Tags.", body: """ Apple Intelligence schlägt dir alle drei vor, auf dem Gerät. Am Mac springst du \ - mit ↹ durch die Felder — prüfen, ⌘S drücken, und das Dokument wandert umbenannt \ + mit ⇥ durch die Felder — prüfen, ⌘S drücken, und das Dokument wandert umbenannt \ ins Archiv. """ ), @@ -127,7 +128,8 @@ extension SiteStrings { ), Promise( title: "Open Source.", - body: "Der Code liegt auf GitHub, falls du nachsehen möchtest." + body: "Der Code liegt auf GitHub, falls du nachsehen möchtest.", + repositoryLinkText: "GitHub" ), Promise( title: "Seit 2018.", diff --git a/Sources/PDFArchiverWebsite/Localization/SiteStrings.swift b/Sources/PDFArchiverWebsite/Localization/SiteStrings.swift index 238983f4..48b08d55 100644 --- a/Sources/PDFArchiverWebsite/Localization/SiteStrings.swift +++ b/Sources/PDFArchiverWebsite/Localization/SiteStrings.swift @@ -34,6 +34,8 @@ struct SiteStrings { struct Promise { let title: String let body: String + /// The word in `body` naming the source repository, which is linked to it. + var repositoryLinkText: String? } /// One capture in the spotlight's device row. @@ -50,6 +52,7 @@ struct SiteStrings { let statement: String } + let skipToContent: String let siteTitle: String /// The home page's ``. Unlike the hero claim it has to carry the words people search /// for, because it is the line they read in the result list. diff --git a/Sources/PDFArchiverWebsite/Theme/HomeContent.swift b/Sources/PDFArchiverWebsite/Theme/HomeContent.swift index c9051f8e..769c0a56 100644 --- a/Sources/PDFArchiverWebsite/Theme/HomeContent.swift +++ b/Sources/PDFArchiverWebsite/Theme/HomeContent.swift @@ -17,7 +17,7 @@ extension Node where Context == HTML.BodyContext { .spotlight(strings), .grammarRule(), .keepCard(strings), - .promises(strings), + .promises(strings, on: site), .testimonials(strings), .trial(strings), .help(in: language, on: site) @@ -172,7 +172,10 @@ private extension Node where Context == HTML.BodyContext { .class("keep-body"), .forEach(strings.keepBody) { .p(.text($0)) } ), - .pre(.class("tree"), .code(.text(strings.archiveTree))) + .div( + .class("tree-wrap"), + .pre(.class("tree"), .code(.text(strings.archiveTree))) + ) ) ) ) @@ -182,13 +185,17 @@ private extension Node where Context == HTML.BodyContext { .figure( .class("filename"), .div( - .class("filename-line"), - .filenamePart(value: example.date, label: example.dateLabel, kind: "date"), - .span(.class("filename-sep"), .text("--")), - .filenamePart(value: example.description, label: example.descriptionLabel, kind: "desc"), - .span(.class("filename-sep"), .text("__")), - .filenamePart(value: example.tags, label: example.tagsLabel, kind: "tags"), - .span(.class("filename-sep"), .text(".pdf")) + .class("filename-line-wrap"), + .style("--name-chars: \(example.characterCount)"), + .div( + .class("filename-line"), + .filenamePart(value: example.date, label: example.dateLabel, kind: "date"), + .span(.class("filename-sep"), .text("--")), + .filenamePart(value: example.description, label: example.descriptionLabel, kind: "desc"), + .span(.class("filename-sep"), .text("__")), + .filenamePart(value: example.tags, label: example.tagsLabel, kind: "tags"), + .span(.class("filename-sep"), .text(".pdf")) + ) ) ) } @@ -201,7 +208,7 @@ private extension Node where Context == HTML.BodyContext { ) } - static func promises(_ strings: SiteStrings) -> Node { + static func promises(_ strings: SiteStrings, on site: PDFArchiverWebsite) -> Node { .section( .class("promises-wrap"), .div( @@ -215,7 +222,7 @@ private extension Node where Context == HTML.BodyContext { .div(.class("promise-icon"), .attribute(named: "aria-hidden", value: "true"), .text("✓")), .div( .h3(.text(promise.title)), - .p(.text(promise.body)) + .promiseBody(promise, repositoryURL: site.githubURL.absoluteString) ) ) } @@ -225,6 +232,20 @@ private extension Node where Context == HTML.BodyContext { } + /// The promise body, with the word naming the repository turned into a link to it. + static func promiseBody(_ promise: SiteStrings.Promise, repositoryURL: String) -> Node { + guard let linkText = promise.repositoryLinkText, + let range = promise.body.range(of: linkText) else { + return .p(.text(promise.body)) + } + + return .p( + .text(String(promise.body[..<range.lowerBound])), + .a(.href(repositoryURL), .text(linkText)), + .text(String(promise.body[range.upperBound...])) + ) + } + static func testimonials(_ strings: SiteStrings) -> Node { .section( .class("testimonials-wrap"), diff --git a/Sources/PDFArchiverWebsite/Theme/PDFArchiverTheme.swift b/Sources/PDFArchiverWebsite/Theme/PDFArchiverTheme.swift index cb2da63c..93a01e6b 100644 --- a/Sources/PDFArchiverWebsite/Theme/PDFArchiverTheme.swift +++ b/Sources/PDFArchiverWebsite/Theme/PDFArchiverTheme.swift @@ -49,8 +49,10 @@ private extension PDFArchiverHTMLFactory { .lang(language.plotLanguage), .siteHead(for: location, on: context.site, in: language), .body( + .a(.class("skip-link"), .href("#main"), .text(language.strings.skipToContent)), .siteHeader(for: location.path, in: language, context: context), - .main(body), + // `tabindex` so following the skip link moves focus, not just the scroll position. + .main(.id("main"), .attribute(named: "tabindex", value: "-1"), body), .siteFooter(for: location.path, in: language, context: context) ) )