diff --git a/content/posts.json b/content/posts.json index 0866782..75881a1 100644 --- a/content/posts.json +++ b/content/posts.json @@ -1,4 +1,23 @@ [ + { + "title": "Fairphone 6 + PostmarketOS working main camera!", + "slug": "fairphone-6-postmarketos-working-main-camera", + "permalink": "https://lemmy.ca/post/69477866", + "url": "", + "community": "linuxphones@lemmy.ca", + "published": "2026-08-17T04:08:09.024305Z", + "excerpt": "Today i bring the working main camera! Building on the work nondescriptpointer did on the wide lens camera i have written the driver for the main camera and now its working alongside auto focus and color correction. The color correction still a work in progress, but you can…", + "body": "Today i bring the working main camera!\n\nBuilding on the work nondescriptpointer did on the wide lens camera i have written the driver for the main camera and now its working alongside auto focus and color correction.\n\nThe color correction still a work in progress, but you can already see how much it improved the image.\n\nbefore:\n\n![](https://ani.social/api/v3/image_proxy?url=https%3A%2F%2Fcatcrafts.net%2Fmedia%2Faaf5491064f7d264.webp)\n\nAfter:\n\n![](https://ani.social/api/v3/image_proxy?url=https%3A%2F%2Fcatcrafts.net%2Fmedia%2F02c6abe644696ff8.webp)\n\nBut the after is still very grainy, plasma camera storing in jpg isn't exactly helping either, ill be working to get rid of the grain.\n\nIf we look at the same scene form my android galaxy A16 you see that there still is alot of work to be done:\n\nI'll continue to work on the camera, but i wanted to get something out today (mainly so i could send to nondescriptpointer xd)\n\nI asked him what role he wanted to play in the upstreaming and we agreed he would send it and i would review and assist. ~~So i successfully pulled down another soul into the linux phone kernel rabbit hole muhahahahaha~~\n\n# News roundup\n\nAlot of things happend lately so good thing to discuss them, the big one:\n\n## Emergency calling test\n\nYou may have noticed that everywhere i put warnings that emergency calling is not verified yet. i wanted to fix that but after searching i couldn't find anything about it.\n\nSo i just called the police non emergency line and explained the situation, the operator told me that the information wasn't public but provided me with an email to send my application too.\n\nI was a bit sad cause if its a non public thing its probably gated behind being big tech, but i send the email anyway, and much to my suprise i got back.\n\n> De testen zijn goedgekeurd voor dinsdag 18 augustus tussen 13:30 en 14:15 uur.\n>\n >\n>\n>Met vriendelijke groet,\n>\n>\n>\n>(name censored) B ICT,\n>\n>Tactisch & Technisch Beheer 1-1-2 (TB112)\n\nTranslation:\n\n>The tests have been approved for Tuesday, August 18, between 1:30 p.m. and 2:15 p.m.\n>\n>\n>\n>Kind regards,\n>\n>\n>\n>(name censored) B ICT,\n>\n>Tactical & Technical Management 1-1-2 (TB112)\n\nSo Im really excited for this, and then you know for sure that you can reach the emergency number with your linux phone.\n\n## Fairphone 6+\n\nSo the Fairphone 6+ has been officially announced an as soon as i can buy one im buying one, testing my image, and fixing any issues.\n\nIm really glad for the donations so i can justify to myself this purchase instead of spending 650 euros for a phone that i already have.\n\n## Donations\n\nI'm really grateful for all the people that donated and still continue to donate, i was over the moon when i got my first donation and i never expected to get this much. From the bottom of my heart thank you all very much.\n\nI want to be open on where it's going. And i think as donators you deserve to know that its being spent wisely. so i made a script that builds this page from my bank statement:\n\nhttps://catcrafts.net/financials\n\nIf it all works then donations should be reflected live and it will show the expense for the FP6+ when i buy it, net will drop in the negative as the remainder is coming out of my pocket.\n\nIf you want to send a donation please do so to the updated link: https://catcrafts.net/shop/donation this will be reflected in the dashboard live and for tax reasons its now mega clear that its a donation.\n\n## Catcrafts as a company\n\nIm contacting a notary with the plans to have Catcrafts corporated as a non profit company (stichting), this is is depending on all the legal stuff however so this isn't set in stone.\n\nIf and if this company goes somewhere and i could quit my job, i will pay myself a salary to live on that will be publicly visible on the financials page. Dutch law requires for non profits that salary to be at max market confirming and not a shadow way for paying out dividends, so its guaranteed that any money in the company will go towards furthering the mission.\n\nI think a non profit phone company has a genuinely good proposition, i praise fairphone alot for the things they do right but in the end they are still an profit seeking business so im a bit wary, and they still post on Musk's X so my long term judgement on their company is still out there\n\nI applied to be a fairphone partner with in my eyes a pretty good pitch, if they accept my opinion will be improved ;) hopefully i just need to have more patience or they are ghosting me ;-;\n\n## Shipping restrictions\n\nI'm sad to announce this but i don't think i can do worlwide shippng, earlier i said i would be shipping worlwide but i must sadly retract that statement for reasons out of my control. I will be shipping worldwide with the following exceptions:\n\nUS, CA: It's seemingly impossible to get a Bedrijfsaansprakelijkheids­verzekering (corporate liability insurance) for the United States and Canada in the netherlands, its all worldwide excluding US and CA. Getting coverage for those requires \"contact us\" with probably a very hefty premium, and selling without insurance is too risky as that would mean financial ruin if i get sued.\n\nI would appreciate if anyone that isn't a massive company has experience with this, will be contacting my insurer aswell to see what's possible on this front\n\nRU, BY, KP: Sanctions make it a criminal offense for me to ship to these countries.\n\n## imsd\n\n| Device | OS | Carrier\n|---|---|---|\n| The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | KPN NL |\n| The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Telekom Deutschland GER |\n| The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Phonero |\n| The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Telia Norge |\n\nThanks to the community we now have 4 confirmed working carriers! If you are using the image please let me know so i can add it to the list!\n\n## Making a Linux phone\n\nEver since this project i've been dreaming about making my own linux phone, i've been looking into it here and there and while this might just be the sleep deprivation talking i think i can do it.\n\nMaking a good linux phone however is the hard part, and i don't think i can make an better arm linux phone then the fairphone 6 as those qualcomm chips are impossible to buy.\n\nSo im not going to, i'll make a RISCV one. will it be bad? yes, will it end up like the pinephone? most probably, will it run hot and have terrible battery life?, most likely. will it cost me a ton of money?, yes.\n\nBUT\n\nIt will be a phone as open as i can make it, with good software, and a (in my eyes ethical) non profit company backing it. i think there is a good business proposition to made there, whatever the case its very long term anyway.\n\n## Meeting the lead pmos dev\n\nImagine my surprise when i see the lead pmos dev on a dutch tech forum, and i'm named, and then heart sank trough the floor when i'm being made out for something that can be disproved with a 10s search.\n\nLuckily the record was set straight fast!\n\n![](https://ani.social/api/v3/image_proxy?url=https%3A%2F%2Fcatcrafts.net%2Fmedia%2Fbad94349e561ebd8.webp)\n\nTranslation:\n\n![](https://ani.social/api/v3/image_proxy?url=https%3A%2F%2Fcatcrafts.net%2Fmedia%2Fdcdbe2d47b7314e5.webp)\n\nI don't want to hide context so here is the full thread:\n\nhttps://tweakers.net/nieuws/250872/fairphone-gaat-smartphone-met-12gb-ram-uitbrengen-voor-649-euro.html?showReaction=22462270#r_22462270\n\n## What's next\n\n1. More color correction\n2. Laser rangefinder autofocus instead of software only.\n3. Selfie camera\n4. Fingerprint sensor\n5. Extensive testing\n\nAnd then the FP6 is done, i will open my shop and continue with the FP6+\n\nSince a long time i feel purpose in my life again, and i have alot of stuff still planned! And getting all the patches upstreamed is also probably a half year commitment atleast.\n\nIf you made it this far thank you for reading! As always ask me anything in the comments and ill do my best to answer.", + "media": [ + { + "src": "https://catcrafts.net/media/51600af94e2b114a.h264.mp4", + "kind": "video", + "poster": "https://ani.social/api/v3/image_proxy?url=https%3A%2F%2Fcatcrafts.net%2Fmedia%2F97d9500a15983406.webp" + } + ], + "score": 433, + "comments": 34 + }, { "title": "Fairhone 6 + PostmarketOS sensor bonanza!", "slug": "fairhone-6-postmarketos-sensor-bonanza", @@ -7,48 +26,16 @@ "community": "linuxphones@lemmy.ca", "published": "2026-08-07T22:14:04.646882Z", "excerpt": "Hello again! I have returned with more patches, this time i bring you. All the sensors! This entire collum can be made green. We are nearing 100% completion, but im afraid true 100% might never happen. I was working on the fingerprint sensor aswell, but its gated behind the TEE,…", - "body": "Hello again!\n\nI have returned with more patches, this time i bring you. All the sensors!\n\n![](/media/21f4e30226d17716.webp)\n\nThis entire collum can be made green.\n\nWe are nearing 100% completion, but im afraid true 100% might never happen.\n\nI was working on the fingerprint sensor aswell, but its gated behind the TEE, and can't be read from the kernel. This is going to be an issue.\n\nSo the end state might not be 100% sadly, but i hope people don't miss the fingerprint sensor too much? (ill see if i can get it working but this is one thing i cannot promise)\n\nBut we are nearing the end, With the sensors done the only thing left is the cameras.\n\nI contacted the person who's blog was posted here: https://ani.social/post/34679544\n\nand cool guy, we agreed to coordinate our efforts with the camera, so expect that one coming soon.\n\n## My hot take on the current discusion\n\nThis might earn me some hate but i am against Jolla, many people argue \"don't let perfection stand in the way of good\" but we have perfection right here, its the FP6.\n\nFor several reasons:\n\n![](/media/a54f222e439c278b.webp)\n\nThis is blatant false advertising.\n>The Jolla phone is not based on Big Tech technology\n\nYet it uses a Big Tech kernel.\nThere is a huge difference between a mainline linux phone and a linux phone using an android kernel.\n\nCombined with the fact that has much debated that there are closed source components and it can't run wayland apps.\n\nThere should be no excuse for closed source components period, no matter if they are small or easily to remove, if there is close source anywhere that means that open source is not an required but a marketing strategy.\n\nAnd that is the issue i have with Jolla, it gives me the wrong vibe, big tech wearing foss clothing.\n\n>Jolla is a Finnish company specializing in privacy-first OS and Edge AI solutions. Founded in 2011, we continue the legacy of Nokia and MeeGo, driving mobile innovation into the human-centric AI era.\n>\n>We develop Sailfish OS, the only European mobile OS, trusted globally for over a decade, and now enhanced for AI-driven applications. Our AppSupport technology enables Android™ apps to run on Linux platforms. Our privacy-focused Jolla Mind2 AI computer delivers secure AI capabilities across industries.\n>\n>With over 60 successful software release deliveries and a proven track record across multiple hardware platforms, we collaborate with partners in the automotive and telecommunications sectors to drive innovation with our OS and Edge AI technologies.\n>\n>https://jolla.com/\n\nThis really makes my alarm bells whistle, are they a private phone company or an AI company??? Why this marketing language?\n\nLet me give you an example:\n![](/media/4ebc81a450b802a3.webp)\n\nThese are the FP6 wire diagrams, free to download at https://support.fairphone.com/hc/en-us/article_attachments/37795041144082\n\nThis is commitment to open source, this single document has allowed me to do this bringup.\n\nDoes Jolla publish one? not that i can find. If they do i retract this point but i haven't been able to find it.\n\nSo why don't they? If they truly believe in openness why not publish these documents? My hypothesis: they don't care. Its all just marketing.\n\nSoon the FP6 will be fully ready, a true daily drivable modern mainline linux phone.\n\nI believe in maximum openess, so lets define mainline:\n\nits the https://github.com/milos-mainline/linux staging kernel, combined with my patches.\n\nAll my patches have already been submitted on the list, but these will take time to be reviewed, it will be a very long time before they will be integrated in the kernel itself.\n\nBut they have been submitted, and im activily working on the maintainer feedback, these will make it eventually.\n\nOnce cameras are done, i will make an easy to install script and post it here.\nSimultaneously i will begin selling preflashed units on my shop: https://catcrafts.net/shop/fp6-pmos\n\nThese will be identical if you flash it yourself, these are for people that can't otherwise buy one, don't want to flash it themselves, or want to support me.\n\nI also want not only the code to be open but also the business to be open and honest, i take whatever price i can source the phones at and add a €50 euro markup, none of this ending the price in 99 nonsense.\n\nI do this to cover the administrative costs and labor costs.\n\nThis is cheaper then the Murena /e/OS version:\n\nhttps://murena.com/shop/smartphones/brand-new/murena-fairphone-6/\nWhich is currently priced at €599 as of time of writing.\n\nAnd this might cost me alot of money but im willing to stand by it:\n>When a product does have to travel (a hardware fault, or a product so far gone it no longer responds to recovery tools at all), the shipping is paid by Catcrafts, both directions\n>\n>https://catcrafts.net/legal/terms\n\nThis applies worlwide.\n\nI also looked at the privacy policy of the Jolla website:\n\n>We may also permit third parties and services providers to use Cookies on our Site to better tailor the services, products and advertising on our Site and other websites. \n>\n>https://commerce.jolla.com/policies/privacy-policy\n\nTHIRD PARTY ADVERTISING COOKIES ON A COMPANY THAT MARKETS ITSELF AS PRIVACY FOCUSED????\n\nWhat a joke.\nThis is a privacy focused policy:\nhttps://catcrafts.net/legal/privacy\n\nThe only thing i collect that i don't need is request logging, but i hope you understand why i do that.\n\nNo cookies, no advertising, nothing.\n\nNo i don't want to make this sound as advertising myself, i want this to sound like what the example of a open, honest, and privacy focused company should look like.\n\nIf you made it this far thank you for reading my rant, if anything is wrong or i have made a mistake please let me know in the comments and i will correct it.", + "body": "Hello again!\n\nI have returned with more patches, this time i bring you. All the sensors!\n\n![](https://ani.social/pictrs/image/b370fd12-9dcc-4700-a117-34f9000424b4.webp)\n\nThis entire collum can be made green.\n\nWe are nearing 100% completion, but im afraid true 100% might never happen.\n\nI was working on the fingerprint sensor aswell, but its gated behind the TEE, and can't be read from the kernel. This is going to be an issue.\n\nSo the end state might not be 100% sadly, but i hope people don't miss the fingerprint sensor too much? (ill see if i can get it working but this is one thing i cannot promise)\n\nBut we are nearing the end, With the sensors done the only thing left is the cameras.\n\nI contacted the person who's blog was posted here: https://ani.social/post/34679544\n\nand cool guy, we agreed to coordinate our efforts with the camera, so expect that one coming soon.\n\n## My hot take on the current discusion\n\nThis might earn me some hate but i am against Jolla, many people argue \"don't let perfection stand in the way of good\" but we have perfection right here, its the FP6.\n\nFor several reasons:\n\n![](https://ani.social/pictrs/image/10297a08-38fe-4bfd-98aa-1a1f7bb83191.webp)\n\nThis is blatant false advertising.\n>The Jolla phone is not based on Big Tech technology\n\nYet it uses a Big Tech kernel.\nThere is a huge difference between a mainline linux phone and a linux phone using an android kernel.\n\nCombined with the fact that has much debated that there are closed source components and it can't run wayland apps.\n\nThere should be no excuse for closed source components period, no matter if they are small or easily to remove, if there is close source anywhere that means that open source is not an required but a marketing strategy.\n\nAnd that is the issue i have with Jolla, it gives me the wrong vibe, big tech wearing foss clothing.\n\n>Jolla is a Finnish company specializing in privacy-first OS and Edge AI solutions. Founded in 2011, we continue the legacy of Nokia and MeeGo, driving mobile innovation into the human-centric AI era.\n>\n>We develop Sailfish OS, the only European mobile OS, trusted globally for over a decade, and now enhanced for AI-driven applications. Our AppSupport technology enables Android™ apps to run on Linux platforms. Our privacy-focused Jolla Mind2 AI computer delivers secure AI capabilities across industries.\n>\n>With over 60 successful software release deliveries and a proven track record across multiple hardware platforms, we collaborate with partners in the automotive and telecommunications sectors to drive innovation with our OS and Edge AI technologies.\n>\n>https://jolla.com/\n\nThis really makes my alarm bells whistle, are they a private phone company or an AI company??? Why this marketing language?\n\nLet me give you an example:\n![](https://ani.social/pictrs/image/39a260df-48c0-4ba5-ab41-d127c4a9b643.webp)\n\nThese are the FP6 wire diagrams, free to download at https://support.fairphone.com/hc/en-us/article_attachments/37795041144082\n\nThis is commitment to open source, this single document has allowed me to do this bringup.\n\nDoes Jolla publish one? not that i can find. If they do i retract this point but i haven't been able to find it.\n\nSo why don't they? If they truly believe in openness why not publish these documents? My hypothesis: they don't care. Its all just marketing.\n\nSoon the FP6 will be fully ready, a true daily drivable modern mainline linux phone.\n\nI believe in maximum openess, so lets define mainline:\n\nits the https://github.com/milos-mainline/linux staging kernel, combined with my patches.\n\nAll my patches have already been submitted on the list, but these will take time to be reviewed, it will be a very long time before they will be integrated in the kernel itself.\n\nBut they have been submitted, and im activily working on the maintainer feedback, these will make it eventually.\n\nOnce cameras are done, i will make an easy to install script and post it here.\nSimultaneously i will begin selling preflashed units on my shop: https://catcrafts.net/shop/fp6-pmos\n\nThese will be identical if you flash it yourself, these are for people that can't otherwise buy one, don't want to flash it themselves, or want to support me.\n\nI also want not only the code to be open but also the business to be open and honest, i take whatever price i can source the phones at and add a €50 euro markup, none of this ending the price in 99 nonsense.\n\nI do this to cover the administrative costs and labor costs.\n\nThis is cheaper then the Murena /e/OS version:\n\nhttps://murena.com/shop/smartphones/brand-new/murena-fairphone-6/\nWhich is currently priced at €599 as of time of writing.\n\nAnd this might cost me alot of money but im willing to stand by it:\n>When a product does have to travel (a hardware fault, or a product so far gone it no longer responds to recovery tools at all), the shipping is paid by Catcrafts, both directions\n>\n>https://catcrafts.net/legal/terms\n\nThis applies worlwide.\n\nI also looked at the privacy policy of the Jolla website:\n\n>We may also permit third parties and services providers to use Cookies on our Site to better tailor the services, products and advertising on our Site and other websites. \n>\n>https://commerce.jolla.com/policies/privacy-policy\n\nTHIRD PARTY ADVERTISING COOKIES ON A COMPANY THAT MARKETS ITSELF AS PRIVACY FOCUSED????\n\nWhat a joke.\nThis is a privacy focused policy:\nhttps://catcrafts.net/legal/privacy\n\nThe only thing i collect that i don't need is request logging, but i hope you understand why i do that.\n\nNo cookies, no advertising, nothing.\n\nNo i don't want to make this sound as advertising myself, i want this to sound like what the example of a open, honest, and privacy focused company should look like.\n\nIf you made it this far thank you for reading my rant, if anything is wrong or i have made a mistake please let me know in the comments and i will correct it.", "media": [ { - "src": "/media/13b048149b5511dd.mp4", + "src": "https://catcrafts.net/media/13b048149b5511dd.h264.mp4", "kind": "video", - "poster": "/media/fb7ad933d0baedb2.png", - "w": 1080, - "h": 1920, - "fallback": "/media/13b048149b5511dd.h264.mp4" + "poster": "https://ani.social/api/v3/image_proxy?url=https%3A%2F%2Ffiles.catbox.moe%2Fqcx6qu.png" } ], - "score": 391, - "comments": 56, - "body_media": [ - { - "src": "/media/21f4e30226d17716.webp", - "kind": "image", - "poster": "", - "fallback": "/media/21f4e30226d17716.png", - "avif": "/media/21f4e30226d17716.avif", - "w": 483, - "h": 341 - }, - { - "src": "/media/4ebc81a450b802a3.webp", - "kind": "image", - "poster": "", - "fallback": "/media/4ebc81a450b802a3.png", - "avif": "/media/4ebc81a450b802a3.avif", - "w": 797, - "h": 1703 - }, - { - "src": "/media/a54f222e439c278b.webp", - "kind": "image", - "poster": "", - "fallback": "/media/a54f222e439c278b.png", - "avif": "/media/a54f222e439c278b.avif", - "w": 1572, - "h": 329 - } - ] + "score": 410, + "comments": 68 }, { "title": "Plasma-mobile working orca screenreader!", @@ -58,47 +45,16 @@ "community": "linuxphones@lemmy.ca", "published": "2026-07-25T00:45:24.764941Z", "excerpt": "While celebrating my previous post of working call audio @pvagner@fedi.ml left this excellent comment: And this reminded me something that i and many devs sadly often forget, if its works for me it doesn't mean it works for everyone. My philosophy is that linux should be as…", - "body": "While celebrating my previous post of working call audio @pvagner@fedi.ml left this excellent comment:\n\nhttps://ani.social/post/34656971/17805917\n\nAnd this reminded me something that i and many devs sadly often forget, if its works for me it doesn't mean it works for everyone.\n\nMy philosophy is that linux should be as accesible as possible, nobody should be forced to be stuck with big tech.\n\nSo i tried my best to add orca screenreader support to plasma-mobile.\n\nI mad this statement on my previous post\n\n> But a successful one!\n\nBut i genuinely don't know if i can claim that today, i don't use a screenreader so i don't know what the user experience should look like. I will glady incorporate feedback.\n\nAs with my kernel patches i will be working to upstream this with KDE, since this is probably not controversial i think it should be accepted quite quickly.\n\n# Small celebration:\n\nI was quite happy when i saw this in my inbox:\n\nhttps://lore.kernel.org/all/20260722234221.884765-8-srinivas.kandagatla@oss.qualcomm.com/\n\n>+maintainers:\n>+ - Jorijn van der Graaf \n>+ - Srinivas Kandagatla \n\nIt looks like im being upgraded from linux contributor to linux maintainer!\n\n# Selling fp6 with pmos preflashed and with my patches preinstalled\n\n![](/media/c285849d694b02fd.webp)\n\n![](/media/3b0986883fa2b453.webp)\n\n![](/media/bffa168176bf8ccc.webp)\n\nSome comments mentioned this and it got me thinking, would there be demand for this?\n\nI would be selling them on my catcrafts.net website, payment methods would be my bunq so wero, bancontact, credit card and crypto.\n\nIf you are interested in this, please upvote my comment, ive been looking into this and there is quite alot of administration that comes with it, so i would like to only do this if there is actual demand.\n\nI would be shipping to any place i can. I will be shipping from the Netherlands, so keep that in mind for import duties and shipping costs.", + "body": "While celebrating my previous post of working call audio @pvagner@fedi.ml left this excellent comment:\n\nhttps://ani.social/post/34656971/17805917\n\nAnd this reminded me something that i and many devs sadly often forget, if its works for me it doesn't mean it works for everyone.\n\nMy philosophy is that linux should be as accesible as possible, nobody should be forced to be stuck with big tech.\n\nSo i tried my best to add orca screenreader support to plasma-mobile.\n\nI mad this statement on my previous post\n\n> But a successful one!\n\nBut i genuinely don't know if i can claim that today, i don't use a screenreader so i don't know what the user experience should look like. I will glady incorporate feedback.\n\nAs with my kernel patches i will be working to upstream this with KDE, since this is probably not controversial i think it should be accepted quite quickly.\n\n# Small celebration:\n\nI was quite happy when i saw this in my inbox:\n\nhttps://lore.kernel.org/all/20260722234221.884765-8-srinivas.kandagatla@oss.qualcomm.com/\n\n>+maintainers:\n>+ - Jorijn van der Graaf \n>+ - Srinivas Kandagatla \n\nIt looks like im being upgraded from linux contributor to linux maintainer!\n\n# Selling fp6 with pmos preflashed and with my patches preinstalled\n\n![](https://ani.social/pictrs/image/df1160ab-6f27-436c-b651-5cefcd79b060.webp)\n\n![](https://ani.social/pictrs/image/7079ac23-dcd3-4349-a8e9-9bff86a4c6b2.webp)\n\n![](https://ani.social/pictrs/image/95cfdf0d-e3d0-4a12-946a-4dce543f7969.webp)\n\nSome comments mentioned this and it got me thinking, would there be demand for this?\n\nI would be selling them on my catcrafts.net website, payment methods would be my bunq so wero, bancontact, credit card and crypto.\n\nIf you are interested in this, please upvote my comment, ive been looking into this and there is quite alot of administration that comes with it, so i would like to only do this if there is actual demand.\n\nI would be shipping to any place i can. I will be shipping from the Netherlands, so keep that in mind for import duties and shipping costs.", "media": [ { - "src": "/media/54d923869e19d71c.mp4", + "src": "https://files.catbox.moe/q0acuo.mp4", "kind": "video", - "poster": "/media/63acea1959c6400d.webp", - "w": 1080, - "h": 1080 + "poster": "https://ani.social/pictrs/image/5948f014-e9d4-4ace-bf6f-6f85e9b5f2fc.webp" } ], - "score": 85, - "comments": 25, - "body_media": [ - { - "src": "/media/3b0986883fa2b453.webp", - "kind": "image", - "poster": "", - "fallback": "/media/3b0986883fa2b453.png", - "avif": "/media/3b0986883fa2b453.avif", - "w": 1664, - "h": 601 - }, - { - "src": "/media/bffa168176bf8ccc.webp", - "kind": "image", - "poster": "", - "fallback": "/media/bffa168176bf8ccc.png", - "avif": "/media/bffa168176bf8ccc.avif", - "w": 1829, - "h": 1544 - }, - { - "src": "/media/c285849d694b02fd.webp", - "kind": "image", - "poster": "", - "fallback": "/media/c285849d694b02fd.png", - "avif": "/media/c285849d694b02fd.avif", - "w": 1122, - "h": 240 - } - ] + "score": 89, + "comments": 25 }, { "title": "Fairphone 6 + PostmarketOS working audio calls!!!", @@ -108,38 +64,16 @@ "community": "linuxphones@lemmy.ca", "published": "2026-07-22T21:36:18.030030Z", "excerpt": "At first, man thinks he controls the modem. But after the 20th reboot, man learns the modem controls him. — Epictetus So here it is! working VoLTE calls with audio on linux! What a rollercoaster it has been i'll tell you that. The frequency between my previous posts was only a…", - "body": "> At first, man thinks he controls the modem.\n>\n> But after the 20th reboot, man learns the modem controls him.\n>\n> — *Epictetus*\n\nSo here it is! working VoLTE calls with audio on linux!\n\nWhat a rollercoaster it has been i'll tell you that.\n\nThe frequency between my previous posts was only a few days, as you can see from the time since my last post this has been a *far* harder project.\n\nBut a successful one!\n\nNow fully powered trough my new imsd project:\n\nhttps://forgejo.catcrafts.net/Catcrafts/imsd\n\nAs i said in a my previous comment no matter what i did the regular modem route wasn't working, so i had to come up with my own.\n\nThis has several advantages however, call audio is now just routed trough wireplumber & pipewire, so its integrated much better.\n\nBoth inbound and outbound calls work.\n\n# How do i get this???\n\nThe patient route is waiting until my patches get accepted upstream, a few already have and are coming in 7.3 probably (pmos runs 7.0 currently still oof), a few will probably take longer, 7.4 or beyond.\n\nbut for the ones that want to jump right on ive published everything on my forgejo:\n\nhttps://forgejo.catcrafts.net/Catcrafts\n\nI will soon be publishing a shell script that does the entire installation soon for easy installation.\n\nSadly there is no way to just plug in an android and do it, so there are still a few manual steps needed, but i believe the path to adoption is reducing friction as much as possible, so ill do my best to make it as easy as possible to use.\n\n# imsd\n\nSo what is an \"imsd\"?\n\nits a full userspace implementation of the IMS protocol, which drives VoLTE (4G) calls.\n\nNormally the modem firmware drives this, but this is quite difficult to work with, now it runs fully via a standard daemon on linux!\n\nAlong with several d-bus helpers that integrate it with standard plasma-dialer.\n\nif made a APKBUILD for pmos that is mutually exclusive with 81voltd, and loads imsd instead. so installation should be easy.\n\n>Now will this actually work?\n\nThat's where you come in!\n\nI am just one guy with one phone with one carrier.\n\nI can say it works on the Fairphone 6 with KPN. it might work with other carriers and other Qualcomm phones aswell, so potentially this could work for the entire linux phone ecosystem, but i have no way to test that.\n\nIf you end up trying it, no matter if it works or doesn't work, please send me a message or open a PR, then we can make sure it works for everyone!\n\n# donations\n![](/media/5d410350f777e5ca.webp)\n\nI really was surprised when people asked me this, i just see myself as a guy doing this in his free time, \"why would people want to send donations to me?\" i thought.\n\nI still stand by my previous point, i have a job, and this project costs me nothing except for time, i don't have any costs. So i felt kinda guilty receiving money from people.\n\nBut i would much rather spend my time making software that benefits society instead of the B2B stuff i do at my job.\n\nI really feel greedy for saying this, but if you want to send me a thank you then that is now possible at:\nhttps://bunq.me/catcrafts\n\nPlease don't feel any pressure to do this, doesn't matter what i get im still continuing with this bring-up. And if you don't want to donate to me there are many linux organizations that could really use the support! In my eyes we are all in the same boat with the same goal.\n\nIf you do decide to, and i cannot express my gratitude enough if you do, please write in the description that it's a donation, as this makes tax easier.\n\n# What's next?\n\nIve got a bunch more stuff coming soon, while i haven't posted here in quite a while i've been plenty busy on the mailing list:\n\n![](/media/0170c8f6099de41a.webp)\n\nSo except some more posts soon.\n\n# Bonus round\n\nHaving fully working hardware is great, but a phone that has no apps is pointless for most people, so i would like to start a discussion in the comments.\n\n**What apps do you need that are preventing you from switching to mobile linux?**\n", + "body": "> At first, man thinks he controls the modem.\n>\n> But after the 20th reboot, man learns the modem controls him.\n>\n> — *Epictetus*\n\nSo here it is! working VoLTE calls with audio on linux!\n\nWhat a rollercoaster it has been i'll tell you that.\n\nThe frequency between my previous posts was only a few days, as you can see from the time since my last post this has been a *far* harder project.\n\nBut a successful one!\n\nNow fully powered trough my new imsd project:\n\nhttps://forgejo.catcrafts.net/Catcrafts/imsd\n\nAs i said in a my previous comment no matter what i did the regular modem route wasn't working, so i had to come up with my own.\n\nThis has several advantages however, call audio is now just routed trough wireplumber & pipewire, so its integrated much better.\n\nBoth inbound and outbound calls work.\n\n# How do i get this???\n\nThe patient route is waiting until my patches get accepted upstream, a few already have and are coming in 7.3 probably (pmos runs 7.0 currently still oof), a few will probably take longer, 7.4 or beyond.\n\nbut for the ones that want to jump right on ive published everything on my forgejo:\n\nhttps://forgejo.catcrafts.net/Catcrafts\n\nI will soon be publishing a shell script that does the entire installation soon for easy installation.\n\nSadly there is no way to just plug in an android and do it, so there are still a few manual steps needed, but i believe the path to adoption is reducing friction as much as possible, so ill do my best to make it as easy as possible to use.\n\n# imsd\n\nSo what is an \"imsd\"?\n\nits a full userspace implementation of the IMS protocol, which drives VoLTE (4G) calls.\n\nNormally the modem firmware drives this, but this is quite difficult to work with, now it runs fully via a standard daemon on linux!\n\nAlong with several d-bus helpers that integrate it with standard plasma-dialer.\n\nif made a APKBUILD for pmos that is mutually exclusive with 81voltd, and loads imsd instead. so installation should be easy.\n\n>Now will this actually work?\n\nThat's where you come in!\n\nI am just one guy with one phone with one carrier.\n\nI can say it works on the Fairphone 6 with KPN. it might work with other carriers and other Qualcomm phones aswell, so potentially this could work for the entire linux phone ecosystem, but i have no way to test that.\n\nIf you end up trying it, no matter if it works or doesn't work, please send me a message or open a PR, then we can make sure it works for everyone!\n\n# donations\n![](https://ani.social/pictrs/image/9673d610-cd1e-4fc0-9c04-647e6cbe12fc.webp)\n\nI really was surprised when people asked me this, i just see myself as a guy doing this in his free time, \"why would people want to send donations to me?\" i thought.\n\nI still stand by my previous point, i have a job, and this project costs me nothing except for time, i don't have any costs. So i felt kinda guilty receiving money from people.\n\nBut i would much rather spend my time making software that benefits society instead of the B2B stuff i do at my job.\n\nI really feel greedy for saying this, but if you want to send me a thank you then that is now possible at:\nhttps://bunq.me/catcrafts\n\nPlease don't feel any pressure to do this, doesn't matter what i get im still continuing with this bring-up. And if you don't want to donate to me there are many linux organizations that could really use the support! In my eyes we are all in the same boat with the same goal.\n\nIf you do decide to, and i cannot express my gratitude enough if you do, please write in the description that it's a donation, as this makes tax easier.\n\n# What's next?\n\nIve got a bunch more stuff coming soon, while i haven't posted here in quite a while i've been plenty busy on the mailing list:\n\n![](https://ani.social/pictrs/image/e5cf3c88-f6cd-4247-9db8-3206f742568b.webp)\n\nSo except some more posts soon.\n\n# Bonus round\n\nHaving fully working hardware is great, but a phone that has no apps is pointless for most people, so i would like to start a discussion in the comments.\n\n**What apps do you need that are preventing you from switching to mobile linux?**\n", "media": [ { - "src": "/media/8bc5b5a6565ba864.mp4", + "src": "https://ani.social/pictrs/image/50e19f20-3667-4368-91ec-d787c515b9e7.mp4", "kind": "video", - "poster": "/media/7133dc70534bf47a.webp", - "w": 854, - "h": 480 + "poster": "https://ani.social/pictrs/image/00dfc344-88e7-46a1-973b-db7f3e58f679.webp" } ], - "score": 505, - "comments": 95, - "body_media": [ - { - "src": "/media/0170c8f6099de41a.webp", - "kind": "image", - "poster": "", - "fallback": "/media/0170c8f6099de41a.png", - "avif": "/media/0170c8f6099de41a.avif", - "w": 972, - "h": 1920 - }, - { - "src": "/media/5d410350f777e5ca.webp", - "kind": "image", - "poster": "", - "fallback": "/media/5d410350f777e5ca.png", - "avif": "/media/5d410350f777e5ca.avif", - "w": 1737, - "h": 1471 - } - ] + "score": 511, + "comments": 95 }, { "title": "Fairphone 6 + PostmarketOS working mic!", @@ -152,16 +86,13 @@ "body": "My quest to 100% feature completeness continues!\n\nToday i bring before you...\nThe working microphone!\n\nNow everyone is going to ask:\n>Does this mean working calls?\n\nI asked the same thing, and with maybe a bit too much excitement i inserted my SIM card.\n\nBut the answer is no, neither audio nor mic work in calls. \n\nIt seems having them work in userspace is not enough.\n\nSo it seems that that requires additional work (coming soon!)\n\nTo give updates on my previous posts aswell.\n\nFor all 3 (GPS, NFC, audio) patches have been submitted, i've had contact with the freedesktop and maintainers on the LKML and they have requested changes, and a v2 is out for all. But don't expect them to reach phones anytime soon.\n\n### GPS:\n\nhttps://gitlab.freedesktop.org/mobile-broadband/libqmi/-/merge_requests/470: maintainer requested some renames, adressed and waiting for review.\n\nhttps://gitlab.freedesktop.org/mobile-broadband/ModemManager/-/merge_requests/1463: stuck as draft until libqmi patch lands\n\n### NFC:\n\nhttps://lore.kernel.org/all/20260705190621.128257-1-jorijnvdgraaf@catcrafts.net/ Sashiko AI review found a bug in my code and maintainer requested small change, both addressed in v2.\n\n### audio:\n\nhttps://lore.kernel.org/all/20260705033743.305783-1-jorijnvdgraaf@catcrafts.net/ maintainer requested significant changes, addressed in v2\n\nhttps://lore.kernel.org/all/20260704192857.88366-1-jorijnvdgraaf@catcrafts.net/ awaiting review\n\nhttps://lore.kernel.org/all/20260705033830.305907-1-jorijnvdgraaf@catcrafts.net/ RFC patch resulting from requested changes, i think this one might get quite the tail...\n\nAudio is turning into quite the story, way more then i expected.\n\nWhen having a working patch ive learned the work is far from over.\n\nI was thinking of maybe hosting my own downstream kernel with all my fixes, if there is demand for it.\n\n(also open letter to the mods if these posts are becoming spam and if its turning too much into my personal blog?)", "media": [ { - "src": "/media/8e0ef8ba7e9769ec.mp4", + "src": "https://ani.social/pictrs/image/752d0c86-666d-48dc-a0c5-aa2bba430afd.mp4", "kind": "video", - "poster": "/media/7f1d353c81fa55e6.webp", - "w": 480, - "h": 854 + "poster": "https://ani.social/pictrs/image/37bcfe3c-0c6b-4250-b432-7a71cf5de927.webp" } ], "score": 297, - "comments": 46, - "body_media": [] + "comments": 46 }, { "title": "Fairphone 6 + PostmarketOS working audio!", @@ -174,16 +105,13 @@ "body": "Well that was easy.\n\nHere i was thinking audio was going to be the boss fight...\n\nThese were small easily identifiable kernel changes, not the entire investigations that GPS and NFC needed.\n\nIll be working to getting calls fully working, but this is already a big step.\n\nAnd speaking about NFC, i just submitted the kernel patch for it to the mailing list! very nervous and excited to see how that turns out.", "media": [ { - "src": "/media/f9d29d90b4c9c9b9.mp4", + "src": "https://ani.social/pictrs/image/5963d0aa-5bff-4d46-bb1c-d3e1f6563918.mp4", "kind": "video", - "poster": "/media/07b79c6415f4c487.webp", - "w": 480, - "h": 854 + "poster": "https://ani.social/pictrs/image/25cca4db-40ab-4664-8a1d-6d93e3ffa249.webp" } ], - "score": 165, - "comments": 14, - "body_media": [] + "score": 167, + "comments": 14 }, { "title": "The linux phone travel experience", @@ -193,31 +121,16 @@ "community": "linuxphones@lemmy.ca", "published": "2026-07-01T16:15:57.088199Z", "excerpt": "No new features today, just wanted to make a short post. It is completely possible to do linux development on the go! Is it convenient? Probably not. Is a laptop better? Most likely. Is it funny? Yes. Also a double table is definitely better: photo of a linux phone with a…", - "body": "No new features today, just wanted to make a short post.\n\nIt is completely possible to do linux development on the go!\n\nIs it convenient? Probably not.\n\nIs a laptop better? Most likely.\n\nIs it funny? Yes.\n\nAlso a double table is definitely better:\n![photo of a linux phone with a bluetooth keyboard and mouse](/media/bd926465d78b0612.webp)\n\nWhile we are mostly focused on Linux mobile development, it is easy to overlook that all you need is a bluetooth keyboard and mouse and it becomes an (albeit tiny) linux desktop!", + "body": "No new features today, just wanted to make a short post.\n\nIt is completely possible to do linux development on the go!\n\nIs it convenient? Probably not.\n\nIs a laptop better? Most likely.\n\nIs it funny? Yes.\n\nAlso a double table is definitely better:\n![photo of a linux phone with a bluetooth keyboard and mouse](https://ani.social/pictrs/image/08073abf-6b1a-4f74-9943-af8b0b805c5d.webp)\n\nWhile we are mostly focused on Linux mobile development, it is easy to overlook that all you need is a bluetooth keyboard and mouse and it becomes an (albeit tiny) linux desktop!", "media": [ { - "src": "/media/1c75b19e98c7b6ef.webp", + "src": "https://ani.social/pictrs/image/a318590f-6ab0-48ac-8a1d-81c37cda77f0.webp", "kind": "image", - "poster": "", - "w": 1920, - "h": 887, - "fallback": "/media/1c75b19e98c7b6ef.png", - "avif": "/media/1c75b19e98c7b6ef.avif" + "poster": "" } ], - "score": 265, - "comments": 44, - "body_media": [ - { - "src": "/media/bd926465d78b0612.webp", - "kind": "image", - "poster": "", - "fallback": "/media/bd926465d78b0612.png", - "avif": "/media/bd926465d78b0612.avif", - "w": 1920, - "h": 887 - } - ] + "score": 266, + "comments": 44 }, { "title": "Fairphone 6 + PostmarketOS working NFC reading! + mini guide", @@ -227,38 +140,16 @@ "community": "linuxphones@lemmy.ca", "published": "2026-06-28T23:58:53.301629Z", "excerpt": "This is the continuation of my ongoing effort to get the FP6 fully feature complete with linux. I chose NFC cause i thought it would be relatively easy like GPS, well that assumption turned out to be wrong. But alot of time later and as you can see in the video now reading…", - "body": "![](/media/d317f689fbaad00f.webp)\n\nThis is the continuation of my ongoing effort to get the FP6 fully feature complete with linux.\n\nI chose NFC cause i thought it would be relatively easy like GPS, well that assumption turned out to be wrong.\n\nBut alot of time later and as you can see in the video now reading works! card emulation is a whole other can of worms that i want to tackle after.\n\nUnlike the GPS which was a userspace problem this is a true kernel patch and im quite nervous about submitting it, if anyone happens to have contributed to the kernel please tell me how it went, i would really appreciate any advice.\n\nI'm trying to flip all of these to a green wall of \"Works\": ![FP6 pmOS feature matrix](/media/4592752200e3cd6b.webp)\n\nGPS is now a solid green works when my patch gets approved, NFC now is partial.\n\n### Mobile Linux development mini guide for beginners\nDisclaimer: This guide is written by some guy who only vaguely knows what he is doing, please don't take this as authority.\n\nI will mostly write this in the perspective of the FP6, but it applies to other phones aswell since i'll try to keep it generic. I don't want to dig into the tech here but more the general process with some common pitfalls.\n\n1. **Pick a phone with a cooperative manufacturer**, Fairphone is excellent in this but there might be more.\n2. **Don't just flash linux onto it directly,** First test every hardware feature running on stock android, then you know 100% for sure it's not a hardware problem. i made this mistake and when i was really stuck with the NFC i thought maybe my phone is just broken since i never tested it on android and had to flash android again, turns out the hardware was fine as shown in the video.\n3. **Android is your friend, use it,** I know software devs love reinventing the wheel but don't, looking at android is the biggest help there. ideally you have a same model phone running (rooted) android by your side so you can directly compare, i know it's a hard ask to purchase 2 phones but i cannot understate how much it helps. what i did is i purchased the linux one for myself and the android one for a family member, which i borrow for development. During the GPS and NFC work i really just compared what the android driver transmitted vs the linux driver and it helps a ton.\n4. **Get a folder with all your research material** Clone the linux kernel, your downstream kernel if there is one for your device, an android kernel, and device trees from the manufacturer, and any technical documents you can find: for the FP6 i am using these: \n\n[downstream kernel](https://github.com/milos-mainline/linux) \n \n[Fairphone kernel](https://code.fairphone.com/projects/fairphone-gen-6/kernel.html) \n\n[e/OS kernel](https://gitlab.e.foundation/e/devices/android_device_fairphone_FP6-kernel.git) \n\n[Fairphone 6 technical manual (most of the document is not very interesting, but all the way at the bottom are wire diagrams, these are very helpfull)](https://www.fairphone.com/wp-content/uploads/2025/08/Fairphone-Gen.-6_-Information-on-how-to-repair-dispose-of-and-recycle-EN-NL-FR-DE.pdf)\n\n5. **Start small**, don't just grab the most valuable task from the beginning, start with the easiest task that ideally can be done in userspace.\n\n6. **Think before you act**, This is more of a do as i say not do as i do, cause i don't follow this rule fully myself but it is very important, you don't want to brick or fry your device, when flashing or working with voltages look twice before you push.\n\n7. **Keep your device charged**, When working on your device you probably have it connected to your PC 99% of the time, but your PC might not provide enough power to have a net positive while you look at your phone and go \"oh it's charging so it's fine\" hook it up to a dedicated USB charger every once in a while if this is the case on your device\n\nI wanted to cover these pitfalls specifically, there are probably better technical guides then i can write out there on stuff like adb, fastboot and the kernel.\n\nAnd if you only know basic C that is genuinely not an issue, good linux knowledge is far more important as you spend far more time troubleshooting then programming (atleast in my experience)\n\nAnd finally, you need alot of patience, it is not extremely difficult, just very time consuming.\n\nI get it is not for everyone, and i don't want to make it so like im pressuring people, but nobody is going to do this except us, the clock is ticking until android gets locked down, and big tech would much rather have us on their proprietary software where they can squize our wallets dry then free software.", + "body": "![](https://ani.social/pictrs/image/7970910a-8d30-4c83-aafa-f8c5ef055125.webp)\n\nThis is the continuation of my ongoing effort to get the FP6 fully feature complete with linux.\n\nI chose NFC cause i thought it would be relatively easy like GPS, well that assumption turned out to be wrong.\n\nBut alot of time later and as you can see in the video now reading works! card emulation is a whole other can of worms that i want to tackle after.\n\nUnlike the GPS which was a userspace problem this is a true kernel patch and im quite nervous about submitting it, if anyone happens to have contributed to the kernel please tell me how it went, i would really appreciate any advice.\n\nI'm trying to flip all of these to a green wall of \"Works\": ![FP6 pmOS feature matrix](https://ani.social/pictrs/image/f461a7c1-229a-4ad7-8134-a146f85f5999.webp)\n\nGPS is now a solid green works when my patch gets approved, NFC now is partial.\n\n### Mobile Linux development mini guide for beginners\nDisclaimer: This guide is written by some guy who only vaguely knows what he is doing, please don't take this as authority.\n\nI will mostly write this in the perspective of the FP6, but it applies to other phones aswell since i'll try to keep it generic. I don't want to dig into the tech here but more the general process with some common pitfalls.\n\n1. **Pick a phone with a cooperative manufacturer**, Fairphone is excellent in this but there might be more.\n2. **Don't just flash linux onto it directly,** First test every hardware feature running on stock android, then you know 100% for sure it's not a hardware problem. i made this mistake and when i was really stuck with the NFC i thought maybe my phone is just broken since i never tested it on android and had to flash android again, turns out the hardware was fine as shown in the video.\n3. **Android is your friend, use it,** I know software devs love reinventing the wheel but don't, looking at android is the biggest help there. ideally you have a same model phone running (rooted) android by your side so you can directly compare, i know it's a hard ask to purchase 2 phones but i cannot understate how much it helps. what i did is i purchased the linux one for myself and the android one for a family member, which i borrow for development. During the GPS and NFC work i really just compared what the android driver transmitted vs the linux driver and it helps a ton.\n4. **Get a folder with all your research material** Clone the linux kernel, your downstream kernel if there is one for your device, an android kernel, and device trees from the manufacturer, and any technical documents you can find: for the FP6 i am using these: \n\n[downstream kernel](https://github.com/milos-mainline/linux) \n \n[Fairphone kernel](https://code.fairphone.com/projects/fairphone-gen-6/kernel.html) \n\n[e/OS kernel](https://gitlab.e.foundation/e/devices/android_device_fairphone_FP6-kernel.git) \n\n[Fairphone 6 technical manual (most of the document is not very interesting, but all the way at the bottom are wire diagrams, these are very helpfull)](https://www.fairphone.com/wp-content/uploads/2025/08/Fairphone-Gen.-6_-Information-on-how-to-repair-dispose-of-and-recycle-EN-NL-FR-DE.pdf)\n\n5. **Start small**, don't just grab the most valuable task from the beginning, start with the easiest task that ideally can be done in userspace.\n\n6. **Think before you act**, This is more of a do as i say not do as i do, cause i don't follow this rule fully myself but it is very important, you don't want to brick or fry your device, when flashing or working with voltages look twice before you push.\n\n7. **Keep your device charged**, When working on your device you probably have it connected to your PC 99% of the time, but your PC might not provide enough power to have a net positive while you look at your phone and go \"oh it's charging so it's fine\" hook it up to a dedicated USB charger every once in a while if this is the case on your device\n\nI wanted to cover these pitfalls specifically, there are probably better technical guides then i can write out there on stuff like adb, fastboot and the kernel.\n\nAnd if you only know basic C that is genuinely not an issue, good linux knowledge is far more important as you spend far more time troubleshooting then programming (atleast in my experience)\n\nAnd finally, you need alot of patience, it is not extremely difficult, just very time consuming.\n\nI get it is not for everyone, and i don't want to make it so like im pressuring people, but nobody is going to do this except us, the clock is ticking until android gets locked down, and big tech would much rather have us on their proprietary software where they can squize our wallets dry then free software.", "media": [ { - "src": "/media/c8de4451ce9d5663.mp4", + "src": "https://ani.social/pictrs/image/fae177a4-73d2-43a7-9dd6-0228af1424f8.mp4", "kind": "video", - "poster": "/media/c6a1a1a41b2883e5.webp", - "w": 854, - "h": 480 + "poster": "https://ani.social/pictrs/image/883180d4-2994-44c9-b2a9-9ba5400b3e59.webp" } ], - "score": 218, - "comments": 16, - "body_media": [ - { - "src": "/media/4592752200e3cd6b.webp", - "kind": "image", - "poster": "", - "fallback": "/media/4592752200e3cd6b.png", - "avif": "/media/4592752200e3cd6b.avif", - "w": 540, - "h": 1460 - }, - { - "src": "/media/d317f689fbaad00f.webp", - "kind": "image", - "poster": "", - "fallback": "/media/d317f689fbaad00f.png", - "avif": "/media/d317f689fbaad00f.avif", - "w": 1920, - "h": 959 - } - ] + "score": 219, + "comments": 16 }, { "title": "Fairphone 6 + PostmarketOS working GPS!", @@ -268,40 +159,16 @@ "community": "linuxphones@lemmy.ca", "published": "2026-06-26T23:16:25.810172Z", "excerpt": "I really want to turn this phone into my daily driver, so i started working on one of the features i would want in a daily driver, working GPS. This is the current component support table of postmarketos: FP6 feature support table i think audio is too complex for me, so i…", - "body": "I really want to turn this phone into my daily driver, so i started working on one of the features i would want in a daily driver, working GPS.\n\nThis is the current component support table of postmarketos:\n![FP6 feature support table](/media/733ea2b14407776a.webp)\n\ni think audio is too complex for me, so i decided to tackle GPS, as you can see its currently listed as broken, but not anymore!\n\nIt really helped i have another FP6 running android, i could directly inspect how it was talking to the modem to get it working.\n\nSo i started pulling threads and here we are.\nI want to upstream this and i'm making an account so i can submit patches for modemmanager and libqmi, but i have never upstreamed something before, so advice is appreciated!\n\nIf you have a linux phone and want to use it as a daily driver, see if you can contribute it! this is how we build up the linux ecosystem.\n\n![I want you poster uncle sam meme with Tux captioned \"I want you to contribute to mobile linux\"](/media/fd8212f14c48b135.webp)", + "body": "I really want to turn this phone into my daily driver, so i started working on one of the features i would want in a daily driver, working GPS.\n\nThis is the current component support table of postmarketos:\n![FP6 feature support table](https://ani.social/pictrs/image/3b42a6d3-3ded-477d-85db-61c2461a8431.webp)\n\ni think audio is too complex for me, so i decided to tackle GPS, as you can see its currently listed as broken, but not anymore!\n\nIt really helped i have another FP6 running android, i could directly inspect how it was talking to the modem to get it working.\n\nSo i started pulling threads and here we are.\nI want to upstream this and i'm making an account so i can submit patches for modemmanager and libqmi, but i have never upstreamed something before, so advice is appreciated!\n\nIf you have a linux phone and want to use it as a daily driver, see if you can contribute it! this is how we build up the linux ecosystem.\n\n![I want you poster uncle sam meme with Tux captioned \"I want you to contribute to mobile linux\"](https://ani.social/pictrs/image/6454870a-cb00-4bca-b18d-3502a668fd22.webp)", "media": [ { - "src": "/media/ce6475f10b3baeee.webp", + "src": "https://ani.social/pictrs/image/c2561f4d-73ce-4e89-89bd-5ada7990ded9.webp", "kind": "image", - "poster": "", - "w": 887, - "h": 1920, - "fallback": "/media/ce6475f10b3baeee.png", - "avif": "/media/ce6475f10b3baeee.avif" + "poster": "" } ], - "score": 472, - "comments": 24, - "body_media": [ - { - "src": "/media/733ea2b14407776a.webp", - "kind": "image", - "poster": "", - "fallback": "/media/733ea2b14407776a.png", - "avif": "/media/733ea2b14407776a.avif", - "w": 525, - "h": 1451 - }, - { - "src": "/media/fd8212f14c48b135.webp", - "kind": "image", - "poster": "", - "fallback": "/media/fd8212f14c48b135.png", - "avif": "/media/fd8212f14c48b135.avif", - "w": 1080, - "h": 1585 - } - ] + "score": 474, + "comments": 24 }, { "title": "Fairphone 6 + PostmarketOS review", @@ -311,29 +178,9 @@ "community": "linuxphones@lemmy.ca", "published": "2026-06-25T01:47:05.645866Z", "excerpt": "This is my second linux phone and coming into it i had high hopes and low expectations, the result was a mixed bag but generally positive. My first was linux phone was the PinePhonePro and i really liked it at the time as a concept, but it was hard to use it as a daily driver, i…", - "body": "This is my second linux phone and coming into it i had high hopes and low expectations, the result was a mixed bag but generally positive.\n\nMy first was linux phone was the PinePhonePro and i really liked it at the time *as a concept*, but it was hard to use it as a daily driver, i think i lasted around a month before buying an android.\n\nThe PPP had several problems, it was very slow, would get very hot, and my primary concern as i said [here](https://ani.social/post/20572592/12808539) its battery was terrible.\n\nAnd the battery has since fully died, so sadly i can't run side by side tests.\n\n![FP6 and PPP side by side](/media/5f8d4b8063302e0d.webp)\n\nSo that brings us to the FP6.\n\nThe installation was pretty smooth, follow the steps from the wiki and for me they worked perfectly.\n\nThe usage itself feels alot more snappy compared to the PPP, general UI work is comparable to a normal android i would say, and no longer each action has a delay.\n\nSo i wanted to tackle the battery point, i ran a few tests, please take these with a grain of salt as they were not done professionally, but they should be indicative:\n\n```\nIdle (not full suspend as the SSH connection was still active):\nPower\t0.43 W (98 mA @ 4.44 V)\nDrain\t2.2 %/hr (45.4h)\nCPU\t0.9%\nGPU\t264 MHz\n\nIdle with screen on:\nPower\t0.95 W (214 mA @ 4.43 V)\nDrain\t4.9 %/hr (20.4 h)\nCPU\t3.0%\nGPU\t277 MHz\n\nWatching 1080p H.264 video with BT headphones:\nPower\t1.57 W (359 mA @ 4.36 V) \nDrain\t8.1 %/hr (12.3h)\nCPU\t20.7%\nGPU 278 MHz\n\nPeak CPU:\nPower draw : 6.15 W (1437 mA @ 4.28 V)\nDrain rate : 32.6 %/hr (3.0 h)\nCPU usage : 100.0 %\nGPU : 277 MHz\n```\n\nTemprature stayed fine across all tests only during the CPU test did it became warm but not too much, which is in stark contrast to the PPP which would get very uncomfortable to hold, and be severly battery drained.\n\nI think these values are interesting when compared to the same test on e/OS, well atleast that's what i would say if i could gather it.\n\n![FP6 with PostmarketOS and e/OS side by side](/media/65f53d89e5372e53.webp)\n\nSadly i was not able to gather detailed power data on android.\n\n```\nIdle with screen on:\nCPU usage : 18.7 %\nGPU usage : 11.7 %\n```\n\n18.7% seemed very sus, but it checks out?\n```\n58% 3760/foundation.e.blisslauncher: 49% user + 9.3% kernel / faults: 13209 minor\n21% 1476/surfaceflinger: 14% user + 6.9% kernel / faults: 18 minor\n12% 1383/vendor.qti.hardware.display.composer-service: 6.7% user + 5.5% kernel / faults: 137 minor\n4.9% 972/crtc_commit:137: 0% user + 4.9% kernel\n4.2% 986/android.system.suspend-service: 0% user + 4.2% kernel\n2.3% 3353/com.android.systemui: 1.6% user + 0.6% kernel / faults: 8031 minor\n2.3% 2364/system_server: 1.1% user + 1.1% kernel / faults: 5054 minor\n2.1% 26001/kworker/u16:8-qc_ufs_qos_swq: 0% user + 2.1% kernel\n2.1% 25893/kworker/u16:3-kgsl_devfreq_wq: 0% user + 2.1% kernel\n2% 539/irq/153-24091000.qcom,bwmon-ddr: 0% user + 2% kernel\n2% 15/rcuog/0: 0% user + 2% kernel\n1.7% 4284/foundation.e.advancedprivacy: 1% user + 0.6% kernel / faults: 693 minor\n1.1% 14/rcu_preempt: 0% user + 1.1% kernel\n0.9% 25150/kworker/u16:13-events_unbound: 0% user + 0.9% kernel\n0.7% 973/crtc_event:137: 0% user + 0.7% kernel\n0.7% 946/kgsl_hwsched: 0% user + 0.7% kernel\n0.7% 25148/kworker/u16:11-wmi_rx_diag_event_work_: 0% user + 0.7% kernel\n0.5% 23213/kworker/u16:0-wmi_rx_event_work_queue: 0% user + 0.5% kernel\n0.6% 1393/vendor.qti.hardware.perf2-hal-service: 0.1% user + 0.4% kernel / faults: 38 minor\n0.4% 26004/kworker/u16:12-events_unbound: 0% user + 0.4% kernel\n0.3% 1338/android.hardware.audio.service: 0.1% user + 0.2% kernel / faults: 34 minor\n0.4% 1362/android.hardware.wifi-service: 0.1% user + 0.2% kernel\n0.3% 23453/kworker/0:2-events: 0% user + 0.3% kernel\n```\n```\nWatching 1080p video:\nCPU usage 19.6%\nGPU usage 7.3%\n```\n\t\nit seems the launcher from e/OS really is slurping down CPU.\n\nSo where does this leave us?\n\nWould i recommend this as a daily driver?\n\nNo. If i gave this phone to someone who is not a linux fan they would probably get very frustrated.\n\nIs it *usable* as a daily driver?\n\nI think this is the more interesting question, if you really wanted you could definitely use this as a working daily driver, if you don't mind not having a camera or audio that is. BT works fine, so when combined with voip its a usable phone. and does all the phone things you would want it to do. Also if you are photosensitive it might not be a fit as there are currently some graphical flicker glitches.\nThe audio driver is in the works if i look at postmarketos so if that gets fixed it would become much more daily driver worthy.\n\nFor the people interested i have also uploaded the [vulkaninfo](https://vulkan.gpuinfo.org/displayreport.php?id=49756)", + "body": "This is my second linux phone and coming into it i had high hopes and low expectations, the result was a mixed bag but generally positive.\n\nMy first was linux phone was the PinePhonePro and i really liked it at the time *as a concept*, but it was hard to use it as a daily driver, i think i lasted around a month before buying an android.\n\nThe PPP had several problems, it was very slow, would get very hot, and my primary concern as i said [here](https://ani.social/post/20572592/12808539) its battery was terrible.\n\nAnd the battery has since fully died, so sadly i can't run side by side tests.\n\n![FP6 and PPP side by side](https://ani.social/pictrs/image/78d852ac-2312-4144-b8ca-0b78348f4547.webp)\n\nSo that brings us to the FP6.\n\nThe installation was pretty smooth, follow the steps from the wiki and for me they worked perfectly.\n\nThe usage itself feels alot more snappy compared to the PPP, general UI work is comparable to a normal android i would say, and no longer each action has a delay.\n\nSo i wanted to tackle the battery point, i ran a few tests, please take these with a grain of salt as they were not done professionally, but they should be indicative:\n\n```\nIdle (not full suspend as the SSH connection was still active):\nPower\t0.43 W (98 mA @ 4.44 V)\nDrain\t2.2 %/hr (45.4h)\nCPU\t0.9%\nGPU\t264 MHz\n\nIdle with screen on:\nPower\t0.95 W (214 mA @ 4.43 V)\nDrain\t4.9 %/hr (20.4 h)\nCPU\t3.0%\nGPU\t277 MHz\n\nWatching 1080p H.264 video with BT headphones:\nPower\t1.57 W (359 mA @ 4.36 V) \nDrain\t8.1 %/hr (12.3h)\nCPU\t20.7%\nGPU 278 MHz\n\nPeak CPU:\nPower draw : 6.15 W (1437 mA @ 4.28 V)\nDrain rate : 32.6 %/hr (3.0 h)\nCPU usage : 100.0 %\nGPU : 277 MHz\n```\n\nTemprature stayed fine across all tests only during the CPU test did it became warm but not too much, which is in stark contrast to the PPP which would get very uncomfortable to hold, and be severly battery drained.\n\nI think these values are interesting when compared to the same test on e/OS, well atleast that's what i would say if i could gather it.\n\n![FP6 with PostmarketOS and e/OS side by side](https://ani.social/pictrs/image/379275ba-b8c9-464e-b0b5-4ea80a3e94f4.webp)\n\nSadly i was not able to gather detailed power data on android.\n\n```\nIdle with screen on:\nCPU usage : 18.7 %\nGPU usage : 11.7 %\n```\n\n18.7% seemed very sus, but it checks out?\n```\n58% 3760/foundation.e.blisslauncher: 49% user + 9.3% kernel / faults: 13209 minor\n21% 1476/surfaceflinger: 14% user + 6.9% kernel / faults: 18 minor\n12% 1383/vendor.qti.hardware.display.composer-service: 6.7% user + 5.5% kernel / faults: 137 minor\n4.9% 972/crtc_commit:137: 0% user + 4.9% kernel\n4.2% 986/android.system.suspend-service: 0% user + 4.2% kernel\n2.3% 3353/com.android.systemui: 1.6% user + 0.6% kernel / faults: 8031 minor\n2.3% 2364/system_server: 1.1% user + 1.1% kernel / faults: 5054 minor\n2.1% 26001/kworker/u16:8-qc_ufs_qos_swq: 0% user + 2.1% kernel\n2.1% 25893/kworker/u16:3-kgsl_devfreq_wq: 0% user + 2.1% kernel\n2% 539/irq/153-24091000.qcom,bwmon-ddr: 0% user + 2% kernel\n2% 15/rcuog/0: 0% user + 2% kernel\n1.7% 4284/foundation.e.advancedprivacy: 1% user + 0.6% kernel / faults: 693 minor\n1.1% 14/rcu_preempt: 0% user + 1.1% kernel\n0.9% 25150/kworker/u16:13-events_unbound: 0% user + 0.9% kernel\n0.7% 973/crtc_event:137: 0% user + 0.7% kernel\n0.7% 946/kgsl_hwsched: 0% user + 0.7% kernel\n0.7% 25148/kworker/u16:11-wmi_rx_diag_event_work_: 0% user + 0.7% kernel\n0.5% 23213/kworker/u16:0-wmi_rx_event_work_queue: 0% user + 0.5% kernel\n0.6% 1393/vendor.qti.hardware.perf2-hal-service: 0.1% user + 0.4% kernel / faults: 38 minor\n0.4% 26004/kworker/u16:12-events_unbound: 0% user + 0.4% kernel\n0.3% 1338/android.hardware.audio.service: 0.1% user + 0.2% kernel / faults: 34 minor\n0.4% 1362/android.hardware.wifi-service: 0.1% user + 0.2% kernel\n0.3% 23453/kworker/0:2-events: 0% user + 0.3% kernel\n```\n```\nWatching 1080p video:\nCPU usage 19.6%\nGPU usage 7.3%\n```\n\t\nit seems the launcher from e/OS really is slurping down CPU.\n\nSo where does this leave us?\n\nWould i recommend this as a daily driver?\n\nNo. If i gave this phone to someone who is not a linux fan they would probably get very frustrated.\n\nIs it *usable* as a daily driver?\n\nI think this is the more interesting question, if you really wanted you could definitely use this as a working daily driver, if you don't mind not having a camera or audio that is. BT works fine, so when combined with voip its a usable phone. and does all the phone things you would want it to do. Also if you are photosensitive it might not be a fit as there are currently some graphical flicker glitches.\nThe audio driver is in the works if i look at postmarketos so if that gets fixed it would become much more daily driver worthy.\n\nFor the people interested i have also uploaded the [vulkaninfo](https://vulkan.gpuinfo.org/displayreport.php?id=49756)", "media": [], - "score": 89, - "comments": 14, - "body_media": [ - { - "src": "/media/5f8d4b8063302e0d.webp", - "kind": "image", - "poster": "", - "fallback": "/media/5f8d4b8063302e0d.png", - "avif": "/media/5f8d4b8063302e0d.avif", - "w": 1920, - "h": 1626 - }, - { - "src": "/media/65f53d89e5372e53.webp", - "kind": "image", - "poster": "", - "fallback": "/media/65f53d89e5372e53.png", - "avif": "/media/65f53d89e5372e53.avif", - "w": 1920, - "h": 1820 - } - ] + "score": 92, + "comments": 14 } ] diff --git a/shared/interfaces/Catcrafts.Shared-Content.cppm b/shared/interfaces/Catcrafts.Shared-Content.cppm index c586722..02419b6 100644 --- a/shared/interfaces/Catcrafts.Shared-Content.cppm +++ b/shared/interfaces/Catcrafts.Shared-Content.cppm @@ -350,10 +350,14 @@ export const std::vector& LegalPages() { } }, { "Where Catcrafts sells", { - "Catcrafts does not sell or ship to the United States or Canada. Checkout refuses a delivery address in either country, and no order can be placed from one. This is a deliberate decision about liability cover, which for a shop this size is written for the world excluding those two countries, and not a judgement about anyone living there.", - "The same applies to an order that is bound for either country by another route: if a parcel is to be forwarded there, or the delivery address belongs to a forwarding service acting for a customer there, the order is refused, and refunded in full if that only becomes clear after payment. Please do not try to route around this as the point is that the sale does not happen, not that the address looks European.", - "Separately, Catcrafts cannot sell or ship to Russia, Belarus or North Korea. Unlike the paragraphs above this is not a choice: European Union sanctions prohibit exporting consumer electronics to those countries, and the prohibition covers indirect routes: a forwarding address, a reseller, or a purchase made on behalf of someone there just as much as a direct parcel. Checkout refuses a delivery address in any of them, and an order that turns out to be bound there by another route is refused and the payment returned.", - "Everywhere else Catcrafts ships is served on the terms above, and the software itself remains free for anyone anywhere: the sources and images are public, and flashing a device you already own is not a purchase and is not restricted by this section.", + "Catcrafts ships to a short list of countries, and the list is the whole policy: the Netherlands, Switzerland, Serbia, Montenegro, Albania, Kosovo, Georgia, Australia, Hong Kong and Singapore. Checkout accepts a delivery address in those and refuses every other country. The current list is always the one checkout enforces, so if a country is not offered at checkout it is not offered at all. Outside the Netherlands you are the importer: the parcel is sent duty unpaid, and your own customs authority charges you import VAT and any duty when it arrives. That is not a way of pushing costs onto you — in two of these countries it is the specific legal reason the sale is permitted at all.", + "The reason it is a list of what is allowed rather than a list of what is not, is that the rules are national and there are nearly two hundred of them. Selling a phone into a country can enrol Catcrafts in that country's recycling and battery schemes, require its own radio approval, or require the handset to be registered in a national database before any local network will carry a call. Those obligations attach before the first parcel, not after it, and a shop this size cannot pretend to have read every country's version. So a country is added when it has actually been checked, and until then the answer is no. That is a deliberately cautious default and it will sometimes say no to a country that would have been fine.", + "If your country is not on the list, please write to orders@catcrafts.net. There is no waiting list and no promised date, but which country gets looked at next is decided by who asks, so asking genuinely works.", + "Catcrafts does not ship to the United States or Canada, and these are worth naming because they are the two people ask about most. Canada is not a choice: the phone holds no Canadian radio certification, and Canadian law prohibits importing, distributing or selling uncertified radio equipment, so it cannot lawfully be sold there by anyone at present. The United States is a combination — a customs entry is now required for every individual parcel, the mobile networks certify which handsets may be activated, and none of the legal protection that shields telephone companies over emergency calls extends to whoever made the device. Insurance is part of the picture too, but it is no longer the interesting part, and buying a policy would not open either country.", + "Separately, Catcrafts cannot sell or ship to Russia, Belarus or North Korea. Unlike everything above this is not a judgement call: European Union sanctions prohibit exporting consumer electronics to those countries, and the prohibition covers indirect routes, a forwarding address, a reseller, or a purchase made on behalf of someone there just as much as a direct parcel. Checkout refuses a delivery address in any of them, and an order that turns out to be bound there by another route is refused and the payment returned.", + "The same anti-routing rule applies to every refused country, not only the sanctioned ones: if a parcel is to be forwarded onward, or the delivery address belongs to a forwarding service acting for a customer elsewhere, the order is refused, and refunded in full if that only becomes clear after payment. Please do not try to route around it. The point is that the sale does not happen, not that the address looks acceptable.", + "One more thing that can refuse an address even inside the list: shipping is priced entirely from the carrier's own rates, so a destination the carrier has no rate for cannot be quoted and cannot be ordered. That is a different and much softer no than the ones above, it is about a price not existing rather than a sale being refused, and it can change whenever the carrier's tariff does.", + "The software itself remains free for anyone anywhere, and none of this touches it: the sources and images are public, and flashing a device you already own is not a purchase and is not restricted by this section.", } }, { "Warranty", { diff --git a/shared/interfaces/Catcrafts.Shared-Form.cppm b/shared/interfaces/Catcrafts.Shared-Form.cppm index 14ba5c8..d7a0580 100644 --- a/shared/interfaces/Catcrafts.Shared-Form.cppm +++ b/shared/interfaces/Catcrafts.Shared-Form.cppm @@ -228,23 +228,55 @@ export inline constexpr std::string_view kPayCrypto = "crypto"; // "quantity=99999999999" before any of that arithmetic runs. export inline constexpr std::int64_t kMaxQuantity = 99; -// What a buyer in a NoSaleCountries destination is told. Stated as a standing -// fact about where the shop sells, not as an apology or an outage: someone -// there should close the tab rather than retry tomorrow or hunt for a -// workaround. The reason itself (insurance territory) is on the terms page — -// a form field is the wrong place for it. +// What a buyer outside Money::ShippableCountries is told. // -// One definition, three renderings: this error, the note above the form, and -// the on-page total preview, so the page can never encourage an order the -// server will refuse. -export inline constexpr std::string_view kNoSaleMessage = - "Catcrafts does not sell or ship to the United States or Canada."; +// One sentence for every refusal that is not sanctions, because from the buyer's +// side they are all one fact: their country has not been cleared. Merging them is +// also the honest shape — the old split between "we are not registered there", +// "the handset would not work there" and "our insurer excludes it" described +// Catcrafts' internal reasons, not anything the buyer can act on. +// +// Names the reason as regulatory rather than commercial, because a Dutch shop +// that plainly posts worldwide would otherwise read as arbitrary, or worse as +// quietly declining someone. The truth is duller and better: selling a phone into +// a country means meeting that country's rules first, and doing it in the wrong +// order is the illegal part. +// +// No date is promised. Which country comes next depends on which one people ask +// for, and an invented timeline is worse than none — so the address is the point +// of the last clause. +// +// One definition, three renderings: this error, the note above the form, and the +// on-page total preview, so the page can never encourage an order the server +// will refuse. +export inline constexpr std::string_view kRegulatoryMessage = + "Catcrafts can't ship there. Selling a phone into a country means meeting " + "that country's own rules first — recycling schemes, radio approval, import " + "registration — and only a few are cleared so far. Email " + "orders@catcrafts.net if you would like yours looked at next."; -// The sanctions refusal, in different words on purpose: kNoSaleMessage states -// a choice, this states a prohibition. Naming the reason here rather than only -// on the terms page — unlike the insurance one — because "sanctions" is the -// whole answer: nothing about the shop could change it, and a buyer told only -// "no" would rightly ask why. +// The standing note above the buy form, and the one place the policy is stated +// POSITIVELY. With an allow-list that is the only useful phrasing: "we ship to +// these five" tells a visitor in one glance what "we cannot ship to two hundred +// others" never would, and it stops someone filling in a whole address before +// the field error tells them no. +// +// Prose rather than generated from Money::ShippableCountries because that array +// holds ISO codes and a buyer should not have to decode "NZ". The duplication is +// real, so ShouldComputeMoney asserts the array's LENGTH — add a country and that +// test fails, pointing here. Update both or neither. +export inline constexpr std::string_view kShipsToMessage = + "Catcrafts currently ships to the Netherlands, Switzerland, Serbia, " + "Montenegro, Albania, Kosovo, Georgia, Australia, Hong Kong and " + "Singapore. Other " + "countries are being worked through one at a time; email " + "orders@catcrafts.net if you would like yours looked at next."; + +// The sanctions refusal, in different words on purpose: kRegulatoryMessage +// describes paperwork that could be done, this states a prohibition that cannot. +// Naming the reason here rather than only on the terms page, because "sanctions" +// is the whole answer: nothing about the shop could change it, and a buyer told +// only "no" would rightly ask why. export inline constexpr std::string_view kSanctionsMessage = "Catcrafts cannot sell or ship to Russia, Belarus or North Korea: " "EU sanctions prohibit exporting consumer electronics there."; @@ -365,14 +397,15 @@ export CheckoutResult ValidateCheckout(const Fields& f) { } else if (!LooksLikeCountryCode(country)) { r.errors.push_back({ "country", "Country must be a two-letter code." }); } else if (Money::IsSanctioned(r.value.country)) { - // Checked before the general refusal because SellsTo denies both and - // the words differ: this one says the law forbids the sale, not that - // the shop chose not to make it. + // Sanctions are checked first because both gates deny and only the words + // differ: this one says the law forbids the sale, not that the shop has + // not got round to that country yet. r.errors.push_back({ "country", std::string(kSanctionsMessage) }); - } else if (!Money::SellsTo(r.value.country)) { - // The refusal happens here, in validation, rather than at the payment - // step: no order record, no payment link, nothing charged to undo. - r.errors.push_back({ "country", std::string(kNoSaleMessage) }); + } else if (!Money::ShipsTo(r.value.country)) { + // Everything not on the shipping list. The refusal happens here, in + // validation, rather than at the payment step: no order record, no + // payment link, nothing charged to undo. + r.errors.push_back({ "country", std::string(kRegulatoryMessage) }); } // Colour: shape only (slug-ish, bounded). Whether it names a variant that diff --git a/shared/interfaces/Catcrafts.Shared-Markdown.cppm b/shared/interfaces/Catcrafts.Shared-Markdown.cppm index 9d30fa7..0f763d3 100644 --- a/shared/interfaces/Catcrafts.Shared-Markdown.cppm +++ b/shared/interfaces/Catcrafts.Shared-Markdown.cppm @@ -18,9 +18,9 @@ No permission is granted to copy, modify, distribute, or create derivative works // WHAT IT SUPPORTS — everything observed in the real bodies, and nothing else: // // blocks ATX headings, fenced code, blockquotes (nested), ordered and -// unordered lists, thematic breaks, paragraphs -// inline links, images, code spans, ** strong **, * emphasis *, and bare -// URLs via Html::Autolink +// unordered lists, pipe tables, thematic breaks, paragraphs +// inline links, images, code spans, ** strong **, * emphasis *, +// ~~ strikethrough ~~, and bare URLs via Html::Autolink // // WHAT IT DELIBERATELY DOES NOT SUPPORT: // @@ -34,8 +34,15 @@ No permission is granted to copy, modify, distribute, or create derivative works // these posts unquoted (kworker/u16:8-qc_ufs_qos_swq), and mangling half a // symbol name into italics is a worse failure than not italicising a word // that used the underscore form. Asterisks are unambiguous here. -// * Setext headings, reference links, tables, footnotes, HTML entities. -// None appear; adding them speculatively is parser surface with no reader. +// * Single-tilde strikethrough, for the same reason. GFM accepts one tilde or +// two; a lone `~` in these posts is a home directory (`~/.local`) or an +// approximation (`~5 minutes`), so only the doubled form strikes anything. +// * Tilde-fenced code. `~~~` would be ambiguous with the above and no body +// uses it; a fence here is written with backticks. +// * Setext headings, reference links, footnotes, HTML entities. None appear; +// adding them speculatively is parser surface with no reader. (Tables did +// appear — the carrier compatibility list and the GPU price comparison are +// written as pipe tables — which is why they are in the list above now.) // * Trailing-double-space hard breaks. An invisible two-character difference // is not something a reader can see in the source or a writer can rely on // having typed; the lines of a paragraph join with a space, and a break @@ -264,6 +271,30 @@ SafeHtml RenderInline(std::string_view text, std::span media, i } } + // ~~struck~~, structurally the same as **strong** and sharing its + // open/close tests, so "a ~~ b ~~ c" stays literal the same way + // "a ** b ** c" does. + // + // rather than GFM's : nothing was removed from this document. + // is a claim that an edit happened, and the one body that uses + // this is striking a joke through for effect — which is exactly what + // is for ("no longer accurate or no longer relevant"). + if (c == '~' && i + 1 < text.size() && text[i + 1] == '~') { + const std::size_t inner = i + 2; + if (depth < kMaxDepth && OpensEmphasis(text, inner)) { + const std::size_t close = FindEmphasisClose(text, inner, "~~"); + if (close != std::string_view::npos) { + flush(i); + out.push_back(Format( + R"({})", + RenderInline(text.substr(inner, close - inner), media, depth + 1))); + i = close + 2; + run = i; + continue; + } + } + } + ++i; } @@ -338,9 +369,123 @@ ListMarker ParseListMarker(std::string_view line) { return m; } -bool StartsBlock(std::string_view line) { +// ── tables ──────────────────────────────────────────────────────────── +// +// GFM pipe tables. These arrived after the parser did: the carrier +// compatibility list in the camera post and the GPU price comparison in +// another are written as tables, and with no table support a table is the +// worst-degrading construct there is — the rows join into one run-on +// paragraph of pipes and dashes, which is neither the data nor prose. +// +// Recognition deliberately needs TWO lines: a row, and under it an alignment +// row agreeing about the number of columns. That is what keeps ordinary prose +// safe, because a pipe by itself is common in these posts (`dmesg | grep`, an +// or-list, a pasted command) and nothing under those lines is `|---|---|`. + +// A column's alignment, as the delimiter row asked for it. Left is absent +// because it is what the CSS already does — `:---` and a bare `---` produce +// identical markup, so only the two that change something carry a class. +enum class Align { Default, Center, Right }; + +// A row split into cells on unescaped pipes, with the optional outer pipes +// dropped. Both are optional independently, which is not pedantry: the real +// carrier table is written `| Device | OS | Carrier` — leading pipe, no +// trailing one — and requiring both would leave it as prose. +// +// `\|` is left in the cell text for RenderInline to turn into a literal pipe; +// that is how a cell contains one. +std::vector SplitRow(std::string_view line) { + std::string_view s = Trim(Undent(line)); + if (s.starts_with('|')) s.remove_prefix(1); + if (s.size() > 1 && s.ends_with('|') && !s.ends_with("\\|")) s.remove_suffix(1); + + std::vector cells; + std::size_t start = 0; + for (std::size_t i = 0; i < s.size(); ++i) { + if (s[i] == '\\') { ++i; continue; } + if (s[i] != '|') continue; + cells.push_back(Trim(s.substr(start, i - start))); + start = i + 1; + } + cells.push_back(Trim(s.substr(start))); + return cells; +} + +// One cell of the delimiter row: `---`, `:---`, `---:`, `:---:` and nothing +// else. A single dash is enough — `| - |` is a table people write. +bool IsDelimiterCell(std::string_view cell, Align& align) { + const bool left = cell.starts_with(':'); + if (left) cell.remove_prefix(1); + const bool right = cell.ends_with(':'); + if (right) cell.remove_suffix(1); + if (cell.empty()) return false; + for (const char c : cell) { + if (c != '-') return false; + } + align = (left && right) ? Align::Center : right ? Align::Right : Align::Default; + return true; +} + +// The alignment of each column, or an empty vector when `line` is not the +// delimiter row of a table whose header had `columns` cells. +std::vector ParseDelimiterRow(std::string_view line, std::size_t columns) { + // A pipe is required, so `---` under a one-cell row stays the thematic + // break it looks like — the same call this file already makes for `- - -` + // over a one-item list. + if (Trim(line).find('|') == std::string_view::npos) return {}; + + std::vector aligns; + for (const std::string_view cell : SplitRow(line)) { + Align align = Align::Default; + if (!IsDelimiterCell(cell, align)) return {}; + aligns.push_back(align); + } + // GFM's rule, and a useful one: a mismatched count is far more likely to be + // prose that happens to contain pipes than a table its author miscounted. + return aligns.size() == columns ? aligns : std::vector{}; +} + +bool IsTableStart(std::span lines, std::size_t at) { + if (at + 1 >= lines.size()) return false; + if (Trim(Undent(lines[at])).find('|') == std::string_view::npos) return false; + return !ParseDelimiterRow(lines[at + 1], SplitRow(lines[at]).size()).empty(); +} + +// A row's cells, as or . Rendered per cell rather than per line, so a +// cell holds links, code spans and emphasis like any other prose. +// +// A row shorter than the header is padded with empty cells so the grid stays +// rectangular; a row LONGER than it keeps its extras rather than having them +// dropped, which follows the rule the rest of this file follows — content that +// confuses the parser shows up looking odd instead of disappearing. +SafeHtml RenderRow(std::span cells, std::span aligns, + bool header, std::span media, int depth) { + std::vector out; + const std::size_t columns = std::max(cells.size(), aligns.size()); + for (std::size_t c = 0; c < columns; ++c) { + const Align align = c < aligns.size() ? aligns[c] : Align::Default; + const SafeHtml cls = + align == Align::Center ? Attr("class", "post-body__cell--center") + : align == Align::Right ? Attr("class", "post-body__cell--right") + : SafeHtml{}; + const SafeHtml inner = + RenderInline(c < cells.size() ? cells[c] : std::string_view{}, media, depth); + out.push_back(header ? Format("{}", cls, inner) + : Format("{}", cls, inner)); + } + return Format("{}", Join(out)); +} + +// ── block dispatch ──────────────────────────────────────────────────── + +// Whether a new block begins at `at`, so a paragraph is interrupted by one +// rather than swallowing it as text. Takes the whole span because a table is +// the one construct that cannot be recognised from a single line. +bool StartsBlock(std::span lines, std::size_t at) { + const std::string_view line = lines[at]; return HeadingLevel(Undent(line)) > 0 || IsFence(line) || IsThematicBreak(line) - || Undent(line).starts_with('>') || ParseListMarker(line).ok; + || Undent(line).starts_with('>') || ParseListMarker(line).ok + || IsTableStart(lines, at); } SafeHtml RenderBlocks(std::span lines, @@ -524,7 +669,7 @@ SafeHtml RenderBlocks(std::span lines, // A non-marker line that would start some other block ends the // list; anything else is this item's text continuing onto the // next line. - if (StartsBlock(lines[j])) break; + if (StartsBlock(lines, j)) break; current.push_back(lines[j]); ++j; } @@ -545,13 +690,48 @@ SafeHtml RenderBlocks(std::span lines, continue; } + // ── table ───────────────────────────────────────────────────── + // + // Below the list branch on purpose: `- a | b` over a `|---|---|` + // satisfies both tests, and a line that opens with a list marker is a + // list item. Ordinary tables are unaffected — a pipe is never a list + // marker, so they reach here either way. + if (IsTableStart(lines, i)) { + const std::vector header = SplitRow(line); + const std::vector aligns = ParseDelimiterRow(lines[i + 1], header.size()); + + std::vector rows; + std::size_t j = i + 2; + while (j < lines.size() && !Blank(lines[j])) { + // A row has to have a pipe in it. A line without one directly + // under a table is prose whose author forgot the blank line — + // rendering it as a lone one-column row would be worse than + // ending the table and letting it be the paragraph it is. + if (Trim(Undent(lines[j])).find('|') == std::string_view::npos) break; + if (StartsBlock(lines, j)) break; + rows.push_back(RenderRow(SplitRow(lines[j]), aligns, false, media, depth)); + ++j; + } + + // The wrapper is what scrolls. A table cannot be narrowed below its + // content, so without a container around it a nine-column price + // comparison scrolls the PAGE sideways on a phone — the one thing + // wide content must never do (same reasoning as the code block). + out.push_back(Format( + R"(
)" + R"({}{}
)", + RenderRow(header, aligns, true, media, depth), Join(rows))); + i = j; + continue; + } + // ── paragraph ───────────────────────────────────────────────── { std::size_t j = i; while (j < lines.size() && !Blank(lines[j])) { // A block marker on a later line interrupts the paragraph // rather than being absorbed into it as text. - if (j > i && StartsBlock(lines[j])) break; + if (j > i && StartsBlock(lines, j)) break; ++j; } out.push_back(RenderParagraph(lines.subspan(i, j - i), media, depth)); diff --git a/shared/interfaces/Catcrafts.Shared-Money.cppm b/shared/interfaces/Catcrafts.Shared-Money.cppm index 8aabffc..ff56138 100644 --- a/shared/interfaces/Catcrafts.Shared-Money.cppm +++ b/shared/interfaces/Catcrafts.Shared-Money.cppm @@ -82,33 +82,19 @@ export bool IsEuCountry(std::string_view cc) { return std::ranges::find(EuCountries(), cc) != EuCountries().end(); } -// Destinations this shop refuses outright. -// -// Not a carrier problem — parcels reach both fine, and the rate tables price -// them. It is an insurance boundary: liability cover for a Dutch shop is -// written "worldwide excluding USA/Canada", and a phone carrying a lithium -// battery and a replaced OS is precisely the product-liability exposure that -// exclusion exists for. A sale into either country would therefore be -// uninsured, with the buyer's own courts deciding the damages, so checkout -// declines it instead of pricing it. Declining also keeps Catcrafts from -// having marketed into those jurisdictions at all, which is the other half of -// why the answer is no rather than a surcharge. -export std::span NoSaleCountries() { - static constexpr std::array blocked{ "US", "CA" }; - return blocked; -} - -// Destinations the law forbids, as opposed to the insurance choice above. +// Destinations the law forbids. // // EU sanctions — Regulation 833/2014 for Russia, its Belarus mirror, and the // North Korea embargo — prohibit exporting consumer electronics to these // countries, by customs code and by the luxury-goods value threshold both, and // the prohibition covers indirect routes (a forwarder, a reseller) as much as // a direct parcel. That binds every EU seller as criminal law; there is no -// small-shop exemption and no surcharge version of compliance. A separate list -// rather than more entries in NoSaleCountries because the refusal needs -// different words: "does not" is a choice, "cannot" is the law, and each gets -// its own explanation on the terms page. +// small-shop exemption and no surcharge version of compliance. +// +// Kept apart from the shipping allow-list below even though both refuse, +// because the refusal needs different words: not shipping somewhere yet is a +// state of the paperwork, while this is a prohibition nothing about the shop +// could change. Each gets its own sentence at checkout and on the terms page. export std::span SanctionedCountries() { static constexpr std::array blocked{ "RU", "BY", "KP" }; return blocked; @@ -118,14 +104,189 @@ export bool IsSanctioned(std::string_view cc) { return std::ranges::find(SanctionedCountries(), cc) != SanctionedCountries().end(); } +// Where this shop ships. An allow-list, and that inversion IS the design. +// +// Everything not named here is refused. That is not timidity, it is the only +// honest posture available: the rules deciding whether a phone may lawfully be +// sold into a country are national, they differ in kind rather than degree, and +// nobody has read all of them. A deny-list ships to every country nobody thought +// about; an allow-list refuses them. Only one of those failure modes is +// survivable, so the default is no, and every code below is a country somebody +// actually checked. +// +// What "checked" has to mean before a code goes in: +// +// * the destination does not reach the FOREIGN SELLER with producer duties of +// its own — packaging, e-waste, batteries. The EU does. The EEA (Iceland, +// Liechtenstein, Norway) does too, through the same directives, which is why +// no EEA country is here. The UK does by national law. +// * a phone posted from here is actually admitted: no national IMEI database +// to be registered against before a local network will attach it, and no +// customs practice of seizing handsets that lack local type approval. +// * the parcel crosses as the BUYER's import, so their own authority charges +// them VAT and duty and their own type-approval rules bind them rather than +// Catcrafts. This is the one thing the price buys: every foreign +// low-value-consignment regime found sits far below €600, so nothing routes +// through a seller-side registration scheme. +// * liability cover reaches it. The AVB is written worldwide EXCLUDING the +// United States and Canada — confirm that territory in the policy schedule +// before trusting this list, because every entry assumes it. +// +// Removed after verification, and NOT to be re-added on a hunch — both were on +// this list once, on the mistaken assumption that a domestic-supply approval +// regime never reaches a personal import: +// +// JP — using a handset without Japan's giteki (技適) mark on a Japanese network +// is a Radio Act offence carrying up to a year's detention or ¥1,000,000. +// The only exemption is for a short-stay visitor carrying a device in; it +// does not reach a resident receiving a parcel. Fairphone holds no Japanese +// certification and says outright that its devices cannot be used in +// countries requiring local homologation. Note where the liability lands: +// the offence is USE, so the person committing it is the customer. Selling +// someone a phone they break the law by switching on is worse than any +// paperwork gap on this page. +// NZ — the radio regulator defines a supplier to include "a seller", says the +// rules apply identically whether stock ships from inside New Zealand or +// reaches the market through a website, and requires a Licence to Supply +// for radio transmitters. The cure is then bolted shut: an overseas company +// cannot register on the compliance database, and unlike Australia there is +// no agent workaround. Structurally the same trap as Norway and the UK, +// living in radio law rather than waste law. +// +// Not here, and deliberately: the United States and Canada. The insurance +// exclusion was only ever half the reason and is no longer the interesting half. +// Canada is legally shut — the Fairphone (Gen. 6) holds no ISED certification, +// and Canadian law bars importing, distributing or selling uncertified radio +// apparatus, so no structure or policy opens it. The US adds per-parcel customs +// entry since de minimis ended, carrier certification that gates activation, and +// no emergency-call immunity of any kind for a device maker. Both are refused for +// regulatory reasons now, not commercial ones, and neither becomes available by +// buying a policy. +// Every entry must also ship DDU/DAP — the buyer as importer of record, paying +// their own authority at the border. That is not a commercial preference, it is +// load-bearing law in three of the five: it is what makes Hong Kong's +// personal-use import exemption apply, what keeps Catcrafts outside Singapore's +// producer definition, and what puts import tax on the consumer everywhere else. +// Appear as importer of record and two of these countries close. +export std::span ShippableCountries() { + static constexpr std::array open{ + // Home. Verpact wants nothing under 50,000 kg of packaging, and the + // phones are already on the Dutch market when Catcrafts buys them, so no + // producer duty attaches. That second clause is load-bearing: source + // stock from another member state and Catcrafts becomes the Dutch + // importer, owing Stichting OPEN before this line is honest again. + "NL", + // Checked end to end against the federal texts. CE accepted under the + // bilateral MRA (its chapter 7 is radio equipment); the e-waste ordinance + // reaches only those importing for COMMERCIAL supply, with no + // distance-seller limb and no producer register; and the mail-order VAT + // duty is scoped to consignments cheap enough to be import-tax-exempt, so + // a phone goes through ordinary import with the buyer paying at the + // border. The packaging ordinance expected 1 Jan 2027 turns out not to + // matter — it was adopted 24 June 2026 and its fee covers GLASS only. + // + // The real watch item is elsewhere, and it is specific: the circular- + // economy revision of the environment act, in force since 1 Jan 2025, + // already empowers a disposal fee on "ausländische Online- + // Versandhandelsunternehmen" — defined as whoever offers products + // digitally and delivers to consumers in Switzerland without a Swiss + // seat or establishment. That is this shop, definitionally. It is dormant + // only because the power is discretionary and no ordinance names a phone, + // and the companion article is deliberately not yet in force. An ordinance + // extending the fee to electrical devices is the day Switzerland flips. + "CH", + // Read out of the Radiocommunications Equipment (General) Rules 2021 + // rather than off a guidance page, because ACMA's own site is misleading + // here. The labelling and registration duties in s25 bind an importer who + // then SUPPLIES — neither limb is met when the consumer imports one phone + // for themselves. What does reach Catcrafts is s12: a person must not + // supply a device failing a prescribed general standard, extended to trade + // between Australia and places outside it. That is a SUBSTANTIVE standards + // duty, not a registration one — so no Australian establishment is needed, + // unlike New Zealand. Phones are outside the e-waste scheme (which covers + // TVs, printers and computers, and binds Australian corporations anyway). + // Two watch items: a mandatory small-electricals stewardship scheme is + // committed but not law, and the A$1,000 low-value-import line sits close + // enough to €600 that FX moves can cross it. + "AU", + // The best-documented jurisdiction of the set. Mobile phones sit outside + // the e-waste producer-responsibility list; user equipment needs no type + // approval (voluntary certification only); the import ordinance exempts + // equipment brought in for reasonable personal use; and there is no VAT or + // GST at all, so no foreign-seller registration can arise. + "HK", + // Both feared hooks miss on the facts. The e-waste producer duty requires + // importing INTO Singapore in furtherance of a Singapore supply business, + // which a DDU parcel is not, and there is no distance-selling limb of the + // kind UK law uses. IMDA states personal-use imports need no registration + // and sets no quantity limit. On tax the price helps: the low-value-goods + // regime caps at S$400, so a €600 phone is above it and the overseas-vendor + // rules cannot reach it at any turnover. + "SG", + // The Western Balkans four. All share the shape that matters: producer + // responsibility attaches to whoever places goods on the DOMESTIC market + // — the in-country importer, so the buyer — with none of the + // "regardless of sales channel" drafting that catches a distance seller in + // the EU, Norway, Iceland, Moldova and Bosnia. CE is accepted, no IMEI + // whitelist exists, and non-resident VAT reaches services only. + // + // RS — the strongest of them, because the answer comes from the customs + // authority itself: conformity documents are demanded only for + // certain drones, no radio-equipment conformity paper is required at + // import, and per the telecoms ministry's published position the + // Radio Equipment Rulebook does not apply to natural persons at all. + // ME — best-evidenced on tax: the VAT act states in terms that where + // transport begins outside Montenegro the IMPORTER makes the supply, + // and a tax representative is needed only for supplies made inside + // the country. EPR bylaws are still pending. + // AL — its WEEE decree still uses the pre-2012 three-limb producer + // definition, so the distance-selling limb simply is not there, and + // registration would need an Albanian tax number nobody can give a + // foreigner. Smartphones are also duty-free in the 2026 tariff. + // HARD DATE: Law 74/2025 takes effect 1 December 2026. Re-read its + // producer definition and any implementing acts before then, because + // that is when this entry could stop being true. + // XK — cleared on the law, with one operational caveat that is not legal: + // Kosovo is not a UPU member, so there is no treaty-based tracking + // guarantee or loss indemnity. On a €600 parcel that matters — ship + // it by courier rather than post. Note also that XK is a + // user-assigned code rather than official ISO 3166-1; if the carrier + // table spells Kosovo differently this entry simply never matches and + // the destination falls through to the no-carrier-rate refusal, which + // is the safe direction to fail. + "RS", "ME", "AL", "XK", + // Held back for a while on the dual-use crypto question rather than + // anything Georgian, and that question turned out to be a paperwork task + // rather than a gate — the mass-market exemption releases this phone for + // the same reason it releases every unlockable Pixel. On its own law + // Georgia is among the cleanest here: producer duties attach to whoever + // IMPORTS, with no distance-selling limb and no authorised-representative + // concept at all; the electronic communications act contains not one + // mention of IMEI; the product-safety code admits goods built to the + // standards of any EU or OECD country; and phones carry no duty, with the + // buyer paying import VAT at the border. Same caveat as Kosovo: the + // national post is slow with unreliable tracking, so send it by courier. + "GE", + }; + return open; +} + +export bool ShipsTo(std::string_view cc) { + return std::ranges::find(ShippableCountries(), cc) != ShippableCountries().end(); +} + // ISO 3166-1 alpha-2, uppercase, like everything else here. Callers ask this -// rather than comparing against "US" themselves, so the policy has exactly one -// definition and adding a country later is a one-line change. Both lists deny: -// most callers only need "is this destination for sale", and only the checkout -// error message cares which refusal it is (IsSanctioned above). +// rather than testing the lists themselves, so the policy has exactly one +// definition and opening a country is a one-line change. +// +// Two HARD gates, in order of permanence: the law, then the shipping list. A +// destination the carrier happens not to price is a third and much softer +// refusal that lives with the rate table (Form::kNoShippingTemplate) — it says +// no price exists, not that the sale is refused, and a carrier contract can +// change it tomorrow. Most callers only need "is this destination for sale"; +// only the checkout error cares which refusal it is. export bool SellsTo(std::string_view cc) { - return !IsSanctioned(cc) && - std::ranges::find(NoSaleCountries(), cc) == NoSaleCountries().end(); + return !IsSanctioned(cc) && ShipsTo(cc); } // Delivery-time tiers. NOT a price concept — every rate comes from the carrier @@ -271,10 +432,17 @@ export struct CurrencyRow { // Only currencies the ECB publishes reference rates for; anywhere else shows // plain euros. Euro countries are deliberately absent — converting EUR to EUR -// is noise. So are USD and CAD: NoSaleCountries means no order can ever be -// charged from those countries, and quoting a visitor a friendly price in -// their own currency before refusing them at checkout is both a worse -// experience and the kind of localisation that reads as marketing there. +// is noise. So are USD and CAD, and that one is a policy choice rather than a +// gap in the ECB feed: those two are refused for regulatory reasons that no +// amount of demand will lift, so quoting a visitor a friendly price in their own +// currency before declining them is both a worse experience and the kind of +// localisation that reads as marketing into a market this shop cannot serve. +// +// The rest of this table deliberately runs AHEAD of ShippableCountries. It +// answers "what would this cost in my money", which stays a fair question for a +// country whose paperwork is merely pending — GB is one small registration from +// opening — and keeping the row spares a delete-and-restore cycle later. The +// refusal that must never be quoted around is enforced in SellsTo, not here. export std::span AllCurrencies() { static constexpr std::array rows{{ { "GB", { "GBP", "£" } }, diff --git a/shared/interfaces/Catcrafts.Shared-Views.cppm b/shared/interfaces/Catcrafts.Shared-Views.cppm index 8918bcb..2b660ce 100644 --- a/shared/interfaces/Catcrafts.Shared-Views.cppm +++ b/shared/interfaces/Catcrafts.Shared-Views.cppm @@ -817,17 +817,21 @@ SafeHtml RenderCheckoutForm(const Product& product, } if (bestUnits <= 0 || bestUnits > Form::kMaxQuantity) bestUnits = Form::kMaxQuantity; - cc += R"(,"x":[)"; - for (std::size_t i = 0; i < Money::NoSaleCountries().size(); ++i) { - if (i) cc += ','; - cc += JsonStr(Money::NoSaleCountries()[i]); - } - cc += std::format(R"(],"xm":{},"s":[)", JsonStr(Form::kNoSaleMessage)); + // Sanctions (s/sm) then the shipping allow-list (w/rm). Note w is what the + // shop CAN ship to, so the preview refuses on absence — which is why this + // payload stays five codes long while most of the world is closed, instead + // of carrying a deny-list of two hundred. + cc += R"(,"s":[)"; for (std::size_t i = 0; i < Money::SanctionedCountries().size(); ++i) { if (i) cc += ','; cc += JsonStr(Money::SanctionedCountries()[i]); } - cc += std::format(R"(],"sm":{}}})", JsonStr(Form::kSanctionsMessage)); + cc += std::format(R"(],"sm":{},"w":[)", JsonStr(Form::kSanctionsMessage)); + for (std::size_t i = 0; i < Money::ShippableCountries().size(); ++i) { + if (i) cc += ','; + cc += JsonStr(Money::ShippableCountries()[i]); + } + cc += std::format(R"(],"rm":{}}})", JsonStr(Form::kRegulatoryMessage)); const SafeHtml payFieldset = offerCrypto ? RenderPayFieldset(prev, errorFor("pay")) : SafeHtml{}; @@ -919,7 +923,7 @@ SafeHtml RenderCheckoutForm(const Product& product, // the lede would only repeat half of them. offerCrypto ? SafeHtml{} : Raw(": iDEAL, card, or a plain bank transfer, handled by Mollie"), - Escape(Form::kNoSaleMessage), + Escape(Form::kShipsToMessage), Escape(Form::kSanctionsMessage), CustomsNote(), formError, @@ -2146,11 +2150,14 @@ inline constexpr std::string_view kGeoPriceHintScript = "var qty=qe?parseInt(qe.value,10)||1:1;" "var k=(ke&&ke.value?ke.value:\"\").replace(/\\s/g,\"\").toUpperCase();" "if(k.length!==2||!unit||qty<1||qty>d.q){if(out)out.hidden=true;return}" - // Refused destination — policy or sanctions: say so where the total would - // have been, instead of pricing an order the server will decline. - "if(d.x&&d.x.indexOf(k)>-1){if(out){out.textContent=d.xm;out.hidden=false}return}" + // Refused destination: say so where the total would have been, instead of + // pricing an order the server will decline. The same two gates as + // ValidateCheckout, in the same order, from the same sentences — sanctions + // first, then absence from the shipping list. + "var eu=ecc.indexOf(k)>-1;" "if(d.s&&d.s.indexOf(k)>-1){if(out){out.textContent=d.sm;out.hidden=false}return}" - "var eu=ecc.indexOf(k)>-1,line=unit*qty;" + "if(d.w&&d.w.indexOf(k)<0){if(out){out.textContent=d.rm;out.hidden=false}return}" + "var line=unit*qty;" "var goods=eu?line:Math.floor((line*10000+6050)/12100);" // No ladder for this destination: there is no price, and saying so beats // quoting a total the submit would then reject. diff --git a/styles/styles.css b/styles/styles.css index 0273cdf..c667618 100644 --- a/styles/styles.css +++ b/styles/styles.css @@ -511,8 +511,9 @@ treatment it replaces, which read as the wrong category for the work. /* The measure goes on the prose, not on the container, so media can use the full column: a post page is reading material and full-viewport line lengths get skimmed rather than read, but a screenshot of a feature - support table is unreadable at text width. */ - .post-body > :not(.post-media) { max-width: var(--measure); } + support table is unreadable at text width. A real table is exempt for + exactly the same reason a screenshot of one is. */ + .post-body > :not(.post-media, .post-body__table) { max-width: var(--measure); } /* .post-media carries its own top margin for the cards, where it follows an excerpt; inside this grid the gap already provides it. */ .post-body > .post-media { margin-top: 0; } @@ -533,7 +534,20 @@ treatment it replaces, which read as the wrong category for the work. .post-body a { overflow-wrap: anywhere; } + /* Struck prose is the author retracting a line for effect. Muted as well as + struck, because a line through body-coloured text on a dark surface reads + as a rendering fault rather than as a deliberate aside. */ + .post-body s { color: var(--text-muted); } + + /* The reset drops list markers globally, and everywhere else on the site + that is right — the nav, the card grids and the chain list are lists for + semantics, not for bullets. A post body is the one place that wants them + back: these are the author's numbered mini-guides, and without a marker an + ordered list is indistinguishable from an unordered one and the `start=` + the parser goes out of its way to emit is invisible. */ .post-body__list { padding-left: var(--s1); display: grid; gap: var(--s-2); } + ul.post-body__list { list-style: disc outside; } + ol.post-body__list { list-style: decimal outside; } .post-body__list li { padding-left: var(--s-3); } .post-body__quote { @@ -567,6 +581,49 @@ treatment it replaces, which read as the wrong category for the work. border-radius: var(--radius-sm); } + /* Pipe tables from a post body. Bordered rather than boxed like the code + block: a table already has structure, and a frame around a grid of lines + reads as two competing ones. */ + .post-body__table { + /* The wrapper is what scrolls. A refuses to be narrower than its + content, so without a scroll container around it a nine-column price + comparison scrolls the whole PAGE sideways — see the code block. */ + overflow-x: auto; + } + .post-body__table table { + border-collapse: collapse; + font-size: var(--step--1); + /* No width:100% on purpose — the table is sized to its content. Stretched + to the column, a two-column table puts a hand-span of nothing between a + label and its value. */ + } + .post-body__table :is(th, td) { + /* Left in both, overriding the browser's centred ") + != std::string_view::npos, + "md: a short row is padded to the header width", + md("| a | b |\n|---|---|\n| 1 |").View()); + // Cells are prose, so everything inline works in them — and everything + // inline is still escaped in them. + Check(md("| [x](https://e.example) | `` |\n|---|---|").View().find( + R"()" + R"()") != std::string_view::npos, + "md: cells render inline markup and stay escaped", + md("| [x](https://e.example) | `` |\n|---|---|").View()); + // `\|` is how a cell contains a pipe. + Check(md("| a \\| b | c |\n|---|---|").View().find("") + != std::string_view::npos, + "md: an escaped pipe is cell content, not a cell boundary", + md("| a \\| b | c |\n|---|---|").View()); + // The GPU comparison in one of these posts is a table inside a quote. + Check(md("> | a | b |\n> |---|---|\n> | 1 | 2 |").View().starts_with( + R"(
)"), + "md: a table inside a blockquote is still a table", + md("> | a | b |\n> |---|---|\n> | 1 | 2 |").View()); + // A table under a prose line with no blank line between is still a table: + // absorbing it into the paragraph is the exact failure this all fixes. + Check(md("text\n| a | b |\n|---|---|").View().starts_with("

