diff --git a/.okf/design/index.md b/.okf/design/index.md index 4eb7d6715..a2cecb630 100644 --- a/.okf/design/index.md +++ b/.okf/design/index.md @@ -12,3 +12,4 @@ GENERATE). Surface-to-source table and the review contract: * [Cover pipeline](cover-pipeline.md) - rebuilding course covers from the design spec * [Course typography](course-typography.md) - the course-scoped reading scale and the specificity trap * [Course-landing component rules](course-landing-components.md) - one loud element, 3 dark zones, and why the post-hero slot is not a router +* [Landing anatomy is settled; register is the variable](landing-anatomy.md) - the shared skeleton, and the register-pilot mechanics: copy stays identical, the accent dies on the inverted band, pinned gates stop guarding diff --git a/.okf/design/landing-anatomy.md b/.okf/design/landing-anatomy.md index dea0b475a..fb63e00f9 100644 --- a/.okf/design/landing-anatomy.md +++ b/.okf/design/landing-anatomy.md @@ -31,6 +31,61 @@ Claude Design artifact `3dc819cf`, the 20.05 spec, and the 2026 external sweep - Every number on the page passes [/content/claims-canon.md](/content/claims-canon.md) BEFORE styling work starts; tenure derives from `site.Params.foundingYear`. +## Register-pilot mechanics (learned building pilots A and B) + +**A pilot stub is a COPY of the previous pilot's, not a rewrite.** Pilot B's +stub was produced with `cp` and then three edits (title, description, +`register`/`font`); `diff` between the two stubs shows six lines and nothing +else. If the copy drifts, the demo compares two skins AND two texts and +measures neither. Give the pilots the same screenshot assertions for the same +reason - a copy edit that reaches only one pilot then fails a test. + +**The accent that passes on paper can fail catastrophically when the band +inverts.** The dark testimonial/closing band is part of the settled anatomy, so +every register meets it, and a register whose own artifact has no dark band has +to invent one. Measured for the Artifact Editorial palette: + +| pair | ratio | verdict | +|---|---|---| +| accent `#a91918` on paper `#fbf9f8` | 7.05:1 | AA | +| accent `#a91918` on ink `#1b1c1c` | **2.31:1** | FAIL - and this is what pilot A shipped | +| light label `#c9c2ba` on ink | 9.69:1 | AA | +| lightened accent `#e07a6e` on ink | 5.84:1 | clears 3:1 for the graphic rule | + +So on an ink band: text takes a light neutral, the accent survives only as a +LIGHTENED cut on non-text rules, and a filled accent button becomes an inverted +paper one - an accent fill against a `#232424` card sits at ~2.2:1, a button +with no visible edge (WCAG 1.4.11). **Measure in the browser, not in the head**: +walking every text node's computed colour against its resolved background +checked 117 elements per viewport and is the only evidence that "every pair +passes" is a fact rather than an intention. + +**A gate pinned to one pilot's path stops guarding the moment pilot N+1 +lands.** `next_rail_test.rb` had the testimonial-verbatim and tenure-derived +gates pinned to the pilot-A path, so pilot B could have smoothed the Wozniak +quote undetected. Both now iterate the same `next/pilots/**/index.html` glob, +with the "found nothing" assertion inside the shared helper so no caller can +iterate zero pages and report green. Widen these when adding a pilot, and prove +it by breaking the new pilot's copy and watching the failure name that file. + +**Self-hosted faces: one file per REGISTER when a register needs several.** +The stub's `font:` key carries one stylesheet and one preload, so a three-family +register ships `static/css/fonts-.css` rather than three +`fonts-.css` files and an edited shared template. Variable families +serve every declared weight from ONE latin binary, which is why two +`@font-face` rules can point at the same URL. + +Ship only the weights the artifact actually uses - then **walk computed +`font-weight` in the rendered page**, because a declaration grep cannot see +inherited weights and a face asked for a weight it does not ship gets +FAKE-BOLDED by the browser. Missed exactly that way on 2026-08-21: JetBrains +Mono shipped 400 only, but the brand mark inherited 700 from `.rr-brand` and +the nav button inherited 600 from `:where(.rr-nav-links a)`, so the same mono +label rendered heavier in the nav than in the hero. The walk that catches it +resolves `fontFamily` per node and histograms `fontWeight`: every mono node +must read 400. Pin `font-weight` on any component that sets `font-family` to a +single-weight face. + # Citations - Research + candidate shortlist: `docs/projects/2608-site-design-system/10-19-research/10.01-design-register-research-2026-08-reference.md` diff --git a/.okf/log.md b/.okf/log.md index 7def78e8a..a03353ec4 100644 --- a/.okf/log.md +++ b/.okf/log.md @@ -51,6 +51,48 @@ make it green: restructure same-day entries under one heading, and add `timestamp` to the 23 concepts missing it (anchored to each file's last commit time, which is verifiable - never invented). +## 2026-08-21 - register pilot B, and the two ways a second pilot breaks the first pilot's gates + +`/next/pilots/editorial/fractional-cto/` ships the "Artifact Editorial" register +over the SAME anatomy and the SAME words as pilot A - `diff` between the two +stubs is six lines (title, description, `register`, `font`). Added to +[/design/landing-anatomy.md](/design/landing-anatomy.md), which was also missing +from the design index until now. + +Two findings worth more than the pilot itself: + +**The accent dies when the band inverts.** The settled anatomy has a dark +testimonial/closing band, so every register meets it even when its own artifact +has none. Deep red `#a91918` is 7.05:1 on paper and **2.31:1** on ink `#1b1c1c` - +pilot A shipped exactly that miss. On ink, text takes a light neutral +(`#c9c2ba`, 9.69:1), the accent survives only as a lightened non-text rule +(`#e07a6e`, 5.84:1), and the filled CTA inverts to paper, because an accent fill +on the `#232424` card is ~2.2:1 - a button with no visible edge (1.4.11). +Verified by walking every text node's computed colour against its resolved +background in the browser: 117 elements at 1440 and at 390, zero failures. +Palette arithmetic in your head is not this evidence. + +**Gates pinned to one pilot's path quietly stop guarding.** The +testimonial-verbatim and tenure-derived gates in `test/unit/next_rail_test.rb` +named the pilot-A path, so pilot B could have smoothed the Wozniak quote +undetected. Both now iterate `next/pilots/**/index.html` through one helper that +owns the "found nothing" assertion. Proved by smoothing pilot B's quote and +watching the failure name `next/pilots/editorial/fractional-cto/index.html`, +then reverting. + +**A declaration grep cannot see an inherited weight, and the browser fake-bolds +the difference.** A multi-family register ships ONE +`static/css/fonts-.css` (the stub's `font:` key is one stylesheet + +one preload) carrying only the weights the artifact uses - but picking those +weights by grepping `font-weight` declarations shipped a defect: JetBrains Mono +ships 400 only, while the brand mark inherited 700 from `.rr-brand` and the nav +button inherited 600 from `:where(.rr-nav-links a)`, so Chrome synthesized both +and the same mono label rendered heavier in the nav than in the hero. Caught by +the reviewer, not by any gate. The check is a computed-weight walk in the +rendered page - resolve `fontFamily` per node, histogram `fontWeight`: 55 mono +nodes, 53 at 400 and 2 synthesized before the fix, 55 at 400 after. Pin +`font-weight` on any component that sets `font-family` to a single-weight face. + ## 2026-08-21 - the visual gate was blind by 50x, and green runs never refresh a baseline `DEFAULT_SCREENSHOT_CONFIG` tolerance 0.02 -> **0.0001** diff --git a/bin/qtest b/bin/qtest index e856d5d92..844ef73fb 100755 --- a/bin/qtest +++ b/bin/qtest @@ -49,6 +49,7 @@ PAGE_TESTS = { "next-pilot" => "next_pilot", # Design-register pilots on the same rail: pages/next-.css "next-rescue-room" => "next_rescue_room", + "next-editorial" => "next_editorial", "simple-page" => "privacy_policy", # critical/privacy-policy-critical.css maps by basename; alias it to the # simple-page tests so a touch there doesn't abort as an unknown key diff --git a/content/next/pilots/editorial/fractional-cto.md b/content/next/pilots/editorial/fractional-cto.md new file mode 100644 index 000000000..d319dfbca --- /dev/null +++ b/content/next/pilots/editorial/fractional-cto.md @@ -0,0 +1,170 @@ +--- +title: Fractional CTO (Editorial pilot) +description: Design-register pilot B - "Artifact Editorial" skin over the settled fractional-CTO landing anatomy. Same copy as pilot A; only the register differs. +layout: landing +type: next +# Drives both the CSS file and the bundle name in layouts/next/landing.html, +# so pilots B and C reuse this template by changing one line. +register: editorial +# Self-hosted display faces for this register (themes/beaver/static/). Another +# register omits this key and ships no webfont at all. +font: + css: /css/fonts-editorial.css + preload: /fonts/source-serif-4-latin.woff2 + +nav: + links: + - name: Services + url: /services/ + - name: Use Cases + url: /use-cases/ + - name: Blog + url: /blog/ + - name: Course + url: /course/tech-for-non-technical-founders-2026/ + - name: Clients + url: /clients/ + cta: + name: Book a free consultation + url: /free-consultation/ + +hero: + eyebrow: Fractional CTO + headline: A technical partner in days, not months of hiring + subhead: Your dev shop isn't working. Your investors want a technical update. You need someone who can audit the code, fix the team, and give you a straight answer. + primary: + name: Book a free consultation + url: /free-consultation/ + secondary: + name: Book a 30-min call + url: /contact-us/ + derisk: You keep the written assessment whether or not we work together. + # Markdown, rendered inline - the bold carries the emphasis the blueprint + # gave . Clutch rating is linked to the profile (claims-canon). + stats: + - Starts in **days** + - No equity, no lock-in + - "[**4.8**/5 on Clutch](https://clutch.co/profile/jetthoughts)" + +monthOne: + headline: What a fractional CTO does in month one + items: + - Reads the codebase and tells you what state it's actually in + - Sits in on your standups and tells you which ones are theatre + - Writes the technical section of your investor update + - Decides what gets rebuilt and what you can live with + - Sends you the Friday report from week one + +comparison: + eyebrow: The maths + headline: What you're comparing it against + fractionalLabel: Fractional CTO + fulltimeLabel: Full-time CTO + rows: + - label: Cost + fractional: A fraction of a full-time hire + fulltime: Full salary, benefits, and equity + - label: Equity + fractional: None + fulltime: Typically required + - label: Time to start + fractional: Days + fulltime: Months of hiring + - label: If it isn't working + fractional: You stop. No severance, no re-hire. + fulltime: Severance and a re-hire + - label: Best when + fractional: Pre-Series A, or stabilising after a bad build + fulltime: Post-Series A, with a team to own + +steps: + eyebrow: How it starts + headline: Four weeks from first email to a plan you can act on + intro: You don't sign a retainer to find out what's wrong. The assessment comes first. + list: + - title: Code review first + body: "You send access. You get a written assessment: what's solid, what's fragile, what's going to cost you." + - title: A 30-minute call + body: We walk you through the assessment in plain English. Most founders take the document to their board whether or not they hire us. + - title: Week one on the ground + body: Your fractional CTO joins standups, reads the backlog, and sends the first Friday report. + - title: Month one plan + body: What gets rebuilt, what gets left alone, what it costs, in what order. You own the code and the plan. + +clients: + eyebrow: Clients + case studies + headline: Clients stay for years, not quarters. + stats: + # `derived: tenure` is what makes the template compute the value from + # site.Params.foundingYear. Keyed on its own field, not on the label - + # a label-keyed branch falls back silently when someone rewords it. + - derived: tenure + label: Years of industry experience + - value: "5" + label: Years of average client relationship + - value: 8+ + label: Years of average developer experience + cases: + - name: Agent Inbox + body: Hired us to build their real-estate communication platform, get it market-ready, and grow the team around it. + tech: [Rails, React, Node.js] + url: /clients/agent-inbox/ + - name: Open Apply + body: Hired us to debug their school-admissions product, optimize the user experience, and streamline internal project management. + tech: [Rails, Hotwire, Tailwind] + url: /clients/open-apply/ + - name: Mobile Coach + body: Hired us to support and extend the products around their enterprise chatbot platform, and to scale their R&D and engineering teams. + tech: [Rails, AWS, PostgreSQL] + url: /clients/mobile-coach/ + +testimonial: + eyebrow: From a client + # VERBATIM tail of the canon description in data/testimonials.yaml (Bruno + # Wozniak). The design blueprint had smoothed it to "They were detailed and + # precise, helping us find problems..." - a real person's words are not copy + # to tighten. Asserted by test/unit/next_rail_test.rb against the yaml. + quote: Their team was also detailed and precise, helping us to find problems before they appear. We knew exactly what was happening and where we were going the whole time. + attribution: Bruno Wozniak · Director of Engineering, PubNative + sourceName: verified Clutch review + sourceUrl: https://clutch.co/profile/jetthoughts + proof: + - "[**4.8**/5.0 on Clutch](https://clutch.co/profile/jetthoughts)" + - "**5-year** average client relationship" + +closing: + headline: Still deciding? + body: The consultation is free. The assessment is yours either way. + cta: + name: Book a free consultation + url: /free-consultation/ + +footer: + columns: + - heading: Services + links: + - name: Fractional CTO + url: /services/fractional-cto/ + - name: Fractional Product Management + url: /services/fractional-product-management/ + - name: App/Web Development + url: /services/app-web-development/ + - name: Talent Recruiting & Training + url: /services/talent-recruiting-training/ + - heading: Proof + links: + - name: Clients + url: /clients/ + - name: Use Cases + url: /use-cases/ + - name: Friday report + url: /friday-report/ + - heading: Learn + links: + - name: Blog + url: /blog/ + - name: Course + url: /course/tech-for-non-technical-founders-2026/ + - name: About us + url: /about-us/ +--- diff --git a/docs/projects/2509-css-migration/css-bundle-ownership-map.md b/docs/projects/2509-css-migration/css-bundle-ownership-map.md index 28eadc2a6..1547a4774 100644 --- a/docs/projects/2509-css-migration/css-bundle-ownership-map.md +++ b/docs/projects/2509-css-migration/css-bundle-ownership-map.md @@ -39,6 +39,7 @@ zero FL-Builder export files remain in any slice. | pagination | `list.html` (second bundle) | — (pagination.css only) | 0.2K → 0.2K | no-FL | | next-pilot | `layouts/next/single.html` (repo root, NOT in theme) | pages/next-pilot.css | new 2026-08-21 | v2 clean-slate rail (ADR-0006): css-variables + navigation.css + footer.css only — no style.css/586/base-4/legacy-theme-skin. Serves `/next/*` staging pages | | next-rescue-room | `layouts/next/landing.html` (repo root, NOT in theme) | pages/next-rescue-room.css | new 2026-08-21 | Design-register pilot A (10.01). The register skin is the bundle's ONLY file — not even css-variables — because the pilot renders its own nav and footer and must be judged on its own surface. Serves `/next/pilots/rescue-room/*`. A new register adds a row here, a skin file, and one line to the `$skins` dict | +| next-editorial | `layouts/next/landing.html` (repo root, NOT in theme) | pages/next-editorial.css | new 2026-08-21 | Design-register pilot B, "Artifact Editorial" (10.01). Same rule as pilot A — the skin is the bundle's only file. Serves `/next/pilots/editorial/*`. Its three webfonts ship as ONE `static/css/fonts-editorial.css`, keyed by register rather than by family, so the shared `font:` frontmatter key stays one stylesheet + one preload | ¹ careers was a byte-identical verbatim move (3086-layout2 had zero dead nodes); the small growth vs 07-12 is upstream content drift (course v2 merge diff --git a/layouts/next/landing.html b/layouts/next/landing.html index 55d734c34..e65935781 100644 --- a/layouts/next/landing.html +++ b/layouts/next/landing.html @@ -20,6 +20,7 @@ register adds one line here. */}} {{- $skins := dict "rescue-room" (resources.Get "css/pages/next-rescue-room.css") + "editorial" (resources.Get "css/pages/next-editorial.css") -}} {{- $resources := slice (index $skins $register) -}} {{ partial "assets/css-processor.html" (dict "resources" $resources "bundleName" (printf "next-%s" $register)) }} diff --git a/test/fixtures/screenshots/macos/desktop/next/editorial.png b/test/fixtures/screenshots/macos/desktop/next/editorial.png new file mode 100644 index 000000000..0568cb71e Binary files /dev/null and b/test/fixtures/screenshots/macos/desktop/next/editorial.png differ diff --git a/test/fixtures/screenshots/macos/mobile/next/editorial.png b/test/fixtures/screenshots/macos/mobile/next/editorial.png new file mode 100644 index 000000000..0391909c6 Binary files /dev/null and b/test/fixtures/screenshots/macos/mobile/next/editorial.png differ diff --git a/test/system/desktop_site_test.rb b/test/system/desktop_site_test.rb index ddd380942..a52841112 100644 --- a/test/system/desktop_site_test.rb +++ b/test/system/desktop_site_test.rb @@ -342,6 +342,18 @@ def test_next_rescue_room assert_stable_screenshot "next/rescue-room" end + # Design-register pilot B, "Artifact Editorial". Deliberately the SAME + # assertions as pilot A: the demo compares two skins over one set of words, + # so a copy edit that reached only one pilot has to fail here. + def test_next_editorial + visit "/next/pilots/editorial/fractional-cto/" + + assert_text "A technical partner in days, not months of hiring" + assert_text "#{Time.now.year - 2008}+" + + assert_stable_screenshot "next/editorial" + end + def test_free_consultation visit "/" diff --git a/test/system/mobile_site_test.rb b/test/system/mobile_site_test.rb index fc5792586..96876995a 100644 --- a/test/system/mobile_site_test.rb +++ b/test/system/mobile_site_test.rb @@ -215,6 +215,17 @@ def test_next_rescue_room assert_stable_screenshot "next/rescue-room" end + # Pilot B on a phone - same assertions as pilot A for the same reason (see + # the desktop test): one anatomy, one set of words, two skins. + def test_next_editorial + visit "/next/pilots/editorial/fractional-cto/" + + assert_text "A technical partner in days, not months of hiring" + assert_text "What you're comparing it against" + + assert_stable_screenshot "next/editorial" + end + def test_free_consultation visit "/" # Add more specific scoping for Talk to an Expert button diff --git a/test/unit/next_rail_test.rb b/test/unit/next_rail_test.rb index 449c52885..973a337f7 100644 --- a/test/unit/next_rail_test.rb +++ b/test/unit/next_rail_test.rb @@ -13,10 +13,13 @@ class NextRailTest < BasePageTestCase PILOT = "next/services/fractional-cto/index.html" # Design-register pilots (10.01): one landing page per candidate register, - # all on the rail. Globbed, not listed - a pilot added without its noindex - # is exactly the failure this rail exists to prevent, and a hand-typed list - # would not see it. - REGISTER_PILOT = "next/pilots/rescue-room/fractional-cto/index.html" + # all on the rail. Every REGISTER-PILOT assertion below runs over the glob, + # not over a named path (PILOT above stays - its three tests target the v2 + # services page, which is not a pilot). A pilot added without its noindex, + # or with a testimonial smoothed for its own skin, is exactly the failure + # these gates exist to catch, and a hand-typed list would not see it. + # Pilot B was added on 2026-08-21 and two of them had to be widened to + # reach it. def test_next_pages_are_marked_noindex robots = parse_html_file(PILOT).css('meta[name="robots"]').first @@ -51,13 +54,8 @@ def test_tenure_stat_is_derived_not_frozen # Every register pilot, not just the one this test knows the name of. def test_every_register_pilot_is_noindexed_and_unlisted - pilots = Dir.glob(File.join(root_path, "next/pilots/**/index.html")) - - assert pilots.any?, "no built pages under next/pilots/ - this gate would pass by finding nothing" - sitemap = File.read(File.join(root_path, "sitemap.xml")) - pilots.each do |path| - relative = path.sub("#{root_path}/", "") + register_pilots.each do |relative| robots = parse_html_file(relative).css('meta[name="robots"]').first assert robots, "#{relative} must carry a robots meta tag" @@ -72,11 +70,14 @@ def test_every_register_pilot_is_noindexed_and_unlisted # derived value equals the "18" a careless author would type, so this cannot # tell them apart until 2027-01-01 - from then on it bites forever. def test_register_pilot_tenure_stat_is_derived_not_frozen - values = parse_html_file(REGISTER_PILOT).css(".rr-stat-value").map(&:text) + register_pilots.each do |relative| + values = parse_html_file(relative).css(".rr-stat-value").map(&:text) - assert_includes values, "#{Time.now.year - 2008}+", - "the pilot's tenure stat must derive from foundingYear, not a frozen number - " \ - "the derived: tenure branch in layouts/next/landing.html falls back to .value silently" + refute_empty values, "#{relative} renders no stat values - this gate would pass by finding nothing" + assert_includes values, "#{Time.now.year - 2008}+", + "#{relative}: the tenure stat must derive from foundingYear, not a frozen number - " \ + "the derived: tenure branch in layouts/next/landing.html falls back to .value silently" + end end # A testimonial is a person's words, not copy to tighten. The design @@ -89,14 +90,16 @@ def test_testimonial_quote_is_canon_verbatim .find { |t| t["name"] == "Bruno Wozniak" } refute_nil canon, "the canon testimonial must exist in data/testimonials.yaml" - rendered = parse_html_file(REGISTER_PILOT).css("blockquote").text.strip.delete("“”") + register_pilots.each do |relative| + rendered = parse_html_file(relative).css("blockquote").text.strip.delete("“”") - # Every string contains "", so a blockquote that vanished would sail - # through the substring assert below - the same false-green shape this - # file already carries scar tissue for. - refute_empty rendered, "the pilot must render a testimonial blockquote" - assert_includes canon["description"], rendered, - "the rendered quote is not a verbatim slice of the canon testimonial" + # Every string contains "", so a blockquote that vanished would sail + # through the substring assert below - the same false-green shape this + # file already carries scar tissue for. + refute_empty rendered, "#{relative} must render a testimonial blockquote" + assert_includes canon["description"], rendered, + "#{relative}: the rendered quote is not a verbatim slice of the canon testimonial" + end end def test_next_pages_canonicalise_to_their_source_page @@ -106,4 +109,17 @@ def test_next_pages_canonicalise_to_their_source_page assert_match %r{/services/fractional-cto/\z}, canonicals.first["href"] refute_match %r{/next/}, canonicals.first["href"] # a fallback to .Permalink must FAIL here end + + private + + # Built paths of every register pilot, relative to the destination root. + # The empty check lives HERE so no caller can iterate zero pages and report + # green - iterating nothing is the false-green this whole file guards against. + def register_pilots + pilots = Dir.glob(File.join(root_path, "next/pilots/**/index.html")) + .map { |path| path.sub("#{root_path}/", "") } + + assert pilots.any?, "no built pages under next/pilots/ - this gate would pass by finding nothing" + pilots + end end diff --git a/themes/beaver/assets/css/pages/next-editorial.css b/themes/beaver/assets/css/pages/next-editorial.css new file mode 100644 index 000000000..4b6ca6165 --- /dev/null +++ b/themes/beaver/assets/css/pages/next-editorial.css @@ -0,0 +1,909 @@ +/* next-editorial — register pilot B, "Artifact Editorial". + * + * Loaded ONLY by layouts/next/landing.html for pages with + * `register: editorial`, and it is the page's ONLY stylesheet: no style.css, + * no navigation.css, no footer.css, not even foundations/css-variables.css. + * A register pilot has to be judged on its own surface, so nothing from the + * legacy cascade may leak in. + * + * Translated from Paul's in-progress Claude Design artifact ("Fractional CTO - + * Artifact Editorial", canvas mode, DESKTOP_1440 + MOBILE_390 screens), whose + * values were inline styles: paper #fbf9f8 with #f3f0ed tint bands, white + * cards on 1px #d8d3cd, sections divided by 1px #e5e1dd hairlines, ink + * #1b1c1c, deep red #a91918 (NOT the standard ruby #cc342d - the artifact + * uses the darker cut, and #cc342d is its hover only), and ZERO border-radius + * anywhere on the page. Source Serif 4 for headlines and the wordmark, Hanken + * Grotesk for body and nav, JetBrains Mono for every eyebrow and label - all + * three self-hosted from /css/fonts-editorial.css. + * + * The class names are the `rr-` set layouts/next/landing.html emits for every + * register (they were named for pilot A). The prefix is shared skeleton + * vocabulary here, not a rescue-room reference. + * + * CONTRAST: every text/background pair on this page computes >= 4.5:1, + * including the ones on the ink band, where the accent CANNOT be used for text + * (#a91918 on #1b1c1c is 2.31:1 - the exact miss pilot A shipped). The band's + * eyebrow and muted tones are the light warm greys below, and the accent rule + * lightens to --ed-accent-ink so even the graphic device clears 3:1. + */ + +:root { + --ed-paper: #fbf9f8; + --ed-tint: #f3f0ed; + --ed-card: #fff; + --ed-line: #d8d3cd; + --ed-rule: #e5e1dd; + --ed-ink: #1b1c1c; + --ed-body: #45413d; + --ed-muted: #6b6560; + --ed-accent: #a91918; + --ed-accent-hover: #cc342d; + --ed-band-ink: #1b1c1c; + --ed-card-ink: #232424; + --ed-ink-label: #c9c2ba; + --ed-ink-muted: #b9b2aa; + --ed-accent-ink: #e07a6e; + --ed-serif: "Source Serif 4", Georgia, "Times New Roman", serif; + --ed-sans: "Hanken Grotesk", system-ui, -apple-system, "Segoe UI", sans-serif; + --ed-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace; + --ed-shell: 1312px; + --ed-gutter: 64px; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--ed-paper); + color: var(--ed-body); + font-family: var(--ed-sans); + font-size: 16px; + line-height: 1.65; + -webkit-font-smoothing: antialiased; +} + +/* Kept from navigation.css rather than pulled in with it: baseof's skip link + is invisible only because of .sr-only, and shipping the rail without it is + how a visible "Skip to main content" reached production in 2026-07. */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +.skip-link:focus { + position: static; + width: auto; + height: auto; + clip: auto; + clip-path: none; + display: inline-block; + padding: 12px 20px; + background: var(--ed-ink); + color: var(--ed-paper); +} + +/* The artifact's own link rule: ink with an underline, accent on hover. + :where() keeps it at zero specificity so the .rr-btn anchors and the label + links below win on their own single class, not on source order (PR-2). */ +:where(a) { + color: var(--ed-ink); + text-decoration-thickness: 1px; + text-underline-offset: 3px; +} + +:where(a):hover { + color: var(--ed-accent); +} + +.rr-container { + max-width: var(--ed-shell); + margin: 0 auto; + padding: 0 var(--ed-gutter); +} + +/* ---- Type scale ---------------------------------------------------- */ + +.rr-h1, +.rr-h2, +.rr-card-title, +.rr-step-title { + font-family: var(--ed-serif); + color: var(--ed-ink); + margin: 0; +} + +.rr-h1 { + font-size: clamp(2.0625rem, 4.2vw, 3.5rem); + font-weight: 700; + line-height: 1.14; + letter-spacing: -0.01em; + margin-bottom: 28px; + text-wrap: balance; +} + +.rr-h2 { + font-size: clamp(1.5rem, 2.6vw, 2rem); + font-weight: 600; + line-height: 1.2; + text-wrap: balance; +} + +.rr-h2-narrow { + max-width: 26ch; +} + +.rr-card-title { + font-size: clamp(1.125rem, 1.6vw, 1.375rem); + font-weight: 600; + line-height: 1.35; +} + +/* Every label on this page is mono, uppercase and tracked - the artifact + never sets a weight on a mono run, so this stays 400. */ +.rr-eyebrow { + font-family: var(--ed-mono); + font-size: 0.8125rem; + font-weight: 400; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ed-accent); + margin: 0 0 24px; +} + +/* The register's signature: a 32x2 accent rule opening each band. The hero is + the one exception - directly under the nav it reads as a stray mark, and + the artifact starts that screen with the eyebrow alone. */ +.rr-eyebrow::before { + content: ""; + display: block; + width: 32px; + height: 2px; + background: var(--ed-accent); + margin-bottom: 20px; +} + +.rr-hero .rr-eyebrow::before { + display: none; +} + +.rr-lede { + font-size: 1.125rem; + line-height: 1.65; + color: var(--ed-body); + margin: 0; + max-width: 62ch; + text-wrap: pretty; +} + +/* ---- Buttons ------------------------------------------------------- */ + +.rr-btn { + display: inline-flex; + align-items: center; + justify-content: center; + font-family: var(--ed-mono); + font-size: 0.8125rem; + /* Pinned, not inherited: JetBrains Mono ships 400 only, and the nav cut sits + inside :where(.rr-nav-links a), which sets 600 - Chrome then FAKE-BOLDS it + and the same label renders heavier in the nav than in the hero. */ + font-weight: 400; + letter-spacing: 0.08em; + text-transform: uppercase; + padding: 17px 32px; + /* Transparent border so the filled and outlined cuts are the same height, + and no radius anywhere - the register is drawn entirely with square + rules. */ + border: 1px solid transparent; + border-radius: 0; + text-decoration: none; +} + +.rr-btn-primary, +.rr-btn-primary:hover { + background: var(--ed-accent); + color: var(--ed-paper); +} + +.rr-btn-primary:hover { + background: var(--ed-accent-hover); +} + +.rr-btn-ghost, +.rr-btn-ghost:hover { + background: transparent; + color: var(--ed-ink); + border: 1px solid var(--ed-ink); +} + +.rr-btn-ghost:hover { + background: var(--ed-ink); + color: var(--ed-paper); +} + +/* ---- Nav ----------------------------------------------------------- */ + +.rr-nav { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + max-width: var(--ed-shell); + margin: 0 auto; + padding: 28px var(--ed-gutter); + border-bottom: 1px solid var(--ed-rule); +} + +.rr-brand { + display: flex; + align-items: center; + gap: 12px; + font-family: var(--ed-serif); + font-weight: 700; + font-size: 1.375rem; + letter-spacing: -0.01em; + color: var(--ed-ink); + text-decoration: none; +} + +/* The artifact's own screen-label chip: a solid ink block with paper mono + type, square. Same device, promoted to the wordmark's colophon. */ +.rr-brand-mark { + background: var(--ed-ink); + color: var(--ed-paper); + font-family: var(--ed-mono); + /* Same trap as .rr-btn: the 700 on .rr-brand would be synthesized here. */ + font-weight: 400; + font-size: 0.6875rem; + letter-spacing: 0.06em; + padding: 4px 7px; +} + +.rr-nav-links { + display: flex; + align-items: center; + gap: 36px; + flex-wrap: wrap; + justify-content: flex-end; +} + +/* :where() flattens this to zero specificity so the .rr-btn-ghost CTA sitting + in the same row keeps its own colour and border. A bare `.rr-nav-links a` + scores 0,1,1 and silently outranks every single-class button component + inside it - the PR-2 defect. */ +:where(.rr-nav-links a) { + color: var(--ed-ink); + text-decoration: none; + font-size: 0.8125rem; + font-weight: 600; + letter-spacing: 0.09em; + text-transform: uppercase; +} + +:where(.rr-nav-links a:hover) { + color: var(--ed-accent); +} + +.rr-nav-links .rr-btn { + font-size: 0.75rem; + padding: 9px 16px; +} + +/* ---- Hero ---------------------------------------------------------- */ + +.rr-hero { + max-width: var(--ed-shell); + margin: 0 auto; + padding: 104px var(--ed-gutter) 96px; + display: flex; + flex-wrap: wrap; + gap: 72px; + align-items: flex-start; + border-bottom: 1px solid var(--ed-rule); +} + +.rr-hero-copy { + flex: 1 1 520px; + min-width: 0; +} + +.rr-hero .rr-lede { + margin-bottom: 40px; +} + +.rr-cta-row { + display: flex; + gap: 12px; + flex-wrap: wrap; + align-items: center; +} + +.rr-derisk { + font-size: 0.9375rem; + color: var(--ed-muted); + margin: 20px 0 28px; +} + +.rr-strip { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 32px; + font-family: var(--ed-mono); + font-size: 0.75rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--ed-muted); + margin: 0; + padding: 0; + list-style: none; +} + +.rr-strip strong { + color: var(--ed-ink); + font-weight: 400; +} + +/* The Clutch rating is a link (claims-canon wants the profile reachable) but + it is one item in a quiet proof strip, not a call to action - a second + colour on half the phrase reads as broken styling. */ +.rr-strip :where(a) { + color: inherit; +} + +.rr-strip :where(a):hover { + color: var(--ed-ink); +} + +/* ---- Cards --------------------------------------------------------- */ + +.rr-card { + background: var(--ed-card); + border: 1px solid var(--ed-line); + padding: 40px; +} + +.rr-month-one { + flex: 1 1 380px; + min-width: 0; +} + +.rr-bullets { + margin: 24px 0 0; + padding: 0; + list-style: none; + display: grid; + gap: 14px; + font-size: 1rem; + line-height: 1.55; + color: var(--ed-body); +} + +/* The artifact marks list items with a solid ink square, never a disc. */ +.rr-bullets li { + position: relative; + padding-left: 22px; +} + +.rr-bullets li::before { + content: ""; + position: absolute; + left: 0; + top: 0.55em; + width: 8px; + height: 8px; + background: var(--ed-ink); +} + +/* ---- Bands --------------------------------------------------------- */ + +.rr-band { + padding: 96px 0; + border-bottom: 1px solid var(--ed-rule); +} + +/* Both tinted bands take the same wash: the artifact alternates paper and + #f3f0ed and lets the hairlines do the dividing, rather than giving each + section its own tone. */ +.rr-band-paper, +.rr-band-sand { + background: var(--ed-tint); +} + +.rr-split { + display: flex; + flex-wrap: wrap; + gap: 64px; + align-items: flex-start; +} + +.rr-split-center { + align-items: flex-start; +} + +.rr-split-copy { + flex: 0 1 340px; + min-width: 0; +} + +.rr-split-copy .rr-h2 { + margin-bottom: 16px; + max-width: 20ch; +} + +.rr-split-copy .rr-lede { + font-size: 1rem; + max-width: 40ch; +} + +/* ---- Comparison table ---------------------------------------------- */ + +.rr-band-paper .rr-h2 { + margin-bottom: 40px; +} + +.rr-table-wrap { + background: var(--ed-card); + border: 1px solid var(--ed-line); + overflow-x: auto; +} + +.rr-table { + width: 100%; + border-collapse: collapse; + font-size: 1rem; + min-width: 640px; +} + +.rr-table th, +.rr-table td { + text-align: left; + padding: 20px 24px; + border-bottom: 1px solid var(--ed-rule); +} + +.rr-table tbody tr:last-child th, +.rr-table tbody tr:last-child td { + border-bottom: 0; +} + +/* The artifact rules the report header off in accent, not in hairline grey - + it is the one place a red line separates rather than decorates. */ +.rr-table thead th { + font-family: var(--ed-mono); + font-size: 0.75rem; + letter-spacing: 0.08em; + text-transform: uppercase; + font-weight: 400; + border-bottom: 1px solid var(--ed-accent); +} + +/* By position, not by class: Hugo 0.165 writeStats records no class attrs on + , so a .rr-th-* selector is absent from hugo_stats.json and PurgeCSS + strips it from the production bundle. The visual gate cannot catch that - + baseline and candidate are both built purged. */ +.rr-table thead th:nth-child(2) { + color: var(--ed-accent); +} + +.rr-table thead th:nth-child(3) { + color: var(--ed-muted); +} + +.rr-table tbody th { + font-family: var(--ed-sans); + color: var(--ed-ink); + font-weight: 600; +} + +.rr-table tbody td { + color: var(--ed-body); + font-weight: 400; +} + +.rr-td-muted { + color: var(--ed-muted); +} + +/* ---- Timeline ------------------------------------------------------ */ + +.rr-steps { + flex: 1 1 520px; + min-width: 0; + list-style: none; + padding: 0; + margin: 0; +} + +/* Hairline-separated rows, the way the artifact sets its FAQ - no connector + line, no dots. The rule between items IS the timeline. */ +.rr-steps li { + position: relative; + padding: 28px 0 28px 76px; + border-top: 1px solid var(--ed-rule); +} + +.rr-steps li:first-child { + border-top: 0; + padding-top: 0; +} + +/* A boxed mono numeral, square - the artifact's STATUS chip applied to the + step marker. */ +.rr-step-num { + position: absolute; + left: 0; + top: 30px; + min-width: 36px; + padding: 4px 0; + border: 1px solid var(--ed-ink); + color: var(--ed-accent); + font-family: var(--ed-mono); + font-size: 0.75rem; + letter-spacing: 0.06em; + text-align: center; +} + +.rr-steps li:first-child .rr-step-num { + top: 2px; +} + +.rr-step-title { + font-size: 1.25rem; + font-weight: 600; + line-height: 1.3; + margin: 0 0 10px; +} + +.rr-steps p { + margin: 0; + font-size: 1rem; + color: var(--ed-body); +} + +/* ---- Clients ------------------------------------------------------- */ + +.rr-clients-head { + display: flex; + flex-wrap: wrap; + gap: 32px 64px; + align-items: flex-end; + justify-content: space-between; + margin-bottom: 48px; +} + +.rr-stats { + display: flex; + gap: 48px; + flex-wrap: wrap; + list-style: none; + margin: 0; + padding: 0; +} + +.rr-stat-value { + display: block; + font-family: var(--ed-serif); + font-size: 2.5rem; + font-weight: 700; + line-height: 1.1; + color: var(--ed-ink); +} + +.rr-stat-label { + display: block; + font-family: var(--ed-mono); + font-size: 0.6875rem; + letter-spacing: 0.06em; + text-transform: uppercase; + line-height: 1.5; + color: var(--ed-muted); + max-width: 20ch; + margin-top: 8px; +} + +.rr-cases { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 24px; +} + +.rr-case { + background: var(--ed-card); + border: 1px solid var(--ed-line); + padding: 32px; +} + +/* The 24x2 accent rule the artifact opens every card with. */ +.rr-case::before { + content: ""; + display: block; + width: 24px; + height: 2px; + background: var(--ed-accent); + margin-bottom: 20px; +} + +.rr-case p { + margin: 12px 0 20px; + font-size: 0.9375rem; + line-height: 1.65; + color: var(--ed-body); +} + +/* Square hairline chips, not pills - the register has no rounded corner + anywhere, and the artifact tags everything with a boxed mono label. */ +.rr-pills { + display: flex; + flex-wrap: wrap; + gap: 8px; + list-style: none; + margin: 0 0 24px; + padding: 0; +} + +.rr-pills li { + border: 1px solid var(--ed-line); + padding: 3px 9px; + font-family: var(--ed-mono); + font-size: 0.6875rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--ed-muted); +} + +/* The artifact's link CTA: mono, tracked, underlined by a real 1px accent + border rather than text-decoration. */ +.rr-case-link { + display: inline-block; + font-family: var(--ed-mono); + font-size: 0.75rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ed-accent); + text-decoration: none; + border-bottom: 1px solid var(--ed-accent); + padding-bottom: 5px; +} + +.rr-case-link:hover { + color: var(--ed-ink); + border-bottom-color: var(--ed-ink); +} + +/* ---- Testimonial + closing band ------------------------------------ */ + +/* The artifact has no dark band - its closing section stays on paper. The + anatomy under test does have one, and it is held constant across the + pilots, so the register is inverted here rather than dropped: ink ground, + paper type, and NO accent on text (2.31:1). */ +.rr-band-ink { + background: var(--ed-band-ink); + color: var(--ed-paper); + border-bottom: 0; +} + +.rr-quote-col { + flex: 1.3 1 460px; + min-width: 0; +} + +.rr-eyebrow-ink { + color: var(--ed-ink-label); +} + +.rr-eyebrow-ink::before { + background: var(--ed-accent-ink); +} + +.rr-quote { + margin: 0; + font-family: var(--ed-serif); + font-size: clamp(1.375rem, 2.4vw, 1.75rem); + font-weight: 600; + line-height: 1.35; + color: var(--ed-paper); + max-width: 32ch; + text-wrap: pretty; +} + +.rr-attrib { + margin: 28px 0 0; + font-family: var(--ed-mono); + font-size: 0.75rem; + letter-spacing: 0.06em; + color: var(--ed-ink-muted); +} + +.rr-attrib a { + color: var(--ed-ink-muted); +} + +.rr-attrib a:hover { + color: var(--ed-paper); +} + +.rr-strip-ink { + color: var(--ed-ink-muted); + margin-top: 32px; +} + +.rr-strip-ink strong { + color: var(--ed-paper); +} + +/* colour inherits from .rr-strip :where(a) above; only the hover differs */ +.rr-strip-ink a:hover { + color: var(--ed-paper); +} + +.rr-card-ink { + flex: 1 1 340px; + min-width: 0; + background: var(--ed-card-ink); + border-color: rgb(255 255 255 / 14%); +} + +.rr-card-ink .rr-card-title { + color: var(--ed-paper); + margin-bottom: 14px; +} + +.rr-card-body { + font-size: 1rem; + color: var(--ed-ink-muted); + margin: 0 0 28px; +} + +/* The accent fill would sit at 2.2:1 against the ink card - a button whose + own edge is invisible (1.4.11). Inverted, it is the loudest thing on the + band, which is what a closing CTA is for. */ +.rr-card-ink .rr-btn-primary, +.rr-card-ink .rr-btn-primary:hover { + background: var(--ed-paper); + color: var(--ed-ink); +} + +.rr-card-ink .rr-btn-primary:hover { + background: var(--ed-ink-label); +} + +/* ---- Footer -------------------------------------------------------- */ + +.rr-footer { + background: var(--ed-paper); + border-top: 1px solid var(--ed-rule); + padding: 64px 0 32px; +} + +.rr-footer-cols { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 32px; +} + +.rr-footer-head { + font-family: var(--ed-mono); + font-size: 0.75rem; + font-weight: 400; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ed-muted); + margin: 0 0 20px; +} + +.rr-footer ul { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 10px; +} + +.rr-footer a { + color: var(--ed-body); + font-size: 0.9375rem; + text-decoration: none; +} + +.rr-footer a:hover { + color: var(--ed-accent); +} + +.rr-footer-legal { + display: flex; + flex-wrap: wrap; + gap: 8px 24px; + align-items: center; + margin-top: 48px; + padding-top: 24px; + border-top: 1px solid var(--ed-rule); + font-family: var(--ed-mono); + font-size: 0.75rem; + letter-spacing: 0.06em; + color: var(--ed-muted); +} + +.rr-footer-legal a { + font-size: inherit; + color: var(--ed-muted); +} + +/* ---- Narrow viewports ---------------------------------------------- */ + +@media (width <= 640px) { + :root { + --ed-gutter: 24px; + } + + .rr-nav { + flex-wrap: wrap; + gap: 12px; + padding-top: 18px; + padding-bottom: 18px; + } + + .rr-nav-links { + gap: 16px 20px; + justify-content: flex-start; + } + + /* Its own row: wrapping inline with the text links leaves whichever link + lands beside it looking orphaned. */ + .rr-nav-links .rr-btn { + flex: 1 0 100%; + } + + .rr-hero { + padding-top: 56px; + padding-bottom: 48px; + gap: 40px; + } + + .rr-hero .rr-lede { + font-size: 1rem; + margin-bottom: 28px; + } + + .rr-band { + padding: 48px 0; + } + + .rr-split { + gap: 32px; + } + + .rr-card { + padding: 24px 20px; + } + + .rr-case { + padding: 22px; + } + + .rr-steps li { + padding: 22px 0 22px 60px; + } + + .rr-stats { + gap: 24px 32px; + } + + .rr-stat-value { + font-size: 2rem; + } + + .rr-btn { + flex: 1 1 auto; + } +} diff --git a/themes/beaver/static/css/fonts-editorial.css b/themes/beaver/static/css/fonts-editorial.css new file mode 100644 index 000000000..f3d158cae --- /dev/null +++ b/themes/beaver/static/css/fonts-editorial.css @@ -0,0 +1,61 @@ +/* Artifact Editorial register - the three faces Paul's design artifact uses + (pilot B, 2026-08-21). Self-hosted on the fonts-caveat.css / + fonts-poppins.css pattern: same Google woff2 binaries + unicode ranges, + served same-origin, so the screenshot runs stay hermetic. + + ONE file for three families, named after the REGISTER rather than a family + (the fonts-.css convention next door), because the stub's `font:` + key carries one stylesheet and one preload - three files would mean editing + the shared landing.html head block for one pilot's sake. + + All three are variable fonts, so each family is a SINGLE latin binary that + both declared weights instantiate from - which is why the 600 and 700 rules + below point at the same url. Weights are exactly the ones the artifact + uses: Source Serif 4 600/700 (headings, brand), Hanken Grotesk 400/600 + (body, nav), JetBrains Mono 400 (eyebrows and labels - never bolder). */ + +/* latin */ +@font-face { + font-family: 'Source Serif 4'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url(/fonts/source-serif-4-latin.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* latin */ +@font-face { + font-family: 'Source Serif 4'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url(/fonts/source-serif-4-latin.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* latin */ +@font-face { + font-family: 'Hanken Grotesk'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(/fonts/hanken-grotesk-latin.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* latin */ +@font-face { + font-family: 'Hanken Grotesk'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url(/fonts/hanken-grotesk-latin.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* latin */ +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(/fonts/jetbrains-mono-latin.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} diff --git a/themes/beaver/static/fonts/hanken-grotesk-latin.woff2 b/themes/beaver/static/fonts/hanken-grotesk-latin.woff2 new file mode 100644 index 000000000..e71775f99 Binary files /dev/null and b/themes/beaver/static/fonts/hanken-grotesk-latin.woff2 differ diff --git a/themes/beaver/static/fonts/jetbrains-mono-latin.woff2 b/themes/beaver/static/fonts/jetbrains-mono-latin.woff2 new file mode 100644 index 000000000..5b9b02f04 Binary files /dev/null and b/themes/beaver/static/fonts/jetbrains-mono-latin.woff2 differ diff --git a/themes/beaver/static/fonts/source-serif-4-latin.woff2 b/themes/beaver/static/fonts/source-serif-4-latin.woff2 new file mode 100644 index 000000000..56a26e1b3 Binary files /dev/null and b/themes/beaver/static/fonts/source-serif-4-latin.woff2 differ