Pixel

Pixel Updates & Events

Server Updates & Events

Announcements

Why C/C++ volatile Is Not Atomic in Multithreading

DEV Community

The Most Dangerous Keyword in C/C++: Why volatile Is Not Atomic

The Most Dangerous Keyword in C/C++: Why volatile Is Not Atomic One of the most persistent myths in systems programming is the belief that declaring a shared variable as volatile makes it safe for concurrent multi-threaded access. Consider the following multi-threaded counter: volatile int counter = 0;

void worker() {
for (int i = 0; i < 100000; i++) {
counter++; // DATA RACE & LOST UPDATES!
}
}
Running two threads executing worker() concurrently will almost never result in 200,000. Increments will be silently lost. To understand why, we must examine the difference between Compiler Optimizations and CPU Hardware Coherence. 1. What volatile Actually Does The volatile keyword was introduced into the C standard in 1989 for a single specific purpose: interacting with Memory-Mapped I/O (MMIO) hardware registers and asynchronous signal handlers. It communicates one instruction to the compiler: "Do not cache this variable inside a CPU register. Do not optimize away repeated reads or writes. Every read must emit a fresh load from memory." // Without volatile:
while (!flag) {} // Compiler may optimize to: if (!flag) while(1){}

// With volatile:
while (!flag) {} // Compiler emits a CMP instruction reading RAM every loop iteration
2. What volatile Fails to Provide Failure 1: Zero Atomicity The operation counter++ is not a single atomic hardware instruction. It decomposes into a 3-step sequence in assembly: mov eax, dword ptr [counter] ; 1. Read from memory into register
add eax, 1 ; 2. Increment register value
mov dword ptr [counter], eax ; 3. Write back to memory
If Thread 1 reads counter = 5, and is preempted before Step 3, Thread 2 can read 5, increment to 6, and write back. When Thread 1 resumes, it writes 6, completely obliterating Thread 2's increment. Failure 2: Zero Memory Barriers Modern superscalar CPUs (x86, ARM64) execute instructions out of order to maximize execution pipeline throughput. volatile imposes constraints on the compiler, but it emits zero hardware memory barrier instructions (like MFENCE or DMB). The CPU is free to reorder reads and writes around volatile variables across cache lines. 3. The Modern Solution: std::atomic For thread-safe operations in C and C++, never use volatile. Use the standard atomic types: #include

std::atomic counter{0};

void worker() {
for (int i = 0; i < 100000; i++) {
counter.fetch_add(1, std::memory_order_relaxed); // Hardware Atomic!
}
}
On x86, this compiles to the hardware instruction lock inc [counter], asserting the CPU bus lock and guaranteeing atomic execution. Read the full systems engineering breakdown at Binary Dose.

Powered by Readybot.io

Why C/C++ volatile Is Not Atomic in Multithreading

Cline Crash Dumps Exposed Plaintext API Keys

DEV Community

Cline's crash dumps wrote plaintext API keys to disk every ~7 minutes (GitHub #15427)

A Cline user running the JetBrains plugin reported the standalone cline-core process crashing with an out-of-memory error roughly every 6-7 minutes. The crash diagnostic meant to help debug it was itself the bigger problem: each crash wrote a ~240MB heap snapshot to ~/.cline/heapsnapshots/, and that snapshot contained hundreds of near-duplicate copies of the session's full in-memory state — including the user's provider API key, in plaintext. What the source says Per the GitHub issue, filed 2026-10-29: cline-core (JetBrains plugin 2.0.9, core 4.1.26) streams a full serialized copy of its globalState — chat transcript and API key included — to the JetBrains webview on every state change, with no backpressure on the update queue. During a burst of changes, memory grew from an idle ~270MB to several gigabytes in about 100 seconds before the process hit a JavaScript heap OOM (exit code 134). Maintainer mkondratek confirmed on 2026-11-02 that the OOM shares its root cause with an earlier report, #15031: the queue never drops stale entries when the IDE falls behind. The same maintainer separately acknowledged the plaintext-key exposure the reporter flagged, saying it "is getting its own fix" tracked apart from the OOM bug itself. What it doesn't establish No data loss or external exposure is reported. The heap snapshots and the key stayed on the user's own disk, and the process restarted automatically after each crash — this isn't a breach, it's a credential sitting somewhere it shouldn't. As of 2026-11-09, only a telemetry commit had landed; neither the OOM fix nor the key-redaction fix was merged. Why it matters anyway Crash diagnostics are usually treated as inert — dump the state, ship it to the bug tracker, move on. This is a reminder that "dump the full in-memory state" and "that state contains a secret" are two decisions that have to be made together, and here they weren't. If you run Cline's JetBrains plugin, it's worth checking ~/.cline/heapsnapshots/ and rotating any key that was active during a crash. Full scoring breakdown and source: https://www.stupidllm.com/incident/STUPID-2026-0166/

Powered by Readybot.io

Cline Crash Dumps Exposed Plaintext API Keys

Automate Campaign Clipping With Claude Code

DEV Community

AI Clipping With Claude Code: How to Automate Clipping From Campaign Brief to Checked, Disclosed Clips

AI clipping usually means one thing: a tool finds moments in a long video and cuts them vertical. In a paid clipping campaign that is the easy part. Clips get rejected for the rest of the job: a missing hashtag, the watermark the brief asked for, the wrong length, an account that isn't verified, a disclosure that isn't there, a link submitted 40 minutes after posting. This post shows how to automate clipping as the whole job, with AI Clipping Skill, a free, MIT-licensed skill for Claude Code (it also runs in Codex, Cursor and OpenClaw). Your agent reads the campaign brief into a checklist, cuts one disclosed clip per account from the footage the campaign supplied, checks every clip against the brief in code, shows one plan page, and after one yes posts to TikTok, Reels and Shorts and hands you each link with the minute its submission window closes. Everything before posting runs on your own computer with Python and ffmpeg and works without signing up for anything. npx skills add social-media-skills/ai-clipping-skill -g -a claude-code -y
Repo: github.com/social-media-skills/ai-clipping-skill The example in the repo: a made-up campaign on a 9-minute NASA interview, three clips for three accounts. Where a real campaign's clip shows its paid-partnership line, the example shows "EXAMPLE ONLY: not a real ad". NASA and Bob Behnken endorse nothing here. What a clipping campaign asks for On Whop and Content Rewards, a campaign pays per 1,000 views for short clips of footage the brand or creator supplies. The brief sets the rules, and Content Rewards' own rejection guide says most rejections come from a detail the clipper skimmed past. A typical brief asks for: Money: a rate per platform, a minimum payout per clip (nothing below it), a cap per clip, a fee, a total budget that can run out. The caption: a hashtag, an @mention, a link, nothing from the banned list. The screen: a watermark or logo, a length range, 9:16. The footage: only the supplied files, sometimes only streams from the last few days. The account: verified in the marketplace, a link in the bio, sometimes a minimum share of viewers in the US and an engagement floor. After posting: submit the link within 30 minutes, one post into one campaign once, stay public and unchanged for 30 days, keep analytics screenshots as proof. And every campaign post is an ad. Content Rewards requires the disclosure at the start of the video and at the top of the caption, Whop's clipper terms require following the FTC's rules, and TikTok takes undisclosed branded posts out of For You. That list is why "automate clipping" can't stop at cutting. Each of those items is a check a script can run before the post exists. The prompt I joined a clipping campaign. Brief is in brief.txt, footage is behnken.mp4. Accounts: TikTok @example.clipper (everyone, comments on, duets and stitches off), Reels @example.clipper.reels, Shorts @exampleclipper (public, not for kids), all verified in the marketplace. No AI-made people or voices. That answers what the agent would otherwise ask in its first message: the brief, the footage, the accounts, whether each is verified, and the settings only the account owner may choose. When posting isn't connected yet, the agent puts the signup link at the top of that first message, so the account gets made while the clips render. Step 1: the brief becomes a checklist The agent copies the brief's own numbers and words into campaign.json, then: python3 /scripts/campaign.py campaign.json
1. Platforms and rates: TikTok $1.00 per 1,000 views, Instagram Reels $0.80 per 1,000 views, YouTube Shorts $0.50 per 1,000 views.
2. Payout: minimum payout per clip $5.00; cap per clip $150.00; marketplace fee 10%; total budget $3,000.00; $2,140.00 left when checked 16 Nov 13:30 UTC. When the budget runs out, views stop paying.
3. Pays from: TikTok 5,000, Instagram Reels 6,250, YouTube Shorts 10,000 views per clip; under that a clip earns nothing.
4. Every caption: sta...

Powered by Readybot.io

Automate Campaign Clipping With Claude Code

DOM Audit: 79 Pages, 23,767 Elements and Script Tags

DEV Community

Our 79 pages contain 3,083 divs and 3,053 script tags, and after hydration the scripts win