text

after

"), + "md: a line with no pipe ends the table", + md("| a | b |\n|---|---|\n| 1 | 2 |\nafter").View()); + // A list marker wins over a table: a line that opens one is a list item. + Check(md("- a | b\n|---|---|").View().starts_with("bold

", "md: strong"); CheckEq(md("*em*"), "

em

", "md: emphasis"); CheckEq(md("2 * 3 * 4"), "

2 * 3 * 4

", "md: spaced asterisks stay literal"); + // , not GFM's : nothing was removed from this document, and the one + // body that uses this is striking a joke through for effect. + CheckEq(md("~~struck~~"), "

struck

", "md: strikethrough"); + CheckEq(md("~~**both**~~"), "

both

", + "md: strikethrough nests"); + // A lone tilde is a home directory or an approximation, never a delimiter. + CheckEq(md("~/.local and ~5 minutes"), "

~/.local and ~5 minutes

", + "md: a single tilde strikes nothing"); + CheckEq(md("a ~~ b ~~ c"), "

a ~~ b ~~ c

", + "md: spaced tildes stay literal"); // Underscores are deliberately inert: these posts paste kernel symbol // names into prose, and italicising half of one is worse than not // italicising a word that used the underscore form. @@ -172,7 +256,11 @@ int main() { Check(!md("[unclosed](").View().empty(), "md: unclosed link terminates"); Check(!md("![](").View().empty(), "md: unclosed image terminates"); Check(!md("`unclosed").View().empty(), "md: unclosed code span terminates"); + Check(!md("~~unclosed").View().empty(), "md: unclosed strikethrough terminates"); Check(!md("> > > > > > > > deep").View().empty(), "md: over-deep nesting terminates"); + Check(!md("|||\n|||").View().empty(), "md: degenerate table terminates"); + Check(!md("|---|---|").View().empty(), "md: a lone delimiter row terminates"); + Check(!md("| a |\n| - |\n|").View().empty(), "md: a ragged table terminates"); if (failures != 0) { std::println(std::cerr, "{} check(s) failed", failures); diff --git a/tests/ShouldShipContent/main.cpp b/tests/ShouldShipContent/main.cpp index 3710490..7ae8a2a 100644 --- a/tests/ShouldShipContent/main.cpp +++ b/tests/ShouldShipContent/main.cpp @@ -100,13 +100,16 @@ void CatalogueContract() { // checkout charges. { // The listing's shipping block is now carrier data, so the - // render needs a table. US is priced here on purpose: the - // carrier will happily quote it and the shop still must not - // advertise it. + // render needs a table. Three of these four are priced on purpose + // and must still not be advertised: US is refused on insurance, DE + // and GB on their missing producer registrations. The carrier will + // happily quote all three, which is exactly why the filter is worth + // asserting — CH is the only one here besides home that sells. const std::vector feedTable{ { "NL", { { 2000, 895 } } }, + { "CH", { { 2000, 2450 } } }, { "DE", { { 2000, 995 } } }, - { "GB", { { 2000, 2450 } } }, + { "GB", { { 2000, 3300 } } }, { "US", { { 2000, 1794 } } }, }; auto pp = Views::RenderProduct(pr, Rates{}, feedTable); @@ -137,6 +140,13 @@ void CatalogueContract() { Check(pp.meta.jsonLd.find("\"8.95\"") != std::string::npos && pp.meta.jsonLd.find("\"24.50\"") != std::string::npos, "schema: shipping rates come from the carrier table"); + // And nothing the shop refuses is advertised, whatever the carrier + // quotes for it — by rate, so a filter that dropped the country code + // but kept the price would still be caught. + Check(pp.meta.jsonLd.find("\"9.95\"") == std::string::npos + && pp.meta.jsonLd.find("\"33.00\"") == std::string::npos + && pp.meta.jsonLd.find("\"17.94\"") == std::string::npos, + "schema: refused destinations are never advertised"); Check(pp.meta.jsonLd.find("\"17.94\"") == std::string::npos && pp.meta.jsonLd.find("\"US\"") == std::string::npos, "schema: a refused destination is never advertised, priced or not"); @@ -378,11 +388,23 @@ void CheckoutPreviewData() { // The blob is a JSON document inside an HTML attribute, so every quote // arrives escaped — matching the escaped form is matching what the // browser actually parses back out. - Check(html.find(""x":["US","CA"]") != std::string_view::npos, - "checkout: the preview carries the no-sale list verbatim"); Check(html.find(""s":["RU","BY","KP"]") != std::string_view::npos, "checkout: the preview carries the sanctions list verbatim"); + // `w` is the shipping ALLOW-list: the preview refuses a country by its + // ABSENCE here, which is why the payload stays five codes long instead of + // enumerating the two hundred that are closed. + Check(html.find(""w":["NL","CH","AU"," + ""HK","SG","RS","ME"," + ""AL","XK","GE"]") != std::string_view::npos, + "checkout: the preview carries the shipping allow-list verbatim"); + Check(html.find(""US"") == std::string_view::npos, + "checkout: no deny-list survives in the preview payload"); + // Both refusals ship their own sentence, or the preview would word a decline + // differently from the submit that follows it. + Check(html.find(""sm"") != std::string_view::npos + && html.find(""rm"") != std::string_view::npos, + "checkout: the preview carries a message for each refusal"); // The unit weight, which is what selects a bracket out of the carrier // table the same blob carries. Check(html.find(""g":700") != std::string_view::npos, diff --git a/tests/ShouldValidateForms/main.cpp b/tests/ShouldValidateForms/main.cpp index 091f5f1..bea6dff 100644 --- a/tests/ShouldValidateForms/main.cpp +++ b/tests/ShouldValidateForms/main.cpp @@ -253,19 +253,43 @@ int main() { return validate("email=a%40b.example&name=Ada&street=x&postal=1&city=y&country=" + std::string(cc)); }; + Check(withCountry("NL").Ok(), "checkout: the home market sells"); + Check(withCountry("CH").Ok() && withCountry("AU").Ok() + && withCountry("HK").Ok() && withCountry("SG").Ok() + && withCountry("RS").Ok() && withCountry("ME").Ok() + && withCountry("AL").Ok() && withCountry("XK").Ok() + && withCountry("GE").Ok(), + "checkout: every cleared destination sells"); + Check(!withCountry("JP").Ok() && !withCountry("NZ").Ok(), + "checkout: destinations refuted on verification stay refused"); + // The default is the point of an allow-list: uncleared, unknown and + // never-considered codes all refuse without anyone listing them. Check(!withCountry("US").Ok(), "checkout: US refused"); Check(!withCountry("CA").Ok(), "checkout: CA refused"); Check(!withCountry("us").Ok(), "checkout: lowercase US refused too"); - Check(withCountry("GB").Ok(), "checkout: other non-EU destinations still sell"); - Check(withCountry("NL").Ok(), "checkout: EU unaffected"); + Check(!withCountry("DE").Ok() && !withCountry("GB").Ok() + && !withCountry("NO").Ok() && !withCountry("TR").Ok(), + "checkout: uncleared destinations refuse"); + Check(!withCountry("XX").Ok(), + "checkout: a syntactically valid code nobody has cleared refuses"); { auto us = withCountry("US"); Check(us.errors.size() == 1 && us.errors[0].field == "country", "checkout: refusal is a country error, nothing else"); - Check(us.errors[0].message == Catcrafts::Form::kNoSaleMessage, - "checkout: refusal says where the shop does not sell"); + Check(us.errors[0].message == Catcrafts::Form::kRegulatoryMessage, + "checkout: refusal gives the regulatory reason"); Check(us.value.country == "US", "checkout: refused country echoed back"); } + { + // One sentence for every non-sanctions refusal, whatever the underlying + // reason was — the buyer cannot act on the difference. + auto de = withCountry("DE"); + auto tr = withCountry("TR"); + Check(de.errors.size() == 1 && tr.errors.size() == 1 + && de.errors[0].message == Catcrafts::Form::kRegulatoryMessage + && tr.errors[0].message == Catcrafts::Form::kRegulatoryMessage, + "checkout: every uncleared destination gets the same sentence"); + } // Sanctioned destinations: same gate, different sentence. The message has // to name the law rather than shop policy — a buyer told "Catcrafts does @@ -284,6 +308,11 @@ int main() { Check(ru.value.country == "RU", "checkout: sanctioned country echoed back"); } + // Sanctions keep their own sentence: a buyer told the generic "email us and + // ask" would waste their time on something no amount of paperwork can lift. + Check(withCountry("RU").errors[0].message != Catcrafts::Form::kRegulatoryMessage, + "checkout: sanctions do not collapse into the regulatory sentence"); + // Parameter pollution against the order gate. Every refusal above reads its // field through Fields::Get, which takes the FIRST of a repeated pair, so // appending a second value cannot reopen a destination the first one closed diff --git a/tests/harness/Catcrafts.E2eHarness.cppm b/tests/harness/Catcrafts.E2eHarness.cppm index b062b6d..5051862 100644 --- a/tests/harness/Catcrafts.E2eHarness.cppm +++ b/tests/harness/Catcrafts.E2eHarness.cppm @@ -84,6 +84,12 @@ inline std::size_t CountOccurrences(std::string_view haystack, std::string_view // The single-unit rates are the €15 / €25 / €55 the totals assert. The second // band exists so the too-heavy refusal has a real ceiling to hit: // 10 kg / 700 g per unit = 14 units per parcel. +// +// CH carries the €55 world rate because it is the export destination the shop +// actually sells to; DE and GB keep their rows even though checkout now refuses +// both (unregistered producer schemes) precisely BECAUSE it refuses them — a +// destination the carrier prices and the policy declines is the case worth +// having a fixture for, and the suites assert that the policy wins. inline constexpr std::string_view kShippingFixture = R"({"method":"e2e fixture","fetched_at":"2026-01-01T00:00:00Z","per_country":{)" "\n" @@ -91,7 +97,9 @@ inline constexpr std::string_view kShippingFixture = "\n" R"("DE":[[2000,2500],[10000,4200]],)" "\n" - R"("GB":[[2000,5500],[10000,7900]]}})" + R"("GB":[[2000,5500],[10000,7900]],)" + "\n" + R"("CH":[[2000,5500],[10000,7900]]}})" "\n"; struct ServerOptions {
. The delimiter row is + the only thing that moves a column, and it says so with a class. */ + text-align: left; + padding: var(--s-2) var(--s-1); + border-bottom: 1px solid var(--border); + /* Cells wrap rather than forcing a scroll for every table, but not below + the width of a short word: nine columns on a phone would otherwise + shred into one character per line. Past that the container scrolls, + which is what it is for. */ + min-width: 6ch; + } + .post-body__table th { + background: var(--surface-1); + border-bottom: 1px solid var(--border-strong); + /* Vertical-align matters here and nowhere else: a wrapped two-line heading + next to a one-line one should share a baseline at the bottom, where the + data starts. */ + vertical-align: bottom; + } + .post-body__table tbody tr:last-child :is(th, td) { border-bottom: 0; } + /* Scoped under the wrapper for specificity, not for scoping: `:is(th, td)` + contributes a type selector, so the bare class would LOSE to the + text-align:left above and an aligned column would silently not be one. */ + .post-body__table .post-body__cell--center { text-align: center; } + .post-body__table .post-body__cell--right { text-align: right; } + /* An image inside a sentence still gets its own line — these are screenshots being pointed at, not icons sitting in the text. The is what sits in the paragraph now (the reset already makes it a block); diff --git a/tests/ShouldComputeMoney/main.cpp b/tests/ShouldComputeMoney/main.cpp index 70911c0..a21ea91 100644 --- a/tests/ShouldComputeMoney/main.cpp +++ b/tests/ShouldComputeMoney/main.cpp @@ -117,21 +117,66 @@ int main() { // Zones still classify US and CA (the arithmetic is destination-blind, and // keeping it that way means one policy switch, not two); the sale is what // stops, in SellsTo. + // Two hard gates, and the shipping one is an ALLOW-list. The assertion that + // matters most is therefore the DEFAULT: a country nobody has cleared + // refuses. This is the test that fails if the gate is ever "simplified" back + // into a deny-list, which would silently reopen most of the world. + Check(SellsTo("NL") && SellsTo("CH") && SellsTo("AU") + && SellsTo("HK") && SellsTo("SG") + && SellsTo("RS") && SellsTo("ME") && SellsTo("AL") && SellsTo("XK") + && SellsTo("GE"), + "policy: every cleared destination sells"); + // Both were cleared once and refuted on verification. JP because using a + // non-giteki handset is a Radio Act offence for the BUYER; NZ because its + // radio regulator's supplier duties expressly reach a website seller and an + // overseas company cannot register to comply. Asserted by name so a future + // "these look fine, add them back" cannot pass silently. + Check(!SellsTo("JP") && !SellsTo("NZ"), + "policy: refuted destinations stay refuted"); + Check(!SellsTo("DE") && !SellsTo("FR") && !SellsTo("BE"), + "policy: uncleared member states refuse by default"); + Check(!SellsTo("KR") && !SellsTo("MX") && !SellsTo("ZA") && !SellsTo("XX"), + "policy: uncleared and unknown codes refuse by default"); + Check(!SellsTo("NO") && !SellsTo("IS"), + "policy: the EEA inherits the EU's distance-seller duties, so it waits"); + Check(!SellsTo("GB"), "policy: GB waits on its EA small-producer entries"); + Check(!SellsTo("TR") && !SellsTo("IN") && !SellsTo("BR"), + "policy: IMEI and type-approval destinations refuse"); + // North America is refused for regulatory reasons now, not insurance ones — + // it simply is not on the list, and there is no separate category for it. Check(!SellsTo("US") && !SellsTo("CA"), "policy: north america refused"); - Check(SellsTo("NL") && SellsTo("DE"), "policy: EU sells"); - Check(SellsTo("GB") && SellsTo("CH") && SellsTo("AU"), - "policy: the rest of the world still sells"); - Check(SellsTo("us"), "policy: matched on the normalised code, like membership"); + // Matching is on the normalised code, as everywhere else here — but note the + // direction the allow-list fails in. Under the old deny-list, "us" missed the + // blocked entry and SOLD; now an unnormalised code is simply absent from the + // list and refuses. Callers still uppercase first (ValidateCheckout does), + // but the consequence of forgetting is a lost sale rather than a shipment to + // a country the shop cannot serve. + Check(!SellsTo("us") && !SellsTo("nl"), + "policy: an unnormalised code fails closed, not open"); Check(ZoneFor("US") == Zone::World, "zone: refused countries still classify"); - // Sanctioned destinations refuse through the same gate, but the two lists - // stay distinguishable — the checkout error names the law for one and the - // shop's own choice for the other. + Check(ShipsTo("NL") && !ShipsTo("US") && !ShipsTo("DE"), + "policy: ShipsTo is the allow-list on its own"); + // Form::kShipsToMessage names these countries in prose for the buy page, and + // prose cannot be generated from ISO codes. If this fails because a country + // was opened, update that sentence too — the two must not drift. + Check(ShippableCountries().size() == 10, + "policy: opening a country means updating Form::kShipsToMessage as well"); + + // Sanctioned destinations refuse through the same gate, but stay + // distinguishable — the checkout error names the law for one and pending + // paperwork for the other, and conflating them would tell a Russian buyer + // to email and ask. Check(!SellsTo("RU") && !SellsTo("BY") && !SellsTo("KP"), "sanctions: RU, BY and KP refused"); Check(IsSanctioned("RU") && IsSanctioned("BY") && IsSanctioned("KP"), "sanctions: the list knows its members"); - Check(!IsSanctioned("US") && !IsSanctioned("NL"), - "sanctions: the insurance refusal is not a sanctions refusal"); + Check(!IsSanctioned("US") && !IsSanctioned("DE") && !IsSanctioned("NL"), + "sanctions: an uncleared country is not a sanctioned one"); + // Belt and braces: a sanctioned code must never reach the allow-list, or the + // wrong sentence would be shown for a criminal-law refusal. + for (const std::string_view cc : SanctionedCountries()) { + Check(!ShipsTo(cc), "sanctions: never on the shipping list", cc); + } // ── carrier weight brackets ─────────────────────────────────────── // The only shipping prices that exist. A ladder covering 2 kg / 10 kg / @@ -264,14 +309,20 @@ int main() { // A country the shop refuses gets no localised price either — the two // tables are kept consistent on purpose, so this is a real invariant and // not a coincidence of the current list. - for (const std::string_view cc : NoSaleCountries()) { - Check(!CurrencyFor(cc).has_value(), - "fx: refused destinations have no display currency", cc); - } for (const std::string_view cc : SanctionedCountries()) { Check(!CurrencyFor(cc).has_value(), "fx: sanctioned destinations have no display currency", cc); } + Check(!CurrencyFor("US").has_value() && !CurrencyFor("CA").has_value(), + "fx: north america has no display currency either"); + // The invariant covers only refusals nothing will lift. The table otherwise + // runs AHEAD of the shipping list on purpose — GB keeps its GBP row while its + // e-waste registrations are pending, because "what would this cost me" stays + // a fair question for a country one small registration from opening, and + // deleting the row to restore it weeks later would be churn. The refusal that + // must not be quoted around is enforced in SellsTo, not here. + Check(CurrencyFor("GB").has_value(), + "fx: a temporarily-closed destination keeps its display currency"); // ── rates loader ────────────────────────────────────────────────── const Rates r = LoadRates( diff --git a/tests/ShouldEmitStructuredData/main.cpp b/tests/ShouldEmitStructuredData/main.cpp index 5de53d5..8cf1065 100644 --- a/tests/ShouldEmitStructuredData/main.cpp +++ b/tests/ShouldEmitStructuredData/main.cpp @@ -107,9 +107,13 @@ int main(int argc, char** argv) { srv.BodyHas("/shop/fp6-pmos", "\"brand\":{\"@type\":\"Brand\",\"name\":\"Fairphone\"}", "product carries the hardware brand"); - // One entry per (transit tier, price) the carrier table produces — three, - // for the fixture's NL/DE/GB. Not a fixed property of the code any more: - // it is whatever the carrier prices, which is the point. + // One entry per (transit tier, price) the carrier table produces, for the + // destinations the shop actually sells to — two, since the fixture prices + // NL/DE/GB/CH and checkout refuses DE and GB pending their producer + // registrations. Not a fixed property of the code: it is whatever the + // carrier prices INTERSECTED with where the shop sells, and publishing a + // rate to a country checkout would decline is an offer that cannot be + // accepted. const Json::Value* shipping = nullptr; if (group) { if (const Json::Value* v = group->Find("hasVariant"); v && v->IsArray() @@ -119,27 +123,33 @@ int main(int argc, char** argv) { } } } - Check(shipping && shipping->IsArray() && shipping->array.size() == 3, + Check(shipping && shipping->IsArray() && shipping->array.size() == 2, "shipping details group the carrier's rates"); - // The advertised rate IS the carrier's single-unit price, and a - // destination the table does not cover is never advertised. + // The advertised rate IS the carrier's single-unit price. Neither a + // destination the table does not cover (AU) nor one the policy refuses + // (DE, priced at €25 in the fixture) is ever advertised. if (shipping && shipping->IsArray()) { std::vector rates; bool au = false; + bool refused = false; for (const Json::Value& detail : shipping->array) { if (const Json::Value* rate = detail.Find("shippingRate")) { rates.emplace_back(rate->Str("value")); } if (const Json::Value* dest = detail.Find("shippingDestination")) { if (const Json::Value* cc = dest->Find("addressCountry"); cc && cc->IsArray()) { - for (const Json::Value& c : cc->array) au = au || c.string == "AU"; + for (const Json::Value& c : cc->array) { + au = au || c.string == "AU"; + refused = refused || c.string == "DE" || c.string == "GB"; + } } } } std::ranges::sort(rates); - Check(rates == std::vector{ "15.00", "25.00", "55.00" }, + Check(rates == std::vector{ "15.00", "55.00" }, "published shipping rates come from the carrier table"); Check(!au, "an uncovered destination is not advertised"); + Check(!refused, "a destination the policy refuses is not advertised"); } if (srv.ShopOpen()) { diff --git a/tests/ShouldProcessCheckout/main.cpp b/tests/ShouldProcessCheckout/main.cpp index 10fb2d1..f463f4d 100644 --- a/tests/ShouldProcessCheckout/main.cpp +++ b/tests/ShouldProcessCheckout/main.cpp @@ -166,20 +166,21 @@ void OpenShopLifecycle(TestServer& srv) { } // A non-EU order: ex-VAT goods, world shipping, and the indicative - // national currency line sourced from the build-time ECB rates. GB rather - // than a North American destination because those are refused outright. - const std::string tokenGb = TokenOf(srv.Post("/shop/fp6-pmos", - "email=gb%40example.org&name=Terry&street=1%20Baker%20St&postal=W1U&city=London&country=GB")); - Check(!tokenGb.empty(), "GB checkout issues an order"); - if (!tokenGb.empty()) { - const std::string page = srv.Body(std::format("/order/{}", tokenGb)); + // national currency line sourced from the build-time ECB rates. CH because + // it is the export destination that actually sells — North America is + // refused on insurance, and GB waits on its e-waste registrations. + const std::string tokenCh = TokenOf(srv.Post("/shop/fp6-pmos", + "email=ch%40example.org&name=Heidi&street=1%20Bahnhofstrasse&postal=8001&city=Zurich&country=CH")); + Check(!tokenCh.empty(), "CH checkout issues an order"); + if (!tokenCh.empty()) { + const std::string page = srv.Body(std::format("/order/{}", tokenCh)); // €474.21 goods (green net) + €55 world shipping = €529.21 Check(page.find("€529.21") != std::string::npos, "export order total is ex-VAT + world shipping"); Check(page.find("Zero-rated export") != std::string::npos, "export order states the VAT treatment"); - Check(std::regex_search(page, std::regex("≈ £[0-9]+")), - "export order shows the indicative GBP amount"); + Check(std::regex_search(page, std::regex("≈ CHF [0-9]+")), + "export order shows the indicative CHF amount"); Check(page.find("indicative") != std::string::npos, "conversion is labelled indicative"); } @@ -187,7 +188,7 @@ void OpenShopLifecycle(TestServer& srv) { // A two-unit white export order: unit €665.38, line €1330.76, net from the // LINE total (not per unit) = €1099.80, plus €55 world shipping = €1154.80. const std::string tokenWhite = TokenOf(srv.Post("/shop/fp6-pmos", - "email=w%40example.org&name=W&street=X%201&postal=1&city=Y&country=GB&color=white&quantity=2")); + "email=w%40example.org&name=W&street=X%201&postal=1&city=Y&country=CH&color=white&quantity=2")); Check(!tokenWhite.empty(), "white ×2 checkout issues an order"); if (!tokenWhite.empty()) { const std::string page = srv.Body(std::format("/order/{}", tokenWhite)); @@ -357,15 +358,15 @@ void OpenShopLifecycle(TestServer& srv) { // The NL order's message, found by its own order link (the same email // address placed two orders, so the address alone would be ambiguous). std::string nlMail; - std::string gbMail; + std::string chMail; for (const auto& entry : std::filesystem::directory_iterator(srv.Work())) { const std::string name = entry.path().filename().string(); if (!name.starts_with("mail-") || !name.ends_with(".eml")) continue; const std::string mail = ReadFile(entry.path()); if (mail.find(std::format("/order/{}", token)) != std::string::npos) nlMail = mail; - if (!tokenGb.empty() - && mail.find(std::format("/order/{}", tokenGb)) != std::string::npos) { - gbMail = mail; + if (!tokenCh.empty() + && mail.find(std::format("/order/{}", tokenCh)) != std::string::npos) { + chMail = mail; } } Check(!nlMail.empty(), "a confirmation email links the NL order"); @@ -392,7 +393,7 @@ void OpenShopLifecycle(TestServer& srv) { } } // The export order's message states the VAT treatment its invoice carries. - Check(!gbMail.empty() && gbMail.find("zero-rated export") != std::string::npos, + Check(!chMail.empty() && chMail.find("zero-rated export") != std::string::npos, "export confirmation states the zero-rated treatment"); // Idempotency comes from the ledger's notified event, not from luck in @@ -467,12 +468,12 @@ void AlwaysOnValidation(TestServer& srv) { "email=a%40b.example&country=NL"); // missing address srv.CheckStatus("/shop/fp6-pmos", "422", "POST", Good("&website=spam")); // honeypot - // Destinations the shop refuses (Money::NoSaleCountries). Well-formed, - // real addresses: the refusal is policy, not a shape check, so it has to - // hold for every spelling the form accepts. Deliberately outside the - // shop-open gate — validation runs before the coming-soon check, so this - // must answer 422 whether the shop is open or not, and it is the - // assertion that would catch the block being lost in a refactor. + // Destinations the shop refuses (Money::SellsTo). Well-formed, real + // addresses: the refusal is policy, not a shape check, so it has to hold for + // every spelling the form accepts. Deliberately outside the shop-open gate — + // validation runs before the coming-soon check, so this must answer 422 + // whether the shop is open or not, and it is the assertion that would catch + // the gate being lost in a refactor. const std::size_t before = LedgerLines(srv).size(); srv.CheckStatus("/shop/fp6-pmos", "422", "POST", "email=us%40example.org&name=Pat&street=1%20Main%20St&postal=43004&city=Columbus&country=US"); @@ -489,6 +490,31 @@ void AlwaysOnValidation(TestServer& srv) { "email=by%40example.org&name=Vanya&street=1%20Kastrychnitskaya&postal=220030&city=Minsk&country=BY"); srv.CheckStatus("/shop/fp6-pmos", "422", "POST", "email=ru%40example.org&name=Sasha&street=1%20Tverskaya&postal=125009&city=Moscow&country=ru"); + // Destinations off the shipping allow-list (Money::ShipsTo). DE and GB are + // the pointed cases: the fixture PRICES both, so a rate exists and the parcel + // is postable — the refusal is entirely the policy's, which is the whole + // reason to assert these rather than a country the carrier never covered. + // + // Status only, like every other refusal in this function. The exact sentence + // is asserted in ShouldValidateForms, where the validator is called directly: + // this function runs in the coming-soon state too, and a closed shop renders + // no order form for a field error to land in. What is worth proving over real + // HTTP is that the refusal holds at all, in both states — which is what 422 + // says here. + srv.CheckStatus("/shop/fp6-pmos", "422", "POST", + "email=de%40example.org&name=Klaus&street=1%20Hauptstr&postal=10115&city=Berlin&country=DE"); + srv.CheckStatus("/shop/fp6-pmos", "422", "POST", + "email=gb%40example.org&name=Terry&street=1%20Baker%20St&postal=W1U&city=London&country=GB"); + srv.CheckStatus("/shop/fp6-pmos", "422", "POST", + "email=no%40example.org&name=Kari&street=1%20Karl%20Johans&postal=0154&city=Oslo&country=NO"); + srv.CheckStatus("/shop/fp6-pmos", "422", "POST", + "email=tr%40example.org&name=Emre&street=1%20Istiklal&postal=34430&city=Istanbul&country=TR"); + srv.CheckStatus("/shop/fp6-pmos", "422", "POST", + "email=br%40example.org&name=Ana&street=1%20Paulista&postal=01310&city=Sao%20Paulo&country=BR"); + // And the default that makes an allow-list worth having: a well-formed code + // nobody ever considered is refused without appearing on any list. + srv.CheckStatus("/shop/fp6-pmos", "422", "POST", + "email=zz%40example.org&name=Sam&street=1%20Main&postal=0000&city=Nowhere&country=ZZ"); // Refused in validation means nothing reached the ledger and no payment // link was ever created. Check(LedgerLines(srv).size() == before, "a refused destination creates no order record"); diff --git a/tests/ShouldRenderMarkdown/main.cpp b/tests/ShouldRenderMarkdown/main.cpp index 876d433..0e615e2 100644 --- a/tests/ShouldRenderMarkdown/main.cpp +++ b/tests/ShouldRenderMarkdown/main.cpp @@ -95,10 +95,94 @@ int main() { Check(md("```\nx").View().find("x") != std::string_view::npos, "md: unterminated fence still renders its content"); + // ── tables ──────────────────────────────────────────────────────── + CheckEq(md("| a | b |\n|---|---|\n| 1 | 2 |"), + R"(
)" + R"()" + R"()" + R"(
ab
12
)", + "md: pipe table"); + // The shape the carrier table in the camera post is actually written in: + // leading pipe, no trailing one. Requiring both would leave it as prose. + CheckEq(md("| Device | OS\n|---|---|\n| FP6 | pmOS |"), + R"(
)" + R"()" + R"()" + R"(
DeviceOS
FP6pmOS
)", + "md: outer pipes are optional independently"); + // The whole reason recognition needs two lines. These posts paste pipelines + // and or-lists into prose constantly; none of them is a table. + CheckEq(md("dmesg | grep ufs"), "

dmesg | grep ufs

", + "md: a pipe in prose is not a table"); + // A mismatched column count is far more likely to be prose with pipes in it + // than a table whose author miscounted — so it stays prose, visibly odd. + Check(md("| a | b |\n|---|").View().starts_with("

"), + "md: delimiter row must agree about the column count", + md("| a | b |\n|---|").View()); + // `---` is a rule, as it is everywhere else in this file. A one-column + // table has to write the pipe. + CheckEq(md("| Device\n---"), "

| Device


", + "md: a bare dash row is still a thematic break"); + CheckEq(md("| a | b | c |\n|:---|:---:|---:|\n| 1 | 2 | 3 |"), + R"(
)" + R"()" + R"()" + R"()" + R"()" + R"()" + R"()" + R"(
abc
123
)", + "md: alignment row moves the column, left needs no class"); + // A short row is padded so the grid stays rectangular. + Check(md("| a | b |\n|---|---|\n| 1 |").View().find("
1x<b>a | bc