new logo
All checks were successful
Deploy / build-deploy (push) Successful in 5m32s

This commit is contained in:
Jorijn van der Graaf 2026-08-05 23:52:40 +02:00
commit b49042a626
8 changed files with 143 additions and 28 deletions

View file

@ -5,6 +5,14 @@
prompts are left-aligned. Replaces the bare ">_" tile, which was honest
about the terminal but silent about the name.
Head geometry is the "flare" revision: the first cut had a flat crown
between wide ear tufts and a square jaw, which read horned-owl. Now the
ears stand tall over a domed skull, the cheeks taper to a rounded chin
(owl faces stay wide all the way down), and the whiskers spring OUTWARD
from the cheek line into the corners the taper frees up — outside the
face so they never crowd the prompt. Whiskers are drawn at 3.5 against
the face's 5 so they read as hair, not bone.
Drawn as paths, not <text>. A text favicon depends on a font being available
at 16px in whatever context the browser rasterises it — including contexts
with no font stack at all — and falls back to a blank tile when it is not.
@ -15,17 +23,23 @@
the tab icon drift apart.
-->
<rect width="64" height="64" rx="12" fill="#0b0d10"/>
<!-- head: flat crown between two ears, rounded jaw -->
<path d="M9 22 L13 7 L25 15 L39 15 L51 7 L55 22 L55 44 Q55 56 43 56 L21 56 Q9 56 9 44 Z"
<!-- head: tall ears, domed crown, cheeks tapering to a rounded chin -->
<path d="M9 26 L13 4 L23 15 Q32 11 41 15 L51 4 L55 26 L54 38 Q52 56 32 56 Q12 56 10 38 Z"
fill="none" stroke="#4cc2ff" stroke-width="5" stroke-linejoin="round"/>
<!-- eyes: the ^ ^ of a happy ASCII cat -->
<path d="M17 34 L23 27 L29 34 M35 34 L41 27 L47 34"
<!-- whiskers: outside the silhouette, anchored to the cheek line -->
<path d="M10.5 40 L4.5 42 M11.5 46 L6 49 M53.5 40 L59.5 42 M52.5 46 L58 49"
fill="none" stroke="#4cc2ff" stroke-width="3.5" stroke-linecap="round"/>
<!-- eyes: the ^ ^ of a happy ASCII cat. Two units above the first cut so
the > keeps clear air after the prompt moved up away from the chin. -->
<path d="M17 32 L23 25 L29 32 M35 32 L41 25 L47 32"
fill="none" stroke="#4cc2ff" stroke-width="5"
stroke-linecap="round" stroke-linejoin="round"/>
<!-- mouth: the shell prompt -->
<path d="M23 40 L29 45 L23 50"
<!-- mouth: the shell prompt. Two units higher than the first cut — the
tapered jaw curves inward through the y48-52 band the glyphs' stroke
edges used to occupy, and they visibly collided with the chin. -->
<path d="M23 38 L29 43 L23 48"
fill="none" stroke="#e6e8ec" stroke-width="5"
stroke-linecap="round" stroke-linejoin="round"/>
<path d="M34 50 L42 50"
<path d="M34 48 L42 48"
fill="none" stroke="#e6e8ec" stroke-width="5" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Before After
Before After

View file

@ -435,6 +435,12 @@ void RunMoneySelfTest() {
}
Check(ld && ld->IsObject() && ld->Str("@type") == "Product" && offersOk,
"schema: product JSON-LD parses, one offer per variant");
// Merchant-grade fields: shipping, returns, brand, sku — what
// Merchant Center's website-crawl feed reads at launch.
Check(pp.meta.jsonLd.find("OfferShippingDetails") != std::string::npos
&& pp.meta.jsonLd.find("MerchantReturnPolicy") != std::string::npos
&& pp.meta.jsonLd.find("\"sku\"") != std::string::npos,
"schema: offers carry shipping, returns and sku");
}
}
Check(!Content::Projects().empty(), "content: projects present");

View file