Lighthouse has an audit called "Avoid an excessive DOM size" that starts complaining at 1,500 elements, 32 levels of depth or 60 children on one parent. I have never once seen it fire on our site, so I never thought about the shape of the DOM at all. This week I measured it properly: every element on all 79 pages in the sitemap, the depth of every branch, every text node and every comment node. const all = [...document.querySelectorAll('')]; // elements
let max = 0;
(function walk(el, d) { if (d > max) max = d; for (const c of el.children) walk(c, d + 1); })(document.documentElement, 1);
const tw = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_COMMENT);
Size elements, all 79 pages 23,767 of those, inside 19,230 elements per page 117 min, 259 median, 929 max body elements per page 65 min, 202 median, 862 max distinct element names site-wide 55 attributes 38,350 (1.61 per element) text nodes with content 9,001 characters in them 382,466 (42.5 per node) comment nodes 1,276 Against Lighthouse's thresholds: 0 pages over 1,500 elements, 0 over depth 32, and 2 pages with a parent holding more than 60 children. The heaviest page on the site, the homepage, is 62 per cent of the way to the warning. The lightest, the cookie policy, is 117 elements, which is small enough to read in the inspector without scrolling. The ratio I find most useful is at the bottom of that table: 5.03 elements per 100 characters of text. A content site is in the business of turning markup into reading, and that is the exchange rate. The tag histogram, and a tie Counted in the HTML we actually serve, all 79 pages: element count div 3,083 script 3,053 a 2,699 p 2,431 span 2,307 meta 1,932 li 1,254 path 1,004 link 866 svg 638 section 556 h2 552 nav 534 h3 439 dt / dd 233 each button 96 td 92 The ratio of script tags to divs is 0.99. On a static marketing site, for every structural div we serve, we serve a script tag. Exactly one of those 3,053 is ours, a single in the root layout. The rest are the framework: the chunk loaders, and above all the React Server Components flight payload, which arrives as a long series of inline push calls rather than as one blob. 200 of them are application/ld+json structured data, which is markup wearing a script costume. And in the live DOM, after hydration, script overtakes div: 3,134 against 3,083, because next/script injects one more element per page once the load event has passed. The most common element on this site is a script element, and nobody chose a single one of them. Check it yourself on any page: document.querySelectorAll('script').length // 43 on /faq
document.querySelectorAll('div').length // 36 on /faq
The FAQ is 268 elements and carries 43 script tags against 36 divs. Depth: 78 pages agree, one does not max depth pages 9 to 11 4 12 50 13 17 14 6 15 1 30 1 50 of 79 pages bottom out at exactly 12 levels, which is what a consistent layout component plus a content renderer produces. One page goes to 30, and here is its deepest branch: html > body > div > main > section > div > div > div > div > div > div > div
> div > div > div > div > div > div > div > div > div > div
> button > svg > g > g > g > g > g > ellipse
Seventeen divs, then a button, then five nested g elements, then an ellipse. That ellipse is part of the mascot on the homepage, drawn as inline SVG inside the demo panels. SVG groups account for 6 of the 30 levels and the illustration is not the problem; the 17 divs in front of it are. It is not wrapper soup, except where it is The metric I expected to be bad. A single-child wrapper is an element with exactly one element child and no text of its own, which is the unit of accidental div nesting: single-child wrappers 1,689 (7.1 per cent of all elements) of those, a div whose only child is a div 111 longest unbroken wrapper chain, 78 pages 2 longest unbroken wrapper chain, homepage 9 78 of 79 pages never stack more than two wrapper...

Powered by Readybot.io*

DOM Audit: 79 Pages, 23,767 Elements and Script Tags

Organize Downloads Automatically with Local AI

DEV Community

Cansei de arrumar minha pasta Downloads: montei um "zelador" com IA local que faz isso por mim

Cansei de arrumar minha pasta Downloads: montei um "zelador" com IA local que faz isso por mim Depois de passar a semana inteira lidando com Active Directory, Entra ID e tickets de infraestrutura, no fim de semana eu gosto de resolver um problema bem mais egoísta: a minha própria bagunça digital. Minha pasta de Downloads virou, com o tempo, um cemitério de PDFs com nome documento (3).pdf, prints de tela chamados Captura de tela 2026-...png, planilhas, instaladores e boletos — tudo misturado, sem nenhuma lógica. Já tinha brincado antes de montar um assistente de IA local para outras coisas, então resolvi reaproveitar a mesma ideia para um problema bem mais mundano: um script que observa a pasta Downloads, entende (de verdade, lendo o conteúdo, não só o nome) o que cada arquivo é, e move ele para o lugar certo — sozinho, rodando o tempo todo em segundo plano, sem nada saindo da minha máquina. A ideia por trás do projeto O conceito não é novo — encontrei pelo menos três projetos de código aberto que fazem exatamente isso, usando a mesma combinação de peças: uma biblioteca Python chamada watchdog para vigiar a pasta[1], e um modelo de IA rodando localmente via Ollama para decidir a categoria de cada arquivo[2][3]. A ideia central é simples e elegante: O watchdog detecta quando um arquivo novo aparece na pasta. O script extrai um pedaço do conteúdo — texto de PDFs, OCR de imagens, texto puro de .txt/.docx. Esse conteúdo (mais o nome do arquivo) vai para um modelo de linguagem rodando local, via Ollama, pedindo para ele responder com uma categoria entre as que eu defini. O script cria a subpasta se não existir e move o arquivo. O detalhe que me convenceu a fazer isso localmente, e não com alguma IA de nuvem: boletos, comprovantes, documentos pessoais e prints de conversas privadas passam por esse pipeline todos os dias. Com Ollama, o texto extraído nunca sai da própria máquina — o modelo responde via uma API local, sem nenhuma chamada externa[4][5]. Montando a base: Ollama O primeiro passo é instalar o Ollama e baixar um modelo pequeno, que já é suficiente para esse tipo de classificação simples (não precisa de um modelo gigante para decidir se um PDF é "nota fiscal" ou "contrato"): # instala o Ollama (Linux/WSL)
curl -fsSL https://ollama.com/install.sh | sh

baixa um modelo leve, bom o suficiente para classificação de texto

ollama pull llama3.2:3b
Isso sobe um serviço local expondo uma API HTTP em http://localhost:11434, que qualquer script Python pode chamar com a biblioteca requests[4][5]. O "zelador": monitorando a pasta com watchdog A segunda peça é o watchdog, uma biblioteca Python que observa eventos do sistema de arquivos — criação, modificação, remoção — e dispara callbacks[1]. A estrutura básica de um observador é direta: from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
import time

class NovoArquivoHandler(FileSystemEventHandler):
def on_created(self, event):
if not event.is_directory:
classificar_e_mover(event.src_path)

if name == "main":
handler = NovoArquivoHandler()
observer = Observer()
observer.schedule(handler, "/caminho/para/Downloads", recursive=False)
observer.start()
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
observer.stop()
observer.join()
Esse handler dispara classificar_e_mover sempre que um arquivo novo aparece. É aqui que a IA entra. A função que "pensa": classificar com o modelo local A função classificar_e_mover faz três coisas: extrai um pedaço do conteúdo (texto puro, ou pdftotext para PDF, ou OCR via pytesseract para imagem), monta um prompt curto pedindo uma resposta em categoria fixa, e chama a API local do Ollama: import requests, json, shutil, os

CATEGORIAS = ["Notas Fiscais", "Comprovantes", "Trabalho", "Contratos", "Imagens", "Outros"]

def classificar(nome_arquivo, trecho_conteudo):
prompt = f"""Classifique o arquivo abaixo em UMA destas c...

Powered by Readybot.io

Organize Downloads Automatically with Local AI

Loan Against Property: Model the LAP Workflow in Python

DEV Community

What is a Loan Against Property (LAP)? How Do You Model Its Workflow in Code?

A developer's guide to the lending product that is mostly paperwork, and how to turn that paperwork into logic. If you build software for banks or NBFCs, sooner or later you will meet a loan against property, or LAP. On the surface it is a simple product. Underneath, it is one of the most document-heavy workflows in lending. This article explains how LAP works in plain terms, then models the key steps in Python: eligibility, document completeness, name matching across documents and exception routing. What is a loan against property? A loan against property is a secured loan. The borrower mortgages a property they already own, such as a house, flat or shop, and the lender gives them a loan against its value. The borrower keeps owning and usually keeps using the property. The lender holds it as security until the loan is repaid. That is the key difference from a home loan. A home loan funds the purchase or construction of a property. LAP raises money against a property the borrower already has, often for business expansion, working capital, education or medical expenses. How does a loan against property work? From a lender's point of view, every LAP application passes through two parallel tracks: Borrower assessment: Can this person repay? Income, existing loans, credit history and bank statements. Property assessment: Is this property acceptable security? Valuation, legal title, ownership history and existing claims. The loan is sanctioned only when both tracks pass. That is why LAP takes longer than a personal loan, and why it is such a good candidate for automation. How do you calculate LAP eligibility? Eligibility is the lower of two limits. The property limit. Lenders lend only a percentage of the property's value, called the loan-to-value (LTV) ratio. A property worth ₹1 crore at 60% LTV supports a loan of up to ₹60 lakh. The income limit. The EMI must fit within the borrower's income after existing obligations. Indian lenders often express this as FOIR (fixed obligations to income ratio): the share of monthly income that can go towards all EMIs combined. def emi(principal, annual_rate, months):
"""Standard reducing-balance EMI."""
r = annual_rate / 12 / 100
return principal * r * (1 + r) ** months / ((1 + r) ** months - 1)

def max_loan_from_emi(max_emi, annual_rate, months):
"""Invert the EMI formula: the largest principal a given EMI can repay."""
r = annual_rate / 12 / 100
return max_emi * ((1 + r) ** months - 1) / (r * (1 + r) ** months)

def lap_eligibility(property_value, ltv, monthly_income, existing_emis,
foir, annual_rate, months):
property_limit = property_value * ltv
max_new_emi = monthly_income * foir - existing_emis
income_limit = max(0, max_loan_from_emi(max_new_emi, annual_rate, months))
eligible = min(property_limit, income_limit)
return {
"property_limit": round(property_limit),
"income_limit": round(income_limit),
"eligible_amount": round(eligible),
"limited_by": "property" if property_limit <= income_limit else "income",
}

print(lap_eligibility(
property_value=10_000_000, ltv=0.60,
monthly_income=150_000, existing_emis=20_000,
foir=0.50, annual_rate=10.5, months=180,
))
For this borrower, the property supports ₹60 lakh, but income supports only about ₹50 lakh, so income is the binding limit. This is exactly the point many borrowers miss: a valuable property alone does not guarantee a large loan. The LTV, FOIR, rate and tenure here are illustrative. Real values come from each lender's credit policy. How do you check whether the document set is complete? A LAP file needs two groups of documents: borrower documents and property documents. A missing document is the most common reason a file stalls, so the first automated check is simply completeness. REQUIRED_DOCS = {
"borrower": {"identity_proof", "address_proof", "income_proof", "bank_statement"},
"property": {"sale_deed", "encumbr...

Powered by Readybot.io

Loan Against Property: Model the LAP Workflow in Python

Blacksmith CI: How GitHub Actions Runners Work

DEV Community

What is Blacksmith CI, and how do its runners work?

What is Blacksmith CI: it is a managed replacement for GitHub-hosted Actions runners, running on bare metal in four of its own regions, which you adopt by installing a GitHub App and changing one runs-on label. Your workflow files, actions, secrets and matrix stay exactly as they are, and what changes is the machine underneath, the cache it talks to, and the per-minute rate. Published Blacksmith Ubuntu x64 tiers, read 20 September 2026, with the standard private-repo GitHub-hosted runner drawn to scale for comparison. Blacksmith does not replace GitHub Actions. It replaces the computer GitHub Actions runs on, which is a much smaller change than the category name suggests and is why adoption is measured in minutes rather than sprints. This page is an explainer, not a pitch. Everything in it was read from blacksmith.sh and docs.blacksmith.sh on 20 September 2026, and where Blacksmith does not publish something, it says so. If you want the head-to-head comparisons instead, Blacksmith against GitHub-hosted runners and Blacksmith against Depot carry the full tables. How it works: an app, a label, and someone else's hardware Adoption is two steps. You install the Blacksmith GitHub App on the repositories that will use it, and you change runs-on from a GitHub label to a Blacksmith one. Nothing else in the workflow moves: uses: steps, marketplace actions, secrets, environments, matrix strategy and artifacts all behave as before, because the runner is still the standard GitHub Actions runner agent, just on a different machine. The GitHub App is not only authentication, and this is the part worth understanding before you roll out. Per the runner docs, Blacksmith watches and provisions capacity for the repositories where the app is installed. A repository that uses blacksmith- labels without the app installed can end up adopting runners provisioned for a different repository, which under-provisions everybody. If jobs queue in an organization that has "no concurrency limits", the missing app install is the first thing to check. Jobs run in one of four Blacksmith regions: Ashburn, Phoenix, Amsterdam and Frankfurt. Nested virtualization is supported on x64 Linux, which is what KVM-dependent jobs and Android emulators need, and is not supported on ARM Linux. jobs:
build:
- runs-on: ubuntu-latest
+ runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v7
- run: npm ci && npm run build
The runner sizes, and what the label encodes The label carries the size, the OS and the architecture: blacksmith-{n}vcpu-{os}-{version}, with -arm appended for ARM Linux. Every tier below is published in the instance-types documentation, read 20 September 2026. Label vCPU RAM Storage blacksmith-2vcpu-ubuntu-2404 2 8 GB 80 GB blacksmith-4vcpu-ubuntu-2404 4 16 GB 80 GB blacksmith-8vcpu-ubuntu-2404 8 32 GB 160 GB blacksmith-16vcpu-ubuntu-2404 16 64 GB 750 GB blacksmith-32vcpu-ubuntu-2404 32 128 GB 1.5 TB blacksmith-2vcpu-ubuntu-2404-arm 2 6 GB 75 GB blacksmith-32vcpu-ubuntu-2404-arm 32 96 GB 1.5 TB blacksmith-2vcpu-windows-2025 (public beta) 2 7 GB 130 GB blacksmith-6vcpu-macos-latest 6 24 GB 150 GB blacksmith-12vcpu-macos-latest 12 48 GB 250 GB Two things stand out against GitHub-hosted. The smallest Blacksmith Linux runner has the same 8 GB of RAM as a standard private-repo GitHub runner but 80 GB of disk against 14 GB, which quietly removes a whole class of disk-full failures without any change to your workflow. And ARM tiers carry less memory than their x64 equivalents at the same core count, 6 GB against 8 GB at 2 vCPU, so an ARM migration is not a like-for-like move if your jobs are memory-bound. Ubuntu 22.04 is published at the same sizes as 24.04. macOS runs on Apple silicon M4 with images from macOS 15 through a macOS 27 public beta, and Windows Server 2025 is itself still labeled public beta. Caching: two mechanisms, and they are not the same The first mechanism needs no workflow change at all. Blacksmith ...

Powered by Readybot.io*

Blacksmith CI: How GitHub Actions Runners Work

Building a Live-Streaming Widget with Go and SRS

DEV Community

What I learned building a live-streaming widget with Go + SRS (and one iframe)

I run a small live-selling widget for Shopify and WooCommerce stores. Not a startup — one person, one Go binary, SRS for media. A few decisions turned out to matter more than the feature list, so I'm writing them down. 1. Iframe instead of a script tag The first version was . It injected a floating player into the merchant's page. It broke constantly. Theme CSS leaked in, my CSS leaked out, and every third-party script on the page fought with mine. Worse: the API key sat in the DOM of a public storefront page. What I ship now:
Same iframe, one public shop id, zero secrets in the markup. The merchant pastes four lines. If they break their theme, they don't break the player. Trade-off: iframe can't inherit the store's fonts. For a player that's fine — isolation is worth more than pixel-matching. 2. SRT for the ingest path, not just RTMP RTMP is everywhere and everyone supports it. It's also TCP with fixed latency and handles packet loss badly — which is the normal condition for a merchant streaming from a phone on store wifi. SRT gives you loss recovery and configurable latency for maybe 200ms more delay. For live selling, 200ms of extra delay costs nothing; a frozen frame costs the sale. I still expose RTMP for compatibility, but the default config in my docs is SRT, and the bitrate defaults are deliberately conservative (1500–2000 kbps, 2s keyframe). Cross-border upload can't sustain 6 Mbps and the failure mode is a black player. 3. Enforce limits on the server, never in the widget Viewer caps, stream keys, tier upgrades — all of it lives in the Go backend. The widget only renders. Early on I had a cap check in the frontend so it would "respond faster". It took one person with devtools to watch a 200-viewer room on a 20-viewer plan. Anything that costs money has to be a server-side decision, even if the client shows a cached number. 4. The bug that taught me the most was caching I deploy static assets by scp'ing them into place and restarting nginx. Everything under /static was served with Cache-Control: max-age=604800, immutable. The day I added a feature that needed new i18n keys, every returning user got the new HTML with the old dictionary — and saw raw keys like lc_title on screen instead. Now every asset reference carries a build version. immutable is only safe if the URL changes when the content does. Obvious in hindsight; not obvious at 2am. What's still hard Payments. If you're a solo developer outside the US/EU, merchant onboarding is the real boss fight — not the media pipeline. I've been through two providers' review processes and neither outcome was about code. Feedback welcome The whole thing is at https://durianlive.com — happy to hear what I got wrong, especially from anyone who has run live video at small scale.

Powered by Readybot.io

Building a Live-Streaming Widget with Go and SRS

Prevent Pandas Joins from Multiplying Rows

DEV Community

Catch a pandas join that silently multiplies attendance rows

An attendance table has five check-ins. After a join, it has six rows. Nothing crashed, but the report is already wrong. A duplicated learner ID in the roster produced two matches for one check-in. This fictional example makes that failure visible before building any totals. Run the example All illustrative records are fictional. Save the following as attendance_join.py. The complete script was run in PythonIDE on iPhone; the screenshots show the actual source and output. import pandas as pd

Fictional learner roster and workshop check-ins.

roster = pd.DataFrame({
'id': ['S1', 'S2', 'S2', 'S3', 'S4'],
'name': ['Ari', 'Bo', 'Bea', 'Cy', 'Dee'],
})
visits = pd.DataFrame({
'id': ['S1', 'S2', 'S1', 'S9', 'S3'],
'session': ['AM', 'AM', 'PM', 'PM', 'PM'],
})
print('FICTIONAL ATTENDANCE CHECK')
print('Visit rows before join:', len(visits))
unsafe = visits.merge(roster, on='id', how='left')
print('Rows from unchecked join:', len(unsafe))

Many visits may match one learner, never two identities.

try:
visits.merge(roster, on='id', how='left',
validate='many_to_one')
except pd.errors.MergeError:
print('Guard caught a non-unique roster key.')

Do not choose arbitrarily between conflicting identities.

ambiguous = roster['id'].duplicated(keep=False)
blocked_ids = set(roster.loc[ambiguous, 'id'])
unique_roster = roster.loc[~ambiguous]
report = visits.merge(
unique_roster, on='id', how='left',
validate='many_to_one', indicator=True,
)
report['review'] = report['_merge'].eq('left_only')
print('\nJOINED REPORT')
print(report[['id', 'session', 'name', 'review']].to_string(index=False))
print('\nREVIEW REASONS')
for learner in report.loc[report['review'], 'id'].unique():
reason = 'ambiguous identity' if learner in blocked_ids else 'unknown ID'
print(f'{learner}: {reason}')

Count people, not check-ins. S1 attended twice.

known = report.loc[~report['review']]
print('\nMatched check-ins:', len(known))
print('Distinct matched learners:', known['id'].nunique())
assert len(report) == len(visits)
assert len(known) == 3
assert known['id'].nunique() == 2
print('Row conservation and distinct-count checks passed.')
Understand the checks and their limits validate="many_to_one" states the intended relationship: many check-ins can refer to one learner. A repeated roster key violates that assumption. Rather than keep the first conflicting name, the example quarantines every roster row belonging to an ambiguous ID. A left join then preserves every check-in and indicator=True shows unmatched entries. The actual iPhone run started with five check-ins and produced six rows in the unchecked join. The many-to-one guard caught the duplicated roster key. The guarded report retained five rows, flagged S2 as ambiguous and S9 as unknown, and counted three matched check-ins belonging to two distinct learners. All row-conservation and distinct-count assertions passed. Those are different metrics. This fixture uses nonmissing string IDs; real keys need normalization and missing-key checks. pandas can match null join keys, unlike the SQL behavior many people expect. Try one change Resolve S2 by replacing the two conflicting roster rows with one verified row. Predict matched check-ins and distinct learners. Then add a repeated check-in and decide whether the session plus learner ID should be unique. I build PythonIDE, a Python coding app for iPhone and iPad. The example is intended to teach a workflow you can inspect and adapt. AI assisted with drafting and translation. The example was checked in PythonIDE. PythonIDE on the App Store

Powered by Readybot.io

Prevent Pandas Joins from Multiplying Rows

The Hidden Cost of Uncapped AI Agent Loops

DEV Community

An AI agent with no step limit stops when its context window is full. On GPT-5.6 that's $160 for one run.

The first agent most of us write is a while True loop. Ask the model what to do, run the tool it picks, append the result to the history, ask again. It stops when the model says "done". Usually it does. Sometimes it doesn't: it reads the same file again, retries a failing test forever, or keeps searching for something that isn't there. If you have no step limit, that loop still ends eventually. It ends when the history no longer fits into the model. I simulated how much that one run costs, and the number is set mostly by the size of the context window. A few words first A token is a chunk of text the model reads as one piece; APIs bill per million of them. Input tokens are what you send, output tokens are what the model writes back, and output is priced higher. The context window is the most tokens one request can hold; a request that's bigger is rejected. An agent loop is code that calls the model repeatedly, letting it choose tools, and sends the full history with every call, because the API itself remembers nothing between calls. A step cap is a hard limit on how many times the loop may go around. The setup Every step resends everything so far and adds two things: the model's tool call, which I set at 200 output tokens, and the tool's result. For the result I measured a typical action, "read 100 lines of a file", by cutting four Python standard library modules into 100-line chunks and counting them with tiktoken (o200k_base, the encoding behind current GPT models). The median chunk was 802 tokens, so I used 800. The loop starts with 4,000 tokens of system prompt, tool descriptions and task. So each step makes the next request 1,000 tokens bigger. The loop runs until the next request plus its answer would overflow the window. Prices are official list prices from the catalog of the LLM gateway I work on, snapshot of 12 November 2026, with no discounts applied. Model (window) Steps until full Runaway run With a 25-step cap GPT-5.6 mini (400K) 396 $16.05 $0.09 GPT-5.6 (400K) 396 $160.54 $0.86 Claude Haiku 5.5 (1M) 996 $50.05 $0.04 Claude Sonnet 5.5 (1M) 996 $1,000.98 $0.85 Claude Opus 5.5 (1M) 996 $2,001.96 $1.70 On GPT-5.6 the runaway run costs 187 times what a capped one does. On the 1M-token models it's 1,178 times. The last step alone on Sonnet 5.5 costs $2, more than the whole capped run. Why the window sets the price Step n sends about n thousand tokens, so the total is 1 + 2 + 3 + … up to the last step. That sum grows with the square of the number of steps, and the number of steps is the window divided by the growth per step. Put together, a runaway run sends roughly window² ÷ (2 × tokens added per step) input tokens: 400,000² ÷ 2,000 = 80 million on GPT-5.6, almost exactly the 79.8 million the simulation counted. That's why a bigger window is a bigger risk. At the same $2/$12 price, I ran the loop with three window sizes: 200K costs $40.26, 400K costs $160.54, 1M costs $1,001.38. Doubling the window made the run 4.0 times more expensive; 2.5 times the window made it 6.2 times more. A model upgrade that "only" raises the window from 200K to 1M multiplies your worst case by 25 without touching the price per token. The fix is two counters Count steps, and count dollars from the usage field every response already returns. Here is the guard, written for OpenAI-style responses: PRICE_IN, PRICE_OUT = 2.00, 12.00 # $ per 1M tokens, your model's list price
MAX_STEPS, MAX_USD = 25, 1.00

def run_agent(client, model, messages, tools, execute_tool):
spent = 0.0
for step in range(MAX_STEPS):
r = client.chat.completions.create(model=model, messages=messages, tools=tools)
u = r.usage
spent += (u.prompt_tokens * PRICE_IN + u.completion_tokens * PRICE_OUT) / 1e6
msg = r.choices[0].message
messages.append(msg)
if not msg.tool_calls:
return msg.content, spent
if spent >= MAX_USD:
raise RuntimeError(f"budget hit after {step + 1} steps: ${spent:...

Powered by Readybot.io

The Hidden Cost of Uncapped AI Agent Loops

Platform Engineering 2026: IDP Tools and Trends

DEV Community

Platform Engineering 2026: Internal Developer Platforms – Between Open Source and SaaS

Platform engineering has evolved over the past two years from a conference buzzword into a strategic discipline. At its core, it’s about a simple question: How do development teams get a self-service infrastructure platform without every team having to become a Kubernetes expert? The answer is an Internal Developer Platform (IDP) – and in 2026 the market is more dynamic than ever. CNCF Technology Radar: The Data The CNCF Q1 2026 Technology Radar, published in March 2026 based on a survey of over 400 professional developers, paints a sobering picture: only 28 percent of organizations have a dedicated platform engineering team. The majority – 41 percent – rely on loose multi-team collaboration, which in practice means the platform remains a shared side project without a fixed owner. 35 percent report a hybrid platform that combines existing developer portals with specialized AI tools. On the tooling side, the picture is clearer. Backstage, Helm and k9s are in the Adopt status for application delivery. Helm achieves the highest maturity rating: 94 percent of developers gave four or five stars for reliability. GitHub Actions scores 91 percent recommendation, and cert-manager 87 percent for stability. Backstage: The Market Leader Backstage, open-sourced by Spotify in 2020 and donated to the CNCF, remains the gold standard. With over 34,000 GitHub stars and more than 200 community plugins, it forms the backbone of most IDP strategies. The catch: Backstage is a framework, not a finished product. Every team that uses it operates its own React/Node application, writes its own plugins, and keeps up with the release pace of versions like v1.55.3 (September 2026). The total cost consideration puts the supposedly free license into perspective. Third-party analyses estimate the actual annual cost for a medium-sized Backstage installation at around $150,000 – driven by engineering time for integration, plugin development, and operations. So if you’re comparing Backstage against a SaaS alternative, you shouldn’t compare with $0, but with that figure. The Commercial Competition: Port, Cortex, and OpsLevel Against the open-source option, three commercial providers have positioned themselves, gaining massive maturity in 2025 and 2026. Port has evolved from managed Backstage hosting into an “Agentic SDLC” company. The launch of Port AI Builder in July 2026, a natural-language interface for agentic workflows, marks the most significant AI push in this segment so far. Pricing is transparent: Basic from $30 per seat per month (up to 50 seats), Standard from $40 (up to 200 seats). Cortex focuses on scorecards and engineering leadership. Instead of starting with a catalog interface, Cortex builds around evaluating services against maturity criteria – security patching, test coverage, on-call readiness. The entry price is around $65 per user per month (Amazon Marketplace reference for 50 users: $39,000 annually). OpsLevel competes directly with Cortex but differentiates through deep CI/CD and deployment integration. Instead of initiative tracking, it focuses on supply chain metrics: build times, deployment frequency, and change failure rate. The Build vs. Buy Dilemma Platform engineering is not a pure technology decision. If you build, you save the license but pay in engineering time. A rough three-year calculation for a 150-person team shows: Backstage (self-hosted) costs cumulatively about $390,000, Port (Standard tier) $216,000, Cortex (Marketplace reference) $351,000. Port’s published prices give the cheapest three-year outlook in the model – though without considering migration costs if you switch later. However, the truly critical factor is not the price, but the internal organization. If you don’t have a dedicated platform team, Backstage is often not an option – operating your own React/Node application plus plugin maintenance overtaxes multi-team structures. Commercial offerings provide faster time-to-value here, bought at the cost of vend...

Powered by Readybot.io

Platform Engineering 2026: IDP Tools and Trends

How to Get Your MCP Server Found on Google

DEV Community

How to make your MCP server show up in Google

You built an MCP server, pushed it to the registry, maybe wrote a GitHub README — and when you search for it, nothing. Or worse: a stale third-party page ranks instead of you. MCP servers are weirdly hard to find in Google, and the reasons are fixable. Here's what actually moves the needle. Why MCP servers are invisible The registry is a JSON feed, not a web page. The official MCP registry is machine-readable. Google indexes pages, and a JSON entry isn't one. GitHub repos rank for the repo name, not intent. Your repo might surface if someone searches your exact name, but not for "MCP server for X" — and external links in a GitHub README are nofollow, so your repo doesn't pass authority to your own site. No dedicated page means nothing to rank. If the only "page" about your server is a registry row and a repo, there's nothing for Google to show for a discovery query. What actually helps 1. Give your server a real, indexable page A landing page or docs page on your own domain, with a clear title and a 100+ word description of what the server does and how to install it. This is the page you want to rank. One page done well beats a thin repo. 2. Write a description that matches how people search Registries and directories pull your description. Write it for a human searching "MCP server for Postgres" or "Stripe MCP server," not for a changelog. Lead with the capability, name the integration, keep it under ~160 characters for the meta description. 3. Get listed where the listing is actually indexable Most MCP directories exist, but many of their pages carry a noindex tag until a listing is claimed or hits a quality bar — so being "listed" isn't the same as being indexed. Check the real page: view source and look for . If it's there, that listing isn't helping you in search. On Serverdex, for example, every server in the registry already has a page, but unclaimed ones are noindex until you claim them. Claiming your listing (free, one GitHub click) flips the page to indexable, adds it to the sitemap, and gives you a dofollow link back to your site — the kind of link that actually helps your own domain rank. 4. Earn a couple of real links A dofollow link from a relevant, indexed page is worth more than ten nofollow mentions. A short dev.to post about your server, a PyPI package with your homepage set, a mention from a blog that covers MCP — those are the links Google counts. GitHub stars and nofollow README badges feel good but do not move search rankings. 5. Don't wait for the registry to do it for you The registry's job is distribution to clients, not SEO. Search visibility is on you: one good page, an honest description, an indexable listing, and a few real links. The quick version One real page on your domain — 100+ words, clear title. A description written for search intent. A listing that is actually indexable (check for noindex). A couple of dofollow links from indexed pages. Ignore nofollow vanity metrics. If you want to check whether your server already has an indexable page — and claim it — the whole registry is searchable at Serverdex.

Powered by Readybot.io

How to Get Your MCP Server Found on Google

Why Notifio’s Comparison Pages Aren’t Real Tables

DEV Community

49 rows of comparison, zero table elements, and 811 words of it carrying five full stops in total

I pulled all 38 pages of notifio.app and counted words and sentences in the visible text, to see where the site's weight actually sits: 36,947 visible words 2,001 sentences 18.5 words per sentence on average thickest page: /terms at 2,440 words thinnest page: /download at 149 words, with /pricing second at 344 That last pair is worth a post of its own some day, because the two pages that have to actually sell something are the two thinnest on the site. But the thing that stopped me was the sentence-length table. The longest sentence on notifio.app, by my splitter, was 202 words. The next three were 191, 168 and 150. The fifth was 141 words and sat in the terms of service, which is where you expect to find a 141-word sentence. The top four were not sentences. They were the comparison tables, and they are not tables. There is no table in the comparison table $ curl -s https://notifio.app/compare/rentbird | grep -c '<table'
0
$ curl -s https://notifio.app/compare/rentbird | grep -c 'role="'
0
No , no , no role="table", no . The side-by-side on all five comparison pages is a CSS grid of div and p elements, 13 grid containers on the page. I counted the rows and their honesty flags last month in 49 rows in our comparison tables, 13 of them say the competitor wins. I counted the argument prose in a required field made us concede on every comparison page. Both times I read the data and never the markup carrying it. Measured per page, taking the text of just the side-by-side section: Page Rows Words in the section Sentence-ending full stops /compare/rentbird 10 196 1 /compare/visualping 11 185 1 /compare/rentslam 10 162 1 /compare/stekkies 10 144 1 /compare/rentola 8 124 1 811 words, 49 rows, and five full stops between them. Each page's single stop is the caption underneath: "Brighter text marks whichever side we think genuinely wins that row, including the rows where it is not us." Flattened, the Rentbird one begins like this: Rentbird Notifio Price Rentbird: €29 for one month, €49 for three Notifio: £20 once, forever What happens when you stop paying Rentbird: Alerts stop Notifio: Nothing, since the licence does not expire The rival's name appears twelve times in the extracted text of a ten-row table. That is not a bug in my extractor. It is two labelling systems in one document. Why it is divs, and why the divs are not lazy // src/app/compare/[target]/page.tsx

 

{target.name}

Notifio

function Cell({ label, value, won }: { label: string; value: string; won: boolean }) {
return (


{${label}:}
{value}


);
}
There are two sets of labels and each is switched off at the breakpoint where the other works. On a wide screen you get a column header row and the per-cell prefixes are display: none. On a phone the header row is display: none and each cell introduces itself, "Rentbird: €29 for one month", which is the only way a three-column row survives being stacked vertically. 98 of those hidden prefixes exist across the five pages. And the accessibility of that is better than I expected when I started writing this. Tailwind's hidden is display: none, and display: none is hidden from assistive technology, not just from eyes. So a screen reader at desktop width hears the column header and no prefix; at phone width it hears the prefix and no header. The labels follow the breakpoint. This is not the classic bug where a visually hidden label gets announced on top of a visible one. What is actually lost Two things, and the second one is the one I had not thought about. Table semantics. Without , a screen reader user gets 49 paragraphs in a row. There is no "column 2 of 3", no row navigation, no way to walk down one product's column and hear only its answers. On...

Powered by Readybot.io

Why Notifio’s Comparison Pages Aren’t Real Tables

Migrate from Namespace Runners Without a Cold Cache

DEV Community

Migrate from Namespace runners without a cold cache

Teams that migrate from Namespace runners are almost never leaving because of the hardware, which is good, and almost always underestimate the same thing: Namespace is not only a label, it is a set of drop-in actions and a cache that behaves unlike any other cache in this market. The label change takes a minute and the cache change decides whether the migration reads as neutral or as a regression. The cache behaviour that changes on the way out, and why a new feature branch starts cold afterwards. Behaviour read from both vendors documentation on 2026-09-21. Start with what moves and what does not. The label moves: runs-on is one line and every destination has a size that matches. The workflow steps mostly move untouched, because they are ordinary steps. Three things do not move, and each one is a real edit. The nscloud- actions have to be swapped back for the standard ones. The cache changes scope, from shared to branch-isolated, which is a behavior change with no error attached. And the remote Docker builders that were on by default stop existing, so a workflow that deleted its buildx setup has to get it back. What a runner profile was carrying Namespace attaches its features to a runner profile rather than to individual steps, which is why the label looks so short and the migration is longer than it looks. A job that reads runs-on: namespace-profile-default may be getting a cache volume, a git mirror, a container image cache and a remote Docker builder without any of those appearing in the workflow file. The undo list below is therefore a list of things to look for in the profile as well as in the YAML. Work through it before you change the label, because after you change it the symptoms are all the same symptom: the job is slower and nothing failed. Namespace surface What it did Standard replacement What changes namespace-profile- label Selected the profile and everything attached to it A size label, for example latchkey-small Features attached to the profile stop being implicit nscloud-checkout-action A faster checkout using a git mirror actions/checkout Clone time returns to an ordinary shallow clone nscloud-cache-action A cache volume, shared across branches actions/cache with explicit keys You now write keys, and restores obey branch scope Remote Docker builders, on by default A warm BuildKit builder beside the runner docker/setup-buildx-action plus cache export Layer cache is exported and imported per build Unit-minute billing Charged at max(vCPU, RAM divided by 2) per minute A fixed per-size rate Memory stops costing extra when cores sit idle Namespace surfaces read from its GitHub Actions documentation on 2026-09-21. The unit-minute model, its platform multipliers and the prepaid against overage split are worked through separately in Namespace alternatives; this page assumes you have already decided to leave and is only about the cutover. The cache is the migration This is the change most likely to make a migrated pipeline slower with nothing in the log to explain it. Namespace documents that its cache action does not support manual partitioning: entries are shared across everything using that runner profile, which includes the default branch and every feature branch at once. actions/cache is the opposite by design, restorable only from the branch that wrote the entry or from the default branch. So a workflow that never thought about cache keys was quietly getting cross-branch hits, and on the other side it will not. The fix is not exotic, it is a prefix key and a restore-keys fallback, but it has to be written before the first run rather than discovered from a timing graph a week later. Write the keys so a new branch falls back to the default branch entry, and accept that the first run on a new dependency set is cold. - uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('
/package-lock.json') }}
restore-keys: |
npm-${{ runner.os ...

Powered by Readybot.io*

Migrate from Namespace Runners Without a Cold Cache

Free Sample Files for QA and Upload Testing

DEV Community

A Clean, Ad-Free Resource for Downloading Sample & Dummy Files for QA and Upload Testing

Every time I build a file upload feature, media player, or document parser, I run into the same small annoyance: finding clean test files in specific sizes (like a 100 KB vs. 5 MB vs. 10 MB file) without clicking through sketchy download sites full of fake "Download" buttons and pop-up ads. Recently I came across SampleFilesHub, and it has quickly become a bookmarked tab in my daily dev and QA workflow. Why it's actually useful for developers Zero ads or dark patterns: No signups, no captchas, and no fake download buttons. You pick a format, pick a file size, and click download. Multiple file sizes per format (100 KB to 10 MB): Really handy when you need to test both happy-path uploads (small files) and edge cases like progress bars, chunked uploads, or server payload limits (client_max_body_size / API body limits). 30+ formats organized by category: Sample Images: JPG, PNG (with alpha transparency), WebP, SVG, GIF, and AVIF. Sample Videos: MP4 (360p up to 4K Ultra HD), MOV, WebM, AVI, and MKV. Sample Audio Files: MP3, WAV (uncompressed PCM), OGG, AAC, FLAC, and M4A. Sample Documents: Multi-page PDF files, DOCX, XLSX spreadsheets, PPTX, CSV datasets (up to ~96,000 rows), TXT, and RTF. Archives & Data Payloads: ZIP, TAR, JSON, XML, Markdown, and HTML. Common scenarios where this saves time Testing file upload validators (checking MIME types, magic bytes, and size limits). Benchmarking image optimization pipelines (Sharp, next/image, Cloudflare Images). Testing PDF text extraction, OCR, or AI RAG document chunking. Stress-testing CSV/JSON importers and streaming parsers with 5 MB–10 MB datasets. All files are royalty-free for development, automated E2E tests (Playwright/Cypress), and commercial QA. Link if you want to bookmark it: https://www.samplefileshub.com What tools or utilities do you all keep bookmarked for quick QA and dummy data testing?

Powered by Readybot.io

Free Sample Files for QA and Upload Testing

Confluence Q&A Migration: Export Before You Move

DEV Community

Moving Confluence Q&A to Cloud: export, migration and the Connect deadline

If your team uses a Q&A app inside Confluence, two dates are worth knowing about before you plan anything else: Data Center: Atlassian stopped selling Data Center to new customers on March 30, 2026, and DC goes read-only on March 28, 2029. Most teams will move to Cloud before then. Connect apps: Atlassian's older app framework (Connect) reaches end of support on January 31, 2027. Admins already see "This app might stop working soon" banners on apps that haven't moved to Forge. Q&A content is easy to forget in a migration plan because it isn't pages. Here's what we learned while building a Q&A app for Confluence Cloud. 1. Questions for Confluence on Data Center: export before you switch off Atlassian's own FAQ is clear that Questions for Confluence Cloud has no export. Data Center is different: it has a REST API and documented database queries. REST: GET /rest/questions/1.0/question?limit=700, then GET /rest/questions/1.0/answer/{answerId} per answer Database: the queries in Atlassian's KB article "How to export Confluence Questions data" Turn that into a CSV with one row per question and one row per answer (linked by the question's id), and keep it, whatever tool you pick next. Once the DC instance is gone, so is the only export path. 2. Q&A apps still on Connect: check now Open Manage apps in Confluence admin. If your Q&A app shows the "might stop working" banner, ask the vendor for their Forge plan and an export. Apps keep running after Jan 31, 2027, but only with critical security fixes, so "it still works" is not a plan. We built a free checker that looks up any Marketplace app and tells you whether it is on Forge, moving, or at risk: https://surfaced-srzj.onrender.com/guides/connect-end-of-support?utm_source=devto&utm_campaign=askbase_launch 3. What to look for in a Cloud Q&A tool From reading a few hundred reviews of Confluence Q&A apps, the complaints repeat: Search only matches titles, so answers are hard to find No import or export, so content is locked in No way to stop duplicates, so the same question is asked five times Everyone can answer, even for HR or legal questions where only experts should No way to ask from the page people are actually reading Whatever you pick, test those five. Disclosure I'm on the team that makes Askbase, a Forge-native Q&A app for Confluence Cloud. Version 2.5 (out this week) does CSV/JSON import and export, searches answers, suggests similar questions while you type, lets moderators merge duplicates, can limit who answers, and adds an "Ask a question" box to any page. It's free for up to 10 users. Import guide (including the Data Center path above): https://surfaced-srzj.onrender.com/apps/askbase/import?utm_source=devto&utm_campaign=askbase_launch Marketplace: https://marketplace.atlassian.com/apps/3507606829/askbase-q-a-for-confluence?utm_source=devto&utm_campaign=askbase_launch Questions or migration stories welcome in the comments.

Powered by Readybot.io

Confluence Q&A Migration: Export Before You Move

Auditing Accessibility Trees Across 76 Web Pages

DEV Community

I dumped the accessibility tree of all 76 of our pages: no unnamed controls, one image, and one heading in the wrong place

Most accessibility checks I have written start from the DOM. Count the aria-label attributes, grep for an alt, assert that there is one h1. It is cheap and it is a guess, because the thing a screen reader uses is not the DOM. It is the accessibility tree the browser computes from it, where names come from six possible sources in a fixed order and whole subtrees disappear for reasons that are not visible in the markup. So for Nakodo, which finds creators and local businesses that fit a brand and emails the good fits, I stopped guessing and asked Chrome for the tree itself. All 76 public pages, one Accessibility.getFullAXTree call each, every node aggregated. Here is what came back, and the one thing it found that a DOM grep had been missing for weeks. The method The tree is not available through normal page JavaScript. There is no document.accessibilityTree. You get it from the DevTools protocol, which means driving the browser rather than running a snippet in it: await page.cdp("Accessibility.enable");
const { nodes } = await page.cdp("Accessibility.getFullAXTree");
Each node arrives with a computed role, a computed name with the list of sources that were considered and which one won, a description, a property list (focusable, level, invalid, describedby) and an ignored flag. That last flag is the interesting one: getFullAXTree returns the nodes the browser decided not to expose as well as the ones it did, so you can measure what you are hiding instead of inferring it. If you want to see the same thing by hand rather than by protocol, open DevTools on any page, go to the Elements panel, open the Accessibility pane in the sidebar and tick "Enable full-page accessibility tree". That is the same data with a different front end. The totals Measure Value Pages 76 Nodes returned 60,481 Ignored (not exposed) 5,218, which is 8.6 per cent Exposed 55,263 Exposed nodes carrying a name 43,460 Distinct roles 42 Nodes per page 343 at the lowest, 1,099 at the highest, 827 median The smallest page is the alternatives hub at 343 nodes. The largest is the home page at 1,099, which is also the page with the largest ignored share at 20.9 per cent. Both of those numbers have the same cause, and I will come back to it. Two roles account for more than half of everything: 20,117 InlineTextBox and 14,182 StaticText. Text is what a website is. After that it is 5,084 links, 4,746 list items, 2,740 paragraphs, 662 headings, 648 table cells and a long tail: 334 disclosure triangles, 314 terms with 314 matching definitions, 63 times, 20 figures with 20 captions, 5 tooltips, 3 radios. Finding one: 5,166 controls, every one of them named The audit flagged any node with an interactive role and an empty computed name. The set was 5,084 links, 43 buttons, 36 text boxes and 3 radios, so 5,166 controls across the site. Zero of them have no name. I was braced for a handful. The usual suspects are icon-only buttons, links wrapped round a decorative image, and inputs whose label is a styled div sitting next to them rather than a label. The reason none of those bite here is boring and worth saying out loud: the icon set is only ever used inside a control that already has text, and the one place where an icon stands alone is the small-screen menu trigger, which carries an explicit label:


That is the whole trick. There is no clever labelling system here, no utility that enforces a name. There is just one component for buttons, one for inputs, and a habit of writing the words before the picture. The computed-name sources in the tree confirm it: the overwhelming majority of those 5,166 names come from contents, which is to say from the text that is already visible on the page, not from an attribute somebody remembered to add. Count them yourself on the page with the most controls, the glossary, which has 118 focusable nodes: [...document.querySelectorAll("a, button...

Powered by Readybot.io

Auditing Accessibility Trees Across 76 Web Pages

Munchable Privacy: Account Export and Data Deletion

DEV Community

Our delete route touches seven tables, our export route reads five, and the two it misses hold the only free text we have

Munchable is a barcode scanner for people with gut conditions, and the thing that makes its privacy notice short is that the health data never arrives: the conditions you pick, your sensitivities and your scan history are on the phone, and the verdict is worked out there too. The policy is at munchable.app/privacy, and the sentence it leads with is that we hold an email address, a subscription record and any labels you contributed. Two route handlers have to make that sentence checkable: one that hands you everything we hold, and one that erases it. They live in sibling folders, they were written months apart, and they disagree about the schema. The export, in full GET /api/export-account is five queries in parallel and one JSON response. const [ent, consent, rewards, contributions, feedback] = await Promise.all([
db.select().from(entitlements).where(eq(entitlements.userId, uid)).limit(1),
db.select().from(userConsent).where(eq(userConsent.userId, uid)).limit(1),
db.select().from(contributionRewards).where(eq(contributionRewards.userId, uid)),
db.select({ barcode: ..., ingredientsText: ..., createdAt: ... })
.from(catalogProductRevisions)
.where(eq(catalogProductRevisions.contributorId, uid)),
db.select().from(catalogProductFeedback).where(eq(catalogProductFeedback.userId, uid)),
]);
It answers 401 to a signed-out caller and to an anonymous session, serialises with JSON.stringify(payload, null, 2) because a human is going to open it, and sets a content-disposition so it lands as a file rather than a wall of text. The field I want to point at is not a table. It is this: note: 'Your conditions, sensitivities, and scan history are stored only on your device and are never sent to our servers, so they are not part of this export.',
An export of almost nothing looks broken. Someone who scans twenty products a week opens this file, sees a subscription row and two contributions, and reasonably concludes the export is incomplete. So part of the payload's job is to explain its own smallness, in the file, where someone reading the file will see it. Our privacy notice says the same thing, but a notice is not what lands in the Downloads folder. The erasure, and the list it uses POST /api/delete-account is a different shape: cancel the Stripe subscription, then one transaction, then delete the identity. await tx.delete(contributionRewards).where(...);
await tx.delete(catalogProductFeedback).where(...);
await tx.update(catalogProductRevisions).set({ contributorId: DELETED_CONTRIBUTOR }).where(...);
await tx.delete(userConsent).where(...);
await tx.delete(entitlements).where(...);
await tx.delete(supportMessages).where(inArray(supportMessages.ticketId, / subquery /));
await tx.delete(supportTickets).where(...);
Seven tables. Six deletes and one update: contributed revisions are re-pointed at a fixed sentinel id rather than removed, because the catalogue rows that reference them are not personal data and should not vanish from other people's app when one contributor leaves. The route also refuses to start if it cannot finish. Without the Supabase service-role key it cannot delete the identity itself, so it answers 501 rather than deleting the rows and leaving the login, which would be a half-erasure that returns 200. The diff between the two lists Side by side: table erased exported
entitlements yes yes
user_consent yes yes
contribution_rewards yes yes
catalog.product_revisions anon 3 of 18 columns
catalog.product_feedback yes yes
support_tickets yes NO
support_messages yes NO
The two tables the export does not read are the two the erasure route is most emphatic about. Its own comment: // Support history goes with the account. A ticket body is whatever the
// person typed, so it is the most sensitive free text Munchable holds and
// erasure has to take it.
One route calls it the most sensitive free tex...

Powered by Readybot.io

Munchable Privacy: Account Export and Data Deletion

Locale vs. Language: A Website Localization Audit

DEV Community

Our site formats money in 24 locales and declares one language, and 14 of those locales are languages we have no words in

Notifio is a desktop app that watches rental search pages and emails you when a new listing appears. The market for that is a person hunting for a room in Amsterdam, Berlin or Madrid, and the site reflects it: fifteen pages about specific rental portals, grouped into six regional sections on /alerts with headings reading Netherlands, United Kingdom, Europe and international, Germany, Spain and southern Europe, Marketplaces. The site also charges each visitor in their own currency, which I have written about from the payments side in three of our 24 currencies are written without decimals and two of our pages tell the browser tab £20 and the visitor €24.99. What I had never done is look at the two halves together: what the site knows about the visitor's locale, against what it tells the browser about its own language. The markup side of the census 38 pages fetched from the live site: $ grep -o ']lang="[^"]"' pages/.html | grep -o 'lang="[^"]"' | sort | uniq -c
38 lang="en"
$ grep -c 'hreflang=' pages/.html | grep -v ':0'
$ grep -c 'dir="' pages/
.html | grep -v ':0'
38 of 38 pages declare lang="en" 0 hreflang links 0 dir attributes, anywhere, on any element 0 inline lang attributes on any element other than 38 of 38 pages carry exactly one canonical link Nothing wrong with any of that for a monolingual site. The site is monolingual. Hold that thought. The data side of the census // server/src/lib/pricing/display-currency.ts
eur: { code: "EUR", locale: "en-IE", rate: 1.1696 },
chf: { code: "CHF", locale: "de-CH", rate: 1.0981 },
isk: { code: "ISK", locale: "is-IS", rate: 166.32 },
huf: { code: "HUF", locale: "hu-HU", rate: 423.31 },
ils: { code: "ILS", locale: "he-IL", rate: 4.0016 },
24 currencies 58 country codes mapped onto them, resolved per request from x-vercel-ip-country 24 distinct BCP 47 locales, one per currency of those, 10 are English variants: en-GB, en-IE, en-US, en-CA, en-AU, en-NZ, en-IN, en-SG, en-HK, en-ZA and 14 are not: de-CH, sv-SE, nb-NO, da-DK, is-IS, pl-PL, cs-CZ, hu-HU, ro-RO, tr-TR, es-MX, pt-BR, ja-JP, he-IL So the site is carrying enough locale knowledge to write a price the way a reader in Reykjavik, Budapest or Tel Aviv writes it, on pages that have never contained a word of Icelandic, Hungarian or Hebrew. The locale is passed to Intl.NumberFormat and nowhere else. It never reaches the document. The euro is formatted in English This is the detail that made me want to write this up. 27 of the 58 country codes map to eur. That bucket is the Netherlands, Germany, Spain, Italy, France, Ireland and twenty-one others, and it includes every country whose rental portals the site has pages about. Its locale is en-IE. en-IE €24.99
nl-NL € 24,99
de-DE 24,99 €
es-ES 24,99 €
A Dutch visitor to /pricing is shown €24.99. Every Dutch site they have ever bought anything from wrote it € 24,99: symbol, space, comma decimal. The number is right, the currency is right, the punctuation is Irish. I want to be fair to the decision, because there is one. Picking en-IE for the euro means one entry in the table instead of twenty-seven, it keeps the decimal separator the same as every other price on an English-language page, and a page that writes "€24.99 once" in one sentence and "24,99 €" in the next looks broken rather than localised. A single euro locale is the only option that does not require deciding which of twenty-seven conventions wins. But it is still a choice nobody wrote down, and the reason I know it was not deliberate is the locale list it sits in. If formatting natively mattered, en-IE would be wrong. If it did not matter, is-IS and hu-HU would not be there. Two invisible characters The Hebrew entry is the one that goes furthest past what the document can support: $ node -e 'console.log(JSON.stringify(new Intl.NumberFormat("he-IL",
{style:"currency",currency:"ILS"}).format(109)))'
"‏109.00 ‏₪"
Those two marks before the digits and before the shekel sign are U+200F RIGHT...

Powered by Readybot.io

Locale vs. Language: A Website Localization Audit

AER-1 Conformance: 3 Implementation Traps

DEV Community

AER-1 in production: 3 things implementers got wrong on the first try

Your implementation passes every test you wrote. Then you run it against the public conformance corpus and it disagrees on a handful of vectors, and the disagreements look like the spec contradicting itself. That is the normal first week with AER-1. The corpus is 143 vectors and 146 checks, frozen, and conformance means byte-identical verdicts to the reference runners, not verdicts that feel right. Almost everyone who has implemented the draft hit the same three traps on the first try. Two of them were surfaced in public this week by an outside team, probityai, who ran the kit, published what they found, and were right about the tension in both cases. The resolutions are now in the errata for draft -26. Here is what went wrong, and the rule that settles each one. 1. Failing receipts that were never required to carry metadata_hash The symptom: your verifier rejects 0.4 receipts that have no metadata_hash, because Section 6 says a 0.4 receipt without it MUST fail. Then the corpus hands you ten receipts in the valid set with no metadata_hash at all, and your verifier fails the conformance run instead. Both texts are real. Table 1 marks metadata_hash "Cond.": required when receipt_schema_version is "0.4" or later, and a verifier MUST recompute and compare it whenever present. The Section 6 sentence governs the metadata-binding claim, not the core verdict. The reading that fits the whole draft: A producer at 0.4 or later MUST emit metadata_hash. The reference producer does. A verifier MUST recompute it when it is present. A mismatch fails. Its absence is not a failure. The receipt is scored on form, output commitment, version agreement, and provenance binding, and the absence stays visible in the report. What you must never do is report the metadata as bound when the member is not there. If you are a consumer who needs tamper-evident metadata, require the member under your profile and reject its absence there (Section 13). The trap is reading one MUST in isolation. The draft's conformance bar is the corpus plus the reference runners, and both pinned 0.4 shapes, with and without the member, are deliberate. 2. Accepting 5.0 as an integer The symptom: a chain commitment says entry_count: 5.0 over a five-entry timeline. It looks like the same number. The corpus calls the vector invalid, and your verifier, which compared values, called it fine. The difference is what happens to the value next. seq, the member Section 7.1 defines with almost the same words, is canonicalized into integer form before it feeds the entry digest, so 1 and 1.0 serialize identically ("1, never 1.0") and both are accepted. entry_count is never canonicalized. It is a recorded member of a standalone commitment artifact, compared exactly as recorded. For a member like that, "a JSON number with an integer value" is read under the draft's form discipline: encode it as a JSON integer, or the commitment check fails closed. Producers: never emit an integer-valued float for a count. Verifiers: a mistyped member is a failed check, not a rounding opportunity. Run locally, the reference runner rejects the 5.0 vector with "commitment entry_count is not an integer" and passes the identical commitment written with 5. The encoding is the only defect. 3. Hashing the string you see instead of the bytes that were sent The symptom: your hashes agree with your own test fixtures and disagree with everyone else's on any receipt whose content is not plain ASCII. Unicode gives you two byte sequences for what looks like one string. An accented character can be stored as a single code point (NFC) or as a base character plus a combining mark (NFD). On screen they are identical. To SHA-256 they are different inputs, so two honest implementations, each hashing "the same" text, produce different commitments, and each concludes the other one is broken. The fix is to decide the form before the hash, not after the argument: canonical bytes are computed over one fixed normalization form, ever...

Powered by Readybot.io

AER-1 Conformance: 3 Implementation Traps

SEO Audit: 991 Pages, Zero Duplicate Metadata

DEV Community

I measured the title and description of all 991 of our pages: 991 distinct, none over budget, and the tests say 47, 48, 59 and 60

CogniPrep publishes 991 pages. Most of them are generated from registries rather than hand-authored one by one, which is the arrangement that normally produces duplicate titles and descriptions at scale. So I fetched every URL in our own sitemap and parsed the head of each one. 991 URLs, 991 answered 200

titles 991 distinct 25 to 60 chars mean 52.6
descriptions 991 distinct 104 to 159 chars mean 146.8
Zero duplicates on either field, zero titles over 60, zero descriptions over 160, and nothing under 104 characters either. I did not expect that, and the interesting part turned out to be why it happened, because it is not the tests. Why the ceiling is exactly 60 The root layout owns a title template: title: {
default: 'CogniPrep - Psychometric Test Practice',
template: '%s | CogniPrep',
},
| CogniPrep is 12 characters. Every page supplies the %s part, and the longest one anybody has written is 48 characters. 48 plus 12 is 60, which is why the distribution has a hard wall at exactly the number a search result starts truncating at. The top of the histogram, measured on the live HTML: rendered length pages 60 4 59 54 58 80 57 104 56 88 55 97 Four pages sit precisely on the line. Having a visible brand suffix turns out to be an accidental forcing function: the budget a writer feels is 48, not 60, because the suffix is already there in the template and they are typing into what is left. One page in 991 has no brand in its title title: { absolute: 'Civitas Senior Living Assessment Practice' },
absolute is Next.js's opt out of the parent template, and one employer page uses it. It is the only one in the repository, and the result is a 41-character title with no | CogniPrep on it, where the template would have produced 53 and fitted comfortably. Nineteen characters of unused budget and no brand, which is not a trade anybody would make on purpose. The homepage is the other page without the suffix, and that one is deliberate: it uses the template's default, which is how you give a site root its own title. So: 989 of 991 titles end in the brand, one is the home page, and one is a mistake that no test could see, because it is not a length problem. Now the part that is a mess There are 98 title-length assertions across 50 test files, and they are written five different ways: assertion occurrences toBeLessThanOrEqual(47) 74 toBeLessThanOrEqual(48) 15 toBeLessThan(60) 5 toBeLessThan(48) 2 toBeLessThanOrEqual(60) 2 Four effective ceilings: 47, 48, 59, 60. Two of them are the pre-suffix budget disagreeing with itself by one character, and two are the post-suffix budget disagreeing with itself by one character, and nothing in the repository says which of the four is the rule. Descriptions are better behaved: 135 assertions, all of them toBeLessThan(160), which gives the 159 maximum the live measurement found. The reason for the spread is how the tests were written. Each provider has its own registration test file, many of them produced on separate branches at different times, and each one asserts the limits for its own content. Nobody was copying from a shared constant, because there is no shared constant. There is a prose comment in a type definition that documents 48, and 74 assertions that say 47. The pages on the line are the ones with no test The four titles at exactly 60 characters are per-test pages under three providers. Their providers' registration tests do have title assertions, and here is one of them: it('has a hub whose title and description fit the limits', () => {
const title = /const TITLE = '([^']+)'/.exec(hub)?.[1] ?? '';
expect(title.length).toBeLessThanOrEqual(47);
That reads the hub page's source as text and checks one constant. The per-test pages below that hub are not in the loop, which is exactly why they are the four allowed to reach 48. Coverage by cluster is lopsided in the same way: The 50 files that assert a title length are, with five exceptions, one per assessment provider. There is...

Powered by Readybot.io

SEO Audit: 991 Pages, Zero Duplicate Metadata

Fix GitHub Actions Buildx Cache Reservation Errors

DEV Community

error writing layer blob: failed to reserve cache

"error writing layer blob: failed to reserve cache" in GitHub Actions is the buildx gha cache exporter being refused when it asks the Actions cache service to reserve an entry. The image was usually built and pushed already; what failed is the cache export, and the fix is to stop exporting from the jobs that either may not write or are racing each other for the same key. The export is the last thing a build does, which is why the failure arrives after the image is already pushed. What this error means The build log looks like a success until the last few seconds. The image is built, the push completes, and then the step fails on the export with "ERROR: failed to solve: error writing layer blob: failed to reserve cache". Sometimes one blob is written before it stops, which is what makes it look like a quota problem. The three things people check first come back clean: the cache is nowhere near its limit, the permissions are right, and the same job passed yesterday. It is intermittent on a matrix and consistent on a fork pull request, and those two shapes have different explanations. The block below is quoted from the canonical issue rather than from a run of ours, and the section on reproduction says why. ```Quoted from docker/build-push-action#1571 ERROR: failed to solve: error writing layer blob: failed to reserve cache

Common causes

The workflow has read-only cache access

The most common cause and the most deterministic. Events such as pull_request_target and issue_comment get read-only access by default, so a build under one can read a cache and cannot reserve an entry. It fails identically every run, which is how you tell it from the matrix race.

You raised the limits and added permissions, and it kept failing

The wasted fix, and the reason the canonical issue runs as long as it does. Its reporter confirmed the cache was well inside the limit, added actions: write, tested two major versions of the action and checked the toolchain, and none of it changed the outcome. Quota and permissions are the first two guesses and usually not the answer.

Two matrix legs reserve the same key at the same time

Layer blobs are keyed by digest, ref and repository, and legs that share a base image share those digests. When two exports collide the second reservation is refused. It is intermittent by nature, and adding scope= does not separate them because the scope is not part of the reservation key.

The toolchain predates the cache service v2 cutover

Since April 2025 only the v2 cache API is served, and Buildx, BuildKit, Compose and Engine each have a minimum version that speaks it. A pinned action or a self-hosted image that has not moved will fail on the export even though everything about the workflow is correct.

How to fix it

Import everywhere, export from one place

  1. Remove cache-to from pull request and comment-triggered workflows, and leave cache-from in place.
  2. Populate the cache from the push workflow on your default branch, where write access exists.
  3. Verify by checking that the cache entries appear after a merge rather than after a pull request build.

```.github/workflows/ci.yml

pull_request: read the cache, never write it

  • uses: docker/build-push-action@v7
    with:
    cache-from: type=gha

push to the default branch: write it

  • uses: docker/build-push-action@v7
    with:
    cache-from: type=gha
    cache-to: type=gha,mode=max
    Stop matrix legs from exporting the same layers Export from one leg rather than all of them, or give the legs genuinely different base layers. Exporting from a single leg keeps the benefit, because the layers a matrix shares are exactly the layers the other legs will import next time. - uses: docker/build-push-action@v7
    with:
    cache-from: type=gha
    cache-to: ${{ matrix.node == 22 && 'type=gha,mode=max' || '' }}
    Bring the toolchain up to the v2 minimums Update setup-buildx-action and the build action to current majors, and on a self-...

    Powered by Readybot.io
Fix GitHub Actions Buildx Cache Reservation Errors

WCAG vs APCA: Testing Dark-Mode Text Contrast

DEV Community

Our most used text colour passes WCAG at 6.29 to 1, and APCA says it is only legible at 42px

Yesterday I ran axe-core against all 79 pages of our site and it reported 981 colour contrast failures. Today I want to look at the other side of that number: the colours axe says are fine. The WCAG 2 contrast ratio is a formula from 2008 that divides two relative luminances and adds 0.05 to each to avoid dividing by zero. It is symmetric, which means it does not care whether your text is light on dark or dark on light, and it has a known weakness in the dark half of the range, where that 0.05 flattens real differences. APCA is the candidate replacement being developed for WCAG 3. It is asymmetric, it accounts for polarity, and it returns a lightness contrast value, Lc, from roughly -108 to 106 instead of a ratio. Our site is dark. #171717 background, grey text. That is exactly the region where the two methods are supposed to disagree, so I scored every text colour on the site with both. The instrument Every element on every page that owns a non-empty text node, with its computed colour and the background it actually sits on. Tailwind opacity modifiers mean the computed color is frequently oklab(... / 0.6), which no regex can read, so each colour goes through a 1x1 canvas: ctx.fillStyle = getComputedStyle(el).color; // any CSS colour at all
ctx.fillRect(0, 0, 1, 1);
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data; // sRGB bytes
Then the alpha is composited over the first opaque ancestor background, and the resulting pair is scored twice: once with the WCAG 2 ratio, once with APCAcontrast(sRGBtoY(txt), sRGBtoY(bg)) from apca-w3 0.1.9, copied into the page so nothing is loaded at runtime. The whole site, 79 pages: elements carrying text 8,575 characters of visible text 381,241 distinct text colours 12 distinct backgrounds 7 distinct font sizes 15 distinct weights 4 (400, 500, 600, 700) distinct colour + background + size + weight combinations 52 52 combinations is the entire typographic surface of the site. Every judgement below is one of 52 rows. What WCAG 2 says Four of the 52 fail AA, covering 1,120 elements: text background size elements pages ratio needs #656565 #171717 14px 1,044 15 3.08 4.5 #727272 #171717 14px 68 34 3.73 4.5 #727272 #171717 12px 4 2 3.73 4.5 #585858 #171717 14px 4 2 2.52 4.5 All four are the same mistake: a Tailwind slash opacity modifier on an already muted grey, text-muted-foreground/60 and /70, which produces a colour nobody chose. The worst one lives on the 15 question category pages at about 68 elements each, and those elements average 4.4 characters, so the most numerous failure on the site is also the one carrying the least text. 1,044 elements, roughly 4,000 characters between them. My element level count of 1,120 against axe's 981 node count is the two tools counting different things, not a disagreement: axe skips nodes whose background it cannot resolve and nodes it judges to have no visible text. Same two colours, either way. What APCA says Using the APCA guidance tiers of Lc 75 for body text, Lc 60 from 24px, and Lc 45 for large or bold text, 20 of the 52 combinations fail, covering 5,603 elements. Breaking that against the WCAG result: combinations elements fail both methods 4 1,120 pass WCAG, fail APCA 16 4,483 fail WCAG, pass APCA 0 0 Zero. Every single colour WCAG objects to, APCA also objects to, and APCA then objects to sixteen more. On a dark site the newer model is strictly stricter, and the gap is not marginal. Here is the one that matters, because it is our most used text colour on 76 pages: #999999 on #171717
WCAG 2: 6.29 : 1 pass AA (needs 4.5), pass AAA for large text
APCA: Lc 46.2
6.29 is not a squeak past the line, it is 40 per cent above it. And APCA's own font size lookup table, which is part of the specification rather than my interpretation, says this about Lc 45: Lc min size at weight 400 min size at weight 700 30 108 px 72 px 45 42 px 24 px 60 24 px 16 px 75 18 px 14 px 90 16 px 14 px 100 15 px 13 px We use #999999 at 12px, 14px, 16px and 18px....

Powered by Readybot.io

WCAG vs APCA: Testing Dark-Mode Text Contrast

CodeGraphy: Local-First Code Architecture Visualizer

DEV Community

I built a local-first architecture visualizer and MCP server so AI agents stop hallucinating about my codebase

Working with AI coding assistants on a growing codebase usually hits the same wall: once a project crosses a few dozen files, the LLM starts losing track of how modules actually connect. You end up burning context window tokens just explaining your folder structure, or worse, watching the AI hallucinate imports that don't exist. To fix this in my own workflow, I built CodeGraphy — a local-first software architecture visualizer and Model Context Protocol (MCP) server. What it does Instead of static documentation that goes out of date the moment you commit, CodeGraphy parses your project locally and turns it into an interactive node graph, while exposing that same structural map directly to AI tools via MCP. 100% Local-First: Your code never leaves your machine. No cloud indexing, no API keys required for parsing. Interactive Visual Graph: Built with React Flow, letting you explore file dependencies, imports, and module boundaries visually instead of jumping through endless IDE tabs. Native MCP Server: Connects directly to MCP-compatible clients (like Claude Desktop, Cursor, etc.), allowing your AI assistant to query the actual dependency graph before suggesting architectural changes. Zero-Config CLI: You can spin up a lightweight version right from your terminal. Under the Hood I wanted the tool to be fast to run locally without heavy setup overhead: Backend & AST Parsing: Python + FastAPI Frontend & Graph Rendering: React, React Flow, and Zustand for state management Distribution: Available as a standalone desktop app and a lightweight Python package Try it out If you want to test it on your own repo, you can install the Lite version directly via pip: Bash pip install codegraphy-lite And run it inside your project directory: Bash codegraphy-lite (Insert a screenshot of the MCP tool call in action or the CLI output here) For the full desktop experience, pre-built binaries, and source code, check out the site: 👉 site: click What's next? I'm currently refining multi-language parsing support and optimizing graph layouts for larger monorepos. If you give it a spin, I'd love to hear what breaks, what's missing, or how you're currently giving your AI assistants architectural context. Drop a comment below!

Powered by Readybot.io

CodeGraphy: Local-First Code Architecture Visualizer

Fix GitHub Actions setup-go Version Errors

DEV Community

GitHub Actions setup-go unable to find go version, and what is in the quotes

GitHub Actions setup-go unable to find go version is thrown after the action has searched the published Go download index and found nothing that satisfies the spec it was given for this operating system and this architecture. The spec is quoted in the message, and reading what is inside the quotes settles almost every instance of this failure, because the spec is frequently not the string anybody wrote. The basename dispatch and the fall-through are read from parseGoVersionFile in src/installer.ts. The framed line is quoted from ctrl-research/mmo#3, shortened at the ellipses. What this error means A setup-go step fails before anything is installed, with a message naming a version, a platform and an architecture. Look at the quoted spec first and ask whether it is what you expected. Three shapes turn up. A plausible version number means the spec was right and no published build matches it for that platform and architecture. A version you never wrote means it came from a file rather than from the workflow. The contents of an entire file, comments and all, means the action read a file whose name it does not recognise and used the whole thing as a version string. The platform and architecture at the end of the message are not decoration: the search matches a per-file operating system and architecture, so a version that exists can still be unfindable here. ```Actions log, quoted from ctrl-research/mmo#3; the reporter marked the elisions [error]Unable to find Go version '# Single source of truth for language/tool versions (asdf/mise format). Humans and AI agents should use these versions... golang 1.26.5 nodejs 24.16.0 ...' for platform linux and architecture x64.

Common causes

go-version-file points at a file the action does not parse

The most surprising cause and the easiest to confirm, because the error quotes the file back at you. Only go.mod, go.work and .tool-versions are recognised, by exact basename. A tested-go.mod, a copied manifest under a different name or a version file in a format the action never supported all fall through to the branch that treats the whole file as a version string.

A toolchain directive in go.mod pins a patch that cannot be resolved

The toolchain line is read before the go line, so a repository that bumps toolchains for the go command is also, silently, changing what setup-go tries to install. In our experience this is the cause whenever the quoted version is a precise patch that nobody remembers putting in the workflow.

The version exists but has no build for this platform and architecture

The match is per file, not per release, so an arm64 macOS job or a Windows job can fail on a version a Linux job installs happily. The end of the message names the platform and architecture that found nothing, which is the only place this cause announces itself.

The spec is an exact patch that was never published

A typo, a patch number invented by a bump script, or a release that was pulled. This is the cause the message reads as if it always means, and it is real, but it is the one to check after the three above rather than before them.

How to fix it

Read what is inside the quotation marks before changing anything

  1. If the quotes hold many lines or a comment, the action fell through to its unrecognised-basename branch and the fix is the file name, not the version.
  2. If the quotes hold a patch nobody wrote in the workflow, search go.mod for a toolchain line.
  3. If the quotes hold the version you expected, read the platform and architecture at the end of the message and check that a build exists for that pair.

Give go-version-file a name the parser recognises

Point the input at a real go.mod or go.work, or copy the manifest to that basename first. A file staged under any other name is not rejected, so nothing warns you; the whole file simply becomes the spec.

```.github/workflows/ci.yml (illustrative)
- run: cp tested-go.mod "${RUNNER...

Powered by Readybot.io

Fix GitHub Actions setup-go Version Errors