@ -41,6 +41,7 @@ export const std::vector<Product>& Products() {
Product p;
p.slug = "fp6-pmos";
p.name = "Fairphone 6 with postmarketOS";
p.brand = "Fairphone";
p.tagline = "A repairable Android phone, reflashed to run mainline Linux with a working IMS/VoLTE stack.";
// Launch day is this one line: "coming-soon" -> "available". The page
// shows launch prices either way; only the order form is held back.

View file

@ -136,6 +136,13 @@ export struct Product {
std::string slug;
std::string name;
std::string tagline;
// The hardware manufacturer, for the Product schema's brand field.
// Deliberately NOT accompanied by a GTIN: the device is materially
// modified (different OS), and Google's product-data rules say a
// customized product must not carry the original manufacturer's GTIN —
// which also matches the legal framing that this is a Fairphone with
// different software, not a Catcrafts-manufactured phone. Empty = omit.
std::string brand;
// "available" — buyable now. "coming-soon" — listed with launch prices,
// orders not open yet. "unavailable" — listed but not sellable (sourcing
// gap, price swing). In both closed states the page stays up, the buy

View file

@ -69,13 +69,17 @@ export std::string FormatEuro(std::int64_t minor) {
// Note NIR/GB: the UK left; Northern Ireland's special goods status is not
// modelled — GB is simply non-EU here, which is the correct default for a
// consumer parcel.
export bool IsEuCountry(std::string_view cc) {
export std::span<const std::string_view> EuCountries() {
static constexpr std::array<std::string_view, 27> eu{
"AT", "BE", "BG", "HR", "CY", "CZ", "DE", "DK", "EE", "ES", "FI",
"FR", "GR", "HU", "IE", "IT", "LT", "LU", "LV", "MT", "NL", "PL",
"PT", "RO", "SE", "SI", "SK",
};
return std::ranges::find(eu, cc) != eu.end();
return eu;
}
export bool IsEuCountry(std::string_view cc) {
return std::ranges::find(EuCountries(), cc) != EuCountries().end();
}
// Shipping zones. Three tiers is deliberate — real carrier pricing has more

View file

@ -86,12 +86,13 @@ export SafeHtml RenderNav(RouteKind current) {
R"(<a class="logo" aria-label="Catcrafts"{}>)"
R"(<span class="logo__mark" aria-hidden="true">)"
R"(<svg viewBox="0 0 64 64" fill="none">)"
R"(<path d="M9 22 L13 7 L25 15 L39 15 L51 7 L55 22 L55 44 Q55 56 43 56 L21 56 Q9 56 9 44 Z" stroke="currentColor" stroke-width="5" stroke-linejoin="round"/>)"
R"(<path d="M17 34 L23 27 L29 34 M35 34 L41 27 L47 34" stroke="currentColor" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>)"
R"(<path d="M9 26 L13 4 L23 15 Q32 11 41 15 L51 4 L55 26 L54 38 Q52 56 32 56 Q12 56 10 38 Z" stroke="currentColor" stroke-width="5" stroke-linejoin="round"/>)"
R"(<path d="M10.5 40 L4.5 42 M11.5 46 L6 49 M53.5 40 L59.5 42 M52.5 46 L58 49" stroke="currentColor" stroke-width="3.5" stroke-linecap="round"/>)"
R"(<path d="M17 32 L23 25 L29 32 M35 32 L41 25 L47 32" stroke="currentColor" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>)"
// R"x(...)x": stroke="var(--text)" contains the plain-delimiter
// terminator )" and would end the literal mid-attribute.
R"x(<path d="M23 40 L29 45 L23 50" stroke="var(--text)" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>)x"
R"x(<path class="logo-cursor" d="M34 50 L42 50" stroke="var(--text)" stroke-width="5" stroke-linecap="round"/>)x"
R"x(<path d="M23 38 L29 43 L23 48" stroke="var(--text)" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>)x"
R"x(<path class="logo-cursor" d="M34 48 L42 48" stroke="var(--text)" stroke-width="5" stroke-linecap="round"/>)x"
R"(</svg>)"
R"(</span>)"
R"(<span class="logo__text">Catcrafts</span></a>)"
@ -223,10 +224,14 @@ export RenderedPage RenderHome(std::span<const Project> projects,
RenderedPage page;
page.meta.title = "Catcrafts — open-source software and hardware";
// Deliberately the hero text, word for word: the mission sentence is the
// site's best self-description, and the search snippet, the og: link
// card and the page itself should all say the same thing. If the hero
// below changes, change this with it.
page.meta.description =
"Catcrafts makes accessible, open-source software and hardware: an "
"alternative to big tech. A C++ engine, build system and networking "
"stack from scratch, and Linux with IMS/VoLTE on the Fairphone 6.";
"Catcrafts makes accessible, open-source software and hardware for "
"the benefit of everyone. The goal: a real alternative to big tech "
"that anyone can use, not just Linux nerds.";
page.meta.canonical = "/";
// The identity record: name, registered VAT identity and the Forgejo
// profile separate this Catcrafts from the name-twins by type and by
@ -694,35 +699,101 @@ export RenderedPage RenderProduct(const Product& product,
// integers the checkout charges — this markup can never advertise a
// number the shop doesn't honour. Availability tracks the status field,
// so launch day flips PreOrder to InStock with no edit here.
//
// Merchant-grade: each offer also carries shippingDetails and a return
// policy, which is what Google Merchant Center's website-crawl feed needs
// to list the product without a CSV in sight. Shipping uses the STATIC
// zone rates on purpose: the checkout charges live carrier rates, which
// run at or below the zone fallbacks — a listing may overstate shipping,
// never understate it.
{
const std::string productUrl = "https://catcrafts.net/shop/" + product.slug;
std::string_view availability =
product.Buyable() ? "https://schema.org/InStock"
: product.ComingSoon() ? "https://schema.org/PreOrder"
: "https://schema.org/OutOfStock";
// "NL", then the other 26 EU members, as JSON string lists.
std::string euList;
for (std::string_view cc : Money::EuCountries()) {
if (cc == "NL") continue;
if (!euList.empty()) euList += ',';
euList += JsonStr(cc);
}
// The world tier can't say "everywhere else" in schema.org, so it
// names the non-EU destinations the shop actually sees demand from.
static constexpr std::string_view kWorldSample[] = {
"US", "CA", "GB", "CH", "NO", "AU", "NZ", "JP",
};
std::string worldList;
for (std::string_view cc : kWorldSample) {
if (!worldList.empty()) worldList += ',';
worldList += JsonStr(cc);
}
auto shipTier = [](std::string_view rate, const std::string& dests,
int transitMin, int transitMax) {
return std::format(
R"({{"@type":"OfferShippingDetails",)"
R"("shippingRate":{{"@type":"MonetaryAmount","value":{},"currency":"EUR"}},)"
R"("shippingDestination":{{"@type":"DefinedRegion","addressCountry":[{}]}},)"
R"("deliveryTime":{{"@type":"ShippingDeliveryTime",)"
R"("handlingTime":{{"@type":"QuantitativeValue","minValue":1,"maxValue":7,"unitCode":"DAY"}},)"
R"("transitTime":{{"@type":"QuantitativeValue","minValue":{},"maxValue":{},"unitCode":"DAY"}}}}}})",
JsonStr(rate), dests, transitMin, transitMax);
};
const std::string shippingDetails = "["
+ shipTier(Money::FormatMinor(product.shipNlMinor), JsonStr("NL"), 1, 2) + ","
+ shipTier(Money::FormatMinor(product.shipEuMinor), euList, 2, 5) + ","
+ shipTier(Money::FormatMinor(product.shipWorldMinor), worldList, 5, 14) + "]";
// Returns, matching the terms page: EU consumers get the statutory
// 14-day withdrawal (return shipping theirs); outside the EU sales
// are final except defects, which are warranty, not returns.
std::string euAll;
for (std::string_view cc : Money::EuCountries()) {
if (!euAll.empty()) euAll += ',';
euAll += JsonStr(cc);
}
const std::string returnPolicy = std::format(
R"([{{"@type":"MerchantReturnPolicy","applicableCountry":[{}],)"
R"("returnPolicyCategory":"https://schema.org/MerchantReturnFiniteReturnWindow",)"
R"("merchantReturnDays":14,"returnMethod":"https://schema.org/ReturnByMail",)"
R"("returnFees":"https://schema.org/ReturnFeesCustomerResponsibility"}},)"
R"({{"@type":"MerchantReturnPolicy","applicableCountry":[{}],)"
R"("returnPolicyCategory":"https://schema.org/MerchantReturnNotPermitted"}}])",
euAll, worldList);
const std::string offerTail = std::format(
R"("availability":"{}","itemCondition":"https://schema.org/NewCondition",)"
R"("url":{},"seller":{{"@type":"Organization","name":"Catcrafts"}},)"
R"("shippingDetails":{},"hasMerchantReturnPolicy":{}}})",
availability, JsonStr(productUrl), shippingDetails, returnPolicy);
std::string offers;
for (const Variant& v : product.variants) {
if (!offers.empty()) offers += ',';
offers += std::format(
R"({{"@type":"Offer","name":{},"price":{},"priceCurrency":"EUR",)"
R"("availability":"{}","itemCondition":"https://schema.org/NewCondition",)"
R"("url":{},"seller":{{"@type":"Organization","name":"Catcrafts"}}}})",
JsonStr(v.label), JsonStr(Money::FormatMinor(v.priceInclMinor)),
availability, JsonStr(productUrl));
R"({{"@type":"Offer","name":{},"sku":{},"price":{},"priceCurrency":"EUR",)",
JsonStr(v.label), JsonStr(product.slug + "-" + v.slug),
JsonStr(Money::FormatMinor(v.priceInclMinor)));
offers += offerTail;
}
// A variantless product still gets its one offer from the base price.
if (offers.empty() && product.priceInclMinor > 0) {
offers += std::format(
R"({{"@type":"Offer","price":{},"priceCurrency":"EUR",)"
R"("availability":"{}","itemCondition":"https://schema.org/NewCondition",)"
R"("url":{},"seller":{{"@type":"Organization","name":"Catcrafts"}}}})",
JsonStr(Money::FormatMinor(product.priceInclMinor)),
availability, JsonStr(productUrl));
R"({{"@type":"Offer","sku":{},"price":{},"priceCurrency":"EUR",)",
JsonStr(product.slug),
JsonStr(Money::FormatMinor(product.priceInclMinor)));
offers += offerTail;
}
const std::string brand = product.brand.empty()
? std::string{}
: std::format(R"("brand":{{"@type":"Brand","name":{}}},)",
JsonStr(product.brand));
page.meta.jsonLd = std::format(
R"({{"@context":"https://schema.org","@type":"Product",)"
R"("name":{},"description":{},"image":{},"url":{},"offers":[{}]}})",
JsonStr(product.name), JsonStr(product.tagline),
R"("name":{},{}"description":{},"image":{},"url":{},"offers":[{}]}})",
JsonStr(product.name), brand, JsonStr(product.tagline),
JsonStr(product.image.empty()
? std::string{}
: "https://catcrafts.net" + product.image),

View file

@ -212,6 +212,18 @@ else
bad "Product schema" "missing, unparseable, or wrong offer count"
fi
body_has /shop/fp6-pmos '"price":"563.30"' "schema price is the checkout integer"
# Merchant-grade offer fields: what Merchant Center's website-crawl feed
# reads. Shipping uses the static zone rates (listing may overstate, never
# understate what checkout charges); returns mirror the terms page.
body_has /shop/fp6-pmos 'OfferShippingDetails' "offers carry shipping details"
body_has /shop/fp6-pmos 'MerchantReturnPolicy' "offers carry a return policy"
body_has /shop/fp6-pmos '"sku":"fp6-pmos-green"' "offers carry per-variant skus"
body_has /shop/fp6-pmos '"brand":{"@type":"Brand","name":"Fairphone"}' "product carries the hardware brand"
if extract_ld /shop/fp6-pmos | jq -e '.offers[0].shippingDetails | length == 3' >/dev/null 2>&1; then
ok "shipping details cover all three zones"
else
bad "shipping zones" "expected NL + EU + world tiers in the first offer"
fi
if [ "$SHOP_OPEN" = 1 ]; then
body_has /shop/fp6-pmos 'schema.org/InStock' "open shop maps to InStock availability"
else