<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Sat, 07 Nov 2026 09:31:55 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Build a PDF Storyboard Generator Using JavaScript ]]>
                </title>
                <description>
                    <![CDATA[ PDF files are commonly used to share presentations, scripts, reports, lesson materials, comic pages, and other visual documents. But reviewing a long PDF page by page can take time, especially when so ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-pdf-storyboard-generator-javascript/</link>
                <guid isPermaLink="false">6aee2d6e123f77519047915d</guid>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ pdf ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Bhavin Sheth ]]>
                </dc:creator>
                <pubDate>Fri, 06 Nov 2026 19:41:34 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/e8109e42-db7b-4690-9d2b-2be82ab57943.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>PDF files are commonly used to share presentations, scripts, reports, lesson materials, comic pages, and other visual documents. But reviewing a long PDF page by page can take time, especially when someone needs to understand the overall sequence of scenes, slides, or visual content.</p>
<p>A storyboard provides a more organized way to review this information. Instead of opening every page individually, users can arrange selected PDF pages into a single sheet containing multiple panels. Each panel can include a page number, a caption, and a visual preview of the original page.</p>
<p>In this tutorial, we'll explore how to build a browser-based PDF Storyboard Generator using JavaScript, PDF.js, and PDF-lib.</p>
<p>The application takes pages from an existing PDF and arranges them into printable storyboard sheets. Users can choose different layouts, select the pages they want to include, add captions, customize the appearance, preview the result, and download the finished storyboard.</p>
<p>The application runs directly in the browser. The uploaded PDF is processed locally rather than uploaded to the website's server.</p>
<p>We'll examine the implementation step by step, including PDF rendering, page selection, caption handling, layout calculations, preview generation, and file export.</p>
<h3 id="heading-what-well-cover">What We'll Cover:</h3>
<ul>
<li><a href="#heading-what-is-a-pdf-storyboard-generator">What Is a PDF Storyboard Generator?</a></li>
<li><a href="#heading-main-features-of-the-application">Main Features of the Application</a></li>
<li><a href="#heading-understanding-the-browser-based-workflow">Understanding the Browser-Based Workflow</a></li>
<li><a href="#heading-project-setup">Project Setup</a></li>
<li><a href="#heading-libraries-used">Libraries Used</a></li>
<li><a href="#heading-preparing-the-html-workspace">Preparing the HTML Workspace</a></li>
<li><a href="#heading-selecting-the-main-dom-elements">Selecting the Main DOM Elements</a></li>
<li><a href="#heading-defining-important-configuration-values">Defining Important Configuration Values</a></li>
<li><a href="#heading-understanding-the-application-state">Understanding the Application State</a></li>
<li><a href="#heading-uploading-a-pdf">Uploading a PDF</a></li>
<li><a href="#heading-loading-the-pdf-with-pdfjs">Loading the PDF with PDF.js</a></li>
<li><a href="#heading-measuring-pdf-pages">Measuring PDF Pages</a></li>
<li><a href="#heading-rendering-pdf-pages-on-canvas">Rendering PDF Pages on Canvas</a></li>
<li><a href="#heading-reusing-rendered-page-previews">Reusing Rendered Page Previews</a></li>
<li><a href="#heading-reading-text-from-pdf-pages">Reading Text from PDF Pages</a></li>
<li><a href="#heading-filling-empty-captions-automatically">Filling Empty Captions Automatically</a></li>
<li><a href="#heading-updating-the-preview">Updating the Preview</a></li>
<li><a href="#heading-choosing-the-storyboard-layout">Choosing the Storyboard Layout</a></li>
<li><a href="#heading-controlling-rows-columns-and-paper-size">Controlling Rows, Columns, and Paper Size</a></li>
<li><a href="#heading-selecting-pdf-pages-and-their-order">Selecting PDF Pages and Their Order</a></li>
<li><a href="#heading-adding-panel-labels">Adding Panel Labels</a></li>
<li><a href="#heading-drawing-the-storyboard-sheet">Drawing the Storyboard Sheet</a></li>
<li><a href="#heading-updating-the-live-preview">Updating the Live Preview</a></li>
<li><a href="#heading-adjusting-caption-appearance">Adjusting Caption Appearance</a></li>
<li><a href="#heading-navigating-between-storyboard-sheets">Navigating Between Storyboard Sheets</a></li>
<li><a href="#heading-reading-scanned-pages-with-ocr">Reading Scanned Pages with OCR</a></li>
<li><a href="#heading-generating-the-final-storyboard-pdf">Generating the Final Storyboard PDF</a></li>
<li><a href="#heading-downloading-png-storyboard-sheets">Downloading PNG Storyboard Sheets</a></li>
<li><a href="#heading-downloading-individual-page-pictures">Downloading Individual Page Pictures</a></li>
<li><a href="#heading-performance-and-important-considerations">Performance and Important Considerations</a></li>
<li><a href="#heading-demo-pdf-storyboard-generator">Demo: PDF Storyboard Generator</a></li>
<li><a href="#heading-conclusion">Conclusion</a></li>
</ul>
<h2 id="heading-what-is-a-pdf-storyboard-generator">What Is a PDF Storyboard Generator?</h2>
<p>A PDF Storyboard Generator converts selected pages from an existing PDF into a structured visual sheet.</p>
<p>For example, imagine that you have a PDF containing 20 presentation slides. Instead of sharing all 20 slides separately, you can arrange them into a storyboard containing three columns and two rows per sheet.</p>
<p>Each panel displays a scaled version of its original PDF page. You can also add a number or label and write a short description underneath.</p>
<p>The same approach can be useful for:</p>
<ul>
<li>Reviewing presentation slides.</li>
<li>Organizing comic pages.</li>
<li>Planning video scenes.</li>
<li>Preparing visual scripts.</li>
<li>Reviewing scanned documents.</li>
<li>Creating printable teaching materials.</li>
<li>Organizing page sequences for creative projects.</li>
</ul>
<p>The application doesn't create new scenes or draw illustrations. It arranges existing PDF pages into a storyboard layout.</p>
<h2 id="heading-main-features-of-the-application">Main Features of the Application</h2>
<p>Before writing the code, let's understand the features we're going to implement.</p>
<h3 id="heading-1-pdf-upload">1. PDF Upload</h3>
<p>Users can select a PDF from their device or drag and drop it into the upload area.</p>
<h3 id="heading-2-multiple-storyboard-layouts">2. Multiple Storyboard Layouts</h3>
<p>The application supports three layouts:</p>
<ul>
<li>Grid: Arranges pages in rows and columns.</li>
<li>Panels with notes: Provides space for written notes alongside the page panels.</li>
<li>Film strip: Arranges pages in a film-strip style.</li>
</ul>
<h3 id="heading-3-page-selection-and-ordering"><strong>3. Page Selection and Ordering</strong></h3>
<p>Users can choose which PDF pages should appear in the storyboard. They can also specify a custom order rather than following the original PDF sequence.</p>
<h3 id="heading-4-captions">4. Captions</h3>
<p>Users can type captions manually, fill empty captions using extracted PDF page text, or use OCR to read text from scanned pages.</p>
<h3 id="heading-5-customization">5. Customization</h3>
<p>Users can customize the paper size, orientation, number of columns and rows, panel labels, sheet colors, caption font, text size, spacing, and panel frames.</p>
<h3 id="heading-6-live-preview">6. Live Preview</h3>
<p>The application generates a preview of the storyboard sheets. Users can review the arrangement before downloading the final output.</p>
<p><strong>7. Multiple Export Formats</strong></p>
<p>Users can export their work as:</p>
<ul>
<li>A PDF storyboard.</li>
<li>PNG storyboard sheets.</li>
<li>A ZIP file containing individual page pictures.</li>
</ul>
<p>The PDF export preserves original page content as vectors when possible, helping maintain sharp text and graphics.</p>
<h2 id="heading-understanding-the-browser-based-workflow">Understanding the Browser-Based Workflow</h2>
<p>The application follows a straightforward process.</p>
<ol>
<li>The user selects or drops a PDF file into the upload area.</li>
<li>PDF.js loads the document and renders its pages.</li>
<li>The application displays page previews and caption fields.</li>
<li>The user selects pages, changes their order, and enters captions.</li>
<li>The user configures the storyboard layout and appearance.</li>
<li>JavaScript calculates the position and dimensions of every panel.</li>
<li>The preview is updated to reflect the selected settings.</li>
<li>PDF-lib and browser canvas functionality are used to generate the requested output.</li>
<li>The browser downloads the finished file.</li>
</ol>
<p><strong>PDF Upload Workspace</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/1d23ecf5-1677-4549-84e1-03a08e8943eb.png" alt="PDF Storyboard Generator upload workspace with a drag-and-drop area and PDF selection button." style="display: block;" width="600" height="400" loading="lazy">

<p><strong>Panels and Captions</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/996d2382-8020-4ddc-b1b8-5956ec86bbdd.png" alt="PDF pages displayed in a storyboard workspace with page previews, caption fields, and OCR controls." style="display: block;" width="600" height="400" loading="lazy">

<p><strong>Board Settings and Preview</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/882dbe78-4c8c-4d21-aa64-6519fea5a4bf.png" alt="Storyboard settings panel showing layout, page selection, paper size, orientation, captions, and a live preview." style="display: block;" width="600" height="400" loading="lazy">

<p><strong>Final Storyboard Preview</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/74d7c855-7a6f-44f0-83c2-09efe38a5c3c.png" alt="Final PDF storyboard preview with three numbered panels and captions arranged on an A4 landscape sheet." style="display: block;" width="600" height="400" loading="lazy">

<p><strong>Downloaded PDF</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/64397878-ad42-46eb-b5b6-d39fbf104335.png" alt="Downloaded PDF storyboard containing three arranged panels with page numbers and captions." style="display: block;" width="600" height="400" loading="lazy">

<h2 id="heading-project-setup">Project Setup</h2>
<p>Before writing the JavaScript functionality, we need to understand how the application is organized.</p>
<p>The PDF Storyboard Generator combines HTML, CSS, and JavaScript. HTML provides the interface, CSS controls the appearance of the workspace and storyboard sheets, and JavaScript handles PDF processing, page previews, captions, layout calculations, and downloads.</p>
<p>For a standalone development project, we can organize the files like this:</p>
<pre><code class="language-text">pdf-storyboard-generator/
├── index.html
├── style.css
├── script.js
└── assets/
</code></pre>
<p>The <code>index.html</code> file contains the application structure, including the upload area, page panels, caption fields, board settings, and preview.</p>
<p>The <code>style.css</code> file controls the appearance of the application. It defines the workspace, panel grid, form controls, buttons, preview area, and responsive layout.</p>
<p>The <code>script.js</code> file contains the main application logic. It loads PDF documents, renders their pages, manages captions, processes OCR requests, calculates storyboard layouts, and generates downloadable files.</p>
<p>The <code>assets</code> directory can hold any additional local resources required by the project.</p>
<p>In the existing AllInOneTools implementation, these components are integrated into the website's tool page rather than being deployed as four separate files. The same separation of responsibilities still applies.</p>
<h2 id="heading-libraries-used">Libraries Used</h2>
<p>The application uses three main JavaScript libraries or browser technologies for its PDF and image-processing workflow.</p>
<h3 id="heading-1-pdfjs">1. PDF.js</h3>
<p>We'll use PDF.js to open PDF documents and render their pages inside the browser.</p>
<p>It allows JavaScript to access individual pages, retrieve their dimensions, create viewports, and draw page content onto an HTML canvas.</p>
<p>For this project, PDF.js is responsible for the original PDF page previews.</p>
<p>The application uses the PDF.js document API to retrieve pages and render them at the required dimensions. This is important because the storyboard must display a visual representation of each selected page before the user downloads the finished sheet.</p>
<h3 id="heading-2-pdf-lib">2. PDF-lib</h3>
<p>We'll use PDF-lib used when generating the final PDF document.</p>
<p>PDF.js and PDF-lib have different responsibilities. PDF.js reads and renders the source document, while PDF-lib creates the new storyboard PDF.</p>
<p>The application uses PDF-lib to create output pages, embed the original PDF pages when possible, and place the finished storyboard elements onto those pages.</p>
<p>Keeping the original PDF pages as vector content helps preserve text and line quality when users zoom in or print the exported document. The implementation also uses a raster-based fallback for password-protected PDFs that can't be loaded through the regular PDF embedding path.</p>
<h3 id="heading-3-tesseractjs">3. Tesseract.js</h3>
<p>This library provides optional Optical Character Recognition (OCR).</p>
<p>Not every PDF contains selectable text. A scanned document, for example, may contain photographs of printed pages instead of actual text data.</p>
<p>When the user selects the OCR option, the application loads Tesseract.js and its English language data. It then processes eligible scanned pages and attempts to extract their text for captions.</p>
<p>OCR is optional, so users don't need to load the OCR engine when they only want to arrange PDF pages and write captions manually.</p>
<p>The following example illustrates how a standalone project could include the libraries through CDN scripts:</p>
<pre><code class="language-html">&lt;script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.4.168/pdf.min.js"&gt;&lt;/script&gt;
&lt;script src="https://unpkg.com/pdf-lib/dist/pdf-lib.min.js"&gt;&lt;/script&gt;
&lt;script src="script.js"&gt;&lt;/script&gt;
</code></pre>
<p>For a production application, use library versions that you've tested and configure the matching PDF.js worker file as required by your chosen PDF.js build.</p>
<h3 id="heading-why-these-libraries-work-together">Why These Libraries Work Together</h3>
<p>Each library has a separate responsibility.</p>
<p>PDF.js reads the source PDF and renders its pages. Tesseract.js optionally extracts text from scanned pages. PDF-lib creates the final storyboard PDF.</p>
<p>JavaScript connects these operations and manages the user interface.</p>
<p>This separation makes the application easier to understand and maintain. It also avoids using one library for tasks that are better handled by another.</p>
<h2 id="heading-preparing-the-html-workspace">Preparing the HTML Workspace</h2>
<p>The interface needs several areas to support the complete storyboard workflow.</p>
<p>The upload area accepts the source PDF. The panel area displays rendered pages and caption fields. The board settings area contains layout and appearance controls. Finally, the preview area displays the generated storyboard sheets.</p>
<p>A simplified HTML structure looks like this:</p>
<pre><code class="language-html">&lt;section id="aiot-sb"&gt;
    &lt;div id="aiot-sb-drop"&gt;
        &lt;input
            type="file"
            id="aiot-sb-file-input"
            accept="application/pdf,.pdf"
        &gt;
        &lt;button type="button" data-act="browse"&gt;
            Select a PDF
        &lt;/button&gt;
    &lt;/div&gt;
    &lt;div id="aiot-sb-grid"&gt;&lt;/div&gt;
    &lt;div id="aiot-sb-settings"&gt;&lt;/div&gt;
    &lt;div id="aiot-sb-preview"&gt;&lt;/div&gt;
&lt;/section&gt;
</code></pre>
<p>This is an illustrative structure showing the main interface areas. The actual application contains additional controls, accessibility attributes, and elements for its different operations.</p>
<p>The <code>aiot-sb</code> container provides a common root for the application. JavaScript can use this root to find the relevant interface elements without searching the entire website.</p>
<h2 id="heading-selecting-the-main-dom-elements">Selecting the Main DOM Elements</h2>
<p>JavaScript needs references to the interface elements before it can respond to user actions.</p>
<p>The application starts by finding its root container and checking whether initialization has already taken place:</p>
<pre><code class="language-javascript">var root = document.getElementById("aiot-sb");
if (!root || root.getAttribute("data-aiot-sb-ready")) {
    return;
}
root.setAttribute("data-aiot-sb-ready", "1");
</code></pre>
<p>This prevents the application from initializing again if the script is executed more than once.</p>
<p>The implementation uses a small helper to find elements inside the application:</p>
<pre><code class="language-javascript">function q(selector) {
    return root.querySelector(selector);
}
</code></pre>
<p>For example, <code>q('[data-act="browse"]')</code> finds the button used to open the PDF file picker.</p>
<p>Using a root-specific helper also helps keep the application code organized when the website contains other tools or interface components.</p>
<h2 id="heading-defining-important-configuration-values">Defining Important Configuration Values</h2>
<p>The application needs a few shared configuration values for its layouts and output settings.</p>
<p>For example, it defines supported paper dimensions and font families:</p>
<pre><code class="language-javascript">var MM = 72 / 25.4;
var PAPERS = {
    a4: [595.28, 841.89],
    letter: [612, 792],
    a3: [841.89, 1190.55],
    slide: [405, 720]
};
var PAPER_NAMES = {
    a4: "A4",
    letter: "Letter",
    a3: "A3",
    slide: "16:9 slide"
};
</code></pre>
<p>PDF page dimensions are commonly expressed in points, while some of the storyboard spacing settings are easier to understand in millimeters. The <code>MM</code> conversion value helps translate between these units.</p>
<p>The <code>PAPERS</code> object stores the available page dimensions. The application can use these values when calculating the size of each storyboard sheet.</p>
<p>The <code>PAPER_NAMES</code> object provides readable labels for the corresponding settings.</p>
<p>The application also defines font families for captions and other text:</p>
<pre><code class="language-javascript">var FONTS = {
    sans: "Arial, 'Helvetica Neue', Helvetica, sans-serif",
    serif: "Georgia, 'Times New Roman', serif",
    mono: "'Courier New', Courier, monospace",
    hand: "'Segoe Print', 'Bradley Hand', cursive"
};
</code></pre>
<p>These values allow the storyboard renderer to use different text styles without repeating font definitions throughout the code.</p>
<h2 id="heading-understanding-the-application-state">Understanding the Application State</h2>
<p>The application needs to remember information about the currently loaded PDF, its pages, captions, selected layout, and export process.</p>
<p>Instead of relying on individual variables scattered throughout the application, the implementation groups related information into a shared state object.</p>
<p>For example, the application tracks information such as:</p>
<ul>
<li>The loaded PDF document.</li>
<li>The original PDF bytes.</li>
<li>The current filename.</li>
<li>Page captions.</li>
<li>Page preview data.</li>
<li>Loading and export status.</li>
<li>The OCR worker when OCR is active.</li>
<li>Cancellation and loading tokens.</li>
</ul>
<p>The actual implementation manages these values within its application state and uses them throughout the rendering and export functions.</p>
<p>This becomes especially important when the user changes a caption or layout setting. The application needs to know which values have changed so it can update the storyboard preview without unnecessarily reopening the source document.</p>
<h2 id="heading-uploading-a-pdf">Uploading a PDF</h2>
<p>The first step is allowing users to select a PDF from their device. The PDF Storyboard Generator supports two methods: selecting a file through the file picker or dragging and dropping it into the upload area.</p>
<p>The file input uses the following HTML:</p>
<pre><code class="language-html">&lt;input
    type="file"
    id="aiot-sb-file-input"
    accept="application/pdf,.pdf"
&gt;
</code></pre>
<p>Let's understand the <code>accept</code> attribute:</p>
<ul>
<li><code>application/pdf</code> is the MIME type for PDF files.</li>
<li><code>.pdf</code> is the file extension.</li>
</ul>
<p>A MIME type (Multipurpose Internet Mail Extensions type) identifies the format of a file. Browsers use MIME types to describe the kind of content a file contains. For example, <code>application/pdf</code> identifies a PDF document.</p>
<p>The application checks both the MIME type and the filename extension. This is useful because some browsers or operating systems may provide an empty or incorrect MIME type for a selected file.</p>
<p>The actual application uses this helper:</p>
<pre><code class="language-javascript">function isPdf(file) {
    return file.type === 'application/pdf' ||
        /\.pdf$/i.test(file.name || '');
}
</code></pre>
<p>The first condition checks the file's MIME type. The second checks whether its filename ends with <code>.pdf</code>. The <code>i</code> flag makes the extension check case-insensitive, so filenames such as <code>document.PDF</code> are also accepted.</p>
<p>After validation, both upload methods use the same <code>handleFiles()</code> function. Its initial steps are:</p>
<pre><code class="language-javascript">function handleFiles(list, password) {
    var all = Array.prototype.slice.call(list || []);
    ui.input.value = '';

    if (!all.length) {
        return Promise.resolve();
    }

    var file = all.filter(isPdf)[0];

    if (!file) {
        toast("That isn't a PDF. Choose a PDF file.", 'error');
        return Promise.resolve();
    }

    var note = all.length &gt; 1
        ? ' Only ' + file.name + ' was opened; a storyboard uses one PDF.'
        : '';

    var token = ++state.loadToken;
    var hadDoc = !!state.doc;
    var task, bytes;

    if (hadDoc) {
        ui.fileMeta.textContent = 'Opening ' + file.name + '…';
    }

    setStatus('Opening ' + file.name + '…', true);

    // The remaining function loads and processes the selected PDF.
}
</code></pre>
<p>The function converts the supplied file list into an array and selects the first valid PDF. If no valid PDF is found, it displays an error message and stops.</p>
<p>The <code>loadToken</code> value helps the application distinguish the current loading operation from an older one. This matters when a user selects another document while a previous document is still being processed.</p>
<p>Next, the application waits for the PDF libraries to become available and reads the selected file:</p>
<pre><code class="language-javascript">return engine().then(function () {
    return file.arrayBuffer();
}).then(function (buf) {
    bytes = buf;

    var opts = {
        data: new Uint8Array(buf.slice(0))
    };

    if (password) {
        opts.password = password;
    }

    task = window.pdfjsLib.getDocument(pdfjsOptions(opts));
    return task.promise;
});
</code></pre>
<p>The <code>file.arrayBuffer()</code> method reads the PDF into browser memory. The application converts the data into a <code>Uint8Array</code> and passes it to PDF.js through <code>getDocument()</code>.</p>
<p>If a password was supplied, it's added to the PDF.js options before the document is opened.</p>
<p>The complete function continues by measuring the PDF pages, checking whether the original pages can be embedded as vectors, updating the application state, and displaying the workspace.</p>
<p>This means the selected PDF is processed in the browser. It isn't uploaded to the website's server.</p>
<h2 id="heading-loading-the-pdf-with-pdfjs">Loading the PDF with PDF.js</h2>
<p>Once a file has been selected, the application reads its contents and passes the data to PDF.js.</p>
<p>The PDF.js loading process returns a document object. This object provides access to the document's pages and metadata.</p>
<p>In the actual application, PDF.js is initialized inside the file-loading process. The application first waits for the PDF libraries to load, reads the selected file, and then passes its data to PDF.js.</p>
<pre><code class="language-javascript">return engine().then(function () {
    return file.arrayBuffer();
}).then(function (buf) {
    bytes = buf;

    var opts = {
        data: new Uint8Array(buf.slice(0))
    };

    if (password) {
        opts.password = password;
    }

    task = window.pdfjsLib.getDocument(pdfjsOptions(opts));
    return task.promise;
});
</code></pre>
<p>The <code>engine()</code> function ensures that the required PDF libraries are available before processing begins. The application then uses <code>file.arrayBuffer()</code> to read the selected PDF into browser memory.</p>
<p>The <code>Uint8Array</code> constructor converts the file data into a format that PDF.js can process. If the user has supplied a password, it's added to the PDF.js options before the document is opened.</p>
<p>The <code>getDocument()</code> method starts the PDF loading process, and <code>task.promise</code> resolves when PDF.js has successfully opened the document.</p>
<p>The resulting document object provides access to the PDF's pages and metadata. The application stores this document for later operations, including measuring pages, extracting text, rendering previews, and generating the storyboard.</p>
<h2 id="heading-measuring-pdf-pages">Measuring PDF Pages</h2>
<p>Before arranging pages into storyboard panels, the application needs to know their dimensions.</p>
<p>PDF documents can contain pages with different sizes and orientations. Measuring each page helps the layout engine fit it correctly inside its panel.</p>
<p>The actual application uses the <code>pageFacts()</code> function to measure every page and periodically update the interface while processing large PDFs.</p>
<pre><code class="language-javascript">async function pageFacts(doc, token) {
    var sizes = [];

    for (var n = 1; n &lt;= doc.numPages; n++) {
        if (token !== state.loadToken) {
            return null;
        }

        if (n % 25 === 0) {
            setStatus(
                'Measuring pages: ' + n + ' of ' + doc.numPages + '…',
                true
            );
            await nextFrame();
        }

        var p = await doc.getPage(n);
        var vp = p.getViewport({ scale: 1 });

        sizes.push([vp.width, vp.height]);
    }

    return sizes;
}
</code></pre>
<p>The function starts by creating an empty <code>sizes</code> array. It then loops through all pages using <code>doc.numPages</code> and retrieves each page with <code>doc.getPage(n)</code>.</p>
<p>The <code>getViewport({ scale: 1 })</code> method measures the page at its original scale. The function stores the resulting width and height in the <code>sizes</code> array.</p>
<p>The <code>token</code> parameter helps prevent an outdated loading operation from continuing. If the current token no longer matches <code>state.loadToken</code>, the function stops and returns <code>null</code>.</p>
<p>For larger PDFs, the application updates the loading status after every 25 pages and calls <code>nextFrame()</code> to give the browser an opportunity to update the interface.</p>
<p>Finally, the function returns the array containing the dimensions of all measured pages. These measurements are used later by the storyboard layout calculations.</p>
<h2 id="heading-rendering-pdf-pages-on-canvas">Rendering PDF Pages on Canvas</h2>
<p>The next task is displaying the PDF pages in the workspace.</p>
<p>PDF.js can render an individual page onto an HTML canvas. The canvas provides a visual representation that can be used in the page cards and storyboard preview.</p>
<p>The core rendering function looks like this:</p>
<pre><code class="language-javascript">async function renderPage(n, w, h) {
    var page = await state.doc.getPage(n);
    var b = page.getViewport({ scale: 1 });

    var vp = page.getViewport({
        scale: Math.min(w / b.width, h / b.height)
    });

    var c = document.createElement('canvas');
    c.width = Math.max(1, Math.round(vp.width));
    c.height = Math.max(1, Math.round(vp.height));

    var g = c.getContext('2d');
    g.fillStyle = '#FFFFFF';
    g.fillRect(0, 0, c.width, c.height);

    await page.render({
        canvas: c,
        canvasContext: g,
        viewport: vp
    }).promise;

    return c;
}
</code></pre>
<p>The function accepts three parameters: <code>n</code> is the PDF page number, while <code>w</code> and <code>h</code> define the available width and height for the preview.</p>
<p>First, <code>state.doc.getPage(n)</code> retrieves the requested PDF page. The function then creates a viewport at scale 1 to obtain the page's original dimensions.</p>
<p>Next, <code>Math.min(w / b.width, h / b.height)</code> calculates a scale that fits the page inside the available space without changing its proportions.</p>
<p>The function creates an HTML canvas and sets its width and height using the calculated viewport dimensions. It fills the canvas with white before rendering the PDF page.</p>
<p>Finally, <code>page.render()</code> draws the page onto the canvas. The function waits for rendering to finish and returns the completed canvas for use in the page previews and storyboard.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/fb6abec4-c11f-44e7-b91e-730afd066723.png" alt="PDF Storyboard Generator displaying three PDF page previews rendered on canvas, including text and graphics from the original document." style="display: block;" width="600" height="400" loading="lazy">

<h2 id="heading-reusing-rendered-page-previews">Reusing Rendered Page Previews</h2>
<p>Rendering the same PDF page repeatedly can consume unnecessary processing time. For example, a page might be rendered once for its card preview and again when the storyboard preview is updated.</p>
<p>The application uses a page-picture cache to reuse previously rendered canvases.</p>
<pre><code class="language-javascript">var picCache = new Map();

// For the preview: reuse a page picture when one at least as big is already made.
async function cachedPic(n, w, h) {
    var got = picCache.get(n);

    if (got &amp;&amp; got.w &gt;= w * 0.92) {
        picCache.delete(n);
        picCache.set(n, got);
        return got.c;
    }

    var c = await renderPage(
        n,
        Math.ceil(w * 1.15),
        Math.ceil(h * 1.15)
    );

    picCache.set(n, { c: c, w: c.width });

    while (picCache.size &gt; 60) {
        var old = picCache.keys().next().value;
        picCache.get(old).c.width = 1;
        picCache.delete(old);
    }

    return c;
}
</code></pre>
<p>The application stores rendered page previews in a JavaScript <code>Map</code> called <code>picCache</code>. Each entry contains the rendered canvas and its width.</p>
<p>When <code>cachedPic()</code> is called, it first checks whether a cached image exists for the requested page. If the cached image is at least 92% of the requested width, the application reuses it instead of rendering the page again.</p>
<p>The function moves a reused entry to the end of the Map. This helps the application keep recently used previews available.</p>
<p>If a suitable cached image isn't available, the function calls <code>renderPage()</code> to create a new canvas. It renders the page at 1.15 times the requested width and height, providing some extra resolution for the preview.</p>
<p>To control memory usage, the cache is limited to 60 entries. When that limit is exceeded, the oldest entry is removed, and its canvas width is reduced to 1 to release its drawing resources.</p>
<p>This caching approach avoids unnecessary PDF rendering while keeping memory usage under control, especially when users work with longer documents.</p>
<h2 id="heading-reading-text-from-pdf-pages">Reading Text from PDF Pages</h2>
<p>The storyboard generator can create captions in three ways:</p>
<ol>
<li>The user writes captions manually.</li>
<li>The application extracts text from the PDF.</li>
<li>OCR reads text from scanned pages.</li>
</ol>
<p>For PDFs containing selectable text, PDF.js provides the <code>getTextContent()</code> method.</p>
<p>The actual application uses the <code>readTexts()</code> function to extract and store text from every page in the loaded PDF.</p>
<pre><code class="language-javascript">async function readTexts(token) {
    var doc = state.doc, out = [];

    for (var n = 1; n &lt;= doc.numPages; n++) {
        if (token !== state.loadToken) {
            return;
        }

        try {
            var tc = await (await doc.getPage(n)).getTextContent();

            out.push(tc.items.map(function (it) {
                return (it.str || '') + (it.hasEOL ? '\n' : '');
            }).join(''));
        } catch (e) {
            out.push('');
        }

        if (n % 20 === 0) {
            await nextFrame();
        }
    }

    if (token !== state.loadToken) {
        return;
    }

    state.texts = out;
    refreshTags(board());
}
</code></pre>
<p>The function starts by accessing the loaded PDF document through <code>state.doc</code> and creating an empty array called <code>out</code> to store the extracted text.</p>
<p>It then loops through every page and calls <code>getTextContent()</code> to retrieve its text items. The <code>map()</code> method combines each item's text into a single string. It also adds a newline when the PDF.js item indicates the end of a line through <code>hasEOL</code>.</p>
<p>If text extraction fails for a page, the function adds an empty string to the results instead of stopping the entire process.</p>
<p>The <code>token</code> check prevents an older PDF-loading operation from updating the current application state. The function also calls <code>nextFrame()</code> after every 20 pages to give the browser an opportunity to update the interface.</p>
<p>After processing all pages, the extracted text is stored in <code>state.texts</code>. The application then calls <code>refreshTags(board())</code> to update the page information displayed in the workspace.</p>
<p>This stored text is used later when users choose to fill empty captions automatically.</p>
<h2 id="heading-filling-empty-captions-automatically">Filling Empty Captions Automatically</h2>
<p>Once the application has extracted text from the PDF pages, it can use that text to create captions.</p>
<p>The important behaviour is that the application fills only empty caption fields. It doesn't overwrite captions that users have already entered.</p>
<p>The application uses the <code>captionFromText()</code> helper to clean and shorten the extracted text:</p>
<pre><code class="language-javascript">function captionFromText(t) {
    var s = String(t || '')
        .replace(/\s+/g, ' ')
        .trim();

    if (letters(s) &lt; 3) {
        return '';
    }

    if (s.length &lt;= 200) {
        return s;
    }

    var cut = s.slice(0, 200);
    var end = Math.max(
        cut.lastIndexOf('. '),
        cut.lastIndexOf('? '),
        cut.lastIndexOf('! '),
        cut.lastIndexOf('। ')
    );

    return end &gt; 60
        ? cut.slice(0, end + 1)
        : cut.replace(/\s+\S*$/, '') + '…';
}
</code></pre>
<p>Here's what this function does:</p>
<ol>
<li>Converts the supplied value into a string.</li>
<li>Replaces repeated whitespace with single spaces and removes whitespace from the beginning and end.</li>
<li>Checks whether the text contains enough letters to be useful as a caption.</li>
<li>Returns the complete text if it contains no more than 200 characters.</li>
<li>For longer text, tries to end the caption at a sentence boundary. If there isn't a suitable boundary, it removes the incomplete final word and adds an ellipsis.</li>
</ol>
<p>The application then uses <code>fillFromText()</code> to apply the generated captions to the selected storyboard pages.</p>
<pre><code class="language-javascript">function fillFromText() {
    var bd = board(), list = bd.error ? [] : unique(bd.list), filled = 0, scans = 0;

    list.forEach(function (n) {
        if (captionFor(n)) { return; }

        var c = captionFromText(state.texts[n - 1]);

        if (c) {
            state.captions[n] = c;
            filled++;

            var ta = ui.grid.querySelector('[data-cap="' + n + '"]');
            if (ta) { ta.value = c; }
        } else if (isScan(n)) {
            scans++;
        }
    });

    if (!state.opts.captions &amp;&amp; filled) {
        state.opts.captions = true;
        savePrefs();
    }

    redrawSoon(0);

    if (!list.length) {
        toast(bd.error || 'No pages on the board.', 'error');
        return;
    }

    toast(
        filled
            ? plural(filled, 'caption', 'captions') +
              ' filled from the page text. Edit them in the cards below.' +
              (scans
                  ? ' ' + plural(scans, 'scanned page has', 'scanned pages have') +
                    ' no text; use OCR for ' + (scans === 1 ? 'it' : 'them') + '.'
                  : '')
            : scans
                ? 'The empty pages are scans with no text. Use Read with OCR.'
                : 'Every panel already has a caption.'
    );
}
</code></pre>
<p>The <code>fillFromText()</code> function starts by calling <code>board()</code> to get the currently selected storyboard pages. It uses <code>unique()</code> to avoid processing the same page more than once.</p>
<p>For each selected page, <code>captionFor(n)</code> checks whether a caption already exists. If one is present, the function skips that page so the user's existing caption isn't overwritten.</p>
<p>If the caption is empty, the function retrieves the extracted text from <code>state.texts</code> and passes it to <code>captionFromText()</code>. When useful text is available, it saves the generated caption in <code>state.captions</code> and updates the corresponding textarea in the page card.</p>
<p>If a page has no extractable text and is identified as a scan, the function increases the scanned-page counter. Users can process these pages separately using OCR.</p>
<p>Finally, the application enables captions if necessary, saves the updated preferences, and schedules a preview refresh. It also displays a message showing how many captions were filled or whether OCR is needed.</p>
<p>This allows users to generate starting captions automatically while keeping their manually written descriptions intact.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/ded521e8-6e44-42c4-b02f-115683f40ea3.png" alt="PDF Storyboard Generator showing automatically generated captions below PDF page previews." style="display: block;" width="600" height="400" loading="lazy">

<h2 id="heading-updating-the-preview">Updating the Preview</h2>
<p>Whenever a caption or relevant setting changes, the storyboard preview needs to be refreshed.</p>
<p>The application uses a small scheduling function to avoid redrawing the preview immediately after every individual change:</p>
<pre><code class="language-javascript">function redrawSoon(ms) {
    clearTimeout(drawTimer);
    drawTimer = setTimeout(
        drawPreview,
        ms === undefined ? 120 : ms
    );
}
</code></pre>
<p>The <code>redrawSoon()</code> function first cancels any previously scheduled preview update using <code>clearTimeout()</code>. It then schedules <code>drawPreview()</code> using <code>setTimeout()</code>.</p>
<p>By default, the application waits 120 milliseconds before refreshing the preview. If another change happens during that time, the previous scheduled redraw is cancelled and a new one is scheduled.</p>
<p>This helps prevent unnecessary repeated rendering when users are typing captions or adjusting settings.</p>
<h3 id="heading-drawing-the-updated-preview">Drawing the Updated Preview</h3>
<p>Once the redraw is scheduled, the <code>drawPreview()</code> function calculates the current storyboard configuration and updates the visible preview canvas.</p>
<p>The following is the actual implementation from the application:</p>
<pre><code class="language-javascript">async function drawPreview() {
    if (!state.doc) { return; }

    var my = ++drawToken, bd = board();
    refreshTags(bd);
    syncPanel();

    if (bd.error) {
        ui.boardMsg.textContent = bd.error;
        ui.sheetPos.textContent = 'No sheets';
        ui.sheetCount.textContent = '';
        root.querySelectorAll('[data-needs-board]').forEach(function (b) {
            b.disabled = true;
        });
        return;
    }

    root.querySelectorAll('[data-needs-board]').forEach(function (b) {
        b.disabled = state.busy;
    });

    var L = bd.L, o = state.opts;
    state.sheet = clamp(state.sheet, 0, L.sheets - 1);

    ui.boardMsg.textContent = L.tight
        ? 'Captions leave little room for the pages. Use fewer caption lines, fewer rows or a smaller text size.'
        : '';

    ui.sheetPos.textContent = 'Sheet ' + (state.sheet + 1) + ' of ' + L.sheets;
    ui.sheetCount.textContent =
        plural(bd.list.length, 'panel', 'panels') + ' on ' +
        plural(L.sheets, 'sheet', 'sheets') + ', ' +
        PAPER_NAMES[o.paper] + ' ' + o.orient;

    q('[data-act="sheet-prev"]').disabled = state.sheet &lt;= 0;
    q('[data-act="sheet-next"]').disabled = state.sheet &gt;= L.sheets - 1;

    ui.sheetWrap.style.aspectRatio = L.W + ' / ' + L.H;

    var cssW = ui.sheetWrap.clientWidth || 600;
    var dpr = Math.min(window.devicePixelRatio || 1, 2);
    var k = cssW * dpr / L.W;

    var off = document.createElement('canvas');
    off.width = Math.round(L.W * k);
    off.height = Math.round(L.H * k);

    var done = await drawSheet(
        off.getContext('2d'),
        o,
        bd.list,
        L,
        state.sheet,
        k,
        'full',
        function (n, w, h) {
            return my === drawToken
                ? cachedPic(n, w, h)
                : Promise.resolve(null);
        }
    );

    if (!done || my !== drawToken) { return; }

    ui.sheet.width = off.width;
    ui.sheet.height = off.height;
    ui.sheet.getContext('2d').drawImage(off, 0, 0);

    off.width = 1;

    var start = state.sheet * L.per;
    var on = bd.list.slice(start, start + L.per);

    ui.sheet.setAttribute(
        'aria-label',
        'Sheet ' + (state.sheet + 1) + ' of ' + L.sheets + ': ' +
        (on.length === 1 ? 'page ' + on[0] : 'pages ' + on.join(', '))
    );

    ui.sheet.setAttribute('data-drawn', String(state.sheet + 1));
}
</code></pre>
<p>The function first checks whether a PDF document is loaded. It then calls <code>board()</code> to retrieve the selected pages and calculate the storyboard layout.</p>
<p>The <code>drawToken</code> value helps prevent an older preview-rendering operation from replacing a newer one. The function also calls <code>refreshTags()</code> and <code>syncPanel()</code> to update the page information and settings panel.</p>
<p>Next, it checks whether the selected board has any errors. If the selection is valid, it calculates the current sheet position and updates the sheet counter, panel count, paper information, and Previous and Next buttons.</p>
<p>The function then calculates the canvas scale using the available preview width and the device pixel ratio. The device pixel ratio is capped at 2 to limit the amount of rendering work.</p>
<p>The <code>drawSheet()</code> function draws the selected storyboard sheet onto a temporary canvas. It receives <code>cachedPic()</code> as a callback so previously rendered PDF page previews can be reused.</p>
<p>After drawing finishes, the function checks whether the operation is still current. If it is, the completed temporary canvas is copied onto the visible preview canvas.</p>
<p>Finally, the temporary canvas is released by reducing its width to 1. The application also updates the preview's accessibility label and records which sheet has been drawn.</p>
<p>This process allows users to see their storyboard changes without downloading a new PDF every time they adjust a caption or setting.</p>
<h2 id="heading-choosing-the-storyboard-layout">Choosing the Storyboard Layout</h2>
<p>The tool supports three layout styles: Grid, Panels with notes, and Film strip. Each layout is designed for a different type of project.</p>
<p>Grid layout arranges PDF pages in rows and columns. It works well for reviewing presentation slides, comic pages, and documents.</p>
<p>Panels with notes provides additional writing space below each page. It's useful for lecture notes, planning, and reviewing visual material.</p>
<p>Film strip arranges pages as a sequence of frames. It's useful for planning videos, reels, and scenes.</p>
<p>The selected layout is stored in the application's state. When users choose a different layout, the application updates the number of columns and rows to provide suitable default settings.</p>
<p>The following excerpt from the actual <code>onField()</code> function handles layout changes:</p>
<pre><code class="language-javascript">if (f === 'layout' &amp;&amp; e.type === 'change') {
    // sensible panel counts for each layout
    if (o.layout === 'notes') {
        o.cols = o.orient === 'landscape' ? 2 : 1;
        o.rows = o.orient === 'landscape' ? 3 : 4;
    }

    if (o.layout === 'strip') {
        o.cols = 4;
        o.rows = o.orient === 'landscape' ? 2 : 4;
    }

    if (o.layout === 'grid') {
        o.cols = o.orient === 'landscape' ? 3 : 2;
        o.rows = o.orient === 'landscape' ? 2 : 3;
    }
}

savePrefs();

if (f === 'cols' || f === 'rows' || f === 'layout') {
    state.sheet = 0;
}

redrawSoon(f === 'gap' ? 60 : 0);
</code></pre>
<p>This code checks which layout the user has selected and adjusts the default column and row counts according to the layout and paper orientation.</p>
<p>For example:</p>
<ul>
<li><strong>Panels with notes:</strong> Uses 2 columns and 3 rows in landscape orientation, or 1 column and 4 rows in portrait orientation.</li>
<li><strong>Film strip:</strong> Uses 4 columns, with 2 rows in landscape orientation or 4 rows in portrait orientation.</li>
<li><strong>Grid:</strong> Uses 3 columns and 2 rows in landscape orientation, or 2 columns and 3 rows in portrait orientation.</li>
</ul>
<p>After updating these values, <code>savePrefs()</code> saves the settings. The application resets the current sheet to the first sheet when the layout changes and calls <code>redrawSoon()</code> to refresh the preview.</p>
<p>Users can still adjust the number of rows and columns manually using the board settings.</p>
<h2 id="heading-controlling-rows-columns-and-paper-size">Controlling Rows, Columns, and Paper Size</h2>
<p>Users can choose between 1 and 6 columns and rows per sheet. This gives them control over how many PDF pages appear together.</p>
<p>The application also supports different paper sizes, including A4, Letter, A3, and a 16:9 slide format. Each format can be displayed in portrait or landscape orientation.</p>
<p>For example, a 3-column and 2-row layout can display six PDF pages on one sheet. A 4-column and 3-row layout can display twelve pages.</p>
<p>The application calculates the number of panels that fit on each sheet by multiplying the selected columns by the selected rows. It then determines how many sheets are needed for all selected pages.</p>
<p>The following function from the actual application calculates the sheet dimensions based on the selected paper size and orientation:</p>
<pre><code class="language-javascript">function sheetSize(o) {
    var p = PAPERS[o.paper] || PAPERS.a4;
    var a = Math.min(p[0], p[1]);
    var b = Math.max(p[0], p[1]);

    return o.orient === 'portrait' ? [a, b] : [b, a];
}
</code></pre>
<p>The <code>PAPERS</code> object contains the dimensions for each supported paper format. The function retrieves the selected dimensions and arranges them according to the chosen orientation.</p>
<p>For portrait orientation, the shorter dimension becomes the width and the longer dimension becomes the height. For landscape orientation, these dimensions are reversed.</p>
<p>The layout calculation then uses the selected rows and columns to determine the number of panels per sheet:</p>
<pre><code class="language-javascript">var per = o.cols * o.rows;
var sheets = Math.max(1, Math.ceil(list.length / per));
</code></pre>
<p>Here, <code>per</code> represents the number of panels on one sheet. The <code>Math.ceil()</code> function rounds up the total number of required sheets so that any remaining pages are placed on an additional sheet.</p>
<p>The layout engine also calculates the available space and positions each panel so that the pages fit within the selected sheet dimensions.</p>
<p>When more pages are selected than can fit on one sheet, the remaining panels are placed on additional sheets.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/6f6ef0dd-5d4b-40db-adfb-265687c4c576.png" alt="PDF Storyboard Generator board settings for choosing layout, columns, rows, paper size, and orientation." style="display: block;" width="600" height="400" loading="lazy">

<h2 id="heading-selecting-pdf-pages-and-their-order">Selecting PDF Pages and Their Order</h2>
<p>Users don't always need every page in the original PDF.</p>
<p>The tool supports using all pages, selecting a page range, or entering a custom page sequence.</p>
<p>For example, a user could enter:</p>
<pre><code class="language-text">1-4, 9, 6
</code></pre>
<p>This creates a storyboard using pages 1, 2, 3, 4, 9, and 6 in that exact order.</p>
<p>The application uses the <code>parseList()</code> function to process the custom page selection and check whether the requested page numbers are valid.</p>
<p>Here is the actual implementation from the application:</p>
<pre><code class="language-javascript">function parseList(text) {
    var t = String(text || '').trim();

    if (!t) {
        return { error: 'Type the pages, for example 1-4, 7, 5.' };
    }

    var out = [], parts = t.split(/[,;\s]+/).filter(Boolean);

    for (var i = 0; i &lt; parts.length; i++) {
        var part = parts[i],
            m = /^(\d+)(?:\s*[-–]\s*(\d*))?$/.exec(part);

        if (!m) {
            return { error: '"' + part + '" isn\'t a page number or range.' };
        }

        var a = parseInt(m[1], 10),
            b = m[2] === undefined
                ? a
                : (m[2] === '' ? state.count : parseInt(m[2], 10));

        if (a &lt; 1) {
            return { error: 'Pages start at 1, so "' + part + '" doesn\'t work.' };
        }

        if (b &lt; a) {
            return { error: '"' + part + '" is backwards. Did you mean ' + b + '-' + a + '?' };
        }

        if (a &gt; state.count) {
            return {
                error: 'Page ' + a + ' is past the end; this PDF has ' +
                    plural(state.count, 'page', 'pages') + '.'
            };
        }

        if (b &gt; state.count) {
            return {
                error: 'Page ' + b + ' is past the end. Type ' + a +
                    '- to go to the last page.'
            };
        }

        for (var n = a; n &lt;= b; n++) {
            out.push(n);
        }
    }

    if (out.length &gt; 500) {
        return { error: 'That\'s ' + out.length + ' panels. Use 500 or fewer.' };
    }

    return { pages: out };
}
</code></pre>
<p>The function first converts the input into a trimmed string. It then splits the selection using commas, semicolons, or spaces.</p>
<p>The regular expression checks whether each entry is a single page number or a page range. For example, <code>3</code> represents page 3, while <code>2-5</code> represents pages 2 through 5.</p>
<p>The function also supports an open-ended range such as <code>4-</code>. In this case, the application uses <code>state.count</code> as the final page number.</p>
<p>Before adding pages to the result, it checks that page numbers start at 1, ranges are in ascending order, and no requested page exceeds the total number of pages in the PDF.</p>
<p>The application also limits custom selections to 500 panels. If the input is valid, the function returns an array of page numbers in the exact order entered by the user.</p>
<p>The <code>panels()</code> function then uses this result when building the storyboard. If the user selects all pages instead of entering a custom list, it returns every page in the original order.</p>
<p>This feature is helpful when creating a storyboard from selected scenes or important slides without including the entire PDF.</p>
<h2 id="heading-adding-panel-labels">Adding Panel Labels</h2>
<p>Panel labels help users identify individual pages when reviewing or discussing a storyboard.</p>
<p>The application supports different label styles, including numbers, scene labels, original PDF page numbers, and no labels.</p>
<p>For example, a storyboard could display:</p>
<ul>
<li>1, 2, 3, 4</li>
<li>Scene 1, Scene 2, Scene 3</li>
<li>Page 5, Page 6, Page 7</li>
</ul>
<p>Users can select their preferred label style from the Panel label setting.</p>
<p>The application uses the <code>labelFor()</code> function to generate the appropriate label for each panel:</p>
<pre><code class="language-javascript">function labelFor(o, idx, n) {
    if (o.label === 'panel') {
        return String(idx + 1);
    }

    if (o.label === 'page') {
        return 'Page ' + n;
    }

    if (o.label === 'scene') {
        return 'Scene ' + (idx + 1);
    }

    return '';
}
</code></pre>
<p>The function accepts three parameters:</p>
<ul>
<li><code>o</code> contains the storyboard settings, including the selected label style.</li>
<li><code>idx</code> represents the panel's position in the selected storyboard sequence. It starts at 0.</li>
<li><code>n</code> represents the original PDF page number.</li>
</ul>
<p>When the user selects the panel number option, <code>idx + 1</code> generates labels such as 1, 2, and 3.</p>
<p>When the user selects the page number option, the function uses <code>n</code> to display the original PDF page number. This is useful when the storyboard contains selected pages in a custom order.</p>
<p>For the scene label option, the function generates labels such as Scene 1, Scene 2, and Scene 3. If the user selects No label, it returns an empty string.</p>
<p>The drawing function uses this result when rendering each panel on the storyboard sheet. This helps users identify individual panels without changing the original PDF pages.</p>
<h2 id="heading-drawing-the-storyboard-sheet">Drawing the Storyboard Sheet</h2>
<p>Once the layout has been calculated, the application draws the storyboard sheet on an HTML canvas.</p>
<p>The drawing process places each selected PDF page inside its calculated panel area. It then adds panel labels and captions according to the user's settings.</p>
<p>The actual application uses the asynchronous <code>drawSheet()</code> function for this process. The following excerpt shows how it prepares the sheet and selects the pages that belong on it:</p>
<pre><code class="language-javascript">async function drawSheet(ctx, o, list, L, sheetIdx, k, mode, getPic) {
    var C = COLORS[o.colors] || COLORS.paper;
    var W = L.W * k;
    var H = L.H * k;
    var token = state.loadToken;
    var drawn = [];

    ctx.save();
    ctx.fillStyle = C.bg;
    ctx.fillRect(0, 0, W, H);
    ctx.textBaseline = 'alphabetic';

    var start = sheetIdx * L.per;
    var items = list.slice(start, start + L.per);
</code></pre>
<p>The function receives the canvas context, storyboard settings, selected page list, calculated layout, sheet index, and rendering scale.</p>
<p>It first selects the sheet colors and paints the background. The <code>start</code> variable determines the first panel on the current sheet, while <code>items</code> contains the PDF pages assigned to that sheet.</p>
<p>The following excerpt shows how the application fits each PDF page inside its panel and retrieves its rendered image:</p>
<pre><code class="language-javascript">for (var i = 0; i &lt; items.length; i++) {
    var cell = L.cells[i], n = items[i], idx = start + i;
    var area = cell.img;
    var strip = o.layout === 'strip';

    if (strip) {
        ctx.fillStyle = C.bandMat;
        ctx.fillRect(
            area.x * k, area.y * k,
            area.w * k, area.h * k
        );
    } else if (o.frame) {
        ctx.fillStyle = C.mat;
        ctx.fillRect(
            area.x * k, area.y * k,
            area.w * k, area.h * k
        );
    }

    var inset = strip ? 0 :
        o.frame ? Math.min(area.w, area.h) * 0.04 : 0;

    var pr = fitPage(n, area, inset);

    var px = {
        x: Math.round(pr.x * k),
        y: Math.round(pr.y * k),
        w: Math.max(1, Math.round(pr.w * k)),
        h: Math.max(1, Math.round(pr.h * k))
    };

    drawn.push({ n: n, rect: pr });

    if (mode === 'overlay') {
        ctx.clearRect(px.x, px.y, px.w, px.h);
    } else {
        ctx.fillStyle = '#FFFFFF';
        ctx.fillRect(px.x, px.y, px.w, px.h);

        var pic = getPic ? await getPic(n, px.w, px.h) : null;

        if (token !== state.loadToken) {
            ctx.restore();
            return null;
        }

        if (pic) {
            ctx.drawImage(pic, px.x, px.y, px.w, px.h);
        }
    }
}
</code></pre>
<p>The <code>fitPage()</code> function calculates the position and dimensions needed to fit the original PDF page inside the panel without changing its proportions.</p>
<p>The application then uses <code>getPic()</code> to retrieve the rendered page image. If the image is available, <code>drawImage()</code> places it on the storyboard canvas.</p>
<p>The function also supports an overlay mode, which leaves the page picture areas transparent. This is useful when exporting PDFs while preserving the original PDF page content.</p>
<h3 id="heading-drawing-panel-labels-and-captions">Drawing Panel Labels and Captions</h3>
<p>After positioning the page previews, the application draws the panel labels and captions.</p>
<p>The following excerpt from the actual <code>drawSheet()</code> function shows how panel labels and captions are drawn on the canvas:</p>
<pre><code class="language-javascript">var t = cell.text;
var fs = cell.fs * k;
var lh = cell.lh * k;
var y = t.y * k + fs;

var label = labelFor(o, idx, n);
ctx.textAlign = 'left';

if (label) {
    ctx.font = '700 ' + fs + 'px ' + FONTS.mono;
    ctx.fillStyle = C.ink;
    ctx.fillText(label, t.x * k, y);
    y += lh * 1.05;
}

var lines = [];

if (o.captions &amp;&amp; captionFor(n)) {
    ctx.font = '400 ' + fs + 'px ' + FONTS[o.font];
    ctx.fillStyle = C.ink;

    var maxL = cell.notes
        ? Math.max(1, Math.floor(
            (t.y * k + t.h * k - y + lh - fs) / lh
        ))
        : o.lines;

    lines = wrap(
        ctx,
        captionFor(n),
        t.w * k,
        Math.min(maxL, cell.notes ? 40 : o.lines)
    );

    lines.forEach(function (ln, j) {
        ctx.fillText(ln, t.x * k, y + j * lh);
    });
}
</code></pre>
<p>The <code>labelFor()</code> function generates the selected panel label. The application then uses <code>fillText()</code> to draw it on the canvas.</p>
<p>If captions are enabled and a caption exists, the application uses <code>captionFor()</code> to retrieve it. The <code>wrap()</code> function breaks the caption into lines that fit within the available panel space.</p>
<p>For the Panels with notes layout, the application also draws ruled lines below the caption area. This provides additional writing space for users.</p>
<p>The drawing process combines the original PDF page previews with the labels, captions, frames, and other selected settings to create the completed storyboard sheet.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/4acf38c9-c401-4e11-86d9-cdf62bb67912.png" alt=" Live PDF storyboard preview showing multiple page panels arranged on an A4 landscape sheet." style="display: block;" width="600" height="400" loading="lazy">

<h2 id="heading-updating-the-live-preview">Updating the Live Preview</h2>
<p>One of the most useful features is the live preview. Users can change the layout, paper size, orientation, captions, font, spacing, and other settings without creating a new PDF every time.</p>
<p>The application uses the <code>board()</code> function to calculate the current storyboard configuration before drawing the preview.</p>
<p>Here's the actual implementation:</p>
<pre><code class="language-javascript">function board() {
    var sel = panels();

    if (sel.error) {
        return { error: sel.error };
    }

    var L = layout(state.opts, sel.pages);

    return {
        list: sel.pages,
        L: L
    };
}
</code></pre>
<p>The function first calls <code>panels()</code> to retrieve the selected PDF pages. If the selection contains an error, it returns that error instead of continuing.</p>
<p>Next, it calls <code>layout()</code> with the current storyboard settings and selected pages. The layout function calculates how the pages should be arranged on the storyboard sheets.</p>
<p>Finally, <code>board()</code> returns an object containing the selected page list and the calculated layout. The <code>drawPreview()</code> function uses this information to render the selected sheet onto a temporary canvas.</p>
<p>Once drawing is complete, the result is copied to the visible preview canvas. The application also updates the sheet counter, such as <code>Sheet 1 of 3</code>, and enables or disables the Previous and Next buttons depending on the current position.</p>
<p>This allows users to move between storyboard sheets and review their changes before downloading the result.</p>
<h2 id="heading-adjusting-caption-appearance">Adjusting Caption Appearance</h2>
<p>Captions help explain what's happening on each page.</p>
<p>The application provides options for enabling captions, selecting a font, changing text size, and controlling the number of caption lines.</p>
<p>Users can also choose whether to display panel frames and sheet numbers.</p>
<p>These settings are stored in <code>state.opts</code>. The <code>syncPanel()</code> function updates the settings controls to match the current application state.</p>
<p>Here's the actual implementation:</p>
<pre><code class="language-javascript">function syncPanel() {
    var o = state.opts;

    ['layout', 'cols', 'rows', 'paper', 'orient', 'label',
     'lines', 'tsize', 'font', 'colors'].forEach(function (f) {
        q('[data-field="' + f + '"]').value = String(o[f]);
    });

    q('[data-field="captions"]').checked = o.captions;
    q('[data-field="frame"]').checked = o.frame;
    q('[data-field="numbers"]').checked = o.numbers;

    q('[data-field="gap"]').value = String(o.gap);
    q('[data-out="gap-val"]').textContent = o.gap + ' mm';

    q('[data-field="pages"]').value = state.pages;

    if (document.activeElement !== q('[data-field="title"]')) {
        q('[data-field="title"]').value = state.title;
    }

    var strip = o.layout === 'strip';

    q('[data-out="cols-label"]').textContent =
        strip ? 'Frames per strip' : 'Columns';

    q('[data-out="rows-label"]').textContent =
        strip ? 'Strips per sheet' : 'Rows';

    q('[data-field="lines"]').disabled =
        !o.captions || o.layout === 'notes';

    q('[data-field="frame"]').disabled = strip;

    ui.listWrap.hidden = state.pages !== 'list';
}
</code></pre>
<p>The function reads the current options from <code>state.opts</code> and updates the corresponding controls. For example, it sets the selected font, text size, caption line count, and paper settings.</p>
<p>It also handles layout-specific controls. In Film strip mode, the columns and rows labels change to Frames per strip and Strips per sheet. The caption-line control is disabled when captions are turned off or when the Panels with notes layout is selected.</p>
<h3 id="heading-fitting-captions-inside-panels">Fitting Captions Inside Panels</h3>
<p>Long captions need to fit inside the available panel space. The application uses a <code>wrap()</code> helper to divide text into lines based on the available canvas width.</p>
<p>Here's how the actual drawing code uses that helper:</p>
<pre><code class="language-javascript">if (o.captions &amp;&amp; captionFor(n)) {
    ctx.font = '400 ' + fs + 'px ' + FONTS[o.font];
    ctx.fillStyle = C.ink;

    var maxL = cell.notes
        ? Math.max(1, Math.floor(
            (t.y * k + t.h * k - y + lh - fs) / lh
        ))
        : o.lines;

    lines = wrap(
        ctx,
        captionFor(n),
        t.w * k,
        Math.min(maxL, cell.notes ? 40 : o.lines)
    );

    lines.forEach(function (ln, j) {
        ctx.fillText(ln, t.x * k, y + j * lh);
    });
}
</code></pre>
<p>The application selects the font using <code>FONTS[o.font]</code> and calculates the available number of caption lines based on the panel layout.</p>
<p>For regular layouts, the selected <code>o.lines</code> setting controls the maximum number of lines. For Panels with notes, the available space determines how many lines can be displayed.</p>
<p>The <code>wrap()</code> function breaks the caption into lines that fit the available width. The <code>fillText()</code> method then draws each line onto the canvas.</p>
<p>When there isn't enough space for the selected caption settings, the preview displays a warning suggesting fewer caption lines, fewer rows, or a smaller text size.</p>
<p>The application also supports panel frames and sheet numbers. These options are stored in the application state and applied when the storyboard is drawn.</p>
<p>This gives users control over the appearance and readability of their storyboard captions without changing the original PDF.</p>
<h2 id="heading-navigating-between-storyboard-sheets">Navigating Between Storyboard Sheets</h2>
<p>A large PDF may require several storyboard sheets.</p>
<p>The application provides Previous and Next controls so users can review each sheet individually. It also displays the current sheet number and the total number of sheets.</p>
<p>The selected sheet is stored in <code>state.sheet</code>. The application uses this value to determine which storyboard sheet should be displayed.</p>
<h3 id="heading-handling-the-previous-and-next-buttons">Handling the Previous and Next Buttons</h3>
<p>The application handles sheet navigation inside its main click event listener. Here's the actual implementation:</p>
<pre><code class="language-javascript">case 'sheet-prev':
    if (state.sheet &gt; 0) {
        state.sheet--;
        drawPreview();
    }
    break;

case 'sheet-next':
    state.sheet++;
    drawPreview();
    break;
</code></pre>
<p>When the user clicks Previous, the application checks whether the current sheet number is greater than zero. If it is, <code>state.sheet</code> is decreased by one and <code>drawPreview()</code> refreshes the displayed sheet.</p>
<p>When the user clicks Next, the sheet number is increased and the preview is refreshed.</p>
<p>The application also prevents users from moving beyond the available sheets. Inside <code>drawPreview()</code>, it checks the current sheet position and disables the navigation buttons when necessary:</p>
<pre><code class="language-javascript">state.sheet = clamp(state.sheet, 0, L.sheets - 1);

ui.sheetPos.textContent =
    'Sheet ' + (state.sheet + 1) + ' of ' + L.sheets;

q('[data-act="sheet-prev"]').disabled = state.sheet &lt;= 0;
q('[data-act="sheet-next"]').disabled =
    state.sheet &gt;= L.sheets - 1;
</code></pre>
<p>The <code>clamp()</code> function keeps the selected sheet within the valid range. The sheet counter displays the current position, starting from 1, and the total number of sheets.</p>
<p>The Previous button is disabled on the first sheet, while the Next button is disabled on the last sheet. This makes it easier to review large documents without displaying every storyboard sheet at once.</p>
<h2 id="heading-reading-scanned-pages-with-ocr">Reading Scanned Pages with OCR</h2>
<p>Some PDF documents contain selectable text, while others contain scanned images of printed pages.</p>
<p>PDF.js can extract text from pages containing actual text data. But it can't directly extract words from a scanned page that contains only an image.</p>
<p>To handle these pages, the application provides an optional OCR feature using Tesseract.js.</p>
<p>When users select Read scanned pages with OCR, the application identifies scanned pages that don't already have captions and processes them using the OCR engine.</p>
<h3 id="heading-loading-tesseractjs-when-needed">Loading Tesseract.js When Needed</h3>
<p>The application loads Tesseract.js only when the user requests OCR. It first checks whether the library is already available and reuses an existing loading promise if the script is still being loaded.</p>
<p>Here's the actual <code>loadTesseract()</code> implementation:</p>
<pre><code class="language-javascript">var tessPromise = null;

function loadTesseract() {
    if (window.Tesseract) {
        return Promise.resolve(window.Tesseract);
    }

    if (tessPromise) {
        return tessPromise;
    }

    tessPromise = new Promise(function (resolve, reject) {
        var s = document.createElement('script');
        s.src = TESS + 'tesseract.min.js';
        s.async = true;

        s.onload = function () {
            if (window.Tesseract) {
                resolve(window.Tesseract);
            } else {
                tessPromise = null;
                reject(new Error('engine'));
            }
        };

        s.onerror = function () {
            tessPromise = null;
            reject(new Error('engine'));
        };

        document.head.appendChild(s);

        setTimeout(function () {
            reject(new Error('engine'));
        }, 45000);
    });

    return tessPromise;
}
</code></pre>
<p>The function checks <code>window.Tesseract</code> to see whether the OCR library is already available. If it is, the function returns it immediately.</p>
<p>If the library is still loading, the existing <code>tessPromise</code> is returned instead of creating another script request.</p>
<p>Otherwise, the application creates a script element, sets its source to the Tesseract.js library, and adds it to the document. The <code>onload</code> and <code>onerror</code> handlers detect whether loading succeeds or fails.</p>
<h3 id="heading-creating-the-ocr-worker">Creating the OCR Worker</h3>
<p>After the library loads, the application creates a Tesseract worker configured for English text recognition.</p>
<p>The following excerpt comes from the actual <code>runOcr()</code> function:</p>
<pre><code class="language-javascript">var T = await loadTesseract();
checkCancel();

var w = await T.createWorker('eng', 1, {
    workerPath: TESS + 'worker.min.js',
    corePath: TESS_CORE,
    logger: function (m) {
        if (!m || token !== state.loadToken || !state.busy) {
            return;
        }

        if (/loading language/i.test(m.status)) {
            setProgress(
                m.progress || 0,
                'Getting the English language data (first time only)…'
            );
        } else if (/recogniz/i.test(m.status)) {
            setProgress(
                (done + (m.progress || 0)) / list.length,
                'Reading page ' + list[done] +
                ' (' + (done + 1) + ' of ' + list.length + ')…'
            );
        }
    }
});

state.worker = w;
checkCancel();

await w.setParameters({ debug_file: '/dev/null' });
</code></pre>
<p>The <code>createWorker()</code> method initializes the OCR engine with the English language data. The worker and core paths tell Tesseract.js where to load its supporting files.</p>
<p>The logger reports language-loading and text-recognition progress. The application uses this information to update its progress indicator while OCR is running.</p>
<h3 id="heading-recognizing-text-from-scanned-pages">Recognizing Text from Scanned Pages</h3>
<p>The application processes each eligible scanned page individually. It renders the page at a higher resolution before sending it to Tesseract.js.</p>
<p>Here's the actual page-processing excerpt from <code>runOcr()</code>:</p>
<pre><code class="language-javascript">for (; done &lt; list.length; done++) {
    checkCancel();

    var n = list[done],
        page = await state.doc.getPage(n),
        b = page.getViewport({ scale: 1 });

    var c = await renderPage(
        n,
        Math.min(b.width * 300 / 72, 2600),
        Math.min(b.height * 300 / 72, 2600)
    );

    checkCancel();

    var png = await pngBytes(c);
    c.width = 1;

    var res = await w.recognize(png);

    checkCancel();

    if (token !== state.loadToken) {
        return;
    }

    var cap = captionFromText(res.data &amp;&amp; res.data.text);

    if (cap &amp;&amp; !captionFor(n)) {
        state.captions[n] = cap;

        var ta = ui.grid.querySelector('[data-cap="' + n + '"]');

        if (ta) {
            ta.value = cap;
        }
    }
}
</code></pre>
<p>The function retrieves each scanned PDF page and calculates its original dimensions. It then renders the page at approximately 300 DPI, with a maximum dimension of 2,600 pixels.</p>
<p>The <code>pngBytes()</code> helper converts the rendered canvas into PNG image data. Tesseract.js receives this data through <code>w.recognize(png)</code> and returns the recognized text.</p>
<p>The application passes the recognized text to <code>captionFromText()</code> to clean and shorten it. If the result is useful and the page still has no caption, the application saves it in <code>state.captions</code> and updates the corresponding caption field.</p>
<p>The application also checks for cancellation and verifies that the PDF-loading operation is still current before updating the results.</p>
<p>After processing, the application displays a message asking users to review the generated captions. OCR isn't guaranteed to recognize every word correctly. Image quality, handwriting, unusual fonts, and page orientation can affect the result.</p>
<p>Users should review generated captions before downloading the storyboard.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/100aaec2-33a2-440a-956e-79d078692589.png" alt="PDF Storyboard Generator showing the OCR option for extracting text from scanned PDF pages." style="display: block;" width="600" height="400" loading="lazy">

<h2 id="heading-generating-the-final-storyboard-pdf">Generating the Final Storyboard PDF</h2>
<p>Once the user is satisfied with the layout and captions, the application can generate the final PDF.</p>
<p>This process uses PDF-lib to create a new PDF document. The application creates one output page for each storyboard sheet. It then places the original PDF pages inside their calculated panel positions and adds the storyboard's titles, labels, and captions.</p>
<h3 id="heading-creating-the-pdf-document">Creating the PDF Document</h3>
<p>The actual application uses the <code>exportPdf()</code> function to generate the storyboard. It first creates a new PDF document and checks whether the original PDF pages can be embedded as vector content.</p>
<p>Here's the first part of the actual implementation:</p>
<pre><code class="language-javascript">async function exportPdf(snap, token) {
    await engine();

    var PL = window.PDFLib,
        L = snap.L,
        o = snap.o,
        list = snap.list;

    var out = await PL.PDFDocument.create(),
        src = null,
        embedded = {};

    if (state.vector) {
        try {
            src = await PL.PDFDocument.load(
                state.bytes.slice(0),
                { updateMetadata: false }
            );
        } catch (e) {
            src = null;
        }
    }

    var k = exportScale(L, 300, 14e6);

    for (var s = 0; s &lt; L.sheets; s++) {
        checkCancel();

        if (token !== state.loadToken) {
            return;
        }

        setProgress(
            s / L.sheets,
            'Making sheet ' + (s + 1) + ' of ' + L.sheets + '…'
        );

        var page = out.addPage([L.W, L.H]);
        var c = document.createElement('canvas');

        c.width = Math.round(L.W * k);
        c.height = Math.round(L.H * k);

        var drawn = await drawSheet(
            c.getContext('2d'),
            o,
            list,
            L,
            s,
            k,
            'overlay',
            null
        );

        if (!drawn) {
            return;
        }
</code></pre>
<p>The function first waits for the required PDF libraries using <code>engine()</code>. It then creates a new PDF document with <code>PDFDocument.create()</code>.</p>
<p>The <code>snap</code> parameter contains a snapshot of the selected layout, options, and page list. The application uses these values so the export follows the settings selected by the user.</p>
<p>The <code>state.vector</code> check determines whether the original PDF can be loaded through PDF-lib for vector embedding. The application also calculates a rendering scale targeting 300 DPI, subject to a maximum pixel limit.</p>
<p>The loop creates one output PDF page for every storyboard sheet. It also calls <code>drawSheet()</code> in <code>overlay</code> mode to prepare the titles, panel labels, and captions that will be placed over the original PDF pages.</p>
<h3 id="heading-preserving-original-pdf-pages">Preserving Original PDF Pages</h3>
<p>When vector embedding is available, the application embeds the original PDF pages rather than converting them into ordinary images.</p>
<p>The following excerpt shows how it handles each selected panel:</p>
<pre><code class="language-javascript">for (var i = 0; i &lt; drawn.length; i++) {
    checkCancel();

    var d = drawn[i],
        r = d.rect,
        yb = L.H - r.y - r.h;

    page.drawRectangle({
        x: r.x,
        y: yb,
        width: r.w,
        height: r.h,
        color: PL.rgb(1, 1, 1)
    });

    if (src) {
        var sp = src.getPage(d.n - 1);

        if (!embedded[d.n]) {
            var cb = sp.getCropBox();

            embedded[d.n] = await out.embedPage(sp, {
                left: cb.x,
                bottom: cb.y,
                right: cb.x + cb.width,
                top: cb.y + cb.height
            });
        }

        var em = embedded[d.n],
            rot = ((sp.getRotation().angle % 360) + 360) % 360;

        var turned = rot === 90 || rot === 270,
            sx = r.w / (turned ? em.height : em.width);

        var ew = em.width * sx,
            eh = em.height * sx,
            at = { x: r.x, y: yb };

        if (rot === 90) {
            at = { x: r.x, y: yb + ew };
        } else if (rot === 180) {
            at = { x: r.x + ew, y: yb + eh };
        } else if (rot === 270) {
            at = { x: r.x + eh, y: yb };
        }

        page.drawPage(em, {
            x: at.x,
            y: at.y,
            width: ew,
            height: eh,
            rotate: PL.degrees(-rot)
        });
    }
</code></pre>
<p>The application retrieves the original PDF page using its page number. It then uses <code>embedPage()</code> to embed that page into the new PDF document.</p>
<p>The <code>embedded</code> object caches embedded pages so the same original page doesn't need to be embedded repeatedly.</p>
<p>The code also checks the original page rotation and adjusts its position and dimensions before drawing it. This helps preserve the correct orientation when placing pages inside storyboard panels.</p>
<h3 id="heading-handling-protected-pdfs">Handling Protected PDFs</h3>
<p>If the original PDF can't be embedded as vector content, the application uses a raster fallback.</p>
<p>Here's the actual fallback code:</p>
<pre><code class="language-javascript">else {
    var pic = await renderPage(
        d.n,
        r.w * k,
        r.h * k
    );

    var jpg = await new Promise(function (res) {
        pic.toBlob(function (b) {
            b.arrayBuffer().then(function (a) {
                res(new Uint8Array(a));
            });
        }, 'image/jpeg', 0.92);
    });

    pic.width = 1;

    page.drawImage(
        await out.embedJpg(jpg),
        {
            x: r.x,
            y: yb,
            width: r.w,
            height: r.h
        }
    );
}
</code></pre>
<p>In this case, the application renders the original PDF page onto a canvas and converts it into JPEG image data. PDF-lib then embeds the image inside the corresponding storyboard panel.</p>
<p>This fallback is used when vector embedding is unavailable, including protected PDFs that can't follow the normal vector workflow.</p>
<h3 id="heading-adding-captions-and-saving-the-pdf">Adding Captions and Saving the PDF</h3>
<p>After the original page images or vector content have been placed, the application adds the storyboard's titles, labels, captions, and other drawing elements as an overlay.</p>
<p>The following excerpt shows how the overlay is added and the final PDF is saved:</p>
<pre><code class="language-javascript">var over = await out.embedPng(await pngBytes(c));
c.width = 1;

page.drawImage(over, {
    x: 0,
    y: 0,
    width: L.W,
    height: L.H
});

await nextFrame();
</code></pre>
<p>After all sheets have been processed, the application checks for cancellation and prepares the final PDF:</p>
<pre><code class="language-javascript">checkCancel();

setProgress(1, 'Saving the PDF…');

out.setTitle(state.title || state.base + ' storyboard');
out.setCreator('AllInOneTools PDF Storyboard');
out.setProducer('AllInOneTools (pdf-lib)');

var bytes = await out.save();

if (token !== state.loadToken) {
    return;
}

save(
    new Blob([bytes], { type: 'application/pdf' }),
    state.base + '-storyboard.pdf'
);

toast(
    'Saved ' + plural(L.sheets, 'sheet', 'sheets') +
    ' as ' + state.base + '-storyboard.pdf.'
);
</code></pre>
<p>The <code>embedPng()</code> method adds the storyboard overlay to the PDF page. This overlay contains the titles, panel labels, captions, and other visual elements.</p>
<p>The application then sets the PDF metadata, saves the document as bytes, and creates a downloadable PDF file.</p>
<p>Progress updates and cancellation checks are used throughout the export process, which is particularly helpful when generating storyboards with multiple sheets.</p>
<p>The final PDF combines the original PDF pages with the storyboard layout and captions, ready to download, share, or print.</p>
<h2 id="heading-downloading-png-storyboard-sheets">Downloading PNG Storyboard Sheets</h2>
<p>Users who don't need a PDF can choose PNG sheets.</p>
<p>The application renders each complete storyboard sheet as a PNG image at 200 DPI. If the storyboard contains only one sheet, the browser downloads a single PNG file. If it contains multiple sheets, the application packages them into a ZIP archive.</p>
<h3 id="heading-exporting-the-storyboard-as-png">Exporting the Storyboard as PNG</h3>
<p>The application handles PNG exports inside the <code>exportJob()</code> function. It first calculates the rendering scale and then processes each storyboard sheet.</p>
<p>The following excerpt shows the actual PNG export implementation:</p>
<pre><code class="language-javascript">var k = exportScale(L, 200, 16e6),
    files = [];

for (var s = 0; s &lt; L.sheets; s++) {
    checkCancel();

    setProgress(
        s / L.sheets,
        'Drawing sheet ' + (s + 1) + ' of ' + L.sheets + '…'
    );

    var c = document.createElement('canvas');

    c.width = Math.round(L.W * k);
    c.height = Math.round(L.H * k);

    var ok = await drawSheet(
        c.getContext('2d'),
        o,
        list,
        L,
        s,
        k,
        'full',
        function (n, w, h) {
            checkCancel();
            return renderPage(n, w, h);
        }
    );

    if (!ok) { return; }

    files.push({
        name: state.base + '-storyboard-sheet-' +
            pad2(s + 1) + '.png',
        data: await pngBytes(c)
    });

    c.width = 1;
}

if (files.length === 1) {
    save(
        new Blob([files[0].data], { type: 'image/png' }),
        state.base + '-storyboard.png'
    );
} else {
    save(makeZip(files), state.base + '-storyboard-sheets.zip');
}

toast(
    files.length === 1
        ? 'Saved the storyboard as a PNG.'
        : 'Saved ' + files.length + ' sheets as PNG files in a ZIP.'
);
</code></pre>
<p>The application uses <code>exportScale()</code> to calculate a suitable rendering scale for 200 DPI while keeping the canvas within a maximum pixel limit of 16 million pixels.</p>
<p>For every sheet, it creates a canvas and calls <code>drawSheet()</code> in <code>full</code> mode. This draws the PDF page previews together with the storyboard titles, labels, captions, and other selected settings.</p>
<p>The <code>pngBytes()</code> helper converts the completed canvas into PNG data. Each image is added to the <code>files</code> array with a filename containing its sheet number.</p>
<p>After processing all sheets, the application checks how many PNG files were generated. A single sheet is downloaded directly as a PNG. Multiple sheets are packaged into a ZIP archive using <code>makeZip()</code>.</p>
<p>This format is useful for sharing storyboard previews in presentations, messages, and other applications.</p>
<h2 id="heading-downloading-individual-page-pictures">Downloading Individual Page Pictures</h2>
<p>The third export option is Page pictures (ZIP).</p>
<p>Unlike the PNG sheets option, this export contains individual pictures of the PDF pages selected for the storyboard.</p>
<p>The application renders each selected page separately and places the resulting PNG files into a ZIP archive.</p>
<h3 id="heading-exporting-individual-pdf-pages">Exporting Individual PDF Pages</h3>
<p>The actual application handles this option inside the <code>exportJob()</code> function. It first creates a unique list of selected pages and then renders each page individually.</p>
<p>Here's the actual implementation:</p>
<pre><code class="language-javascript">var pages = unique(list),
    out = [];

for (var i = 0; i &lt; pages.length; i++) {
    checkCancel();

    setProgress(
        i / pages.length,
        'Saving page ' + pages[i] +
        ' (' + (i + 1) + ' of ' + pages.length + ')…'
    );

    var pg = await state.doc.getPage(pages[i]),
        vb = pg.getViewport({ scale: 1 }),
        sc = Math.min(
            2,
            Math.sqrt(12e6 / (vb.width * vb.height))
        );

    var pc = await renderPage(
        pages[i],
        vb.width * sc,
        vb.height * sc
    );

    out.push({
        name: 'panel-' +
            pad2(list.indexOf(pages[i]) + 1) +
            '-page-' + pad2(pages[i]) + '.png',
        data: await pngBytes(pc)
    });

    pc.width = 1;
}

save(makeZip(out), state.base + '-panels.zip');

toast(
    'Saved ' +
    plural(out.length, 'page picture', 'page pictures') +
    ' in a ZIP.'
);
</code></pre>
<p>The function first calls <code>unique()</code> to remove duplicate page numbers from the selected list.</p>
<p>It then processes each page individually. The <code>getPage()</code> method retrieves the original PDF page, and <code>getViewport()</code> obtains its dimensions.</p>
<p>The scale calculation limits the rendering scale to 2 while keeping the rendered page within approximately 12 million pixels. This helps control memory usage when processing large pages.</p>
<p>The <code>renderPage()</code> function creates the page image, while <code>pngBytes()</code> converts the canvas into PNG data.</p>
<p>Each image is added to the <code>out</code> array with a filename containing both its panel position and original PDF page number. This makes it easier to identify the pictures and maintain their storyboard order.</p>
<p>Finally, <code>makeZip()</code> packages all the generated PNG images into a ZIP archive, which the browser downloads.</p>
<p>This option is useful when users want to work with individual PDF page pictures rather than complete storyboard sheets.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/895e4a25-0a30-4402-a728-d4a51fb97b38.png" alt="Board settings and preview, including the PDF, PNG sheets, and Page pictures download buttons." style="display: block;" width="600" height="400" loading="lazy">

<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/bacaaf21-ffb9-476d-abca-9b0a0d8dc537.png" alt="Exported storyboard PDF opened in a PDF viewer." style="display: block;" width="600" height="400" loading="lazy">

<h2 id="heading-performance-and-important-considerations">Performance and Important Considerations</h2>
<p>Rendering large PDFs and generating high-resolution images can consume significant browser memory.</p>
<p>The application uses page-preview caching, temporary canvases, progress reporting, and cancellation controls to manage this work.</p>
<p>For better results, use fewer panels per sheet when working with large documents. A3 paper can also provide more room when a storyboard contains many panels.</p>
<p>Remember that layout preferences are remembered on the device, but the PDF and captions remain in the current browser tab. Download your work before closing the tab.</p>
<h2 id="heading-demo-pdf-storyboard-generator">Demo: PDF Storyboard Generator</h2>
<p>You can try the finished application here:</p>
<p><a href="https://allinonetools.net/pdf-storyboard-generator/?utm_source=chatgpt.com">PDF Storyboard Generator – AllInOneTools</a></p>
<p>Upload a PDF, arrange its pages, add captions, customize the storyboard, and download the finished result.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Building a PDF Storyboard Generator combines several useful browser technologies into one application.</p>
<p>PDF.js handles PDF loading and page rendering. Tesseract.js provides optional text recognition for scanned pages. PDF-lib creates the final storyboard PDF, while HTML canvas handles previews and image exports.</p>
<p>By connecting these components with JavaScript, we can create a practical tool for organizing presentation slides, comic pages, scripts, and other PDF documents.</p>
<p>The project also demonstrates how browser-based applications can process documents locally without requiring users to upload their files to a server.</p>
<p>With the complete workflow in place, users can turn ordinary PDF pages into organized storyboard sheets that are easier to review, print, and share.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Godot 4 Roguelike Course – Build a Complete Top-Down Action Game ]]>
                </title>
                <description>
                    <![CDATA[ A top-down action roguelite only works when combat feels sharp, rooms feel connected, and every run asks you to trade power for risk. Godot 4 has the tools for that, but most tutorials stop before tho ]]>
                </description>
                <link>https://www.freecodecamp.org/news/godot-4-roguelike-course-build-a-complete-top-down-action-game/</link>
                <guid isPermaLink="false">6aedfb097070ca4b7e00e608</guid>
                
                    <category>
                        <![CDATA[ Godot ]]>
                    </category>
                
                    <category>
                        <![CDATA[ youtube ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Beau Carnes ]]>
                </dc:creator>
                <pubDate>Fri, 06 Nov 2026 16:06:33 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/gql/5f68e7df6dfc523d0a894e7c/1bca1e16-d488-4afc-bc2f-ede5902092c4.jpg" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>A top-down action roguelite only works when combat feels sharp, rooms feel connected, and every run asks you to trade power for risk. Godot 4 has the tools for that, but most tutorials stop before those systems fit together into a game someone else can open and play.</p>
<p>We just published a comprehensive 10-hour course on the freeCodeCamp.org YouTube channel that will teach you how to build a complete top-down action roguelite in Godot 4.7.2, from an empty project to an exported game. This course is taught by Omar Zaki, creator of Coding Quests, and most lessons include a Your Turn section where you build the next piece yourself before you see the solution.</p>
<p>Here is what the course covers:</p>
<ul>
<li><strong>Dash Combat &amp; I-Frames</strong><br>Build weighty movement, aiming, and a dodge with invincibility frames so combat feels fair instead of cheap.</li>
<li><strong>Enemies, Telegraphs &amp; a Boss Tell</strong><br>Create five enemy types from a shared base, then add a boss with a clear warning before the hard hit lands.</li>
<li><strong>Procedural Connected Rooms</strong><br>Generate random floors of linked rooms in code, track where you are, and lock a fight until the room is clear.</li>
<li><strong>Items, Corruption &amp; Shops</strong><br>Stack items into builds, take corruption that trades power for fragility, and risk gold at shops and a gambling altar.</li>
<li><strong>Run Loop, Unlocks &amp; Export</strong><br>Wire death, permanent unlocks, a title screen, sound, and an export so a stranger can download and play your game.</li>
</ul>
<p>Watch the full course on <a href="https://youtu.be/eHHxRJulX74">the freeCodeCamp.org YouTube channel</a> (10-hour watch).</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/eHHxRJulX74" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Use shadcn/ui in React Native Applications [Full Handbook] ]]>
                </title>
                <description>
                    <![CDATA[ If you've built a couple of React Native apps, the following probably feels familiar. You start with a colors.ts file and a simple Button component. Everything looks fine at first. Then the design cha ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-use-shadcn-ui-in-react-native-applications-full-handbook/</link>
                <guid isPermaLink="false">6aecb9efa7a2ab6fb8e3fe94</guid>
                
                    <category>
                        <![CDATA[ shadcn ]]>
                    </category>
                
                    <category>
                        <![CDATA[ shadcn ui ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React Native ]]>
                    </category>
                
                    <category>
                        <![CDATA[ handbook ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Ajay Patel ]]>
                </dc:creator>
                <pubDate>Thu, 05 Nov 2026 17:16:30 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/ddea2027-27b4-407e-83a5-d84bfd295627.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>If you've built a couple of React Native apps, the following probably feels familiar. You start with a <code>colors.ts</code> file and a simple <code>Button</code> component. Everything looks fine at first. Then the design changes a few times, and that small color file starts growing. Your Button also picks up another prop every time someone asks for a slightly different style.</p>
<p>The setup works at first, but after a few design changes, even a small update can mean checking several files across the project.</p>
<p>Here's what a typical color file looks like after a while:</p>
<pre><code class="language-tsx">export const colors = {
  primary: '#6e4836',
  primaryPressed: '#5a3a2b',
  textPrimary: '#261d16',
  textSecondary: '#72665e',
  // and forty more
};
</code></pre>
<p>And here's what a typical <code>Button</code> looks like after a few redesigns:</p>
<pre><code class="language-tsx">&lt;Button
  variant="primary"
  size="md"
  fullWidth
  loading={busy}
  containerStyle={styles.checkoutButton}
  labelStyle={styles.checkoutLabel}
/&gt;
</code></pre>
<p>These are simplified examples, but you get the idea. The color object keeps growing, and the Button collects more props to handle every new design request.</p>
<p>Each change seems reasonable when you make it. But the annoying part comes later, when changing one design decision means checking several files to see that nothing else has changed unexpectedly.</p>
<p>Part of the problem is how many component libraries are distributed. Their code lives in <code>node_modules</code>, so you usually customize them through the props and options they expose. When the library doesn't cover your exact use case, you add another prop or override. Keep doing that and the component can become harder to maintain than the original problem you were trying to solve.</p>
<p>This is one reason I like shadcn/ui's approach. You don't install a traditional component package and treat its components as fixed. The CLI copies the source code into your project, where you can change it whenever you need to. For React Native, there's a project built around the same idea called <a href="https://reactnativereusables.com/">React Native Reusables</a>.</p>
<p>To make this practical, I'll use React Native Reusables, Expo, and Uniwind to build a <strong>cart and checkout screen for a coffee shop's pickup orders</strong>. It includes order items with quantity controls, a pickup-time dropdown, a name field, an "own cup" toggle, a bill summary, a checkout bar that stays at the bottom, and an order-confirmation dialog. If you want to see how reusable sections come together in complete interfaces, you can also browse <a href="https://shadcnstudio.com/templates">shadcn templates</a>.</p>
<p>I'll also point out a few things that are easy to miss when working with these components, including:</p>
<ul>
<li>How shadcn/ui and its CLI work, and why you own the component code</li>
<li>How design tokens (named colors) replace a <code>colors.ts</code> file</li>
<li>How to use components such as Card, Select, Switch, and AlertDialog in React Native</li>
<li>How to handle mobile-specific issues, such as safe areas, keyboards, and tap targets</li>
<li>How to switch between light and dark mode, and how to change the whole theme from one file</li>
</ul>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><a href="#heading-prerequisites">Prerequisites</a></li>
<li><a href="#heading-tools-you-will-use">Tools You Will Use</a></li>
<li><a href="#heading-how-shadcnui-works">How shadcn/ui Works</a></li>
<li><a href="#heading-what-react-native-reusables-gives-you">What React Native Reusables Gives You</a></li>
<li><a href="#heading-what-youre-building">What You're Building</a></li>
<li><a href="#heading-step-1-create-the-app">Step 1: Create the App</a><ul>
<li><a href="#heading-answer-the-clis-questions">Answer the CLI's Questions</a></li>
<li><a href="#heading-look-at-the-project-structure">Look at the Project Structure</a></li>
<li><a href="#heading-run-the-app">Run the App</a></li>
</ul>
</li>
<li><a href="#heading-step-2-understand-the-theme-file">Step 2: Understand the Theme File</a><ul>
<li><a href="#heading-the-palette-also-exists-in-libthemets">The Palette Also Exists inlib/theme.ts</a></li>
<li><a href="#heading-try-it-change-one-color">Try It: Change One Color</a></li>
</ul>
</li>
<li><a href="#heading-step-3-read-three-core-components">Step 3: Read Three Core Components</a><ul>
<li><a href="#heading-the-text-component">The Text Component</a></li>
<li><a href="#heading-the-button-component">The Button Component</a></li>
<li><a href="#heading-the-icon-component">The Icon Component</a></li>
</ul>
</li>
<li><a href="#heading-step-4-add-the-components-you-need">Step 4: Add the Components You Need</a></li>
<li><a href="#heading-step-5-set-up-the-data-and-the-screen">Step 5: Set Up the Data and the Screen</a><ul>
<li><a href="#heading-the-screen-layout">The Screen Layout</a></li>
</ul>
</li>
<li><a href="#heading-step-6-build-the-order-rows">Step 6: Build the Order Rows</a></li>
<li><a href="#heading-step-7-build-the-pickup-options">Step 7: Build the Pickup Options</a><ul>
<li><a href="#heading-problem-1-portals-and-the-portalhost">Problem 1: Portals and the PortalHost</a></li>
<li><a href="#heading-problem-2-safe-area-insets">Problem 2: Safe Area Insets</a></li>
<li><a href="#heading-problem-3-the-select-value-is-an-object">Problem 3: The Select Value Is an Object</a></li>
<li><a href="#heading-making-the-label-tappable">Making the Label Tappable</a></li>
<li><a href="#heading-keyboard-props-for-the-name-field">Keyboard Props for the Name Field</a></li>
</ul>
</li>
<li><a href="#heading-step-8-build-the-bill-component">Step 8: Build the Bill Component</a></li>
<li><a href="#heading-step-9-build-the-fixed-checkout-bar">Step 9: Build the Fixed Checkout Bar</a><ul>
<li><a href="#heading-handling-the-home-indicator">Handling the Home Indicator</a></li>
<li><a href="#heading-a-note-on-thin-borders">A Note on Thin Borders</a></li>
</ul>
</li>
<li><a href="#heading-step-10-add-the-confirmation-and-put-the-screen-together">Step 10: Add the Confirmation and Put the Screen Together</a><ul>
<li><a href="#heading-1-it-also-needs-the-portalhost">1. It Also Needs thePortalHost</a></li>
<li><a href="#heading-2-the-footer-reverses-its-order-on-phones">2. The Footer Reverses its Order on Phones</a></li>
<li><a href="#heading-haptics">Haptics</a></li>
<li><a href="#heading-the-full-screen">The Full Screen</a></li>
</ul>
</li>
<li><a href="#heading-step-11-add-dark-mode">Step 11: Add Dark Mode</a><ul>
<li><a href="#heading-1-the-navigation-header">1. The Navigation Header</a></li>
<li><a href="#heading-2-the-status-bar">2. The Status Bar</a></li>
<li><a href="#heading-3-saving-the-choice">3. Saving the Choice</a></li>
</ul>
</li>
<li><a href="#heading-step-12-create-your-own-theme">Step 12: Create Your Own Theme</a><ul>
<li><a href="#heading-update-the-tokens">Update the Tokens</a></li>
<li><a href="#heading-update-libthemets">Update lib/theme.ts</a></li>
</ul>
</li>
<li><a href="#heading-common-mistakes-and-how-to-fix-them">Common Mistakes and How to Fix Them</a></li>
<li><a href="#heading-conclusion">Conclusion</a><ul>
<li><a href="#heading-resources">Resources</a></li>
</ul>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along, you should have:</p>
<ul>
<li><strong>Node.js 20 or newer.</strong> You can use pnpm, npm, yarn, or bun. The commands in this tutorial use pnpm, so swap the prefix if you use something else.</li>
<li><strong>Basic knowledge of React and TypeScript</strong>, especially hooks such as <code>useState</code>.</li>
<li><strong>Some familiarity with React Native</strong>, such as <code>View</code>, <code>Text</code>, and <code>ScrollView</code>. You don't need to know Tailwind, but it helps.</li>
<li><strong>Optional:</strong> Xcode or Android Studio if you want to run the app in a simulator. Everything also runs in a web browser through React Native Web, and that's how the screenshots in this tutorial were taken.</li>
</ul>
<p>You don't need any accounts or API keys, and nothing here costs money.</p>
<h2 id="heading-tools-you-will-use">Tools You Will Use</h2>
<p>Before creating the app, let's look at the tools in this project. Each one has a specific job, and knowing that will make the setup easier to follow.</p>
<p><strong>shadcn/ui</strong> is a collection of UI components for the web. It's not installed as a package. Instead, a command-line tool (CLI) copies the source code of each component into your project, and from then on the code is yours to edit.</p>
<p><strong>React Native Reusables</strong> is the React Native version of shadcn/ui. It gives you components such as Button, Card, Select, and Dialog that are written for React Native and styled with Tailwind-style class names. It uses the same CLI idea as shadcn/ui.</p>
<p><strong>RN Primitives</strong> is a React Native port of Radix UI, which is the library that shadcn/ui uses for behavior on the web. A "primitive" is a component that handles the behavior but has no styling. For example, the <code>select</code> primitive knows how a dropdown opens and closes and how it's announced to screen readers, but it has no colors or borders. React Native Reusables adds the looks on top.</p>
<p><strong>Uniwind</strong> is a tool that lets you style React Native components with Tailwind CSS class names, such as <code>className="bg-primary p-4"</code>. It converts those classes into regular React Native styles while the app is being bundled, so there's no extra work at runtime. <a href="https://www.nativewind.dev/">Nativewind</a> does a similar job, and React Native Reusables supports both. This tutorial uses Uniwind.</p>
<p><strong>Expo and Expo Router</strong> provide the project setup, the development server, and file-based navigation. If you have used Next.js, Expo Router works in a similar way.</p>
<h2 id="heading-how-shadcnui-works">How shadcn/ui Works</h2>
<p>The React Native Reusables documentation opens with a quote from shadcn:</p>
<blockquote>
<p>This is not a component library. It is how you build your component library.</p>
</blockquote>
<p>You won't add shadcn/ui as a regular dependency in <code>package.json</code>. It uses a <strong>registry</strong> instead. A registry is a collection of JSON files hosted on a server. Each file describes a component and includes the information the CLI needs to add it to your project. When you run the CLI, it reads the relevant file and copies the component's source code into your project.</p>
<p>To see what this looks like, here's the registry entry for the <strong>Badge</strong> component. A Badge is a small label, such as the "Medium" tag next to a coffee in the cart you will build later. This is a shortened version of the real file:</p>
<pre><code class="language-json">{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "badge",
  "type": "registry:ui",
  "title": "Badge",
  "author": "@mrzachnugent",
  "description": "Displays a badge or a component that looks like a badge.",
  "dependencies": ["@rn-primitives/slot"],
  "registryDependencies": ["https://reactnativereusables.com/r/uniwind/text.json"],
  "files": [
    {
      "path": "./node_modules/@rnr/registry/src/uniwind/components/ui/badge.tsx",
      "content": "import { TextClassContext } from '@/components/ui/text';\nimport ...",
      "type": "registry:ui"
    }
  ]
}
</code></pre>
<p>The file is public, so you can look at it yourself:</p>
<pre><code class="language-bash">curl https://reactnativereusables.com/r/uniwind/badge.json
</code></pre>
<p>Here's what the important fields mean:</p>
<ul>
<li><code>content</code> holds the full source code of the component, stored as a string.</li>
<li><code>dependencies</code> lists npm packages that the component needs. The CLI installs these first.</li>
<li><code>registryDependencies</code> lists other registry entries that the component needs. In this case, Badge needs the <strong>Text</strong> component, which is a small wrapper around React Native's <code>Text</code> that you will read about in Step 3. When you run the CLI to add Badge, it also adds Text for you because of this field.</li>
</ul>
<p>That's how a component gets from the registry into your app. The registry stores the component information, and the CLI uses it to create the files in your repository. React Native Reusables uses the shadcn CLI behind the scenes, so you can follow the same workflow when adding React Native components.</p>
<p>There are a few reasons this approach is useful when you're building an app:</p>
<ol>
<li><strong>You own the file:</strong> A component such as <code>components/ui/button.tsx</code> sits in your repository and shows up in your Git diffs. If you want a darker pressed state, you edit that line. You don't need to invent a new prop, maintain a fork, or open an issue.</li>
<li><strong>Design lives in named colors instead of props:</strong> Colors are declared once as named roles, such as <code>primary</code>, <code>muted</code>, <code>border</code>, and <code>destructive</code>. You then use them in class names, like <code>className="bg-primary"</code>. This replaces the <code>colors.ts</code> file from the introduction, because there's one place that defines your colors, and every component reads from it. You'll set this up in Step 2.</li>
<li><strong>Behavior comes from a separate package:</strong> Things such as focus handling, open and close state, and screen reader support live in RN Primitives. You only write the styling.</li>
</ol>
<p>There's one trade-off to keep in mind. Since the component code is copied into your project, updates from the registry aren't applied automatically. If a bug is fixed upstream, you'll need to run the CLI again with <code>--overwrite</code>. If you have customized that component, you may also need to reapply your changes. This is worth considering before you make large changes to several components.</p>
<h2 id="heading-what-react-native-reusables-gives-you">What React Native Reusables Gives You</h2>
<p>At the time of writing, React Native Reusables includes thirty components: accordion, alert, alert-dialog, aspect-ratio, avatar, badge, button, card, checkbox, collapsible, context-menu, dialog, dropdown-menu, hover-card, input, label, menubar, popover, progress, radio-group, select, separator, skeleton, switch, tabs, text, textarea, toggle, toggle-group, and tooltip. The available components cover many of the building blocks you need for a typical app interface.</p>
<p>React Native is different from the web in several ways, so the components can't be an exact copy of the web version. The documentation lists the main differences, and you'll run into each of them in this tutorial. If you're also building web interfaces with shadcn/ui, <a href="https://shadcnstudio.com/components">shadcn components</a> can be useful when you need more ready-made examples to work from.</p>
<table>
<thead>
<tr>
<th>The difference</th>
<th>What it means</th>
<th>Where you will see it</th>
</tr>
</thead>
<tbody><tr>
<td>Portals need a <code>PortalHost</code></td>
<td>On the web, floating content such as dropdowns renders into <code>document.body</code>. React Native has no <code>document.body</code>, so you need a <code>PortalHost</code> component to render into.</td>
<td>Step 7</td>
</tr>
<tr>
<td>Styles don't cascade</td>
<td>A text color set on a parent view doesn't reach the text inside it, so React Native Reusables uses a <code>Text</code> component with a workaround.</td>
<td>Step 3</td>
</tr>
<tr>
<td>There are no <code>data-*</code> attributes</td>
<td>Web shadcn changes styles based on attributes like <code>data-state</code>. React Native has none, so variants depend on props and state.</td>
<td>Step 7</td>
</tr>
<tr>
<td>Icons take props instead of classes</td>
<td>Icon libraries expect <code>color</code> and <code>size</code> props, so icons are wrapped to accept class names.</td>
<td>Step 3</td>
</tr>
</tbody></table>
<h2 id="heading-what-youre-building">What You're Building</h2>
<p>We'll build a cart and checkout screen for a coffee shop where customers place an order and pick it up at the counter. The screen uses twelve components from the registry. While putting it together, I'll also explain a few React Native details that are easy to miss, including how dropdowns are rendered, how safe areas affect the layout, and how to keep the checkout bar visible while the rest of the page scrolls:</p>
<table>
<thead>
<tr>
<th>Part of the screen</th>
<th>Components used</th>
<th>The mobile problem it teaches</th>
</tr>
</thead>
<tbody><tr>
<td>Item rows</td>
<td>Card, Avatar, Badge, Button</td>
<td>No cascading styles, no <code>truncate</code>, small tap targets</td>
</tr>
<tr>
<td>Quantity stepper</td>
<td>Button, Icon</td>
<td>Icons take props, not classes</td>
</tr>
<tr>
<td>Pickup time</td>
<td>Select, Label</td>
<td>Dropdowns need a portal</td>
</tr>
<tr>
<td>Name field</td>
<td>Input, Label</td>
<td>The keyboard can cover the field</td>
</tr>
<tr>
<td>Own cup toggle</td>
<td>Switch, Label</td>
<td>Making the label text tappable</td>
</tr>
<tr>
<td>Bill</td>
<td>Separator, Text</td>
<td>Digits that don't line up</td>
</tr>
<tr>
<td>Checkout bar</td>
<td>Button, AlertDialog</td>
<td>No <code>position: fixed</code>, and the home indicator</td>
</tr>
</tbody></table>
<p>Here's the finished screen:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5e0f2c2e490269cb30227a2b/a30e05d8-efcb-41ed-ad2c-fd22763d7ea2.png" alt="The finished cart screen in light mode" style="display: block;" width="600" height="400" loading="lazy">

<img src="https://cdn.hashnode.com/uploads/covers/5e0f2c2e490269cb30227a2b/f24bc348-c9a2-4197-92a6-c60479fb3686.png" alt="The finished cart screen in dark mode" style="display: block;" width="600" height="400" loading="lazy">

<p>The complete code is available in the <a href="https://github.com/shadcnstudio/shadcn-react-native-app">GitHub repository</a> if you want to compare your work with the final result.</p>
<h2 id="heading-step-1-create-the-app">Step 1: Create the App</h2>
<p>We can let the React Native Reusables CLI create the initial project for us. It sets up Expo Router, the styling configuration, the theme file, and a few starter components. Run the following command to get started:</p>
<pre><code class="language-bash">pnpm dlx @react-native-reusables/cli@latest init
</code></pre>
<h3 id="heading-answer-the-clis-questions">Answer the CLI's Questions</h3>
<p>The CLI asks four questions.</p>
<p><strong>What is the name of your project?</strong> The name you type becomes the folder name. It suggests <code>my-app</code>.</p>
<p><strong>Select a template.</strong> There are three options, and the one you need isn't the one selected by default:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5e0f2c2e490269cb30227a2b/7ce945cb-ac0e-49f0-990b-c0c2cf956f29.png" alt="The template selection prompt in the terminal" style="display: block;" width="600" height="400" loading="lazy">

<pre><code class="language-plaintext">❯ Minimal (Nativewind)
  Minimal (Uniwind)
  Clerk auth (Nativewind)
</code></pre>
<p>Press the down arrow once and choose <strong>Minimal (Uniwind)</strong>. This is the one choice that's tricky to change later. Nativewind and Uniwind are two different ways to use Tailwind in React Native, and the CLI keeps a separate registry for each one. The components it writes for Uniwind are different from the ones it writes for Nativewind, so the code in this tutorial won't match if you pick Nativewind.</p>
<p><strong>Would you like to install dependencies?</strong> Choose yes, and select the package manager you use.</p>
<p><strong>Would you like to initialize a Git repository?</strong> Choose yes. You're about to replace the starter screen, and having a commit to compare against is helpful.</p>
<h3 id="heading-look-at-the-project-structure">Look at the Project Structure</h3>
<p>The CLI creates the following files:</p>
<pre><code class="language-plaintext">shadcn-react-native-app/
├── app/
│   ├── _layout.tsx        # ThemeProvider, Stack, and the PortalHost
│   ├── +html.tsx          # web only, the root HTML shell
│   ├── +not-found.tsx
│   └── index.tsx          # the starter screen
├── components/ui/
│   ├── button.tsx
│   ├── icon.tsx
│   └── text.tsx
├── lib/
│   ├── theme.ts           # the same palette again, in JavaScript
│   └── utils.ts           # cn()
├── global.css             # the theme tokens
├── components.json        # the shadcn config, same as on the web
├── metro.config.js        # withUniwindConfig
└── uniwind-types.d.ts     # generated, do not edit
</code></pre>
<p>Before moving on, let's take a look at a few files in this structure. You'll use them throughout the tutorial:</p>
<ul>
<li><code>components.json</code> is the same shadcn configuration file used on the web. It tells the CLI where to put components (<code>@/components/ui</code>) and where the <code>cn()</code> helper lives (<code>@/lib/utils</code>).</li>
<li><code>global.css</code> holds your theme tokens, which you'll learn about in Step 2.</li>
<li><code>lib/utils.ts</code> exports <code>cn()</code>, a helper that combines class names and resolves conflicts between them.</li>
<li><code>app/_layout.tsx</code> contains a <code>PortalHost</code> component. It looks like leftover scaffolding, but you must not delete it. Step 7 explains why.</li>
</ul>
<p>Uniwind reads <code>global.css</code> while Metro bundles the app and turns your class strings into React Native style objects. This happens at build time, so a long <code>className</code> string doesn't slow down your app.</p>
<h3 id="heading-run-the-app">Run the App</h3>
<p>Start the development server:</p>
<pre><code class="language-bash">pnpm dev
</code></pre>
<p>Press <code>w</code> to open it in the browser, <code>i</code> for the iOS simulator, or <code>a</code> for Android. The starter screen has a theme toggle in the header, which shows that the theme setup is working.</p>
<p>You can also run a diagnostic command at any point if something seems wrong:</p>
<pre><code class="language-bash">pnpm dlx @react-native-reusables/cli@latest doctor
</code></pre>
<p>It checks your setup and reports which styling library it detects. For this project, the output should be:</p>
<pre><code class="language-plaintext">ℹ Styling Library: Uniwind
✔ All checks passed.
</code></pre>
<p>If it says Nativewind, fix that before you add any components.</p>
<p>Once the setup is working, you should see the starter screen. Try the theme toggle in the header to make sure light and dark mode both work, and check that the console is free of errors:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5e0f2c2e490269cb30227a2b/72fbc819-a026-41cb-824d-6f50b9e88625.png" alt="The starter screen" style="display: block;" width="600" height="400" loading="lazy">

<h2 id="heading-step-2-understand-the-theme-file">Step 2: Understand the Theme File</h2>
<p>Now that the app is running, let's look at its theme setup. The <code>global.css</code> file is where we'll define the colors used by the components. If you've previously kept these values in a <code>colors.ts</code> or <code>theme.ts</code> file, this is the setup that replaces it.</p>
<pre><code class="language-css">@import "tailwindcss";
@import "uniwind";

@import "tw-animate-css";

@theme {
  --radius: 10px;
  --radius-sm: calc(var(--radius) - 4px);
  --radius-md: calc(var(--radius) - 2px);
  --radius-lg: var(--radius);
  --radius-xl: calc(var(--radius) + 4px);

  --spacing-hairline: hairlineWidth();
}

@layer theme {
  :root {
    @variant light {
      --color-background: oklch(1 0 0);
      --color-foreground: oklch(0.145 0 0);
      --color-card: oklch(1 0 0);
      --color-primary: oklch(0.205 0 0);
      --color-muted-foreground: oklch(0.556 0 0);
      --color-border: oklch(0.922 0 0);
      /* the rest of the list */
    }

    @variant dark {
      --color-background: oklch(0.145 0 0);
      --color-foreground: oklch(0.985 0 0);
      --color-card: oklch(0.205 0 0);
      --color-primary: oklch(0.922 0 0);
      --color-muted-foreground: oklch(0.708 0 0);
      --color-border: oklch(1 0 0 / 10%);
      /* the rest of the list */
    }
  }
}
</code></pre>
<p>There are two conventions in this file that you need to know.</p>
<p>First, the <code>--color-</code> prefix creates the class names. When you declare <code>--color-primary</code>, Tailwind automatically creates <code>bg-primary</code>, <code>text-primary</code>, and <code>border-primary</code>. If you declare <code>--primary</code> instead, you only get a CSS variable that nothing uses.</p>
<p>This is important because most shadcn theme generators produce the web format, where the variables are named <code>--primary</code>. If you paste that output without renaming the variables, nothing breaks, but nothing changes either. Step 12 shows you how to convert them.</p>
<p>Second, <code>@variant light</code> and <code>@variant dark</code> replace <code>:root</code> and <code>.dark</code>. On the web, dark mode works by adding a <code>.dark</code> class to the page. A phone has no page to add a class to, so Uniwind manages the switch. When you call <code>Uniwind.setTheme</code>, it swaps the active values. You'll use this in Step 11.</p>
<p>You may also notice that the file uses <code>oklch()</code> colors. Phones don't have a browser to read that format, but Uniwind converts the colors to a format React Native understands while bundling, so you can use modern color syntax anyway.</p>
<h3 id="heading-the-palette-also-exists-in-libthemets">The Palette Also Exists in <code>lib/theme.ts</code></h3>
<p>If you open <code>lib/theme.ts</code>, you'll find the same colors again as a JavaScript object. This duplication is needed because React Navigation, which draws the header and screen background, reads colors from a plain JavaScript object and can't read CSS variables. As a result, you have to keep two copies of your palette in sync. Step 12 shows what happens when they differ.</p>
<h3 id="heading-try-it-change-one-color">Try It: Change One Color</h3>
<p>Let's change one of the tokens to see how the theme works. Update the primary color in the light theme to a value that's easy to notice:</p>
<pre><code class="language-css">--color-primary: oklch(0.6 0.24 25);
</code></pre>
<p>Save the file and look at the starter screen. The <strong>Browse the Docs</strong> button is now red, even though you didn't edit <code>button.tsx</code>:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5e0f2c2e490269cb30227a2b/0d2116eb-9052-40a1-9163-69f1a89dbd14.png" alt="The starter button turned red after changing one token" style="display: block;" width="600" height="400" loading="lazy">

<p>Notice that every component using <code>bg-primary</code> picks up the new color. You only changed one token, but the button updated without touching its component file. Change the value back before continuing.</p>
<h2 id="heading-step-3-read-three-core-components">Step 3: Read Three Core Components</h2>
<p>The starter project already gives us three components: <code>Text</code>, <code>Button</code>, and <code>Icon</code>. I'd take a few minutes to look through them before building the screen. They introduce patterns you'll see again in the other registry components.</p>
<h3 id="heading-the-text-component">The Text Component</h3>
<p>In React Native, styles don't cascade the way they do in CSS. If you set a text color on a <code>View</code>, the text inside it doesn't inherit that color. This is a problem for a component such as <code>&lt;Button variant="destructive"&gt;</code>, which needs its label to be white. The label is a <code>Text</code> component that the Button doesn't control, because it comes in through <code>children</code>. On the web, the Button would simply set a color and let it flow down to the label.</p>
<p>React Native Reusables solves this with React context. This is the code in <code>components/ui/text.tsx</code>:</p>
<pre><code class="language-tsx">const TextClassContext = React.createContext&lt;string | undefined&gt;(undefined);

function Text({ className, asChild = false, variant = 'default', ...props }) {
  const textClass = React.useContext(TextClassContext);
  const Component = asChild ? Slot : RNText;
  return (
    &lt;Component
      className={cn(textVariants({ variant }), textClass, className)}
      role={variant ? ROLE[variant] : undefined}
      aria-level={variant ? ARIA_LEVEL[variant] : undefined}
      {...props}
    /&gt;
  );
}
</code></pre>
<p>A parent component, such as Button, puts a class string into the context. Every <code>Text</code> inside it reads that string and applies it. Your own <code>className</code> still wins because it comes last in the <code>cn()</code> call.</p>
<p>The <code>Text</code> component also has a <code>variant</code> prop with options like <code>h1</code> to <code>h4</code>, <code>p</code>, <code>code</code>, <code>lead</code>, <code>large</code>, <code>small</code>, and <code>muted</code>. For example, <code>&lt;Text variant="muted"&gt;</code> gives you the mobile version of <code>text-muted-foreground text-sm</code>. The variants also set the <code>role</code> and <code>aria-level</code> props, so screen readers announce headings correctly without any extra work.</p>
<h3 id="heading-the-button-component">The Button Component</h3>
<p>The Button in <code>components/ui/button.tsx</code> uses <code>cva</code> (class-variance-authority) the same way the web version does. Here's a shortened version:</p>
<pre><code class="language-tsx">const buttonVariants = cva(
  cn(
    'group shrink-0 flex-row items-center justify-center gap-2 rounded-md shadow-none',
    Platform.select({
      web: "focus-visible:border-ring focus-visible:ring-ring/50 ... transition-all",
    })
  ),
  {
    variants: {
      variant: {
        default: cn(
          'bg-primary active:bg-primary/90 shadow-sm shadow-black/5',
          Platform.select({ web: 'hover:bg-primary/90' })
        ),
        /* ... */
      },
    },
  }
);
</code></pre>
<p>There are four differences from the web version:</p>
<ul>
<li><code>flex-row</code> <strong>is written out:</strong> React Native lays out children in a column by default, which is the opposite of CSS. Every horizontal layout in this tutorial includes <code>flex-row</code> for that reason.</li>
<li><code>active:</code> <strong>replaces</strong> <code>hover:</code><strong>:</strong> A finger can't really hover, so <code>active:</code> is the pressed state on a phone.</li>
<li><strong>Web-only classes are wrapped in</strong> <code>Platform.select</code><strong>:</strong> Classes such as <code>focus-visible:ring-[3px]</code> mean nothing in React Native, so they're only included in the web build.</li>
<li><strong>A</strong> <code>TextClassContext.Provider</code> <strong>wraps the</strong> <code>Pressable</code><strong>:</strong> This uses the context from the previous section, and it's why <code>&lt;Button variant="destructive"&gt;&lt;Text&gt;Delete&lt;/Text&gt;&lt;/Button&gt;</code> has white text automatically.</li>
</ul>
<h3 id="heading-the-icon-component">The Icon Component</h3>
<p>Icons from <code>lucide-react-native</code> take <code>color</code> and <code>size</code> as props, not as class names. The <code>Icon</code> component in <code>components/ui/icon.tsx</code> teaches Uniwind how to convert one into the other:</p>
<pre><code class="language-tsx">const StyledIcon = withUniwind(IconImpl, {
  size: { fromClassName: 'className', styleProperty: 'width' },
  color: { fromClassName: 'className', styleProperty: 'color' },
});
</code></pre>
<p>This code reads the <code>width</code> from the class string and passes it as <code>size</code>, and it reads the <code>color</code> and passes it as <code>color</code>. That's why <code>&lt;Icon as={PlusIcon} className="size-4 text-muted-foreground" /&gt;</code> works the way you would expect. <code>withUniwind</code> is useful any time you use a third-party component that takes props where you want to use classes.</p>
<p>You don't need to run anything for this step. The important part is understanding what <code>TextClassContext</code> is doing. Once that clicks, the other components in the registry are much easier to follow.</p>
<h2 id="heading-step-4-add-the-components-you-need">Step 4: Add the Components You Need</h2>
<p>We already have three components from the template. For the checkout screen, we need a few more. The CLI can add all of them in one go:</p>
<pre><code class="language-bash">pnpm dlx @react-native-reusables/cli@latest add card separator input label select switch badge alert-dialog avatar
</code></pre>
<p>The CLI detects that you're using Uniwind and writes the matching versions. It also installs any required <code>@rn-primitives</code> packages and adds a file called <code>native-only-animated-view.tsx</code>, which Select and AlertDialog use for their animations.</p>
<p>The output looks like this:</p>
<pre><code class="language-plaintext">ℹ Styling Library: Uniwind
✔ Created 10 files:
  - components/ui/separator.tsx
  - components/ui/input.tsx
  - components/ui/label.tsx
  - components/ui/switch.tsx
  - components/ui/avatar.tsx
  - components/ui/native-only-animated-view.tsx
  - components/ui/card.tsx
  - components/ui/badge.tsx
  - components/ui/select.tsx
  - components/ui/alert-dialog.tsx
ℹ Skipped 3 files: (files might be identical, use --overwrite to overwrite)
  - components/ui/text.tsx
  - components/ui/icon.tsx
  - components/ui/button.tsx
</code></pre>
<p>The CLI created ten files, which is more than the nine you asked for, because <code>native-only-animated-view.tsx</code> is a dependency of Select and AlertDialog. It skipped the three files that the template already gave you, so it does not overwrite your copies. This is the "you own the file" idea at work.</p>
<p>Run <code>pnpm dev</code> again to make sure the app still starts. The <code>components/ui/</code> folder should now contain thirteen files.</p>
<h2 id="heading-step-5-set-up-the-data-and-the-screen">Step 5: Set Up the Data and the Screen</h2>
<p>Before we start building the UI, let's add the sample menu data and decide where the screen state will live. I'm going to store prices in cents rather than floating-point numbers. This avoids calculation results such as <code>$4.7499999</code>.</p>
<p>We'll also keep the state in the main screen component and pass the relevant values down as props. That way, changing an item quantity or selecting the own-cup option automatically updates the bill.</p>
<p>Create <code>lib/menu.ts</code>:</p>
<pre><code class="language-tsx">export type CartItem = {
  id: string;
  name: string;
  detail: string;
  size?: 'Small' | 'Medium' | 'Large';
  emoji: string;
  /** Cents. Money in floats is how you end up charging someone $4.7499999. */
  unitPrice: number;
  qty: number;
};

export const INITIAL_CART: CartItem[] = [
  {
    id: 'flat-white',
    name: 'Flat white',
    detail: 'Oat milk, extra shot',
    size: 'Medium',
    emoji: '☕️',
    unitPrice: 475,
    qty: 2,
  },
  {
    id: 'cold-brew',
    name: 'Cold brew',
    detail: 'Hold the ice',
    size: 'Large',
    emoji: '🧋',
    unitPrice: 525,
    qty: 1,
  },
  {
    id: 'croissant',
    name: 'Almond croissant',
    detail: 'Warmed',
    emoji: '🥐',
    unitPrice: 390,
    qty: 1,
  },
];

export const PICKUP_TIMES = [
  { value: 'asap', label: 'As soon as it is ready' },
  { value: '15', label: 'In 15 minutes' },
  { value: '30', label: 'In 30 minutes' },
  { value: '60', label: 'In an hour' },
];

export const CUP_DISCOUNT = 25;
export const TAX_RATE = 0.0825;

export function money(cents: number) {
  const sign = cents &lt; 0 ? '-' : '';
  return `${sign}$${(Math.abs(cents) / 100).toFixed(2)}`;
}

export function billFor(items: CartItem[], ownCup: boolean) {
  const subtotal = items.reduce((sum, item) =&gt; sum + item.unitPrice * item.qty, 0);
  const discount = ownCup &amp;&amp; subtotal &gt; 0 ? CUP_DISCOUNT : 0;
  const tax = Math.round((subtotal - discount) * TAX_RATE);

  return { subtotal, discount, tax, total: subtotal - discount + tax };
}
</code></pre>
<p>The <code>PICKUP_TIMES</code> list is in this file instead of inside a component because two different components need it. You'll see why in Step 7.</p>
<h3 id="heading-the-screen-layout">The Screen Layout</h3>
<p>This is basically how <code>app/index.tsx</code> looks. The parts that don't exist yet are left as comments, and you'll write the full file later in Step 10.</p>
<pre><code class="language-tsx">&lt;View className="bg-background flex-1"&gt;
  &lt;ScrollView
    className="flex-1"
    contentContainerClassName="gap-4 p-4 pb-8"
    keyboardShouldPersistTaps="handled"
    automaticallyAdjustKeyboardInsets&gt;
    {/* the three cards */}
  &lt;/ScrollView&gt;

  &lt;CheckoutBar {...} /&gt;
&lt;/View&gt;
</code></pre>
<p>There are three details in this layout that you should examine more closely:</p>
<ul>
<li>First, <code>className</code> on a <code>ScrollView</code> styles the scroll container, not its content. This is the class name version of the <code>style</code> and <code>contentContainerStyle</code> split in React Native. If you put padding in <code>className</code>, it gets clipped, and <code>gap</code> has no effect. Use <code>contentContainerClassName</code> for anything that should apply to the content. Uniwind adds this prop to <code>ScrollView</code>.</li>
<li>There's no <code>space-y-4</code>. In CSS, <code>space-y-*</code> works by selecting siblings, and React Native has no selectors. Use <code>gap-4</code> on the parent instead, which React Native supports on iOS, Android, and the web.</li>
<li><code>keyboardShouldPersistTaps="handled"</code> keeps taps working while the keyboard is open. Without it, the first tap after the keyboard appears only closes the keyboard, so the user has to tap twice. <code>automaticallyAdjustKeyboardInsets</code> is an iOS feature that scrolls the content so the focused input isn't hidden behind the keyboard.</li>
</ul>
<p>At this stage, you should see only a header with the new title and an empty page below it:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5e0f2c2e490269cb30227a2b/02188707-8809-4cc8-bc1d-1a79330bebd3.png" alt="An empty screen with the new header title" style="display: block;" width="600" height="400" loading="lazy">

<h2 id="heading-step-6-build-the-order-rows">Step 6: Build the Order Rows</h2>
<p>The order row has quite a bit going on in a small space: an avatar, product name, size badge, price, and quantity controls. Long names make this especially tricky on a phone, so we'll handle the layout carefully.</p>
<p>Create <code>components/cart/order-row.tsx</code>:</p>
<pre><code class="language-tsx">import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { Text } from '@/components/ui/text';
import { money, type CartItem } from '@/lib/menu';
import { MinusIcon, PlusIcon, Trash2Icon } from 'lucide-react-native';
import { View } from 'react-native';

type OrderRowProps = {
  item: CartItem;
  onChangeQty: (id: string, qty: number) =&gt; void;
};

export function OrderRow({ item, onChangeQty }: OrderRowProps) {
  const lastOne = item.qty &lt;= 1;

  return (
    &lt;View className="flex-row gap-3"&gt;
      &lt;Avatar alt={item.name} className="size-14 rounded-2xl"&gt;
        &lt;AvatarFallback className="bg-muted rounded-2xl"&gt;
          &lt;Text className="text-2xl"&gt;{item.emoji}&lt;/Text&gt;
        &lt;/AvatarFallback&gt;
      &lt;/Avatar&gt;

      &lt;View className="flex-1 gap-2"&gt;
        &lt;View className="flex-row items-start gap-3"&gt;
          &lt;View className="flex-1 gap-1"&gt;
            &lt;View className="flex-row items-center gap-2"&gt;
              &lt;Text numberOfLines={1} className="shrink font-medium"&gt;
                {item.name}
              &lt;/Text&gt;
              {item.size ? (
                &lt;Badge variant="secondary"&gt;
                  &lt;Text&gt;{item.size}&lt;/Text&gt;
                &lt;/Badge&gt;
              ) : null}
            &lt;/View&gt;
            &lt;Text numberOfLines={1} variant="muted"&gt;
              {item.detail}
            &lt;/Text&gt;
          &lt;/View&gt;
          &lt;Text className="text-sm font-medium tabular-nums"&gt;
            {money(item.unitPrice * item.qty)}
          &lt;/Text&gt;
        &lt;/View&gt;

        &lt;View className="border-border flex-row items-center gap-1 self-start rounded-full border p-0.5"&gt;
          &lt;Button
            size="icon"
            variant="ghost"
            hitSlop={8}
            className="size-7 rounded-full"
            accessibilityLabel={lastOne ? `Remove ${item.name}` : `One fewer ${item.name}`}
            onPress={() =&gt; onChangeQty(item.id, item.qty - 1)}&gt;
            &lt;Icon as={lastOne ? Trash2Icon : MinusIcon} className="size-4" /&gt;
          &lt;/Button&gt;
          &lt;Text className="w-5 text-center text-sm font-medium tabular-nums"&gt;{item.qty}&lt;/Text&gt;
          &lt;Button
            size="icon"
            variant="ghost"
            hitSlop={8}
            className="size-7 rounded-full"
            accessibilityLabel={`One more ${item.name}`}
            onPress={() =&gt; onChangeQty(item.id, item.qty + 1)}&gt;
            &lt;Icon as={PlusIcon} className="size-4" /&gt;
          &lt;/Button&gt;
        &lt;/View&gt;
      &lt;/View&gt;
    &lt;/View&gt;
  );
}
</code></pre>
<p>A few details in this component deserve a closer look.</p>
<p>First, <code>Avatar</code> requires the <code>alt</code> prop. TypeScript won't compile without it. The value becomes the accessibility label, so a screen reader says "Flat white" instead of just "image".</p>
<p>The radius is repeated on the avatar fallback. <code>Avatar</code> and <code>AvatarFallback</code> are each <code>rounded-full</code> by default. If you only set <code>rounded-2xl</code> on <code>Avatar</code>, the emoji background stays a circle and pokes out of the rounded square.</p>
<p>You should also use <code>numberOfLines={1}</code> instead of <code>truncate</code>. Tailwind's <code>truncate</code> relies on <code>text-overflow: ellipsis</code>, which React Native doesn't support, and Uniwind doesn't convert it. The class compiles to nothing, so a long product name pushes the price off the screen. The <code>numberOfLines</code> prop is the React Native way to do this, and it also works in the web build.</p>
<p>The name has <code>shrink</code> and its column has <code>flex-1</code>. Without <code>shrink</code>, a long name won't get smaller and will push the badge out of the row. This is regular Flexbox behavior, but it shows up more on a phone because there's less width to work with.</p>
<p><code>hitSlop={8}</code> makes small buttons easier to tap. Apple recommends touch targets of at least 44 points and Android recommends 48dp, but a <code>size-7</code> button is only 28 points wide. The <code>hitSlop</code> prop makes the touchable area larger without changing how the button looks. There's no CSS equivalent.</p>
<p>Finally, <code>tabular-nums</code> keeps digits the same width. In most fonts, the digit <code>1</code> is narrower than <code>8</code>, so prices would shift sideways every time the quantity changes. Uniwind converts <code>tabular-nums</code> to React Native's <code>fontVariant</code>, so it works on iOS, Android, and the web.</p>
<p>After adding this component and rendering it inside a Card (which you'll do in Step 10), you should see three rows:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5e0f2c2e490269cb30227a2b/d361c2e7-8eaa-466d-aed1-7b162b6ace95.png" alt="The order rows inside a card" style="display: block;" width="600" height="400" loading="lazy">

<p>Tapping plus on the flat white should change its price from <code>$9.50</code> to <code>$14.25</code>. Tapping the trash icon on an item with a quantity of one should remove the row. The minus button turns into a trash icon whenever the quantity is one.</p>
<h2 id="heading-step-7-build-the-pickup-options">Step 7: Build the Pickup Options</h2>
<p>The pickup card brings together three controls: a Select, an Input, and a Switch. The input and switch are fairly straightforward. The Select needs a little more attention because it has three platform-related quirks, and two of them can fail silently.</p>
<p>Create <code>components/cart/pickup-card.tsx</code>:</p>
<pre><code class="language-tsx">import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Text } from '@/components/ui/text';
import { PICKUP_TIMES } from '@/lib/menu';
import { View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';

type PickupCardProps = {
  pickupValue: string;
  onPickupValueChange: (value: string) =&gt; void;
  name: string;
  onNameChange: (value: string) =&gt; void;
  ownCup: boolean;
  onOwnCupChange: (value: boolean) =&gt; void;
};

export function PickupCard({
  pickupValue,
  onPickupValueChange,
  name,
  onNameChange,
  ownCup,
  onOwnCupChange,
}: PickupCardProps) {
  const insets = useSafeAreaInsets();
  // Look the label up from our own data. The Option handed back by onValueChange
  // does not carry a usable label on web. See the note below.
  const selected = PICKUP_TIMES.find((time) =&gt; time.value === pickupValue);
  const contentInsets = {
    top: insets.top,
    bottom: insets.bottom,
    left: 16,
    right: 16,
  };

  return (
    &lt;View className="gap-5"&gt;
      &lt;View className="gap-2"&gt;
        &lt;Label nativeID="pickup-time"&gt;Pickup time&lt;/Label&gt;
        &lt;Select
          value={selected}
          onValueChange={(option) =&gt; {
            if (option) {
              onPickupValueChange(option.value);
            }
          }}&gt;
          &lt;SelectTrigger className="w-full" aria-labelledby="pickup-time"&gt;
            &lt;SelectValue placeholder="Pick a time" /&gt;
          &lt;/SelectTrigger&gt;
          &lt;SelectContent insets={contentInsets} className="w-full"&gt;
            {PICKUP_TIMES.map((time) =&gt; (
              &lt;SelectItem key={time.value} value={time.value} label={time.label} /&gt;
            ))}
          &lt;/SelectContent&gt;
        &lt;/Select&gt;
      &lt;/View&gt;

      &lt;View className="gap-2"&gt;
        &lt;Label nativeID="order-name"&gt;Name for the order&lt;/Label&gt;
        &lt;Input
          aria-labelledby="order-name"
          value={name}
          onChangeText={onNameChange}
          placeholder="Who is picking it up?"
          autoCapitalize="words"
          autoComplete="name"
          returnKeyType="done"
        /&gt;
      &lt;/View&gt;

      &lt;View className="flex-row items-center justify-between gap-4"&gt;
        &lt;View className="flex-1 gap-1"&gt;
          &lt;Label nativeID="own-cup" onPress={() =&gt; onOwnCupChange(!ownCup)}&gt;
            Bringing my own cup
          &lt;/Label&gt;
          &lt;Text variant="muted"&gt;Takes 25¢ off and saves a lid.&lt;/Text&gt;
        &lt;/View&gt;
        &lt;Switch nativeID="own-cup" checked={ownCup} onCheckedChange={onOwnCupChange} /&gt;
      &lt;/View&gt;
    &lt;/View&gt;
  );
}
</code></pre>
<h3 id="heading-problem-1-portals-and-the-portalhost">Problem 1: Portals and the PortalHost</h3>
<p>When a dropdown opens, it needs to appear above the other content on the screen. For that reason, <code>SelectContent</code> isn't rendered in the same place where you declare it in the component tree. It uses <code>@rn-primitives/portal</code> to render the content in another part of the app. This technique is called a portal.</p>
<p>On the web, a portal renders into <code>document.body</code>, and every page has one. React Native has nothing like that. So the portal package keeps a list of portal contents in memory, and a component called <code>PortalHost</code> reads that list and displays it. This is a simplified version of that code from <code>node_modules/@rn-primitives/portal/dist/index.js</code>:</p>
<pre><code class="language-jsx">var DEFAULT_PORTAL_HOST = "INTERNAL_PRIMITIVE_DEFAULT_HOST_NAME";
var updatePortal = (hostName, name, children) =&gt; { /* writes into the map */ };
function PortalHost({ name = DEFAULT_PORTAL_HOST }) { /* reads the map */ }
</code></pre>
<p>If no <code>PortalHost</code> is mounted, the content is saved but never displayed. The dropdown opens into nothing, and you get no error or warning.</p>
<p>The template already includes a <code>PortalHost</code> at the bottom of the root layout:</p>
<pre><code class="language-tsx">&lt;ThemeProvider value={NAV_THEME[theme ?? 'light']}&gt;
  &lt;StatusBar style={theme === 'dark' ? 'light' : 'dark'} /&gt;
  &lt;Stack /&gt;
  &lt;PortalHost /&gt;
&lt;/ThemeProvider&gt;
</code></pre>
<p>It looks like unnecessary code, but you need to keep it. The AlertDialog in Step 10 uses it too.</p>
<h3 id="heading-problem-2-safe-area-insets">Problem 2: Safe Area Insets</h3>
<p>There's another issue to consider when showing a dropdown on a phone. Parts of the screen are reserved for the operating system, such as the area around the notch and the home indicator. If a dropdown opens near the bottom of the screen, one of its options could appear underneath the home indicator. In that area, a tap may be handled by the operating system instead of your app.</p>
<p>To avoid this, pass the safe area insets to the content:</p>
<pre><code class="language-tsx">const insets = useSafeAreaInsets();
const contentInsets = { top: insets.top, bottom: insets.bottom, left: 16, right: 16 };

&lt;SelectContent insets={contentInsets} className="w-full"&gt;
</code></pre>
<p>The <code>insets</code> prop is part of <code>PositionedContentProps</code> in <code>@rn-primitives/types</code>, so every positioned component in the registry accepts it, including Popover, DropdownMenu, HoverCard, Tooltip, and Select. It's a good habit to pass it every time.</p>
<h3 id="heading-problem-3-the-select-value-is-an-object">Problem 3: The Select Value Is an Object</h3>
<p>One more thing to watch out for is the value returned by <code>onValueChange</code>. It may look like you're selecting a string, but the callback returns an <code>Option</code> object:</p>
<pre><code class="language-tsx">type Option = { value: string; label: string } | undefined;
</code></pre>
<p>This causes a bug that only appears on one platform. If you store the whole <code>Option</code> in state and show <code>option.label</code> later, such as in a confirmation dialog, it displays correctly on a phone ("In 30 minutes") but incorrectly in the browser ("30").</p>
<p>The reason is in the primitive's source code. This is the native version, from <code>select.js</code>:</p>
<pre><code class="language-jsx">onValueChange({ value: itemValue, label });
</code></pre>
<p>And this is the web version, from <code>select.web.js</code>. It uses Radix, which only returns a value:</p>
<pre><code class="language-jsx">onValueChange({ value: val, label: val });
</code></pre>
<p>So the label is correct on native, but on the web it's a copy of the value. If you only test in a simulator, you won't notice this problem.</p>
<p>The fix is to keep only the value in state and look up the label from your own list:</p>
<pre><code class="language-tsx">const selected = PICKUP_TIMES.find((time) =&gt; time.value === pickupValue);
</code></pre>
<p>This is why <code>PICKUP_TIMES</code> is in <code>lib/menu.ts</code>. The pickup card needs it to build the options, and the main screen needs it to write the confirmation message.</p>
<h3 id="heading-making-the-label-tappable">Making the Label Tappable</h3>
<p>The switch itself is quite small, so users may tap the label next to it instead. It makes sense to let that label toggle the switch as well. You can do that by adding an <code>onPress</code> handler to the <code>Label</code>:</p>
<pre><code class="language-tsx">&lt;Label nativeID="own-cup" onPress={() =&gt; onOwnCupChange(!ownCup)}&gt;
  Bringing my own cup
&lt;/Label&gt;
&lt;Switch nativeID="own-cup" checked={ownCup} onCheckedChange={onOwnCupChange} /&gt;
</code></pre>
<p>Giving both elements the same <code>nativeID</code> connects them for screen readers. The <code>onPress</code> on the <code>Label</code> is what makes the text toggle the switch, because React Native has no automatic <code>&lt;label for&gt;</code> behavior.</p>
<p>While you're here, open <code>components/ui/switch.tsx</code>. On the web, shadcn colors a switch with <code>data-[state=checked]:bg-primary</code>, which reads an attribute set in the DOM. React Native has no <code>data-*</code> attributes, so the component checks the prop in JavaScript:</p>
<pre><code class="language-tsx">props.checked ? 'bg-primary' : 'bg-input dark:bg-input/80',
</code></pre>
<p>The result is the same. Keep this in mind if you copy a <code>data-[state=...]</code> selector from a web example and it has no effect.</p>
<h3 id="heading-keyboard-props-for-the-name-field">Keyboard Props for the Name Field</h3>
<p><code>Input</code> is a <code>TextInput</code> with your border and radius applied. You should set these four props on every text input:</p>
<ul>
<li><code>autoCapitalize="words"</code> capitalizes each word, which suits a name.</li>
<li><code>autoComplete="name"</code> lets the operating system offer to fill in the name.</li>
<li><code>returnKeyType="done"</code> changes the return key label to "Done".</li>
<li><code>keyboardType="number-pad"</code> shows a numeric keyboard when you need digits.</li>
</ul>
<p>Together with <code>automaticallyAdjustKeyboardInsets</code> on the ScrollView from Step 5, this keeps the field visible while the keyboard is open. If you need more control, <code>react-native-keyboard-controller</code> is already a dependency of the template, and it provides <code>KeyboardAwareScrollView</code> and <code>KeyboardProvider</code>.</p>
<p>When you open the dropdown, it should appear right under the trigger at the same width, with a check mark next to the current value:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5e0f2c2e490269cb30227a2b/d5234c02-19f7-4938-b889-24be5f13c175.png" alt="The open pickup time dropdown" style="display: block;" width="600" height="400" loading="lazy">

<p>Try picking a time and check that the trigger updates. Tap the words "Bringing my own cup" and confirm the switch flips. Type in the name field and confirm the keyboard doesn't cover it. If the dropdown opens and shows nothing, check your <code>PortalHost</code>.</p>
<h2 id="heading-step-8-build-the-bill-component">Step 8: Build the Bill Component</h2>
<p>The bill component is pretty small, but there are two details you should pay particular attention to here, especially when the values change while someone is editing their order.</p>
<p>Create <code>components/cart/bill.tsx</code>:</p>
<pre><code class="language-tsx">import { Separator } from '@/components/ui/separator';
import { Text } from '@/components/ui/text';
import { cn } from '@/lib/utils';
import { money } from '@/lib/menu';
import { View } from 'react-native';

type BillProps = {
  subtotal: number;
  discount: number;
  tax: number;
  total: number;
};

function Line({ label, value, tone }: { label: string; value: number; tone?: 'muted' | 'credit' }) {
  return (
    &lt;View className="flex-row items-center justify-between"&gt;
      &lt;Text className={cn('text-sm', tone === 'muted' &amp;&amp; 'text-muted-foreground')}&gt;{label}&lt;/Text&gt;
      &lt;Text
        className={cn(
          'text-sm tabular-nums',
          tone === 'muted' &amp;&amp; 'text-muted-foreground',
          tone === 'credit' &amp;&amp; 'text-foreground'
        )}&gt;
        {money(value)}
      &lt;/Text&gt;
    &lt;/View&gt;
  );
}

export function Bill({ subtotal, discount, tax, total }: BillProps) {
  return (
    &lt;View className="gap-2"&gt;
      &lt;Line label="Subtotal" value={subtotal} tone="muted" /&gt;
      {discount &gt; 0 ? &lt;Line label="Own cup" value={-discount} tone="credit" /&gt; : null}
      &lt;Line label="Tax" value={tax} tone="muted" /&gt;
      &lt;Separator className="my-1" /&gt;
      &lt;View className="flex-row items-center justify-between"&gt;
        &lt;Text className="font-medium"&gt;Total&lt;/Text&gt;
        &lt;Text className="text-lg font-semibold tabular-nums"&gt;{money(total)}&lt;/Text&gt;
      &lt;/View&gt;
    &lt;/View&gt;
  );
}
</code></pre>
<p>First, use <code>tabular-nums</code> on every number. Right-aligned prices in a proportional font don't line up at the decimal point, and using <code>tabular-nums</code> fixes that, as in the order rows.</p>
<p>Second, only show the discount row when it applies. The code uses <code>{discount &gt; 0 ? &lt;Line /&gt; : null}</code> instead of <code>{discount &amp;&amp; &lt;Line /&gt;}</code>. In JSX, if the left side of <code>&amp;&amp;</code> is the number <code>0</code>, React renders the <code>0</code>. In React Native, a bare number outside a <code>Text</code> component throws the error <code>Text strings must be rendered within a &lt;Text&gt; component</code>. Using a boolean check and returning <code>null</code> avoids this whole category of bugs.</p>
<p>To test it, turn on the own cup switch and scroll down. An <strong>Own cup</strong> line should appear with <code>-$0.25</code>, and the tax should change from <code>$1.54</code> to <code>$1.52</code>, because tax is calculated on the discounted subtotal:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5e0f2c2e490269cb30227a2b/5fd0c79e-fcf4-4d7e-9129-bfb1e4d120d5.png" alt="The bill with the own cup discount applied" style="display: block;" width="600" height="400" loading="lazy">

<p>The total changes from <code>$20.19</code> to <code>$19.92</code>, which is 27 cents lower instead of 25. The extra two cents come from the lower tax.</p>
<h2 id="heading-step-9-build-the-fixed-checkout-bar">Step 9: Build the Fixed Checkout Bar</h2>
<p>I want the total and checkout button to remain visible while the user scrolls through the cart. On the web, you might reach for <code>position: sticky</code> and <code>bottom: 0</code>. React Native doesn't work that way, but we can get the same result with a simple flex layout.</p>
<p>You can get the same result with Flexbox. This is the layout from Step 5 again:</p>
<pre><code class="language-tsx">&lt;View className="bg-background flex-1"&gt;
  &lt;ScrollView className="flex-1" ...&gt;{/* content */}&lt;/ScrollView&gt;
  &lt;CheckoutBar ... /&gt;
&lt;/View&gt;
</code></pre>
<p>The outer <code>View</code> is a column. The <code>ScrollView</code> has <code>flex-1</code>, so it takes all the leftover height. The checkout bar is a sibling placed after it, so it sits at the bottom and doesn't scroll. You don't need any positioning or z-index.</p>
<h3 id="heading-handling-the-home-indicator">Handling the Home Indicator</h3>
<p>On phones without a home button, the bottom 34 points or so belong to the system. A button drawn there is hard or impossible to press. To avoid this, add the bottom safe area inset as padding.</p>
<pre><code class="language-tsx">const insets = useSafeAreaInsets();

&lt;View
  className="border-border bg-background flex-row items-center gap-4 border-t px-4 pt-3"
  style={{ paddingBottom: insets.bottom + 12 }}&gt;
</code></pre>
<p>The bottom padding is an inline <code>style</code> instead of a class because its value depends on the device and is only known when the app runs. Using <code>className</code> and <code>style</code> on the same element is fine, and this is the case it's meant for.</p>
<p>On devices with no gesture bar, and in a browser tab, <code>insets.bottom</code> is 0, so the extra <code>12</code> keeps the bar from looking cramped.</p>
<p>Create the full component in <code>components/cart/checkout-bar.tsx</code>. It includes the confirmation dialog, which Step 10 explains.</p>
<pre><code class="language-tsx">import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
  AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { Text } from '@/components/ui/text';
import { money } from '@/lib/menu';
import * as Haptics from 'expo-haptics';
import { ArrowRightIcon } from 'lucide-react-native';
import { View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';

type CheckoutBarProps = {
  total: number;
  itemCount: number;
  pickupLabel: string;
  name: string;
  onConfirm: () =&gt; void;
};

export function CheckoutBar({ total, itemCount, pickupLabel, name, onConfirm }: CheckoutBarProps) {
  const insets = useSafeAreaInsets();
  const empty = itemCount === 0;

  function confirm() {
    // expo-haptics ships a web implementation that falls back to navigator.vibrate,
    // so this needs no platform guard.
    Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
    onConfirm();
  }

  return (
    &lt;View
      className="border-border bg-background flex-row items-center gap-4 border-t px-4 pt-3"
      style={{ paddingBottom: insets.bottom + 12 }}&gt;
      &lt;View className="flex-1"&gt;
        &lt;Text variant="muted" className="text-xs"&gt;
          {empty ? 'Nothing in the cart' : `${itemCount} ${itemCount === 1 ? 'item' : 'items'}`}
        &lt;/Text&gt;
        &lt;Text className="text-xl font-semibold tabular-nums"&gt;{money(total)}&lt;/Text&gt;
      &lt;/View&gt;

      &lt;AlertDialog&gt;
        &lt;AlertDialogTrigger asChild&gt;
          &lt;Button size="lg" disabled={empty}&gt;
            &lt;Text&gt;Place order&lt;/Text&gt;
            &lt;Icon as={ArrowRightIcon} /&gt;
          &lt;/Button&gt;
        &lt;/AlertDialogTrigger&gt;
        &lt;AlertDialogContent&gt;
          &lt;AlertDialogHeader&gt;
            &lt;AlertDialogTitle&gt;Send this to the counter?&lt;/AlertDialogTitle&gt;
            &lt;AlertDialogDescription&gt;
              {`${money(total)} for ${name.trim() || 'pickup'}, ${pickupLabel.toLowerCase()}. We start making it right away, so it cannot be cancelled after this.`}
            &lt;/AlertDialogDescription&gt;
          &lt;/AlertDialogHeader&gt;
          &lt;AlertDialogFooter&gt;
            &lt;AlertDialogCancel&gt;
              &lt;Text&gt;Not yet&lt;/Text&gt;
            &lt;/AlertDialogCancel&gt;
            &lt;AlertDialogAction onPress={confirm}&gt;
              &lt;Text&gt;Send it&lt;/Text&gt;
            &lt;/AlertDialogAction&gt;
          &lt;/AlertDialogFooter&gt;
        &lt;/AlertDialogContent&gt;
      &lt;/AlertDialog&gt;
    &lt;/View&gt;
  );
}
</code></pre>
<h3 id="heading-a-note-on-thin-borders">A Note on Thin Borders</h3>
<p><code>border-t</code> draws a line that's one logical point thick, which is two or three physical pixels on most modern screens. Next to a true one-pixel line, it looks heavy. React Native's <code>StyleSheet.hairlineWidth</code> gives you the thinnest line the screen can draw, and the template connects it to Tailwind with this line in <code>global.css</code>:</p>
<pre><code class="language-css">--spacing-hairline: hairlineWidth();
</code></pre>
<p>This adds <code>hairline</code> to Tailwind's spacing scale, so <code>h-hairline</code> gives you a divider that's one physical pixel tall. The Common Mistakes section later explains one catch when you try to use it as an override.</p>
<p>That's the checkout bar in place. Scroll to the bottom of the screen and check that it stays below the bill:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5e0f2c2e490269cb30227a2b/a0c4a55c-a723-4a00-b6ff-c46e75c27b30.png" alt="The checkout bar at the bottom after scrolling down" style="display: block;" width="600" height="400" loading="lazy">

<p>Scroll back to the top and the bar stays in the same place:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5e0f2c2e490269cb30227a2b/5af1750b-9b8c-422d-9273-6b2321096e73.png" alt="The checkout bar at the bottom after scrolling up" style="display: block;" width="600" height="400" loading="lazy">

<p>On a phone with a notch, the button should be above the gesture bar. If you remove all items with the trash buttons, the button should be disabled and the label should say "Nothing in the cart".</p>
<h2 id="heading-step-10-add-the-confirmation-and-put-the-screen-together">Step 10: Add the Confirmation and Put the Screen Together</h2>
<p>The checkout bar already contains the confirmation dialog. Before putting the full screen together, there are two differences between the React Native implementation and the web version that are worth looking at.</p>
<h3 id="heading-1-it-also-needs-the-portalhost">1. It Also Needs the <code>PortalHost</code></h3>
<p>On iOS, it also wraps itself in <code>FullWindowOverlay</code> from <code>react-native-screens</code>, so it draws above the native navigation header instead of under it. This is handled inside <code>alert-dialog.tsx</code>, which is why that file imports something that looks unrelated.</p>
<h3 id="heading-2-the-footer-reverses-its-order-on-phones">2. The Footer Reverses its Order on Phones</h3>
<p><code>AlertDialogFooter</code> uses <code>flex-col-reverse</code> and only becomes a row at the <code>sm:</code> breakpoint. On a phone, the button you write last is shown on top. Writing Cancel first and Action second puts the confirm button where the thumb is and the cancel button below it.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5e0f2c2e490269cb30227a2b/0187ff24-1e34-414c-8189-e6f816f64d48.png" alt="The confirmation dialog" style="display: block;" width="600" height="400" loading="lazy">

<h3 id="heading-haptics">Haptics</h3>
<p>When the customer confirms the order, the <code>confirm</code> function calls <code>Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)</code>. This gives a short success vibration on supported devices.</p>
<p>You might expect to wrap this call in a platform check because browsers don't have the same haptics support as native apps. In this case, <code>expo-haptics</code> already includes a web implementation that uses <code>navigator.vibrate</code>, with a fallback for iOS Safari. If the browser can't provide haptics, the call simply does nothing, so we don't need an extra platform check.</p>
<p><code>notificationAsync</code> is meant for outcomes such as success or failure. <code>impactAsync</code> is for physical collisions, and <code>selectionAsync</code> is for moving through a list of options. Using the wrong one won't cause an error, but it feels slightly off.</p>
<h3 id="heading-the-full-screen">The Full Screen</h3>
<p>Now we can bring everything together in the main screen. This is where we'll keep the state, calculate the bill using <code>billFor</code>, and pass the required values and callbacks to each component.</p>
<p>When an order is placed, it sets <code>orderNumber</code>, and the screen shows a confirmation instead. Inside that confirmation, <code>&lt;Stack.Screen options&gt;</code> changes the header title to "Order sent" without navigating anywhere, so the whole flow stays in one file and one route.</p>
<p>Replace <code>app/index.tsx</code> with:</p>
<pre><code class="language-tsx">import { Bill } from '@/components/cart/bill';
import { CheckoutBar } from '@/components/cart/checkout-bar';
import { OrderRow } from '@/components/cart/order-row';
import { PickupCard } from '@/components/cart/pickup-card';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Icon } from '@/components/ui/icon';
import { Separator } from '@/components/ui/separator';
import { Text } from '@/components/ui/text';
import { billFor, INITIAL_CART, PICKUP_TIMES, type CartItem } from '@/lib/menu';
import { Stack } from 'expo-router';
import { CircleCheckIcon, MoonStarIcon, SunIcon } from 'lucide-react-native';
import * as React from 'react';
import { ScrollView, View } from 'react-native';
import { Uniwind, useUniwind } from 'uniwind';

const SCREEN_OPTIONS = {
  title: 'Your order',
  headerRight: () =&gt; &lt;ThemeToggle /&gt;,
};

export default function CartScreen() {
  const [items, setItems] = React.useState&lt;CartItem[]&gt;(INITIAL_CART);
  const [pickupValue, setPickupValue] = React.useState(PICKUP_TIMES[0].value);
  const [name, setName] = React.useState('');
  const [ownCup, setOwnCup] = React.useState(false);
  const [orderNumber, setOrderNumber] = React.useState&lt;string | null&gt;(null);

  const bill = billFor(items, ownCup);
  const itemCount = items.reduce((count, item) =&gt; count + item.qty, 0);
  const pickupLabel =
    PICKUP_TIMES.find((time) =&gt; time.value === pickupValue)?.label ?? PICKUP_TIMES[0].label;

  function changeQty(id: string, qty: number) {
    setItems((current) =&gt;
      qty &lt;= 0
        ? current.filter((item) =&gt; item.id !== id)
        : current.map((item) =&gt; (item.id === id ? { ...item, qty } : item))
    );
  }

  function placeOrder() {
    setOrderNumber(`A-${Math.floor(Math.random() * 90 + 10)}`);
  }

  function startOver() {
    setItems(INITIAL_CART);
    setOwnCup(false);
    setName('');
    setPickupValue(PICKUP_TIMES[0].value);
    setOrderNumber(null);
  }

  if (orderNumber) {
    return (
      &lt;&gt;
        &lt;Stack.Screen options={{ ...SCREEN_OPTIONS, title: 'Order sent' }} /&gt;
        &lt;View className="bg-background flex-1 items-center justify-center gap-6 p-8"&gt;
          &lt;Icon as={CircleCheckIcon} className="text-primary size-16" /&gt;
          &lt;View className="gap-2"&gt;
            &lt;Text variant="h3" className="text-center"&gt;
              We are on it
            &lt;/Text&gt;
            &lt;Text variant="muted" className="text-center"&gt;
              {`Order ${orderNumber} for ${name.trim() || 'pickup'}. Come to the far end of the counter and look for your name on the cup.`}
            &lt;/Text&gt;
          &lt;/View&gt;
          &lt;Button variant="outline" onPress={startOver}&gt;
            &lt;Text&gt;Start another order&lt;/Text&gt;
          &lt;/Button&gt;
        &lt;/View&gt;
      &lt;/&gt;
    );
  }

  return (
    &lt;&gt;
      &lt;Stack.Screen options={SCREEN_OPTIONS} /&gt;
      &lt;View className="bg-background flex-1"&gt;
        &lt;ScrollView
          className="flex-1"
          contentContainerClassName="gap-4 p-4 pb-8"
          keyboardShouldPersistTaps="handled"
          automaticallyAdjustKeyboardInsets&gt;
          &lt;Card className="gap-3 py-4"&gt;
            &lt;CardHeader className="px-4"&gt;
              &lt;CardTitle&gt;Fernwood Coffee&lt;/CardTitle&gt;
              &lt;CardDescription&gt;112 Marine Drive, counter pickup&lt;/CardDescription&gt;
            &lt;/CardHeader&gt;
            &lt;CardContent className="gap-4 px-4"&gt;
              {items.length === 0 ? (
                &lt;Text variant="muted"&gt;Your cart is empty. Add something warm.&lt;/Text&gt;
              ) : null}
              {items.map((item, index) =&gt; (
                &lt;React.Fragment key={item.id}&gt;
                  {index &gt; 0 ? &lt;Separator /&gt; : null}
                  &lt;OrderRow item={item} onChangeQty={changeQty} /&gt;
                &lt;/React.Fragment&gt;
              ))}
            &lt;/CardContent&gt;
          &lt;/Card&gt;

          &lt;Card className="gap-3 py-4"&gt;
            &lt;CardHeader className="px-4"&gt;
              &lt;CardTitle&gt;Pickup&lt;/CardTitle&gt;
            &lt;/CardHeader&gt;
            &lt;CardContent className="px-4"&gt;
              &lt;PickupCard
                pickupValue={pickupValue}
                onPickupValueChange={setPickupValue}
                name={name}
                onNameChange={setName}
                ownCup={ownCup}
                onOwnCupChange={setOwnCup}
              /&gt;
            &lt;/CardContent&gt;
          &lt;/Card&gt;

          &lt;Card className="gap-3 py-4"&gt;
            &lt;CardHeader className="px-4"&gt;
              &lt;CardTitle&gt;Bill&lt;/CardTitle&gt;
            &lt;/CardHeader&gt;
            &lt;CardContent className="px-4"&gt;
              &lt;Bill {...bill} /&gt;
            &lt;/CardContent&gt;
          &lt;/Card&gt;
        &lt;/ScrollView&gt;

        &lt;CheckoutBar
          total={bill.total}
          itemCount={itemCount}
          pickupLabel={pickupLabel}
          name={name}
          onConfirm={placeOrder}
        /&gt;
      &lt;/View&gt;
    &lt;/&gt;
  );
}

const THEME_ICONS = {
  light: SunIcon,
  dark: MoonStarIcon,
};

function ThemeToggle() {
  const { theme } = useUniwind();

  return (
    &lt;Button
      onPressIn={() =&gt; Uniwind.setTheme(theme === 'dark' ? 'light' : 'dark')}
      size="icon"
      variant="ghost"
      className="ios:size-9 web:mx-4 rounded-full"
      accessibilityLabel="Switch theme"&gt;
      &lt;Icon as={THEME_ICONS[theme ?? 'light']} className="size-5" /&gt;
    &lt;/Button&gt;
  );
}
</code></pre>
<p>Notice that <code>pickupLabel</code> is the fix from Step 7 in use. The screen looks up the label from <code>PICKUP_TIMES</code> and passes a plain string to <code>CheckoutBar</code>, so <code>CheckoutBar</code> never touches an <code>Option</code> object or the incorrect web label.</p>
<p>To test it, place an order. The dialog should show your name and the pickup time you chose (such as "in 30 minutes"), not the raw value <code>30</code>. Confirm the order, and you should see the confirmation screen:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5e0f2c2e490269cb30227a2b/36e526dd-72aa-4ed6-84f0-ae16abf720a2.png" alt="The order sent screen" style="display: block;" width="600" height="400" loading="lazy">

<p>Tap "Start another order" and the cart returns to its original state.</p>
<p>The checkout screen is now working, and each part is kept in its own component. You can use the same approach when you build other screens: keep the component focused, pass the data it needs through props, and let the shared theme handle the styling.</p>
<h2 id="heading-step-11-add-dark-mode">Step 11: Add Dark Mode</h2>
<p>We already added the theme toggle in Step 10. Here's the component again so we can look at how it switches between light and dark mode:</p>
<pre><code class="language-tsx">import { Uniwind, useUniwind } from 'uniwind';

function ThemeToggle() {
  const { theme } = useUniwind();

  return (
    &lt;Button
      onPressIn={() =&gt; Uniwind.setTheme(theme === 'dark' ? 'light' : 'dark')}
      size="icon"
      variant="ghost"
      className="ios:size-9 web:mx-4 rounded-full"
      accessibilityLabel="Switch theme"&gt;
      &lt;Icon as={THEME_ICONS[theme ?? 'light']} className="size-5" /&gt;
    &lt;/Button&gt;
  );
}
</code></pre>
<p><code>Uniwind.setTheme</code> changes the values behind every token, so everything that uses <code>bg-card</code>, <code>text-muted-foreground</code>, and similar classes updates at once. <code>useUniwind</code> subscribes the component to the current theme, so the icon changes too.</p>
<p>This code also uses the <code>ios:</code> and <code>web:</code> prefixes. Uniwind supports platform variants, so <code>web:mx-4</code> only adds margin in the browser, and <code>ios:size-9</code> only applies on iOS. It's similar to <code>Platform.select</code>, but it stays inside the class string.</p>
<p>Three things aren't handled by <code>setTheme</code>, and if you forget them, you end up with a screen that's only partly dark. The template already sets up the first two.</p>
<h3 id="heading-1-the-navigation-header">1. The Navigation Header</h3>
<p>React Navigation takes its colors from a plain object, so the root layout needs to pass it the right one:</p>
<pre><code class="language-tsx">const { theme } = useUniwind();

&lt;ThemeProvider value={NAV_THEME[theme ?? 'light']}&gt;
</code></pre>
<h3 id="heading-2-the-status-bar">2. The Status Bar</h3>
<p>The clock and battery icons are drawn by the operating system, so you need to tell it which style to use. Without this, you can get dark text on a dark bar.</p>
<pre><code class="language-tsx">&lt;StatusBar style={theme === 'dark' ? 'light' : 'dark'} /&gt;
</code></pre>
<h3 id="heading-3-saving-the-choice">3. Saving the Choice</h3>
<p><code>Uniwind.setTheme</code> only applies to the current session. The next time the app starts, it follows the system setting again. To keep the user's choice, save it with <code>expo-secure-store</code> or <code>AsyncStorage</code> and call <code>setTheme</code> when the app starts. You can call <code>setTheme('system')</code> to go back to following the operating system.</p>
<p>When you tap the header icon, the cards, borders, inputs, switch, header, and status bar should all change together. You can explore more ready-made <a href="https://shadcnstudio.com/blocks">shadcn blocks</a> if you want examples of how these components are used in larger interfaces:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5e0f2c2e490269cb30227a2b/4e54a370-5335-42fe-95df-8eee7b033e78.png" alt="The screen in dark mode" style="display: block;" width="600" height="400" loading="lazy">

<p>Look for anything that stays light while the rest goes dark. A light header usually means the <code>ThemeProvider</code> is not receiving the new <code>NAV_THEME</code>.</p>
<h2 id="heading-step-12-create-your-own-theme">Step 12: Create Your Own Theme</h2>
<p>The screen is currently using the default gray theme. Let's change it to a coffee-inspired palette with warm cream backgrounds and brown accents. This will also help us see why the palette needs to be updated in both <code>global.css</code> and <code>lib/theme.ts</code>.</p>
<h3 id="heading-update-the-tokens">Update the Tokens</h3>
<p>Replace the <code>@layer theme</code> block in <code>global.css</code> with this coffee palette:</p>
<pre><code class="language-css">@layer theme {
  :root {
    @variant light {
      --color-background: oklch(0.98 0.008 75);
      --color-foreground: oklch(0.24 0.02 60);
      --color-card: oklch(1 0.003 90);
      --color-card-foreground: oklch(0.24 0.02 60);
      --color-popover: oklch(1 0.003 90);
      --color-popover-foreground: oklch(0.24 0.02 60);
      --color-primary: oklch(0.44 0.06 45);
      --color-primary-foreground: oklch(0.99 0.01 85);
      --color-secondary: oklch(0.93 0.03 80);
      --color-secondary-foreground: oklch(0.35 0.05 45);
      --color-muted: oklch(0.95 0.012 80);
      --color-muted-foreground: oklch(0.52 0.02 60);
      --color-accent: oklch(0.92 0.04 78);
      --color-accent-foreground: oklch(0.32 0.05 45);
      --color-destructive: oklch(0.577 0.245 27.325);
      --color-border: oklch(0.89 0.02 75);
      --color-input: oklch(0.89 0.02 75);
      --color-ring: oklch(0.44 0.06 45);
    }

    @variant dark {
      --color-background: oklch(0.17 0.012 60);
      --color-foreground: oklch(0.96 0.008 85);
      --color-card: oklch(0.22 0.016 62);
      --color-card-foreground: oklch(0.96 0.008 85);
      --color-popover: oklch(0.22 0.016 62);
      --color-popover-foreground: oklch(0.96 0.008 85);
      --color-primary: oklch(0.85 0.09 75);
      --color-primary-foreground: oklch(0.22 0.03 55);
      --color-secondary: oklch(0.29 0.022 62);
      --color-secondary-foreground: oklch(0.94 0.01 85);
      --color-muted: oklch(0.27 0.018 62);
      --color-muted-foreground: oklch(0.72 0.015 75);
      --color-accent: oklch(0.31 0.03 65);
      --color-accent-foreground: oklch(0.95 0.01 85);
      --color-destructive: oklch(0.704 0.191 22.216);
      --color-border: oklch(1 0 0 / 12%);
      --color-input: oklch(1 0 0 / 15%);
      --color-ring: oklch(0.85 0.09 75);
    }
  }
}
</code></pre>
<p>If you don't want to choose thirty colors by hand, you can generate a theme with the <a href="https://shadcnstudio.com/theme-generator">shadcn theme generator</a> or <a href="https://tweakcn.com/">tweakcn</a>. Both export CSS variables, but you need to make two changes before the output works in this project:</p>
<ol>
<li>Rename each variable to include the <code>--color-</code> prefix. For example, <code>--primary</code> becomes <code>--color-primary</code>.</li>
<li>Move the <code>:root</code> block inside <code>@variant light</code>, and the <code>.dark</code> block inside <code>@variant dark</code>.</li>
</ol>
<p>Also remove <code>--radius</code> from what you paste, because the template already defines it along with the derived sizes. Remove any <code>--shadow-*</code> variables too, since nothing in the registry reads them.</p>
<h3 id="heading-update-libthemets">Update lib/theme.ts</h3>
<p>After you save and reload, the result looks like this:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5e0f2c2e490269cb30227a2b/eb94958b-f7d3-4d64-9703-2b9153e4ea6f.png" alt="The new theme with a white header" style="display: block;" width="600" height="400" loading="lazy">

<p>The page is cream, the button is brown, and the badges have the right color, but the header is still white with a visible line under it. The reason is that React Navigation draws the header using <code>lib/theme.ts</code>, and that file still has the old palette. Nothing is broken and nothing warns you, so this mistake is easy to miss. If you're designing the same interface in Figma, the <a href="https://shadcnstudio.com/figma">shadcn Figma resources</a> can also help you keep the design and implementation aligned.</p>
<p>Update <code>lib/theme.ts</code> to match:</p>
<pre><code class="language-tsx">import { DarkTheme, DefaultTheme, type Theme } from 'expo-router/react-navigation';

export const THEME = {
  light: {
    background: '#fcf8f3',
    foreground: '#261d16',
    card: '#fffffd',
    primary: '#6e4836',
    border: '#e3d9cd',
    destructive: '#e7000b',
    /* the rest of the list */
  },
  dark: {
    background: '#130e0a',
    foreground: '#f4f1ec',
    card: '#201913',
    primary: '#f1c68b',
    border: '#3a3129',
    destructive: '#ff6467',
    /* the rest of the list */
  },
};

export const NAV_THEME: Record&lt;'light' | 'dark', Theme&gt; = {
  light: {
    ...DefaultTheme,
    colors: {
      background: THEME.light.background,
      border: THEME.light.border,
      card: THEME.light.background,
      notification: THEME.light.destructive,
      primary: THEME.light.primary,
      text: THEME.light.foreground,
    },
  },
  dark: {
    ...DarkTheme,
    colors: {
      background: THEME.dark.background,
      border: THEME.dark.border,
      card: THEME.dark.background,
      notification: THEME.dark.destructive,
      primary: THEME.dark.primary,
      text: THEME.dark.foreground,
    },
  },
};
</code></pre>
<p>This isn't a direct copy of <code>global.css</code>, so three points need explanation:</p>
<ul>
<li><strong>Use hex colors instead of</strong> <code>oklch()</code><strong>:</strong> Uniwind converts <code>oklch()</code> for your classes, but React Navigation does not, so give it a format that React Native can read directly. You can convert a color with any online converter, or paste the <code>oklch()</code> value into your browser's developer tools color picker and read the hex value.</li>
<li><strong>Map React Navigation's</strong> <code>card</code> <strong>to your</strong> <code>background</code><strong>, not your</strong> <code>card</code><strong>.</strong> The two names look the same but mean different things. In shadcn, <code>card</code> is a raised surface on top of the page. In React Navigation, <code>card</code> is the background of the screen and header. If you map them by name, the header ends up a shade lighter than the page. You won't notice this with the stock template because it uses the same value for <code>card</code> and <code>background</code>. It only shows up once you create a theme where cards stand out from the page, like this one.</li>
<li><strong>Use a solid color in place of a transparent border.</strong> In dark mode, <code>--color-border</code> is <code>oklch(1 0 0 / 12%)</code>, which is white at 12% opacity. A navigation theme needs a solid color, so work out what that border looks like on your background and use that value.</li>
</ul>
<p>After this change, the header and page match in both light and dark mode, and you didn't edit any component files:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5e0f2c2e490269cb30227a2b/f24b38ac-9742-44d3-92d6-2061477bc314.png" alt="The final theme with a matching header" style="display: block;" width="600" height="400" loading="lazy">

<img src="https://cdn.hashnode.com/uploads/covers/5e0f2c2e490269cb30227a2b/8627fc51-5083-4f8f-bae4-370f860b389e.png" alt="The final theme in the other mode" style="display: block;" width="600" height="400" loading="lazy">

<p>Switch between light and dark a few times and watch the header. If it's a shade lighter or darker than the page, one of the values in <code>lib/theme.ts</code> is still from the old palette.</p>
<h2 id="heading-common-mistakes-and-how-to-fix-them">Common Mistakes and How to Fix Them</h2>
<p>If you're coming from web development, some of these differences may catch you off guard. I've collected the issues covered in the tutorial in the table below, along with the fix for each one. You can refer back to it when something doesn't behave as expected.</p>
<table>
<thead>
<tr>
<th>What you do</th>
<th>What happens</th>
<th>The fix</th>
</tr>
</thead>
<tbody><tr>
<td><code>&lt;Badge&gt;Medium&lt;/Badge&gt;</code></td>
<td>Red screen: "Text strings must be rendered within a <code>&lt;Text&gt;</code>."</td>
<td>Wrap the text: <code>&lt;Badge&gt;&lt;Text&gt;Medium&lt;/Text&gt;&lt;/Badge&gt;</code>. The same applies to <code>Button</code>.</td>
</tr>
<tr>
<td><code>{count &amp;&amp; &lt;Line/&gt;}</code> when <code>count</code> is <code>0</code></td>
<td>The same error, caused by a stray <code>0</code></td>
<td><code>{count &gt; 0 ? &lt;Line/&gt; : null}</code></td>
</tr>
<tr>
<td><code>space-y-3</code> on a column</td>
<td>Nothing happens and there is no warning</td>
<td>Use <code>gap-3</code> on the parent</td>
</tr>
<tr>
<td><code>className="p-4"</code> on a <code>ScrollView</code></td>
<td>Padding is clipped and gaps are ignored</td>
<td>Use <code>contentContainerClassName</code></td>
</tr>
<tr>
<td><code>truncate</code> on a long name</td>
<td>The text isn't truncated and the price is pushed off screen</td>
<td>Use <code>numberOfLines={1}</code></td>
</tr>
<tr>
<td>Using <code>flex</code> and expecting a row</td>
<td>Items stack vertically</td>
<td>React Native defaults to a column, so add <code>flex-row</code></td>
</tr>
<tr>
<td><code>hover:bg-accent</code></td>
<td>Nothing happens on a device</td>
<td>Use <code>active:</code> for the pressed state</td>
</tr>
<tr>
<td>Deleting <code>PortalHost</code> from the layout</td>
<td>Dropdowns open into nothing, with no error</td>
<td>Put it back</td>
</tr>
<tr>
<td>A Select near the bottom of the screen</td>
<td>The last option is under the home indicator</td>
<td>Pass <code>insets</code> to <code>SelectContent</code></td>
</tr>
<tr>
<td>Showing <code>option.label</code> from <code>onValueChange</code></td>
<td>Correct on native, but the raw value on the web</td>
<td>Look up the label from your own data</td>
</tr>
<tr>
<td>A bottom bar with only <code>p-4</code></td>
<td>The button is under the gesture bar</td>
<td>Use <code>paddingBottom: insets.bottom + 12</code> in <code>style</code></td>
</tr>
<tr>
<td>28-point stepper buttons</td>
<td>Hard to tap with a thumb</td>
<td>Add <code>hitSlop={8}</code></td>
</tr>
<tr>
<td>Changing only <code>global.css</code> for a new theme</td>
<td>The header keeps the old colors</td>
<td>Update <code>lib/theme.ts</code> too</td>
</tr>
<tr>
<td><code>&lt;Avatar&gt;</code> without <code>alt</code></td>
<td>TypeScript won't compile</td>
<td><code>alt</code> is required, and it becomes the accessibility label</td>
</tr>
<tr>
<td><code>rounded-2xl</code> on <code>Avatar</code> only</td>
<td>A circular fallback inside rounded corners</td>
<td>Repeat it on <code>AvatarFallback</code></td>
</tr>
<tr>
<td>Guarding haptics with <code>Platform.OS !== 'web'</code></td>
<td>A working web fallback is removed</td>
<td><code>expo-haptics</code> already handles the web</td>
</tr>
<tr>
<td>Right-aligned prices in a normal font</td>
<td>Digits shift whenever the number changes</td>
<td>Use <code>tabular-nums</code>, which also works on native</td>
</tr>
<tr>
<td><code>&lt;Separator className="h-hairline" /&gt;</code></td>
<td>Both height classes are kept</td>
<td>See below</td>
</tr>
</tbody></table>
<p>The last row needs more explanation, because it looks like a bug in <code>cn()</code>, but it's not.</p>
<p><code>cn()</code> uses <code>tailwind-merge</code>, which resolves conflicts between classes. It knows the default Tailwind scale, but it doesn't know about custom values you declare in <code>global.css</code>. The value <code>hairline</code> comes from <code>--spacing-hairline: hairlineWidth()</code>, so <code>tailwind-merge</code> can't tell that <code>h-hairline</code> and <code>h-[1px]</code> set the same property, and it keeps both:</p>
<pre><code class="language-jsx">cn('h-[1px] w-full', 'h-hairline my-1')  // 'h-[1px] w-full h-hairline my-1'
cn('h-[1px] w-full', 'h-2 my-1')         // 'w-full h-2 my-1'
</code></pre>
<p>The second result is correct. In the first, which height wins depends on the order of the CSS. The other overrides in this tutorial, such as <code>size-14</code> over <code>size-8</code>, work correctly because those are names <code>tailwind-merge</code> recognizes. If you want it to understand your custom scale, you can teach it with <code>extendTailwindMerge</code> in <code>lib/utils.ts</code>. Otherwise, use an arbitrary value, or edit the component file directly, since you own it.</p>
<p>There's a related case in the stepper buttons. They pass <code>size-7</code> to a <code>Button</code> whose icon size is <code>h-10 w-10 sm:h-9 sm:w-9</code>:</p>
<pre><code class="language-jsx">cn('h-10 w-10 sm:h-9 sm:w-9', 'size-7 rounded-full')
// 'sm:h-9 sm:w-9 size-7 rounded-full'
</code></pre>
<p><code>size-7</code> replaces the base <code>h-10 w-10</code>, but the two <code>sm:</code> classes stay, because <code>tailwind-merge</code> treats a responsive variant as a different class. On a phone this makes no difference, since <code>sm:</code> starts at 640px and phones are narrower than that. In a desktop browser, though, those buttons grow to 36 points.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this tutorial, we built a coffee shop cart and checkout screen with React Native Reusables, Expo, and Uniwind. We added the order rows, pickup options, bill summary, fixed checkout bar, and confirmation dialog. We also looked at how the registry and CLI add component source code to the project and how the theme tokens control the app's colors.</p>
<p>One thing worth getting into the habit of is checking the component source when something doesn't behave the way you expect. Since the components live in your project, you can open them and see how they work. That's how we understood the difference in the Select label between web and native. It also makes customization easier because you can see which part of the component needs to change instead of adding more workarounds around it.</p>
<p>You can find the complete project in the <a href="https://github.com/shadcnstudio/shadcn-react-native-app">GitHub repository</a>. If you run into an issue or want to compare your code with the finished screen, clone the repository and check the implementation. If you're looking for more complete starting points for your next project, take a look at <a href="https://shadcnstudio.com/templates">shadcn templates</a>.</p>
<p>I captured the screenshots using the React Native Web build at a phone-sized viewport. The same project also runs on iOS and Android. I've called out the platform differences in the relevant sections, including the Select label and portal host in Step 7, safe area handling in Step 9, and haptics in Step 10.</p>
<h3 id="heading-resources">Resources</h3>
<p><strong>React Native Reusables</strong></p>
<ul>
<li><a href="https://reactnativereusables.com/">reactnativereusables.com</a>: documentation and the registry</li>
<li><a href="https://reactnativereusables.com/docs">Introduction</a>: the list of differences from web shadcn</li>
<li><a href="https://reactnativereusables.com/docs/cli">CLI reference</a>: the <code>init</code>, <code>add</code>, and <code>doctor</code> commands</li>
<li><a href="https://reactnativereusables.com/docs/create-your-own-registry">Create your own registry</a>: publish components your team can add with the CLI</li>
<li><a href="https://github.com/founded-labs/react-native-reusables">The repository</a>: MIT licensed</li>
<li>Any registry entry is a public URL: <code>reactnativereusables.com/r/uniwind/&lt;name&gt;.json</code> or <code>.../r/nativewind/&lt;name&gt;.json</code></li>
</ul>
<p><strong>Tools used</strong></p>
<ul>
<li><a href="https://rnprimitives.com/">RN Primitives</a></li>
<li><a href="https://docs.uniwind.dev/">Uniwind</a></li>
<li><a href="https://www.nativewind.dev/">Nativewind</a></li>
<li><a href="https://docs.expo.dev/router/introduction/">Expo Router</a></li>
<li><a href="https://ui.shadcn.com/">shadcn/ui</a> and the <a href="https://ui.shadcn.com/docs/registry">registry specification</a></li>
</ul>
<p><strong>APIs used</strong></p>
<ul>
<li><a href="https://github.com/appandflow/react-native-safe-area-context"><code>useSafeAreaInsets</code></a></li>
<li><a href="https://docs.expo.dev/versions/latest/sdk/haptics/"><code>expo-haptics</code></a></li>
<li><a href="https://reactnative.dev/docs/view#hitslop"><code>hitSlop</code></a></li>
<li><a href="https://reactnative.dev/docs/text#numberoflines"><code>numberOfLines</code></a></li>
<li><a href="https://github.com/dcastil/tailwind-merge"><code>tailwind-merge</code></a></li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/color_value/oklch"><code>oklch()</code></a></li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ What a CAPTCHA Farm Attack Actually Looks Like ]]>
                </title>
                <description>
                    <![CDATA[ CAPTCHAs are something most of us have come across while browsing the internet. Sometimes we're asked to identify traffic lights in pictures, solve a small puzzle, or simply tick a box to confirm we'r ]]>
                </description>
                <link>https://www.freecodecamp.org/news/captcha-farm-attack/</link>
                <guid isPermaLink="false">6aeba47df91e889efe09a17a</guid>
                
                    <category>
                        <![CDATA[ cybersecurity ]]>
                    </category>
                
                    <category>
                        <![CDATA[ captcha ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Ethical Hacking ]]>
                    </category>
                
                    <category>
                        <![CDATA[ internet security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ online fraud ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Reetain Raina ]]>
                </dc:creator>
                <pubDate>Wed, 04 Nov 2026 21:32:45 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/392975d9-8fb2-4311-bbd9-8ec42af322ef.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>CAPTCHAs are something most of us have come across while browsing the internet. Sometimes we're asked to identify traffic lights in pictures, solve a small puzzle, or simply tick a box to confirm we're human. Websites use these challenges to help distinguish real users from automated bots.</p>
<p>But there's an interesting problem with this approach. What happens when a bot doesn't need to solve the CAPTCHA itself because a real person can solve it for them?</p>
<p>This is how CAPTCHA farms work. Attackers use services where real people solve CAPTCHA challenges on behalf of automated programs. The person completing the challenge is human, but the activity happening behind it may still be controlled by a bot. This makes it harder for websites to identify suspicious requests, even when their CAPTCHA system appears to be working correctly.</p>
<p>Here, we'll look at how CAPTCHA farm attacks work, why attackers use human solvers, and what makes these attacks difficult to detect. We'll also go through some of the security measures developers can use to identify suspicious activity and better protect their websites.</p>
<h3 id="heading-what-well-cover">What We'll Cover:</h3>
<ul>
<li><a href="#heading-what-is-a-captcha-farm-attack">What Is a CAPTCHA Farm Attack?</a></li>
<li><a href="#heading-behind-the-scenes-the-four-part-infrastructure">Behind the Scenes: The Four-Part Infrastructure</a></li>
<li><a href="#heading-how-an-attack-unfolds-a-step-by-step-look">How an Attack Unfolds: A Step-by-Step Look</a></li>
<li><a href="#heading-why-attackers-rely-on-human-solvers">Why Attackers Rely on Human Solvers</a></li>
<li><a href="#heading-what-captcha-farms-target">What CAPTCHA Farms Target</a></li>
<li><a href="#heading-why-these-attacks-are-difficult-to-stop">Why These Attacks Are Difficult to Stop</a></li>
<li><a href="#heading-how-websites-can-defend-themselves">How Websites Can Defend Themselves</a><ul>
<li><a href="#heading-behavioral-telemetry-and-biometrics">Behavioral Telemetry and Biometrics</a></li>
<li><a href="#heading-network-and-device-fingerprinting">Network and Device Fingerprinting</a></li>
<li><a href="#heading-modern-rate-limiting-models">Modern Rate-Limiting Models</a></li>
<li><a href="#heading-post-authentication-anomaly-detection">Post-Authentication Anomaly Detection</a></li>
</ul>
</li>
<li><a href="#heading-security-beyond-the-checkbox">Security Beyond the Checkbox</a></li>
<li><a href="#heading-conclusion">Conclusion</a></li>
</ul>
<h2 id="heading-what-is-a-captcha-farm-attack">What Is a CAPTCHA Farm Attack?</h2>
<p>To understand how these attacks work, it helps to distinguish between the two primary ways attackers deal with verification gates: machine-driven recognition and human-driven outsourcing.</p>
<p>In an automated CAPTCHA-solving attack, an attacker uses computer vision models, optical character recognition (OCR), or neural networks to parse the challenge image and guess the answer without human intervention.</p>
<p>This approach is fast and computationally cheap, but it frequently fails when security vendors update their visual distortions, introduce interactive logic puzzles, or assess human-like mouse kinematics.</p>
<p>A CAPTCHA farm attack takes the opposite route. Instead of attempting to outsmart the defensive algorithm with software, the attacker routes the challenge to a human solving service. These commercial operations maintain large networks of manual workers who solve puzzles continuously around the clock. By inserting a human into the loop, the attacker converts what should be a technical roadblock into an outsourced labor pipeline.</p>
<p>The attack doesn't break the cryptography or reverse-engineer the underlying challenge code. Instead, it relies on a CAPTCHA relay attack: software manages the speed and scale of the attack, while real human eyes provide the cognitive validation the website expects.</p>
<h2 id="heading-behind-the-scenes-the-four-part-infrastructure">Behind the Scenes: The Four-Part Infrastructure</h2>
<p>A functional CAPTCHA farm is neither an ad-hoc group of rogue hackers nor an obscure black-box script. It's a commercial, multi-tiered infrastructure built around standard web development protocols.</p>
<p>Four distinct components work together to make the relay happen in a matter of seconds.</p>
<p>First is the attacker’s automated script. This client bot typically runs headlessly via browser automation tools like Puppeteer, Playwright, or custom HTTP client libraries. The script navigates to a target platform, such as a user registration portal, and fills in usernames, passwords, and demographic fields at machine speed until it hits a defensive challenge.</p>
<p>Second is the CAPTCHA-solving platform's API. Commercial solver platforms act as brokers between abuse operators and manual laborers. When the attacker's script encounters a challenge, it intercepts the site's public key, the challenge URL, and the visual assets, packaging them into an HTTP POST request directed at the solving service’s application programming interface (API).</p>
<p>Third is the distributed human workforce. Workers access these services through dedicated web dashboards, mobile apps, or browser plug-ins. When the API receives a task from an attacker, it pushes the image directly onto a worker’s screen. The worker identifies the bicycles or fire hydrants, types the distorted characters, or drags the puzzle slider into place, earning a microscopic payout, often ranging from $0.50 to $2.00 per thousand puzzles solved.</p>
<p>Fourth is the payload delivery back to the target. Once the worker submits the correct solution, the broker API returns the generated verification token back to the attacker’s script. The script injects this validation token into the target website's hidden form field and fires the final submission request. To the receiving server, the request bears an authentic verification signature generated by a real human session.</p>
<h2 id="heading-how-an-attack-unfolds-a-step-by-step-look">How an Attack Unfolds: A Step-by-Step Look</h2>
<p>To see how these parts interact in practice, consider a credential-stuffing or mass-account registration campaign against an online service.</p>
<ol>
<li><strong>Initiation:</strong> The attacker's script dispatches an HTTP request to establish a session with the target registration page, populating the input fields with generated credentials.</li>
<li><strong>Challenge trigger:</strong> The target application evaluates the incoming request. Detecting an unfamiliar IP address or an incomplete browser fingerprint, it triggers a CAPTCHA modal, pausing the registration sequence until a valid solution token is supplied.</li>
<li><strong>Extraction and dispatch:</strong> The bot reads the page DOM, captures the puzzle iframe parameters, site key, and challenge payload, and forwards this data to the CAPTCHA farm API.</li>
<li><strong>Human execution:</strong> The farm's queuing system allocates the challenge to an active human worker. The worker completes the puzzle within an average response window of 10 to 25 seconds.</li>
<li><strong>Token retrieval:</strong> The worker’s response yields an official validation token from the CAPTCHA provider's origin server. The solving farm's API returns this signed string to the attacker’s script.</li>
<li><strong>Form finalization:</strong> The script injects the token into the page's response payload (such as g-recaptcha-response) and submits the form. The website’s backend queries the CAPTCHA provider's verification API, receives a confirmation that the challenge was successfully passed, and creates the account.</li>
</ol>
<p>The entire loop completes well before typical session timeouts kick in, leaving the target server with no obvious visual anomaly to flag.</p>
<h2 id="heading-why-attackers-rely-on-human-solvers">Why Attackers Rely on Human Solvers</h2>
<p>Building and maintaining custom neural networks to break modern visual challenges requires constant development effort. Defensive providers frequently alter noise patterns, camera angles, distortion algorithms and semantic puzzles specifically to break automated image classifiers.</p>
<p>Human solvers bypass this arms race entirely because the human visual cortex processes semantic context effortlessly. A person instantly understands what constitutes a "motorcycle," even if the image is cropped awkwardly, partially obscured by street foliage, or rendered under strange lighting.</p>
<p>This human adaptability is supported by extensive security research. In a foundational study, <a href="https://www.researchgate.net/publication/333536967_Towards_understanding_the_security_of_modern_image_captchas_and_underground_captcha-solving_services"><u>Towards Understanding the Security of Modern Image Captchas and Underground Captcha-Solving Services</u></a>, researchers analyzed the commercial ecosystem across 152 solving platforms. They found that human-solving services achieved near-flawless accuracy rates across complex, diverse image challenges where purely automated computer vision solvers repeatedly struggled or degraded over time.</p>
<p>Outsourcing the problem is also remarkably inexpensive. Because labor pools are drawn from regions with low living costs, attackers can bypass thousands of protective checkpoints for less than the price of a cup of coffee. The workflow is highly scalable: software handles millions of connections, while human labor is summoned on-demand only for the few seconds required to clear a barrier.</p>
<h2 id="heading-what-captcha-farms-target">What CAPTCHA Farms Target</h2>
<p>CAPTCHA farms rarely serve as self-contained attacks. Instead, they act as an operational enabler for wider abusive campaigns across the web.</p>
<p>First, they enable mass fake account creation. Malicious actors generate millions of disposable profiles on social media, dating networks, and developer platforms to distribute phishing links, spam search engines, or run coordinated disinformation campaigns.</p>
<p>They also facilitate credential stuffing. When attackers test databases of leaked usernames and passwords against banking or retail portals, defensive systems usually throw a CAPTCHA after a handful of failed attempts. Solver APIs allow the credential-testing bots to continue cycling through login credentials uninterrupted.</p>
<p>Inventory hoarding and scalping is another common use case. High-demand retail drops, concert tickets, and travel bookings are frequent targets. Security researchers tracking real-world bot traffic at <a href="https://datadome.co/threat-research/how-datadome-stopped-a-captcha-farm-behind-18-million-ticket-scalping-attempts/"><u>DataDome</u></a> documented an operation where an attacker combined bot automation with a CAPTCHA farm to orchestrate 18 million automated ticket-scalping attempts, successfully bypassing front-line interactive verification.</p>
<p>And they enable data scraping at scale. Web crawlers harvesting competitive pricing, directory databases, or intellectual property frequently utilize solver APIs whenever protected search and discovery pages present challenge gates.</p>
<p>In every case, the CAPTCHA farm doesn't execute the underlying abuse: it merely removes the specific verification tripwire meant to halt it.</p>
<h2 id="heading-why-these-attacks-are-difficult-to-stop">Why These Attacks Are Difficult to Stop</h2>
<p>Stopping CAPTCHA farms is inherently difficult because they exploit the core assumption behind standard challenge-response security: that the entity answering the challenge is the entity making the request.</p>
<p>This systemic blind spot is analyzed in the research paper <a href="https://link.springer.com/chapter/10.1007/978-3-319-66399-9_24"><u>On the Security and Usability of Dynamic Cognitive Game CAPTCHAs</u></a>, which evaluated how puzzle formats hold up against relay systems. The study demonstrated that regardless of how cognitively engaging, gamified, or technically dynamic a test is, relay architectures successfully decouple cognitive processing from the requesting client. The server receives a mathematically legitimate, human-generated solution token, making the puzzle itself effectively irrelevant.</p>
<p>Also, attackers route their client requests through residential proxy networks. The initial HTTP request comes from one residential IP address, the CAPTCHA is rendered by an automated browser mimicking common hardware profiles, and the solution is completed remotely. The defender’s server can't verify the physical proximity between the solver and the requesting client, creating an asymmetric advantage for the attacker.</p>
<h2 id="heading-how-websites-can-defend-themselves">How Websites Can Defend Themselves</h2>
<p>Because human solvers can easily clear visual and cognitive challenges, treating a solved puzzle as a blanket guarantee of trustworthiness is no longer a viable defensive strategy. Web applications require a defense-in-depth model that monitors behaviors before, during, and long after a verification challenge appears.</p>
<h3 id="heading-behavioral-telemetry-and-biometrics">Behavioral Telemetry and Biometrics</h3>
<p>Automated scripts can easily spoof screen resolutions and user-agent strings, but reproducing organic human cursor movement across an entire page requires far more work. Real users demonstrate micro-deviations, variable acceleration curves, pauses, and continuous mouse tremors.</p>
<p>When a CAPTCHA farm attack takes place, there's often a distinct behavioral mismatch: the form fields are filled out instantaneously or via scripted keyboard events, followed by an abrupt pause while the remote worker solves the puzzle, followed by an instantaneous token injection. Monitoring telemetry across the whole lifecycle of the page exposes this artificial stitching.</p>
<h3 id="heading-network-and-device-fingerprinting">Network and Device Fingerprinting</h3>
<p>Defenders must assess context beyond the single page interaction. Inspecting TCP/IP packet behavior, TLS fingerprints (JA3/JA4 fingerprints), HTTP/2 frame parameters, and device APIs reveals whether the underlying runtime environment is a standard consumer browser or an orchestrated automation tool running inside a data center.</p>
<h3 id="heading-modern-rate-limiting-models">Modern Rate-Limiting Models</h3>
<p>Relying entirely on puzzle solving often introduces accessibility barriers for real users while doing little to stop funded attackers. As explored in research published by the Association for Computing Machinery regarding <a href="https://dl.acm.org/doi/10.1145/3638759"><u>mCaptcha and Proof-of-Work Rate Limiting</u></a>, defensive architectures can enforce cryptographic proof of work puzzles at the transport layer.</p>
<p>By forcing the connecting client's CPU to compute complex mathematical problems proportional to the platform's current traffic load, defense systems raise the economic and computational cost of running automated campaigns, neutralizing attacks without demanding manual human puzzle-solving.</p>
<h3 id="heading-post-authentication-anomaly-detection">Post-Authentication Anomaly Detection</h3>
<p>Passing an authentication challenge should never grant unconditional trust. Web architectures should monitor post-login activity for anomalous actions:</p>
<ul>
<li>Does an account immediately attempt hundreds of data queries per minute right after registering?</li>
<li>Does it immediately navigate directly to high-value purchase endpoints without loading associated asset stylesheets or images?</li>
<li>Does the session change IP ranges and geographic locations between the initial page load and the final transaction?</li>
</ul>
<p>Identifying these signals allows security teams to invalidate sessions even if the initial challenge was solved cleanly.</p>
<h2 id="heading-security-beyond-the-checkbox">Security Beyond the Checkbox</h2>
<p>CAPTCHA farms show why websites can't rely on a single security check to keep automated attacks away. Even when someone successfully completes a CAPTCHA, the request may still be part of an automated operation.</p>
<p>CAPTCHAs are designed to check whether a person can solve a particular challenge. But they can't tell whether that person is the actual user trying to access the website or someone solving the challenge on behalf of a bot. This is one of the reasons CAPTCHA farms can bypass a security measure that would normally stop automated traffic.</p>
<p>For developers, this means a completed CAPTCHA should be treated as just one part of the verification process. Websites also need to monitor user behavior, network activity, and suspicious requests to identify possible attacks. Combining these checks makes it easier to spot activity that a CAPTCHA alone might miss.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>CAPTCHA farms make it difficult for websites to tell whether a completed verification challenge actually came from a legitimate user. Even when a real person solves the puzzle, the request may still be part of an automated attack.</p>
<p>For developers, this means it's worth paying attention to what happens before and after a CAPTCHA is completed. Things like unusual login attempts, repeated requests, unexpected changes in user behavior, and suspicious account activity can provide useful clues.</p>
<p>Using CAPTCHA alongside rate limiting, behavioral monitoring, and other security checks can help websites identify these attacks more effectively. It's also important to review these protections regularly as attackers change how they operate.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Introducing Socrates and the New freeCodeCamp Forum ]]>
                </title>
                <description>
                    <![CDATA[ freeCodeCamp just turned 12 years old. And so did our beloved forum. And today I'm thrilled to say that the forum is now focused on discussions rather than Q&A. This is thanks to Socrates, a new tool  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/introducing-socrates-and-the-new-freecodecamp-forum/</link>
                <guid isPermaLink="false">6aeb864c3b9bd5f8621e17c4</guid>
                
                    <category>
                        <![CDATA[ freeCodeCamp.org ]]>
                    </category>
                
                    <category>
                        <![CDATA[ coding ]]>
                    </category>
                
                    <category>
                        <![CDATA[ learning ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Quincy Larson ]]>
                </dc:creator>
                <pubDate>Wed, 04 Nov 2026 19:23:56 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/6b482fb0-6ce1-4cd9-8241-e83e65c390f8.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>freeCodeCamp just turned 12 years old. And so did our beloved forum. And today I'm thrilled to say that the forum is now focused on discussions rather than Q&amp;A.</p>
<p>This is thanks to Socrates, a new tool we just launched. Socrates is built right into the freeCodeCamp curriculum. It can look at your code and give you hints to help you get unstuck.</p>
<p>When you ask Socrates for help, first it references the thousands of hints and test cases we've human-authored for the curriculum. And if none of those cover your situation, Socrates will come up with new hints to guide you.</p>
<p>Here're some photos of Socrates in action:</p>
<img src="https://cdn.hashnode.com/uploads/covers/66196241167088a3bb195cd6/e7025e4a-9172-4a6d-a82f-a53de6f4d712.png" alt="An image of Socrates at work" style="display: block;" width="600" height="400" loading="lazy">

<img src="https://cdn.hashnode.com/uploads/covers/5dfa3725066598ab2752cc5d/c5a7f2aa-25a7-4ed6-9b67-cd45b4572855.webp" alt="And another image of Socrates at work" style="display: block;" width="600" height="400" loading="lazy">

<p>Socrates is live now. Try it on the <a href="https://www.freecodecamp.org/learn">freeCodeCamp curriculum</a>.</p>
<h2 id="heading-socrates-frees-up-the-forum-to-serve-as-a-place-for-discussions">Socrates frees up the forum to serve as a place for discussions</h2>
<p>Since most people will be getting unstuck using Socrates, we think far fewer people will need to ask for help on the forum. So we can instead use the forum as a place to talk with fellow human beings about our hobbies, passions, studies, and goals.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5dfa3725066598ab2752cc5d/8df87b65-bd44-42a9-aba2-82751c314fff.png" alt="Screenshot of the forum" style="display: block;" width="600" height="400" loading="lazy">

<p>Over the years we've built up a super chill mod team. Folks from all over the world with a broad range of backgrounds. These knights and dames keep the vibe positive and productive, and keep out the bots and spammers.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5dfa3725066598ab2752cc5d/d5531680-3274-432f-9b18-db6135ae31c2.png" alt="screenshot of leaderboard for the past week" style="display: block;" width="600" height="400" loading="lazy">

<p>So here's what I want you to do.</p>
<ol>
<li>Read our <a href="https://www.freecodecamp.org/news/code-of-conduct/">85-word-long code of conduct</a>.</li>
<li><a href="https://forum.freecodecamp.org">Sign into the freeCodeCamp forum</a>, read some threads, reply to some threads, and start some threads of your own.</li>
</ol>
<p>As you use the forum, we'll trust you with more and more power there. More people will come to recognize you as a chill person who brings useful insights.</p>
<p>If you're looking for an alternative to divisive social media, a place where people talk WITH each other rather than PAST each other, well this is it. You've hit the jackpot.</p>
<p>I'm spending a lot of time there and hope you'll do the same.</p>
<p>Shout out to all the mods for helping everybody get the most out of the limited minutes in their day. <a href="https://forum.freecodecamp.org">See you on the forum</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Find and Fix Common Technical SEO Issues on Your Website ]]>
                </title>
                <description>
                    <![CDATA[ Let's say you've built a website. You've invested in the design, bought a domain, and so on. But a website isn't only what you see on the screen. A lot happens behind the scenes. You have to understan ]]>
                </description>
                <link>https://www.freecodecamp.org/news/fix-common-technical-seo-issues/</link>
                <guid isPermaLink="false">6aea2f4db1deb69c0283692a</guid>
                
                    <category>
                        <![CDATA[ SEO ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Beginner Developers ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Saar Twito ]]>
                </dc:creator>
                <pubDate>Tue, 03 Nov 2026 19:00:29 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/8d158690-136a-44a2-aa34-e24be77e004c.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Let's say you've built a website. You've invested in the design, bought a domain, and so on. But a website isn't only what you see on the screen. A lot happens behind the scenes. You have to understand how the internet works, as well as how Search Engine Optimization (SEO) works.</p>
<p>Speed is a key part of it, too. A slow site can have very weak SEO, because most people won't spend much time waiting for a page to load, especially these days.</p>
<p>Site speed is a well-known topic with many problems of its own to consider, like Largest Contentful Paint (LCP), Cumulative Layout Shift (CLS), and server speed. But I'll leave those for another article.</p>
<p>This one is about the problems that stop search engines from reaching and reading your pages.</p>
<p>In my opinion, technical SEO is much harder to apply than regular SEO, which focuses on content and keywords. The technical side usually requires a developer's knowledge, and might need some changes that can feel a bit uncomfortable if you don't have a technical background.</p>
<p>A technical problem can stay unnoticed for months, because you usually don't see it. It's not a design problem. You only find it when you run specific checks, for example with <a href="https://pagespeed.web.dev/">Google PageSpeed Insights</a>.</p>
<p>In this tutorial, you'll find and fix the most common ones on a small practice site. I didn't want to take a real website and shame its owners, so I built a practice site with the problems already in it.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><a href="#heading-prerequisites">Prerequisites</a></li>
<li><a href="#heading-what-is-technical-seo">What Is Technical SEO?</a></li>
<li><a href="#heading-how-common-are-these-problems-on-real-websites">How Common Are These Problems on Real Websites?</a></li>
<li><a href="#heading-how-to-set-up-the-practice-site">How to Set Up the Practice Site</a></li>
<li><a href="#heading-how-to-check-a-pages-http-status-code">How to Check a Page's HTTP Status Code</a><ul>
<li><a href="#heading-what-is-a-soft-404">What Is a Soft 404?</a></li>
</ul>
</li>
<li><a href="#heading-how-to-find-and-fix-redirect-chains">How to Find and Fix Redirect Chains</a></li>
<li><a href="#heading-how-to-make-sure-robotstxt-isnt-blocking-important-pages">How to Make Sure robots.txt Isn't Blocking Important Pages</a></li>
<li><a href="#heading-how-to-find-an-accidental-noindex">How to Find an Accidental noindex</a><ul>
<li><a href="#heading-how-to-check-the-robots-meta-tag">How to Check the Robots Meta Tag</a></li>
<li><a href="#heading-how-to-check-the-x-robots-tag-header">How to Check the X-Robots-Tag Header</a></li>
</ul>
</li>
<li><a href="#heading-how-to-use-canonical-tags-correctly">How to Use Canonical Tags Correctly</a></li>
<li><a href="#heading-how-to-fix-missing-and-duplicate-titles-and-meta-descriptions">How to Fix Missing and Duplicate Titles and Meta Descriptions</a></li>
<li><a href="#heading-how-to-make-your-links-crawlable">How to Make Your Links Crawlable</a></li>
<li><a href="#heading-how-to-check-whether-your-content-needs-javascript-to-appear">How to Check Whether Your Content Needs JavaScript to Appear</a></li>
<li><a href="#heading-can-you-check-a-page-in-five-minutes">Can You Check a Page in Five Minutes?</a></li>
<li><a href="#heading-conclusion">Conclusion</a></li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>You don't need to be an experienced programmer to follow along. You'll need:</p>
<ul>
<li>A web browser, such as Chrome, Firefox, Edge or Safari. In Safari, first turn on <strong>Show features for web developers</strong> under Settings &gt; Advanced.</li>
<li>A terminal: Terminal on macOS, PowerShell on Windows, or any terminal on Linux.</li>
<li><code>curl</code>, a small program that downloads web pages. It comes with macOS, most Linux distributions, and Windows 10 (version 1803 and later) and Windows 11.</li>
<li><a href="https://nodejs.org/">Node.js</a>, to run the practice site. The code was tested with Node.js 26.8 and curl 8.7.</li>
</ul>
<p>On Windows PowerShell, type <code>curl.exe</code> instead of <code>curl</code>, because there <code>curl</code> is a shortcut to a <a href="https://learn.microsoft.com/en-us/windows/curl/">different command</a>. Replace <code>| grep -i "text"</code> with <code>| Select-String "text"</code>, and <code>/dev/null</code> with <code>NUL</code>.</p>
<h2 id="heading-what-is-technical-seo">What Is Technical SEO?</h2>
<p>Google has programs called crawlers. They search the internet all the time for new websites and new pages. When Google discovers a new address, it may crawl the page and process what's on it (the text, images, and videos).</p>
<p>If Google decides the page is worth indexing, it may add it to a very big database of web pages, called the index. Google can then consider the pages in its index for its search results, when they're relevant to what someone searches for. Google's guide to <a href="https://developers.google.com/search/docs/fundamentals/how-search-works">how Search works</a> calls these three steps crawling, indexing, and serving.</p>
<p>So crawling doesn't guarantee indexing, and indexing doesn't guarantee a place in the results. The same guide says Google doesn't guarantee any of the three stages, even for a page that follows all of its rules.</p>
<p>Fixing technical problems won't necessarily make your site rank first on Google. But it does strengthen your chances of ranking higher, because every problem you fix makes your site better.</p>
<p>Technical SEO has to come together with regular SEO, like keywords, backlinks, engagement on social media, and so on.</p>
<h2 id="heading-how-common-are-these-problems-on-real-websites">How Common Are These Problems on Real Websites?</h2>
<p>The numbers below come from audits run with the site-auditing tool I build. Each website counts once, using its first audit, and this data comes from October 25th.</p>
<p>Crawls check many pages on one site (a median of 10 pages here). Single-page audits check one page, usually the home page.</p>
<table>
<thead>
<tr>
<th>Problem</th>
<th>How often</th>
<th>Sample</th>
</tr>
</thead>
<tbody><tr>
<td>At least one page with no canonical tag</td>
<td>64.2% of sites</td>
<td>53 crawled sites, Aug 2025 to Oct 2026</td>
</tr>
<tr>
<td>Two or more pages with exactly the same title</td>
<td>61.8% of sites</td>
<td>34 crawled sites with two or more pages that loaded</td>
</tr>
<tr>
<td>At least one page with no meta description</td>
<td>49.1% of sites</td>
<td>53 crawled sites</td>
</tr>
<tr>
<td>At least one link leading to a page that returns 404</td>
<td>18.9% of sites</td>
<td>53 crawled sites</td>
</tr>
<tr>
<td>Page built mostly with JavaScript</td>
<td>17.7% of pages</td>
<td>277 single-page audits, May to Oct 2026</td>
</tr>
<tr>
<td>At least one page with no title</td>
<td>13.2% of sites</td>
<td>53 crawled sites</td>
</tr>
<tr>
<td>A link that search engines may not be able to follow</td>
<td>8.3% of pages</td>
<td>278 single-page audits</td>
</tr>
<tr>
<td>A robots.txt file with errors, or one that failed to load</td>
<td>7.9% of sites</td>
<td>252 single-page audits, Jul to Oct 2026</td>
</tr>
<tr>
<td>Page blocked by noindex or by robots.txt</td>
<td>4.8% of pages</td>
<td>251 single-page audits, Jul to Oct 2026</td>
</tr>
</tbody></table>
<p>These are all technical problems, but they don't all weigh the same. Missing canonical tags are at the top, but Google doesn't require a canonical tag, so a page without one isn't broken by itself. It matters when the same or very similar content is reachable at more than one URL, as you'll see in the canonical section.</p>
<p>Duplicate titles are something I had myself: when you write code, it's easy to miss things like that. That's why there are tools that check for these problems.</p>
<p>Let's go over each issue in the table to make sure they're all clear:</p>
<ul>
<li><strong>Missing canonical tag:</strong> a canonical tag is basically one of the many tags you put in the <code>&lt;head&gt;</code> of your HTML. When two or three pages have pretty much the same content, it tells search engines like Google which URL is the original, main version, the one you prefer to get indexed. It's not required, though: without it, Google picks the main version itself, so it matters mostly for pages that have duplicates.</li>
<li><strong>Duplicate titles:</strong> it's really easy to end up with the same title on two pages. You don't want a duplicated title because it's not helpful and can confuse Google. Each page should say what it's about.</li>
<li><strong>Missing meta description:</strong> this one is really important, because it helps search engines as well as AI tools like ChatGPT. The meta description is there to explain very briefly what the page is about and what it's for. If you go to Google and search for a website, you'll see it under the actual link to the site.</li>
<li><strong>A link to a 404 page:</strong> this basically means you have a broken link (maybe a link you probably don't use anymore) to a page that isn't there. So there's no reason for it to lead to something that doesn't exist. Use it to lead to something similar, or to something that does exist, so your links remain relevant.</li>
<li><strong>A page built mostly with JavaScript:</strong> Google can run JavaScript, so this alone doesn't keep a page out of Google. But the content appears only after the JavaScript loads and runs, which is slower for visitors and one more step that can fail, and not every crawler runs JS. That's why I prefer <a href="https://www.freecodecamp.org/news/server-side-rendering-in-next-js-for-improved-seo/">server-side rendering</a> for the content that matters most.</li>
<li><strong>A link search engines can't follow:</strong> this isn't a huge deal, and it really depends where the link goes. But say I wrote a really nice article and I want it to help me rank on Google. If search engines can't follow the link to it, then you invested all this time and energy in this article that relatively few people will likely see.</li>
<li><strong>Errors in robots.txt:</strong> robots.txt basically helps you talk to search engines. If there are errors in this file, you might stop search engines from crawling pages you do want in search results.</li>
<li><strong>A page blocked by noindex or robots.txt:</strong> these are two different things. A <code>noindex</code> rule asks search engines to keep a page out of their results, while a robots.txt rule only stops them from crawling it. You'll learn more about both, and why the difference matters, later in this tutorial.</li>
</ul>
<p>Soft 404s, redirect chains, and wrong canonical tags aren't in the table, because the audits don't record them.</p>
<h2 id="heading-how-to-set-up-the-practice-site">How to Set Up the Practice Site</h2>
<p>The practice site is a tiny website for a made-up shoe store called Acme Shoes. Every page on it has one problem built in. You don't need to understand every line of the code.</p>
<p>Create a new folder, add a file called <code>server.js</code>, and paste in this code:</p>
<pre><code class="language-javascript">// server.js: a tiny practice site with common technical SEO problems built in
const http = require('node:http');

const PORT = 8080;
const BASE = 'http://localhost:' + PORT;

// Builds a simple HTML page. Leave out a field to leave that tag out.
function page({ title, description, canonical, robots, body, script }) {
  return [
    '&lt;!doctype html&gt;',
    '&lt;html lang="en"&gt;',
    '&lt;head&gt;',
    '&lt;meta charset="utf-8"&gt;',
    title &amp;&amp; '&lt;title&gt;' + title + '&lt;/title&gt;',
    description &amp;&amp; '&lt;meta name="description" content="' + description + '"&gt;',
    canonical &amp;&amp; '&lt;link rel="canonical" href="' + BASE + canonical + '"&gt;',
    robots &amp;&amp; '&lt;meta name="robots" content="' + robots + '"&gt;',
    '&lt;/head&gt;',
    '&lt;body&gt;',
    body,
    script &amp;&amp; '&lt;script&gt;' + script + '&lt;/script&gt;',
    '&lt;/body&gt;',
    '&lt;/html&gt;',
  ]
    .filter(Boolean)
    .join('\n');
}

const routes = {
  '/': {
    html: page({
      title: 'Acme Shoes: Running Shoes for Every Runner',
      description: 'Lightweight running shoes for road and trail, with free returns.',
      canonical: '/',
      body: [
        '&lt;h1&gt;Acme Shoes&lt;/h1&gt;',
        '&lt;a href="/products/blue-runner"&gt;Blue Runner&lt;/a&gt;',
        '&lt;span onclick="location.href=\'/sale\'"&gt;Summer sale&lt;/span&gt;',
      ].join('\n'),
    }),
  },
  // Problem: a vague title and no meta description
  '/products/blue-runner': {
    html: page({
      title: 'Acme Shoes',
      canonical: '/products/blue-runner',
      body: '&lt;h1&gt;Blue Runner&lt;/h1&gt;',
    }),
  },
  // Problem: a missing product that still answers "200 OK" (a soft 404)
  '/products/red-runner': {
    html: page({
      title: 'Acme Shoes',
      body: '&lt;h1&gt;Sorry, that product could not be found.&lt;/h1&gt;',
    }),
  },
  // Problem: a redirect chain, /old-sale -&gt; /sale-2025 -&gt; /sale
  '/old-sale': { redirect: '/sale-2025' },
  '/sale-2025': { redirect: '/sale' },
  '/sale': {
    html: page({
      title: 'Summer Sale: Up to 40% Off Running Shoes | Acme Shoes',
      description: "Save up to 40% on last season's running shoes until August 31.",
      canonical: '/sale',
      body: '&lt;h1&gt;Summer sale&lt;/h1&gt;',
    }),
  },
  // Problem: a noindex header nobody can see in the page itself
  '/about': {
    headers: { 'X-Robots-Tag': 'noindex' },
    html: page({
      title: 'About Acme Shoes',
      description: 'How a two-person workshop became a running shoe brand.',
      canonical: '/about',
      body: '&lt;h1&gt;About us&lt;/h1&gt;',
    }),
  },
  // Problem: a canonical tag that points to the home page
  '/blog/choosing-trail-shoes': {
    html: page({
      title: 'How to Choose Trail Running Shoes | Acme Shoes',
      description: 'Grip, drop and fit: what to look for in a trail running shoe.',
      canonical: '/',
      body: '&lt;h1&gt;How to choose trail running shoes&lt;/h1&gt;',
    }),
  },
  // Problem: the content only exists after JavaScript runs
  '/catalog': {
    html: page({
      title: 'All Running Shoes | Acme Shoes',
      description: 'Browse every Acme running shoe.',
      canonical: '/catalog',
      body: '&lt;div id="app"&gt;Loading...&lt;/div&gt;',
      script:
        "fetch('/api/products').then((r) =&gt; r.json()).then((products) =&gt; {" +
        "document.getElementById('app').innerHTML = products.map((p) =&gt; '&lt;h2&gt;' + p + '&lt;/h2&gt;').join('');" +
        '});',
    }),
  },
  '/api/products': {
    type: 'application/json',
    html: JSON.stringify(['Blue Runner', 'Trail Grip 2', 'City Glide']),
  },
  // Problem: "Disallow: /blog" blocks every path that starts with /blog
  '/robots.txt': {
    type: 'text/plain',
    html: 'User-agent: *\nDisallow: /blog\n',
  },
};

http
  .createServer((req, res) =&gt; {
    const path = new URL(req.url, BASE).pathname;
    const route = routes[path];

    if (!route) {
      res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' });
      return res.end(page({ title: 'Page not found', body: '&lt;h1&gt;Page not found&lt;/h1&gt;' }));
    }
    if (route.redirect) {
      res.writeHead(301, { Location: route.redirect });
      return res.end();
    }
    res.writeHead(200, {
      'Content-Type': (route.type || 'text/html') + '; charset=utf-8',
      ...route.headers,
    });
    res.end(route.html);
  })
  .listen(PORT, () =&gt; console.log('Practice site running at ' + BASE));
</code></pre>
<p>Each key in <code>routes</code> is a page address. The comments that start with <code>Problem:</code> say what's wrong with each page.</p>
<p>Start the server from the same folder:</p>
<pre><code class="language-bash">node server.js
</code></pre>
<p>You should see:</p>
<pre><code class="language-text">Practice site running at http://localhost:8080
</code></pre>
<p>Leave the server running and open a second terminal for the commands below. When you change <code>server.js</code>, stop the server with Ctrl+C and start it again.</p>
<h2 id="heading-how-to-check-a-pages-http-status-code">How to Check a Page's HTTP Status Code</h2>
<p>I remember when I started building websites, I built all kinds of small things. I'm a full stack developer, and I kept trying experiments.</p>
<p>Once I had a problem on the server side and saw in the Network tab that it returned a 500 error. Another time I removed a page on purpose, because I wanted to see if I'd get a 404 or other problems. If you just play around with the site you're building, you'll learn a lot from the results.</p>
<p>HTTP status codes are the first thing a search engine reads. They're the server's way of talking to the client, such as your browser. The codes are split into five ranges: 1xx, 2xx, 3xx, 4xx, and 5xx. A 5xx code is a server problem, for example, and a 4xx code is a client-side problem.</p>
<p>404 is the most common error. It means a page on the site wasn't found. You see 404s all the time, and the fix is typically just to maintain your site and your links.</p>
<p>The codes you'll see in this tutorial are:</p>
<ul>
<li><code>200 OK</code>: here's the page.</li>
<li><code>301 Moved Permanently</code>: the page lives at another address.</li>
<li><code>404 Not Found</code>: there's no page here.</li>
</ul>
<p>Google doesn't index URLs that return a <code>4xx</code> code, according to its documentation on <a href="https://developers.google.com/search/docs/crawling-indexing/http-network-errors">HTTP status codes</a>.</p>
<p>To see the status code of the practice site's home page, use <code>-I</code>, which shows only the headers, and <code>-s</code>, which hides curl's progress meter:</p>
<pre><code class="language-bash">curl -sI http://localhost:8080/
</code></pre>
<pre><code class="language-text">HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Date: Sun, 25 Oct 2026 17:42:53 GMT
Connection: keep-alive
Keep-Alive: timeout=5
</code></pre>
<p>Now ask for an address that isn't in <code>routes</code>, to see what a correct 404 looks like:</p>
<pre><code class="language-bash">curl -sI http://localhost:8080/products/does-not-exist
</code></pre>
<pre><code class="language-text">HTTP/1.1 404 Not Found
Content-Type: text/html; charset=utf-8
Date: Sun, 25 Oct 2026 17:42:53 GMT
Connection: keep-alive
Keep-Alive: timeout=5
</code></pre>
<p>In the browser, open the developer tools (F12, or Cmd+Option+I on a Mac), go to the <strong>Network</strong> tab, and reload. The <strong>Status</strong> column shows the code.</p>
<h3 id="heading-what-is-a-soft-404">What Is a Soft 404?</h3>
<p>Check a product the store no longer sells:</p>
<pre><code class="language-bash">curl -sI http://localhost:8080/products/red-runner
</code></pre>
<pre><code class="language-text">HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Date: Sun, 25 Oct 2026 17:42:53 GMT
Connection: keep-alive
Keep-Alive: timeout=5
</code></pre>
<p>In the browser, the same page says "Sorry, that product could not be found."</p>
<p>A soft 404 is basically a "page not found", but the server still answers <code>200</code>. It confuses Google, because the address is still there, but what's on it isn't current anymore.</p>
<p>To fix it, make missing pages return a real <code>404</code>, which is what <a href="https://support.google.com/webmasters/answer/7440203">Google recommends</a>. In the practice site, delete the <code>'/products/red-runner'</code> entry from <code>routes</code>. The server then answers <code>404</code>.</p>
<p>Broken links lead to the same dead end. In the data, 18.9% of the 53 crawled sites had at least one link to a page that returned <code>404</code>.</p>
<p>So don't leave links that lead to something that doesn't exist. Update them to lead to something similar, or to something that does exist.</p>
<p>If the page moved, add a <code>301</code> redirect from the old address to the new one. A <code>404</code> is the right answer only for a page that's really gone.</p>
<h2 id="heading-how-to-find-and-fix-redirect-chains">How to Find and Fix Redirect Chains</h2>
<p>A redirect is basically when you want to send one URL (perhaps one that no longer exists) to another URL (that does exist). You might set up a redirect if, for example, you change your domain from <code>example-x.com</code> to <code>example-y.com</code>. You'll want everyone who types <code>example-x.com</code> to be redirected to <code>example-y.com</code> automatically.</p>
<p>Redirects are fine, and they can be quite helpful when used properly. A <code>301</code> means the page moved for good. A <code>302</code> means the move is temporary. Google's documentation on <a href="https://developers.google.com/search/docs/crawling-indexing/301-redirects">redirects</a> explains how it treats each one, and its crawlers <a href="https://developers.google.com/search/docs/crawling-indexing/http-network-errors">follow up to 10 redirect hops</a>.</p>
<p>But a redirect chain happens when an old address, such as a domain you used in the past, redirects to a new one, and then that one redirects again, and again...you get the picture. A few redirects aren't a big deal, but five, six, or seven can be a real problem.</p>
<p>The first command below shows every step of the chain. The second gives you a one-line summary.</p>
<p>Add <code>-L</code> so curl follows the redirects:</p>
<pre><code class="language-bash">curl -sIL http://localhost:8080/old-sale
</code></pre>
<pre><code class="language-text">HTTP/1.1 301 Moved Permanently
Location: /sale-2025
Date: Sun, 25 Oct 2026 17:42:53 GMT
Connection: keep-alive
Keep-Alive: timeout=5

HTTP/1.1 301 Moved Permanently
Location: /sale
Date: Sun, 25 Oct 2026 17:42:53 GMT
Connection: keep-alive
Keep-Alive: timeout=5

HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Date: Sun, 25 Oct 2026 17:42:53 GMT
Connection: keep-alive
Keep-Alive: timeout=5
</code></pre>
<p><code>/old-sale</code> goes to <code>/sale-2025</code>, which goes to <code>/sale</code>. For a one-line summary:</p>
<pre><code class="language-bash">curl -sL -o /dev/null -w "%{num_redirects} redirects, ended at %{url_effective}\n" http://localhost:8080/old-sale
</code></pre>
<pre><code class="language-text">2 redirects, ended at http://localhost:8080/sale
</code></pre>
<p><code>-o /dev/null</code> tells curl to write the page's HTML nowhere, so it doesn't fill your terminal and only the summary line is printed. <code>-w</code> sets what that summary line says.</p>
<p>To fix it, point the old/original address straight at the final page:</p>
<pre><code class="language-javascript">'/old-sale': { redirect: '/sale' },
</code></pre>
<p>Restart the server and run the summary again. It now says <code>1 redirects, ended at http://localhost:8080/sale</code>.</p>
<h2 id="heading-how-to-make-sure-robotstxt-isnt-blocking-important-pages">How to Make Sure robots.txt Isn't Blocking Important Pages</h2>
<p>robots.txt is the site's way of telling search engines like Google (and their crawlers) what they're allowed to explore and where they have access.</p>
<p>Look at the practice site's file:</p>
<pre><code class="language-bash">curl -s http://localhost:8080/robots.txt
</code></pre>
<pre><code class="language-text">User-agent: *
Disallow: /blog
</code></pre>
<p><code>Disallow: /blog</code> is dangerous because it stops crawlers from reading every path that starts with <code>/blog</code>. And usually, your articles are what promote your site the most.</p>
<p>Google's <a href="https://developers.google.com/search/docs/crawling-indexing/robots/robots_txt">robots.txt documentation</a> gives the same kind of example: <code>Disallow: /fish</code> also blocks <code>/fish.html</code> and <code>/fishheads</code>.</p>
<p>To fix it, make the rule match only what you meant to block, for example a folder of drafts:</p>
<pre><code class="language-text">User-agent: *
Disallow: /blog-drafts/
</code></pre>
<p>In the practice site, change the <code>html</code> line of the <code>'/robots.txt'</code> entry:</p>
<pre><code class="language-javascript">html: 'User-agent: *\nDisallow: /blog-drafts/\n',
</code></pre>
<p>robots.txt controls crawling, not indexing. Google says it's <a href="https://developers.google.com/search/docs/crawling-indexing/robots/intro">not a mechanism for keeping a page out of Google</a>. For that, you need <code>noindex</code>.</p>
<p>A URL blocked in robots.txt can even appear in search results, usually without a description, if other pages link to it. Google just never reads what's on the page.</p>
<h2 id="heading-how-to-find-an-accidental-noindex">How to Find an Accidental noindex</h2>
<p><code>noindex</code> tells Google not to index a particular page. It's harmful when it ends up on pages you do want indexed and ranked, like a product page or your home page.</p>
<p>But it can be useful on pages you don't need to rank for, or that don't add much to the site, such as log in, sign in, and sign up pages.</p>
<p><code>noindex</code> works only when the crawler can reach the page and read the rule. If robots.txt blocks the same URL, Google never sees the <code>noindex</code>, and the page can still show up in search results. So for a page you want out of Google, use <code>noindex</code> and leave the page crawlable.</p>
<p>In the data, 11 of 457 audited pages (2.4%) had a <code>noindex</code> robots meta tag. There are <a href="https://developers.google.com/search/docs/crawling-indexing/block-indexing">two ways to set it</a>, and you need to check both.</p>
<h3 id="heading-how-to-check-the-robots-meta-tag">How to Check the Robots Meta Tag</h3>
<p>The first way is a tag in the page's <code>&lt;head&gt;</code>:</p>
<pre><code class="language-html">&lt;meta name="robots" content="noindex"&gt;
</code></pre>
<p>Search the page for the word:</p>
<pre><code class="language-bash">curl -s http://localhost:8080/about | grep -i noindex
</code></pre>
<p>On the About page, this prints nothing. You can also right-click the page, choose <strong>View Page Source,</strong> and search for <code>noindex</code>.</p>
<p>Fixing a noindex is really easy. You basically remove it, and then search engines can index the page.</p>
<p>Look for it in the page's code or template, and in your CMS or SEO plugin settings. In WordPress, the setting <strong>Discourage search engines from indexing this site</strong> adds it to every page.</p>
<h3 id="heading-how-to-check-the-x-robots-tag-header">How to Check the X-Robots-Tag Header</h3>
<p>The second way is an HTTP header. Check the About page's headers:</p>
<pre><code class="language-bash">curl -sI http://localhost:8080/about
</code></pre>
<pre><code class="language-text">HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
X-Robots-Tag: noindex
Date: Sun, 25 Oct 2026 17:42:59 GMT
Connection: keep-alive
Keep-Alive: timeout=5
</code></pre>
<p>The header doesn't appear in View Page Source, only in the response headers. In the data, 5 of the 12 blocked pages had no <code>noindex</code> in their HTML: 2 had this header, and 3 were blocked from crawling by robots.txt.</p>
<p>To fix it in the practice site, delete the <code>headers</code> line from the <code>'/about'</code> entry. On a real site, check the server configuration, the hosting provider's header settings, and any SEO plugin.</p>
<h2 id="heading-how-to-use-canonical-tags-correctly">How to Use Canonical Tags Correctly</h2>
<p>If you have several pages that talk about more or less the same thing, but without enough meat to be different, you want to designate one main page. The canonical tag tells search engines which page that is.</p>
<p>The same page can also live at several addresses:</p>
<pre><code class="language-text">https://example.com/blog/choosing-trail-shoes
https://example.com/blog/choosing-trail-shoes?utm_source=newsletter
https://www.example.com/blog/choosing-trail-shoes
</code></pre>
<p>These three addresses show the same article. The first one is the main one: the address you want Google to show in search results. So all three carry the same canonical tag, pointing to that main page:</p>
<pre><code class="language-html">&lt;link rel="canonical" href="https://example.com/blog/choosing-trail-shoes"&gt;
</code></pre>
<p>When Google sees this tag on the <code>?utm_source=newsletter</code> version, it knows that page is a copy of the first address. The main address carries the same tag, pointing to itself. That's called a self-referencing canonical, and it says: I'm the main version.</p>
<p>Google treats it as a <a href="https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls">strong signal, not a command</a>. If you use canonical tags, a self-referencing one on each main page is a good practice, and Google recommends it.</p>
<p>But you don't have to use canonical tags at all. Google says specifying a canonical isn't required: without one, it picks the version it thinks is best to show users. A canonical tag is a good choice when the same or very similar content is reachable at more than one URL, like the three addresses above.</p>
<p>That's why a missing canonical tag isn't a defect by itself. In the data, 27.1% of 457 audited pages had no canonical tag. What matters is whether any of them have duplicates, like a version with tracking parameters, that need one.</p>
<p>Check the practice site's trail shoe article:</p>
<pre><code class="language-bash">curl -s http://localhost:8080/blog/choosing-trail-shoes | grep -i canonical
</code></pre>
<pre><code class="language-text">&lt;link rel="canonical" href="http://localhost:8080/"&gt;
</code></pre>
<p>The article says its main version is the home page, so Google may show the home page instead of it, or ignore the tag.</p>
<p>The article isn't a copy of anything, so it's its own main version. To fix it, point the article's canonical tag to the article itself:</p>
<pre><code class="language-javascript">canonical: '/blog/choosing-trail-shoes',
</code></pre>
<h2 id="heading-how-to-fix-missing-and-duplicate-titles-and-meta-descriptions">How to Fix Missing and Duplicate Titles and Meta Descriptions</h2>
<p>A good title or meta description is the right length, isn't overloaded, and says briefly what the page is about. It's sort of the essence of the page, like its introduction.</p>
<p>Look at the Blue Runner product page:</p>
<pre><code class="language-bash">curl -s http://localhost:8080/products/blue-runner | grep -i -E "&lt;title&gt;|name=\"description\""
</code></pre>
<pre><code class="language-text">&lt;title&gt;Acme Shoes&lt;/title&gt;
</code></pre>
<p>The title says nothing about the page, and there's no description line at all. In PowerShell, use <code>| Select-String '&lt;title&gt;|name="description"'</code> for this one.</p>
<p>Google's guides to <a href="https://developers.google.com/search/docs/appearance/title-link">titles</a> and <a href="https://developers.google.com/search/docs/appearance/snippet">snippets</a> ask for a unique, descriptive title and description on every page.</p>
<p>To fix it in the practice site, replace the <code>title</code> line of the <code>'/products/blue-runner'</code> entry with:</p>
<pre><code class="language-javascript">title: 'Blue Runner Road Running Shoe | Acme Shoes',
description: 'A lightweight road running shoe with a cushioned sole and free returns.',
</code></pre>
<h2 id="heading-how-to-make-your-links-crawlable">How to Make Your Links Crawlable</h2>
<p>Look at the practice site's home page:</p>
<pre><code class="language-bash">curl -s http://localhost:8080/
</code></pre>
<pre><code class="language-text">&lt;!doctype html&gt;
&lt;html lang="en"&gt;
&lt;head&gt;
&lt;meta charset="utf-8"&gt;
&lt;title&gt;Acme Shoes: Running Shoes for Every Runner&lt;/title&gt;
&lt;meta name="description" content="Lightweight running shoes for road and trail, with free returns."&gt;
&lt;link rel="canonical" href="http://localhost:8080/"&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;h1&gt;Acme Shoes&lt;/h1&gt;
&lt;a href="/products/blue-runner"&gt;Blue Runner&lt;/a&gt;
&lt;span onclick="location.href='/sale'"&gt;Summer sale&lt;/span&gt;
&lt;/body&gt;
&lt;/html&gt;
</code></pre>
<p>Everything else on this page is fine: the title, the description, the canonical tag and the Blue Runner link, which is a real <code>&lt;a&gt;</code> element with an <code>href</code>. The only problem is the "Summer sale" <code>&lt;span&gt;</code>.</p>
<p>A <code>&lt;span&gt;</code> is basically HTML that isn't semantic, meaning it has no meaning. A real link does have meaning: search engines can understand it's a link, and screen readers can understand it's a link.</p>
<p>Which links need to be crawlable? Basically, everything you want search engines to see. I mean the products you sell, your articles, your courses, and other things like that.</p>
<p>Google's <a href="https://developers.google.com/search/docs/crawling-indexing/links-crawlable">link guidelines</a> say it can only reliably crawl an <code>&lt;a&gt;</code> element with an <code>href</code>.</p>
<p>To fix it in the practice site, replace the <code>&lt;span&gt;</code> line with:</p>
<pre><code class="language-javascript">'&lt;a href="/sale"&gt;Summer sale&lt;/a&gt;',
</code></pre>
<h2 id="heading-how-to-check-whether-your-content-needs-javascript-to-appear">How to Check Whether Your Content Needs JavaScript to Appear</h2>
<p>With client-side rendering, the JavaScript has to load first, and only then can the other content load.</p>
<p>JavaScript usually builds things like product lists, prices, reviews and menus. Without it, a client-side page often shows only a loading message or an empty box.</p>
<p>Google does render JavaScript, but <a href="https://developers.google.com/search/docs/crawling-indexing/javascript/javascript-seo-basics">in a later phase</a>. The page waits in a rendering queue, which can take a few seconds or longer, and a script that fails or is blocked can leave the rendered page without its content.</p>
<p>That's why server-side rendering is still a good idea, and it's what I prefer for the content that matters. Google's JavaScript guide says the same: server-side rendering makes the site faster for users and crawlers, and not all bots can run JavaScript.</p>
<p>In the data, 17.7% of 277 audited pages were built mostly with JavaScript. That doesn't mean Google couldn't index them, only that their main content depended on JavaScript running correctly.</p>
<p>With server-side rendering, the HTML content comes straight to the browser, and only after that does the JavaScript render.</p>
<p>Google can index pages built either way. Its crawler runs JavaScript with an up-to-date version of Chrome, so server-side rendering isn't a requirement for getting into Google's index.</p>
<p>The next test checks whether the important content is visible without JavaScript. It doesn't show what Google sees, because Google runs the JavaScript, but it shows what every crawler gets before any JavaScript runs.</p>
<p>Open <code>http://localhost:8080/catalog</code> in your browser. You'll see three products. Now download it with curl, which doesn't run JavaScript:</p>
<pre><code class="language-bash">curl -s http://localhost:8080/catalog | grep -i "trail grip"
</code></pre>
<p>This prints nothing, because the product names aren't in the HTML the server sends. Inside <code>&lt;body&gt;</code>, the server only sends:</p>
<pre><code class="language-html">&lt;div id="app"&gt;Loading...&lt;/div&gt;
&lt;script&gt;fetch('/api/products').then((r) =&gt; r.json()).then((products) =&gt; {document.getElementById('app').innerHTML = products.map((p) =&gt; '&lt;h2&gt;' + p + '&lt;/h2&gt;').join('');});&lt;/script&gt;
</code></pre>
<p>Without curl, use <strong>View Page Source</strong> in your browser. On this page, you'll see "Loading..." and the script, but none of the product names, because the browser adds them only after the JavaScript runs.</p>
<p>On your own site, search the source for an important sentence from the page.</p>
<p>To see the page the way Google renders it, use Google's own tools on a live page. On your own site, the URL Inspection tool in Search Console can run a live test (<strong>Test live URL</strong>, then <strong>View tested page</strong>) and show the HTML Google rendered and a screenshot.</p>
<p>For any public page, the <a href="https://search.google.com/test/rich-results">Rich Results Test</a> shows the rendered HTML too. Google <a href="https://developers.google.com/search/docs/crawling-indexing/javascript/fix-search-javascript">recommends both</a> for finding JavaScript problems. Neither can reach <code>localhost</code>, so try them on your real site.</p>
<p>If you want the product names in the HTML from the start, render them on the server. In the practice site, add the product list above <code>routes</code>:</p>
<pre><code class="language-javascript">const products = ['Blue Runner', 'Trail Grip 2', 'City Glide'];
</code></pre>
<p>Replace the catalog's <code>body</code> and <code>script</code> lines with:</p>
<pre><code class="language-javascript">body: '&lt;div id="app"&gt;' + products.map((p) =&gt; '&lt;h2&gt;' + p + '&lt;/h2&gt;').join('') + '&lt;/div&gt;',
</code></pre>
<p>And in the <code>'/api/products'</code> entry:</p>
<pre><code class="language-javascript">html: JSON.stringify(products),
</code></pre>
<p>Restart the server and run the curl command again:</p>
<pre><code class="language-text">&lt;div id="app"&gt;&lt;h2&gt;Blue Runner&lt;/h2&gt;&lt;h2&gt;Trail Grip 2&lt;/h2&gt;&lt;h2&gt;City Glide&lt;/h2&gt;&lt;/div&gt;
</code></pre>
<p>This works because the server now writes the product names into the HTML before it sends the page. Any crawler sees them, even one that can't run JavaScript, and you can still use JavaScript for the interactive parts.</p>
<h2 id="heading-can-you-check-a-page-in-five-minutes">Can You Check a Page in Five Minutes?</h2>
<p>Honestly, it's very hard. There can be a lot of problems, and five minutes isn't enough.</p>
<p>To really know what the problems are, you need to run a full check, for example in Google PageSpeed Insights. If the site is yours, also use the <a href="https://support.google.com/webmasters/answer/9012289">URL Inspection tool</a> in Google Search Console.</p>
<p>The commands from this tutorial are a good start:</p>
<ol>
<li><code>curl -sI</code> for the status code and an <code>X-Robots-Tag</code> header.</li>
<li><code>curl -sIL</code> on an old address to count redirects.</li>
<li><code>/robots.txt</code>, line by line.</li>
<li>View Page Source for <code>noindex</code>, <code>canonical</code>, <code>&lt;title&gt;</code>, <code>description</code> and your important content.</li>
</ol>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this tutorial, you fixed eight problems on a practice site: a soft 404, a redirect chain, a robots.txt rule, a hidden <code>noindex</code> header, a wrong canonical tag, a vague title and a missing description, a link crawlers couldn't follow, and content that needed JavaScript.</p>
<p>The most important thing to remember is that you'll need to check your site all the time. Run scans, do maintenance, and make sure nothing breaks, especially when you make improvements or changes to the site.</p>
<p>If you want to go further, my tutorial on <a href="https://www.freecodecamp.org/news/nextjs-json-ld-structured-data/">adding structured data (JSON-LD) to a Next.js site</a> is a good next step.</p>
<p>If you'd like to check a live page for many of these issues in one go, my free tool <a href="https://www.greadme.com/page-audit">Greadme Page Audit</a> tests any URL with no sign-up needed.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Automate Docker Image Creation for Postgres Using Shell Commands ]]>
                </title>
                <description>
                    <![CDATA[ If you have certain lines of code that repeat over and over again in a project, there's a pretty standard solution for it: using a function. But what about when you have the same project file setup th ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-automate-docker-image-creation-for-postgres-using-shell/</link>
                <guid isPermaLink="false">6aea2aaffbe759481e70cb16</guid>
                
                    <category>
                        <![CDATA[ shell ]]>
                    </category>
                
                    <category>
                        <![CDATA[ shell scripting ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Bash ]]>
                    </category>
                
                    <category>
                        <![CDATA[ docker images ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Docker compose ]]>
                    </category>
                
                    <category>
                        <![CDATA[ postgres ]]>
                    </category>
                
                    <category>
                        <![CDATA[ vibe coding ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Workflow Automation ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ jabo Landry ]]>
                </dc:creator>
                <pubDate>Tue, 03 Nov 2026 18:40:47 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/60aa4c00-42d7-4e36-84b4-89f55b140dca.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>If you have certain lines of code that repeat over and over again in a project, there's a pretty standard solution for it: using a function.</p>
<p>But what about when you have the same project file setup that gets repeated over and over? How do you solve that?</p>
<p>Meet bash scripting. Bash scripts are written to execute certain sequences of commands on your machine automatically without you having to do anything. This is known as workflow automation, and that's what I'll be teaching you how to do in this article.</p>
<h2 id="heading-table-of-contents">Table Of Contents</h2>
<ul>
<li><a href="#heading-prerequisites">Prerequisites</a></li>
<li><a href="#heading-what-motivated-this-project">What Motivated This Project?</a></li>
<li><a href="#heading-introduction">Introduction</a></li>
<li><a href="#heading-conditions-in-bash">Conditions in bash</a></li>
<li><a href="#heading-interactive-shell">Interactive Shell</a></li>
<li><a href="#heading-how-to-create-and-write-to-a-file">How to Create and Write to a File</a></li>
<li><a href="#heading-bash-arguments-and-flags">Bash Arguments and Flags</a></li>
<li><a href="#heading-how-to-make-your-scripts-executable-and-available-on-your-pcs-path">How to Make Your Scripts Executable and Available on Your PC's Path</a></li>
<li><a href="#heading-conclusion">Conclusion</a></li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along with this article, you should have:</p>
<ul>
<li>Git-bash installed on your machine</li>
<li>Some experience using a terminal or command prompt to run and execute commands (helpful but not required)</li>
<li>Understanding of basic programming concepts (like conditions and loops)</li>
<li>Docker installed on your machine</li>
<li>Basic understanding of Docker concepts (like images and containers)</li>
<li>Experience using Postgres (optional but recommended)</li>
</ul>
<h2 id="heading-what-motivated-this-project">What Motivated This Project?</h2>
<p>I was recently setting up a Docker image for Postgres on a few projects I was working on, and I noticed that I was following the same pattern in creating the images. The only difference was that they had different database credentials and database names.</p>
<p>I also realized that a large portion of today's rapidly developed apps tend to use Supabase as their backend, which can come with resource limits such as database storage, bandwidth, concurrent connections, and API request quotas (depending on the service tier and size of the project). And these issues can be solved by setting up your database.</p>
<p>So I thought, why not make a CLI tool that could automate creating Docker images for Postgres? That's when I learned about writing executable shell commands using bash to create workflow automations.</p>
<p>You can find the full working example used in this guide in this <a href="https://github.com/jabo-arnold-landry/Postgres-docker-image-generator">git-repo</a>.</p>
<h2 id="heading-introduction">Introduction</h2>
<p>Bash is a shell and scripting language used to write and execute commands or scripts that perform operations on a computer, such as creating directories and files, manipulating files, automating tasks, and managing system processes.</p>
<p>In this guide, you'll learn to automate the process of creating a Docker image for a Postgres database so that you don't have to create the image from scratch for each new project.</p>
<h3 id="heading-bash-scripting-file-setup">Bash Scripting File Setup</h3>
<p>To get started, open you preferred text-editor and create a directory or folder there. Then within that directory, create a file with the extension <code>.sh</code>.</p>
<p>At the beginning of a file, type:</p>
<pre><code class="language-shell">#!/bin/bash
</code></pre>
<p><code>#!/bin/bash</code> (known as a "shebang") tells the operating system to run the script using the Bash shell, so the commands inside the file are executed as Bash/terminal commands.</p>
<h2 id="heading-conditions-in-bash">Conditions in bash</h2>
<p>After you've finished creating a script file and adding shebang, let's start by exploring how if statements are used in bash.</p>
<p>The syntax of <code>if</code> looks like this:</p>
<pre><code class="language-shell">if [[ condition ]];
    then
        command to execute
fi
</code></pre>
<p>The <code>if</code> in bash is no different from what you're used to in other programming languages:</p>
<ul>
<li>You use double square braces ([]) to add a condition you're checking against and a semicolon (;).</li>
<li>You use <code>then</code> after the <code>if</code> declaration to specify what should be executed if the condition is true.</li>
<li>You use the <code>fi</code> to close the condition statement.</li>
</ul>
<p>When you're writing the condition inside the square braces, make sure you leave a space before the condition and another one after it (as you can see in the code above). If you omit these spaces, you'll get errors.</p>
<h3 id="heading-working-example">Working Example:</h3>
<p>Since we are automating Postgres database Docker image creation, first check if the file exists and has content. If it does have content, end the script. If not, we'll proceed with the next script.</p>
<pre><code class="language-shell">#!/bin/bash

if [[ -s docker-compose.yml ]];
    then
        echo "The file already exists"
        exit 0
fi

echo "Next command"
</code></pre>
<p>In the if condition, we use the flag <code>-s</code> to check if the file exists and has contents. File flags check file availability and permissions, and include but aren't limited to:</p>
<ul>
<li>-f: used to check if the file exists</li>
<li>-s: used to check if the file exists and has contents</li>
<li>-d: used to check if a directory exists</li>
<li>-r: used to check if a file is readable</li>
<li>-w: used to check if a file is writable</li>
<li>-x: used to check if a file is executable</li>
</ul>
<p>In the script, we're using <code>-s</code> to check if a file named <code>docker-compose.yml</code> exists in the directory where we're executing the command. If it's false, we use the <code>echo</code> keyword to print the text "Next command". If it's true, we print the text "The file already exists".</p>
<p><code>exit</code> acts as a return keyword for bash scripting, 0 is used for success, and 1 is used for failure.</p>
<p>Let's test all this by creating a Docker Compose file and adding some content in the same directory where we're writing the bash script. In your terminal type <code>bash name-of-file.sh</code>, and you should see "The file already exists":</p>
<img src="https://cdn.hashnode.com/uploads/covers/69c7bcff7cf27065100ae8be/14d2b684-5e37-4e70-b6be-05386777fd68.png" alt="output after creating the already existing file using bash" style="display: block;" width="600" height="400" loading="lazy">

<p>But if we delete the content in the file and run the command again, we should see the output saying "Next command":</p>
<img src="https://cdn.hashnode.com/uploads/covers/69c7bcff7cf27065100ae8be/130698ee-7309-4f3e-9eb0-37632b954ded.png" alt="output of a successful file creation in bash" style="display: block;" width="600" height="400" loading="lazy">

<h2 id="heading-interactive-shell">Interactive Shell</h2>
<p>To make an automation dynamic and flexible you will need to receive inputs, take an example of a database name you won't always have the same database name, the name will change based on the project,</p>
<p>you do need to setup away to use a given name of the database when creating a new docker image for that you read inputs using the <code>read</code> keyword.</p>
<p>There are different types of inputs based on the operation. And there are flags that are used to specify which type of input you're expecting, such as:</p>
<ul>
<li>-p: for prompting the user to write a text</li>
<li>-s: used on passwords when you want the user input to stay hidden when writing it in the terminal</li>
<li>-a: used when you're expecting the user to pass in multiple inputs or if you're expecting an array from user.</li>
</ul>
<p>Here's the syntax for reading user input:</p>
<pre><code class="language-shell">read -p 'input_label: ' variable_name
</code></pre>
<h3 id="heading-working-example">Working Example:</h3>
<p>Now let's create scripts to receive user input about the Postgres database image you're going to create.</p>
<pre><code class="language-shell">#!/bin/bash

# database credentials
read -p 'DataBase-name(*): ' db
read -p 'DataBase-user(*): ' user
read -sp 'DataBase-password(*): ' pwd
echo
echo "your database is -&gt; $db with a username of $user and a password of $pwd"
</code></pre>
<p>The script uses <code>-p</code> on the <code>read</code> keyword that prompts a string of text and <code>-sp</code> that prompts the user to enter a string of text that's hidden (because the <code>-s</code> hides what's being typed).</p>
<p>If you don't use the <code>-p</code> option together with <code>-s</code>, you won't be able to display a prompt asking the user to enter a password. With <code>-s</code> alone, the input is hidden, but no prompt is shown unless you print one using <code>echo</code> or <code>printf</code>.</p>
<p>To print or use the input the user has added, use the dollar sign ($) followed with a variable name for the input you're expecting from the user. In the example above, we named the variable for getting the database name <code>db</code>, so we'll use <code>$db</code> when we want to access the name of the database the user has input.</p>
<p>Now if you run the script in the terminal, you should see a sequence of terminal prompts waiting for inputs. Once they're filled, you'll see all the input's values in the terminal:</p>
<img src="https://cdn.hashnode.com/uploads/covers/69c7bcff7cf27065100ae8be/9871cea4-07f0-4626-ba2a-f4ca738e447b.png" alt="output on how to get and display user inputs " style="display: block;" width="600" height="400" loading="lazy">

<h3 id="heading-how-to-validate-and-sanitize-input">How to Validate and Sanitize Input</h3>
<p>You'll also need to validate input before executing anything. This is important because sometimes crucial field might be missing or mistyped. For example, you might forget to input the database name for the image you're trying to create.</p>
<p>When validating input, you'll use one of these flags (among others):</p>
<ul>
<li>-z: checks if a variable value is empty</li>
<li>-n: checks if a variable value is not empty</li>
<li>-lt: checks if a number is less than a given number</li>
<li>-gt: checks if a number is greater than a given number</li>
<li>-eq: checks for equality of two values</li>
</ul>
<p>You can use the <code>-z</code> flag on every input that you need to make sure isn't empty before moving on to the next step:</p>
<pre><code class="language-shell">#!/bin/bash

# database credentials
read -p 'DataBase-name(*): ' db
if [[ -z $db ]]
    then
        echo "The database field cannot be empty!"
        exit 1
fi

read -p 'DataBase-user(*): ' user
if [[ -z $user ]]
    then
        echo "The user field cannot be empty!"
        exit 1
fi

read -sp 'DataBase-password(*): ' pwd
if [[ -z $pwd ]]
    then
        echo "The password field cannot be empty!"
        exit 1
fi
</code></pre>
<p>This script helps you make sure that all critical information about the database is present before proceeding to another input.</p>
<h3 id="heading-default-values">Default Values</h3>
<p>You may want some fields to use default value (for example, the container name of the image, port, or volume of the container). For such scenarios you can use <code>:=</code> to assign a variable default value:</p>
<pre><code class="language-shell">#!/bin/bash

# container information
read -p 'Container-name: ' container_name
read -p 'Container-port(if omitted, the container will use default postgresql default port of 5432): ' port
read -p 'Volume-name: ' vol_name

echo "container-name-&gt; ${container_name:=cont_one}, port-&gt;${port:=5432}, volume_name-&gt;${vol_name:=backend_data}"
</code></pre>
<p>When you run the script and skip all three of those inputs, bash will use the default value(s) you've used as fallbacks.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69c7bcff7cf27065100ae8be/63c5a5df-10f6-4b9e-b3f1-28460b0db3e4.png" alt="Output for setting up a default value on variables" style="display: block;" width="600" height="400" loading="lazy">

<p>But if you pass value for those inputs, bash uses the provided values. For example, supply all three inputs and see the output you get:</p>
<img src="https://cdn.hashnode.com/uploads/covers/69c7bcff7cf27065100ae8be/d62949f4-b87e-448e-b702-393a04ed19ab.png" alt="Receiving user inputs and displaying the recevied input" style="display: block;" width="600" height="400" loading="lazy">

<h2 id="heading-how-to-create-and-write-to-a-file">How to Create and Write to a File</h2>
<p>Now that you know how to create a Docker image for a Postgres database, you need to create a <code>docker-compose.yml</code> file that will be executed when starting and stopping your containers.</p>
<p>In Bash, there are multiple ways of creating a file:</p>
<ul>
<li>First, you can use <code>&gt; filename</code>. This creates a file if it doesn't exist. If it exists, its contents are overwritten (replaced).</li>
<li>There's also <code>&gt;&gt; filename</code>: This appends contents to a file or creates a file if one doesn't exist.</li>
<li>Finally, there's <code>touch filename</code>: This creates a file if it doesn't exist. If the file already exists, its contents are preserved and only the file's timestamps are updated.</li>
</ul>
<h3 id="heading-how-to-use-gt-to-create-and-write-to-a-file">How to Use <code>&gt;</code> to Create and Write to a File</h3>
<p>When you want to create a file and write content to it at the same time, you'll want to use echo in front of <code>&gt;</code> and then add the filename, like this:</p>
<pre><code class="language-shell">#!/bin/bash

echo Hello&gt; test.txt
</code></pre>
<p>This will create a <code>test.txt</code> file with "hello" as content inside.</p>
<h3 id="heading-how-to-use-gtgt-to-append-contents-to-an-existing-file">How to Use <code>&gt;&gt;</code> to Append Contents to an Existing File</h3>
<p>To append contents to an existing file, add the contents before the <code>&gt;&gt;</code> and then follow with the filename you want to append content onto. Let's add Africa after Hello in the <code>test.txt</code> file we created in the previous script.</p>
<pre><code class="language-shell">#!/bin/bash

echo Africa!&gt;&gt; test.txt
</code></pre>
<p>Now test.txt will have Hello and Africa! on the new line.</p>
<p>Using those symbols, let's create a <code>docker-compose.yml</code> file that will hold contents about our Postgres database setup.</p>
<pre><code class="language-shell">#!/bin/bash

&gt; docker-compose.yml
</code></pre>
<p>And just like that you have your docker-compose file created in your directory.</p>
<h3 id="heading-how-to-write-multiple-different-contents-to-a-file">How to Write Multiple Different Contents to a File</h3>
<p>You can also use the <code>cat</code> keyword to write to a file. Typically, <code>cat</code> is used for reading the contents of a file. But when you combine it with <code>&lt;&lt;EOF</code> and the sign for appending contents to a file <code>&gt;&gt;</code> (that we just discussed), you can add multiple contents to a file in the format you want. The syntax looks like this:</p>
<pre><code class="language-shell">
# writing docker compose contents

cat &lt;&lt;EOF&gt;&gt; docker-compose.yml

  # file contents
EOF
</code></pre>
<p>The <code>&lt;&lt;EOF</code> tells bash to write contents to a file until it finds the EOF keyword (end of file). Then you use <code>&gt;&gt; filename</code> to append the contents to the file. With a Docker compose setup it would look like this:</p>
<pre><code class="language-shell">#!/bin/bash

#file creation and writing
 &gt;  docker-compose.yml

cat&lt;&lt;EOF&gt;&gt;docker-compose.yml
 services:
    postgres:
        image: postgres:18-alpine
        container_name: ${container_name:= $db}

        environment:
            POSTGRES_USER: $user
            POSTGRES_PASSWORD: $pwd
            POSTGRES_database: $db

        ports:
            - ${port:=5432}:5432
        volumes:
            - ${vol_name:=database-data}:/var/postgresql

 volumes:
    ${vol_name:=database-data}:

EOF
</code></pre>
<h2 id="heading-bash-arguments-and-flags">Bash Arguments and Flags</h2>
<p>With Bash arguments, you can pass values in the same line while executing a script. To use arguments, add the values while executing the script on the same line, like this:</p>
<pre><code class="language-shell">bash script val1 val2 
</code></pre>
<p>To read arguments value(s), use the dollar sign ($) followed by the position in which you inputted your values (positions start at 1, not 0).</p>
<p>In the example, if you want to echo the content of the second argument value, you can use <code>echo $2</code>.</p>
<p>To understand this better, let's enhance data input experience by passing the database credentials on the fly using arguments:</p>
<pre><code class="language-shell">#!/bin/bash

# database credentials

if [[ -z $1 ]]; then
    read -p 'DataBase-name(*): ' db
 else
    db="$1"
fi

# database checking and validating

if [[ -z $2 ]]; then
    read -p 'DataBase-user(*): ' user
 else
    user="$2"
fi

# user checking and validating

if [[ -z $1 ]]; then
    read -sp 'DataBase-password(*): ' pwd
 else
    pwd=$3
fi

# password vaidation and checking
</code></pre>
<p>The snippet first checks if there's an argument. If there aren't any, you use the prompt to input the value. If there are arguments, you assign the argument value to a variable that represents the given database credentials (database name, password, and so on...). Now you can run the script with arguments:</p>
<pre><code class="language-shell">bash image.sh customers ABC-Company secret123!
</code></pre>
<p>The image that will be created from the above script will have a database of <code>customers</code>, user <code>ABC-Company</code>, and a password of <code>secret123!</code>.</p>
<h3 id="heading-bash-flagsoptions">Bash Flags/Options</h3>
<p>The script has a section that asks for information about the image that you're creating, but the info can be optional. With bash option/flags, you can skip the step that prompts for container information input using the <code>-D</code> option. This lets you explicitly keep the default container info before the script runs.</p>
<p>Here's an example:</p>
<pre><code class="language-bash">
#!/bin/bash

while getopts "ny" options; do
	case "$options" in
		n) echo "You choose no";;
		y) echo "You choose yes";;
		*) echo "No such option/flag"; exit 1;;
    esac
done

shift $((OPTIND - 1))
</code></pre>
<p>In the above script:</p>
<ol>
<li>The while loop keeps running as long as <code>getopts</code> isn't empty</li>
<li><code>getopts</code> gets the passed option in the script</li>
<li><code>"ny"</code> are the options we're expecting from user when running the script</li>
<li><code>options</code> is a variable that will keep track of the input option between "n" and "y"</li>
<li><code>do</code> starts the loop</li>
<li><code>case</code> checks if the selected option is in the defined option using the <code>in</code> keyword</li>
<li>We then define what happens when we call the script with the option <code>-n</code> or <code>-y</code> and use a double semi-colon (;;) to close the option definition</li>
<li>The <code>*</code> is used in case the input options aren't defined.</li>
<li><code>esac</code> then closes the case</li>
<li><code>done</code> ends the loop so the loop doesn't continue running forever</li>
<li><code>shift $((OPTIND - 1))</code> helps avoid a conflict that may occur when the script also expects arguments by shifting the remaining arguments left <em>over</em> the flags, effectively discarding the flags from the parameter list.</li>
</ol>
<p>You can then apply the syntax in the script to skip the step that asks for container info when <code>-D</code> is passed as an option during the script execution.</p>
<pre><code class="language-shell">
#!/bin/bash

ask_container_info=true

while getopts "D" options; do
	case "$options" in
		D) ask_container_info=false;;
		*) echo "No such option/flag"; exit 1;;
    esac
done

shift $((OPTIND - 1))

if [[ $ask_container_info == true ]]; then
	#Prompt the user to input custom container information
    read -p 'Container-name: ' container_name
    read -p 'Container-port(if omitted, the container will use default postgresql default port of 5432): ' port
    read -p 'Volume-name: ' vol_name
fi
</code></pre>
<p>There is one option here, which is why the option section only has <code>D</code>. <code>ask_container_info</code> starts as true, meaning unless the option is passed, the script will prompt the user to enter the container information. We then change the value of <code>ask_container_info</code> to false when the <code>-D</code> is passed with the script's execution.</p>
<h2 id="heading-how-to-make-your-scripts-executable-and-available-on-your-pcs-path">How to Make Your Scripts Executable and Available on Your PC's Path</h2>
<p>The next step is to make the script available and executable in every terminal you use on your machine. At the moment, the script just works in the current directory you're using to create and write your scripts.</p>
<p>We'll start by making the script file executable and remove the file extension from the file, so we don't need to always call our script with the <code>.sh</code> extension.</p>
<h3 id="heading-how-to-make-a-file-executable">How to Make a File Executable</h3>
<p>To make a file executable, use the Linux <code>chmod</code> command. It stands for change mode. Mode includes file permissions like write, read, and execute.</p>
<pre><code class="language-shell">chmod +x image
</code></pre>
<p>Here, <code>x</code> makes the file executable, and then you've listed the file name you want to make executable (<code>image</code>).</p>
<h3 id="heading-how-to-add-your-directory-to-the-systemmachine-path">How to Add Your Directory to the <code>system/machine</code> Path</h3>
<p>To make this available in every terminal you open, regardless of the directory, you'll need to register your scripts in the system paths where all global scripts and commands are stored.</p>
<p>To do that, copy the full path of your directory and then in your terminal type the following:</p>
<pre><code class="language-shell">export PATH="$PATH:&lt;YOUR_DIRECTORY_LOCATION&gt;"
</code></pre>
<p>Replace <code>&lt;YOUR_DIRECTORY_LOCATION&gt;</code> with your directory. This command will add the script directory into the current working terminal. But if you try to switch to another terminal and run your script, it'll fail.</p>
<p>Now let's configure the directory globally so every terminal will be able to execute the script:</p>
<pre><code class="language-shell">nano ~/.bash_profile
</code></pre>
<p>After this you should see something that looks like this:</p>
<img src="https://cdn.hashnode.com/uploads/covers/69c7bcff7cf27065100ae8be/eb9d4588-4d21-43d5-a666-d406cdd9946d.png" alt="Nano editor for bash profiles and its contents" style="display: block;" width="600" height="400" loading="lazy">

<p>You can see in the image above that I already have my path declared in the file (see the selected text).</p>
<p>If it's not in you're file, you can copy-paste <code>export PATH="$PATH:&lt;YOUR_DIRECTORY_LOCATION&gt;"</code> inside the file. When you're done, press <code>Ctrl+O</code> and then <code>enter</code> to save. Then press <code>Ctrl+x</code> to exit the nano editor.</p>
<p>Then re-load the machine path again to include your scripts there:</p>
<pre><code class="language-shell">source ~/.bash_profile
</code></pre>
<p>After this, you'll be able to run your script in any terminal on your machine using the script file name only (I named my file <code>image</code>):</p>
<img src="https://cdn.hashnode.com/uploads/covers/69c7bcff7cf27065100ae8be/09dcf19f-0712-48f7-9ac6-f00723aa267c.png" alt="Output of a successfully script execution for the docker image creation script" style="display: block;" width="600" height="400" loading="lazy">

<h2 id="heading-conclusion">Conclusion</h2>
<p>In this guide, you learned how to create your own scripts to automate your workflows. I hope it helps you reduce the amount of energy and time you waste on repetitive tasks that can be automated using script commands.</p>
<p>You can find the full working CLI tool that we used as an example in this guide on this <a href="https://github.com/jabo-arnold-landry/Postgres-docker-image-generator">git-repo</a>. There you'll find the installation guide and usage tips. If you find it helpful, please consider starring the repo and sharing it with your collogues who might benefit from it.</p>
<p>Thanks!</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Partner with Design Studios on Client Builds: A Freelance Developer's Guide ]]>
                </title>
                <description>
                    <![CDATA[ Let's say a design studio gets a new client project. The branding is ready, the wireframes are approved, the Figma file is polished, and every screen looks pretty much perfect. Then the developer join ]]>
                </description>
                <link>https://www.freecodecamp.org/news/freelance-developer-design-studios/</link>
                <guid isPermaLink="false">6aea10a0338bcff0a27d3b67</guid>
                
                    <category>
                        <![CDATA[ Freelance Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Ui/Ux Design ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Software Engineering ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Collaboration ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Reetain Raina ]]>
                </dc:creator>
                <pubDate>Tue, 03 Nov 2026 16:49:36 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/4192997d-6653-4cd6-a51f-22c379b2565c.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Let's say a design studio gets a new client project. The branding is ready, the wireframes are approved, the Figma file is polished, and every screen looks pretty much perfect. Then the developer joins the project. You say, “Here are the designs. Can you build this?”</p>
<p>That sounds simple, until the first question appears. What happens when the mobile version doesn't exist? What if the animation that looks perfect in Figma performs badly on a real phone? Who decides what happens when the client requests Google login three days before launch? And when a technical problem appears, does the developer speak directly to the client or go through the studio?</p>
<p>These questions aren't unimportant side issues. They're part of building the product. Research on designer-developer collaboration supports this. A <a href="https://www.researchgate.net/publication/391931704_Who_is_to_Blame_A_Comprehensive_Review_of_Challenges_and_Opportunities_in_Designer-Developer_Collaboration"><u>2025 systematic review</u></a> covering 45 papers found recurring challenges around how designers and developers communicate, coordinate work, and understand each other's responsibilities.</p>
<p>If you're a freelance developer, your job isn't just about writing code. You also need to make sure that design, technology, scope, communication and client expectations are moving in the same direction.</p>
<p>In this guide, we'll look at how you can work effectively with design studios, from understanding project requirements and handling design handoffs to managing client expectations and technical challenges. We'll also cover some practical ways to make sure projects move smoothly from the initial design to the final launch.</p>
<h3 id="heading-what-well-cover-here">What We'll Cover Here:</h3>
<ul>
<li><a href="#heading-first-understand-what-the-design-studio-is-actually-hiring-you-for">First, Understand What the Design Studio Is Actually Hiring You For</a><ul>
<li><a href="#heading-are-you-the-developer-the-technical-partner-or-just-the-implementer">Are You the Developer, the Technical Partner, or Just the Implementer?</a></li>
</ul>
</li>
<li><a href="#heading-dont-start-coding-until-you-understand-the-project">Don't Start Coding Until You Understand the Project</a><ul>
<li><a href="#heading-ask-questions-before-opening-your-code-editor">Ask Questions Before Opening Your Code Editor</a></li>
</ul>
</li>
<li><a href="#heading-treat-the-figma-file-as-a-conversation-not-a-contract">Treat the Figma File as a Conversation, Not a Contract</a></li>
<li><a href="#heading-define-who-owns-which-decisions">Define Who Owns Which Decisions</a><ul>
<li><a href="#heading-the-one-question-every-freelancer-should-ask">The One Question Every Freelancer Should Ask</a></li>
</ul>
</li>
<li><a href="#heading-set-up-a-workflow-that-doesnt-depend-on-constant-messages">Set Up a Workflow That Doesn't Depend on Constant Messages</a><ul>
<li><a href="#heading-give-every-stage-a-clear-done">Give Every Stage a Clear "Done"</a></li>
<li><a href="#heading-create-a-single-source-of-truth">Create a Single Source of Truth</a></li>
</ul>
</li>
<li><a href="#heading-handle-design-handoffs-like-an-engineer">Handle Design Handoffs Like an Engineer</a><ul>
<li><a href="#heading-look-for-the-screens-that-dont-exist">Look for the Screens That Don't Exist</a></li>
</ul>
</li>
<li><a href="#heading-scope-creep-is-easier-to-prevent-than-to-fight">Scope Creep Is Easier to Prevent Than to Fight</a><ul>
<li><a href="#heading-use-a-simple-change-rule">Use a Simple Change Rule</a></li>
</ul>
</li>
<li><a href="#heading-dont-hide-technical-problems-until-the-deadline">Don't Hide Technical Problems Until the Deadline</a></li>
<li><a href="#heading-review-the-build-together-not-just-at-the-end">Review the Build Together, Not Just at the End</a></li>
<li><a href="#heading-learn-to-push-back-without-becoming-difficult-to-work-with">Learn to Push Back Without Becoming Difficult to Work With</a></li>
<li><a href="#heading-keep-the-relationship-healthy-after-launch">Keep the Relationship Healthy After Launch</a></li>
<li><a href="#heading-a-simple-collaboration-checklist-for-freelance-developers">A Simple Collaboration Checklist for Freelance Developers</a></li>
<li><a href="#heading-final-thoughts">Final Thoughts</a></li>
</ul>
<h2 id="heading-first-understand-what-the-design-studio-is-actually-hiring-you-for">First, Understand What the Design Studio Is Actually Hiring You For</h2>
<p>This is an important section because freelancers often misunderstand their exact role in a project. A design studio or a <a href="https://helmsworkshop.com/"><u>branding agency</u></a> might hire you for frontend development, full-stack development, prototyping, or straight design to code implementation. They might also bring you in for CMS integration, API integration, performance optimization, or ongoing maintenance after launch.</p>
<p>But the role can vary dramatically depending on the studio's internal capabilities and the client's specific needs.</p>
<h3 id="heading-are-you-the-developer-the-technical-partner-or-just-the-implementer">Are You the Developer, the Technical Partner, or Just the Implementer?</h3>
<p>You must explain the difference and define your boundaries.</p>
<p>As an implementer, you simply receive finished designs and build them exactly as specified without questioning the logic. As a developer, you build the product but also actively raise technical concerns when you spot potential issues. As a technical partner, you get involved much earlier in the process and help determine what can actually be built within the budget and timeline.</p>
<p>Clarifying this exact dynamic before starting the work prevents many structural problems later.</p>
<h2 id="heading-dont-start-coding-until-you-understand-the-project">Don't Start Coding Until You Understand the Project</h2>
<p>A Figma file can tell you exactly what something looks like on a screen. It doesn't necessarily tell you how it should behave in the real world.</p>
<p>It won't show you what happens when data is missing from the server. It doesn't explain what happens on slow cellular networks or what happens on obscure mobile devices. It also leaves out what happens when user-generated text becomes too long or when users make critical mistakes. And it doesn't talk about which interactions are absolutely essential, and which parts are merely visual prototypes rather than final requirements.</p>
<h3 id="heading-ask-questions-before-opening-your-code-editor">Ask Questions Before Opening Your Code Editor</h3>
<p>You need a practical checklist to run through before touching your keyboard.</p>
<p>Regarding the product, ask: What problem is the product solving? Who is using it? What's the single most important user action?</p>
<p>Regarding the design, ask: Are these designs entirely final? Are there dedicated mobile and tablet versions available? Are all the shown animations part of the strict requirement?</p>
<p>Regarding the technical side, ask: Is there an existing backend in place? What specific technology stack is expected? Are all necessary APIs already available? Who handles the hosting environment?</p>
<p>Regarding the client, ask: Who communicates directly with the client? Who ultimately approves changes? Who gives you final feedback?</p>
<p><a href="https://getdx.com/blog/site-reliability-engineering/"><u>Google's engineering guidance</u></a> similarly stresses clear objectives and requirements because poor communication and vague expectations directly contribute to project failures</p>
<h2 id="heading-treat-the-figma-file-as-a-conversation-not-a-contract">Treat the Figma File as a Conversation, Not a Contract</h2>
<p>Explain to your partners that developers shouldn't treat design files as sacred objects that must be reproduced pixel for pixel regardless of technical reality. Instead, treat them as a starting point for an ongoing discussion.</p>
<p>For example, a designer creates a complex scrolling animation that looks genuinely great in the initial prototype. But during implementation you discover that it causes massive performance issues on lower-end mobile devices.</p>
<p>The wrong approach is saying something like: "The design says so, so I'll build it anyway." Another wrong approach is shutting it down by saying: "That simply won't work."</p>
<p>A much better approach is saying something along the lines of: "This interaction is entirely possible, but it may affect performance on mobile devices. Could we simplify the animation specifically for smaller screens?" That's constructive technical feedback without turning the conversation into a hostile design vs development argument.</p>
<p>This dynamic is supported by a <a href="https://www.researchgate.net/publication/391931704_Who_is_to_Blame_A_Comprehensive_Review_of_Challenges_and_Opportunities_in_Designer-Developer_Collaboration"><u>comprehensive 2025 systematic review</u></a> of designer-developer collaboration, which revealed that separate decision-making procedures and a lack of mutual understanding are persistent challenges in the field.</p>
<h2 id="heading-define-who-owns-which-decisions">Define Who Owns Which Decisions</h2>
<p>This is especially important when you're a freelancer working directly between a studio and its end client. You need to create a simple, clear responsibility framework. Usually, visual design is led solely by the design studio. UX decisions are generally handled by the design studio alongside the client.</p>
<p>Technical architecture, database structure and API implementation are led exclusively by the developer. Product priorities are decided by the client and the studio. Technical feasibility falls entirely to the developer. Final design approval rests with the client and the studio.</p>
<p>Emphasize that this framework isn't totally rigid. The important thing is universally knowing who has the final say in any given category.</p>
<h3 id="heading-the-one-question-every-freelancer-should-ask">The One Question Every Freelancer Should Ask</h3>
<p>"If we disagree about a design or technical decision, who makes the final call?" That one specific question can save countless hours of confusion and resentment later in the process.</p>
<h2 id="heading-set-up-a-workflow-that-doesnt-depend-on-constant-messages">Set Up a Workflow That Doesn't Depend on Constant Messages</h2>
<p>A simple, predictable workflow eliminates chaos, and you can systematically organize a standard workflow.</p>
<p>First is the brief, followed by the design review. Next comes technical planning and then the actual development phase. After that is the internal review, followed by client feedback. This leads to the revision stage, moving into QA and finally culminating in the launch.</p>
<p>Explaining each phase briefly ensures everyone knows where the project stands at any given moment.</p>
<h3 id="heading-give-every-stage-a-clear-done">Give Every Stage a Clear "Done"</h3>
<p>For example, a design review is officially complete when desktop and mobile designs exist, missing interactive states are identified, visual assets are available, and complex interactions are understood.</p>
<p>The development phase is complete when the feature actually works, responsive behavior is thoroughly checked, known bugs are actively documented, and the studio has a functional review build.</p>
<p>This clarity inherently prevents the classic miscommunication of: "I thought you were still working on it."</p>
<h3 id="heading-create-a-single-source-of-truth">Create a Single Source of Truth</h3>
<p>The core problem with freelance collaborations is that crucial information can become quickly scattered across Slack, email, WhatsApp, Figma comments, GitHub, Notion, Google Docs, random video calls, and text messages. Then six days later someone inevitably asks: "Why did we decide to remove that specific feature?" Nobody likely remembers the exact reason.</p>
<p>Recommend having one centralized place for core requirements, open questions, finalized decisions, technical constraints, scope changes, project deadlines and feedback.</p>
<p>An <a href="https://research.ibm.com/publications/collaboration-information-seeking-and-communication-an-observational-study-of-software-developers-work-practices"><u>observational study</u></a> of software developers' work practices found that developers spend about 45% of their time collaborating with colleagues and seeking information, making information visibility an essential part of software work. <a href="https://getdx.com/blog/site-reliability-engineering/"><u>Google's SRE guidance</u></a> also recommends writing decisions and project information down to heavily reduce problems caused by distance and fragmented communication.</p>
<h2 id="heading-handle-design-handoffs-like-an-engineer">Handle Design Handoffs Like an Engineer</h2>
<p>Instead of simply receiving a Figma link and guessing what to do, create a proper, methodical handoff checklist. Check typography, colors, spacing and breakpoints. Verify all assets, icons, component states and hover states. Confirm loading states, error states and empty states. Double-check mobile behavior, accessibility requirements and complex animations.</p>
<h3 id="heading-look-for-the-screens-that-dont-exist">Look for the Screens That Don't Exist</h3>
<p>Designers naturally often show the happy path where everything works perfectly. But developers absolutely have to build the empty dashboards and the failed API requests. You must account for loading screens, invalid forms, expired sessions, and missing images. You have to handle long usernames breaking the layout and slow connections timing out. This is precisely where developers add tangible value beyond simply translating a Figma canvas into HTML, CSS, and JavaScript.</p>
<h2 id="heading-scope-creep-is-easier-to-prevent-than-to-fight">Scope Creep Is Easier to Prevent Than to Fight</h2>
<p>The studio might initially say: "The client also wants a small dashboard." Then a few days later: "Can we subtly add notifications?" Then the next week: "Could we also seamlessly add Google login?"</p>
<p>None of these requests sound huge individually. But together, they can easily turn a straightforward two week project into an exhausting month-long project.</p>
<h3 id="heading-use-a-simple-change-rule">Use a Simple Change Rule</h3>
<p>Whenever something new appears in the pipeline, ask a series of questions. Is this part of the original scope? How much active work will it require? Does it negatively affect the deadline? Does it conflict with another feature? Who formally approves the change?</p>
<p>Don't make the section sound like aggressive negotiation advice. Frame it entirely as protecting the overall project from unclear expectations and budget overruns.</p>
<h2 id="heading-dont-hide-technical-problems-until-the-deadline">Don't Hide Technical Problems Until the Deadline</h2>
<p>Explain the freelancer's ethical and professional responsibility here. If you discover the API isn't actually ready, state it immediately. If the animation isn't practical, the design doesn't account for accessibility, the database structure needs completely changing, or a feature will take three days instead of three hours, say it early. Not two agonizing days before the scheduled launch. You can introduce a very simple principle: bad news gets significantly more expensive when you delay it.</p>
<h2 id="heading-review-the-build-together-not-just-at-the-end">Review the Build Together, Not Just at the End</h2>
<p>Explain clearly why continuous reviews are infinitely better than one giant final review at the very end of the timeline. A suggested review rhythm keeps everyone in the loop.</p>
<p>After a major component is finished, conduct a studio review. After the main page is completed, run a design review. After responsive implementation is coded, initiate a mobile review. Before the actual launch, perform a full QA.</p>
<p>This creates significantly smaller, highly manageable feedback loops. Instead of hearing: "The entire website is completely wrong," you get: "The spacing on this specific card is 4px too large." This is much easier and cheaper to fix.</p>
<h2 id="heading-learn-to-push-back-without-becoming-difficult-to-work-with">Learn to Push Back Without Becoming Difficult to Work With</h2>
<p>Developers sometimes respond to highly unrealistic requirements with a flat: "That's impossible."</p>
<p>Instead, you should frame it differently: "We can certainly build this, but there are two distinct trade-offs." Then explain the available paths clearly. Option A is visually closer to the design, but requires much more development time. Option B is a much simpler implementation, delivering significantly better performance. Option C is to keep the current approach for desktop interfaces and drastically simplify it on mobile devices.</p>
<p>This directly makes the developer a strategic problem solver rather than someone who simply says yes or no to every request.</p>
<h2 id="heading-keep-the-relationship-healthy-after-launch">Keep the Relationship Healthy After Launch</h2>
<p>The professional relationship shouldn't abruptly end the exact second the site goes live on the server. Clarify the boundaries of the bug-fix period and ongoing maintenance. Discuss future features, hosting responsibilities and comprehensive documentation. Manage the handover of access credentials and establish clear code ownership.</p>
<p>Most importantly, don't ever leave ownership ambiguous. The design studio should confidently know what happens if the client comes back three months later aggressively asking for a brand new feature.</p>
<h2 id="heading-a-simple-collaboration-checklist-for-freelance-developers">A Simple Collaboration Checklist for Freelance Developers</h2>
<p>Before the project officially starts, thoroughly define the core business logic, clarify your exact engineering responsibilities, and audit the UI/UX designs for edge cases or missing interactive states.</p>
<p>Once you understand the primary goals, confirm the complete technology stack, from the frontend framework to the database architecture, while setting strict boundaries around API dependencies, version control repositories, and sprint deadlines.</p>
<p>As you move into active development, consistently flag performance bottlenecks or query inefficiencies early, ensuring you document all major architectural shifts within your codebase. Rather than jumping straight into writing, break down the visual files into reusable component logic and shared design tokens before implementation begins. You should also share live staging builds frequently, track scope creep meticulously through an issue tracker, and continuously test how your DOM renders across various viewport breakpoints to catch responsive errors.</p>
<p>Finally, before pushing to the production environment, audit the compiled build against the original designs to ensure high-fidelity accuracy. Rigorously test state management across critical user routing flows, debug any layout shifts on mobile browsers, and verify that your semantic HTML meets baseline WCAG accessibility standards.</p>
<p>To wrap up the collaboration seamlessly, lock down the CI/CD deployment pipeline responsibilities and hand over a comprehensively documented repository that clearly explains the environmental variables, build scripts, and third-party integrations the studio will need moving forward.</p>
<h2 id="heading-final-thoughts">Final Thoughts</h2>
<p>A Figma file shows how a product should look, while development reveals everything it needs to handle when real people start using it.</p>
<p>That's why working with a design studio involves more than a simple design-to-code handoff. You'll also need to catch technical problems early, keep responsibilities clear, and make decisions before small issues become launch-day problems.</p>
<p>Throughout the project, stay in touch with the design team, share your technical concerns, and discuss changes before they affect the timeline. This will make it easier to handle unexpected problems and deliver a product that works well for the client and the people who will eventually use it.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Learn Vector Databases for Beginners – Embeddings, Similarity Search & ChromaDB ]]>
                </title>
                <description>
                    <![CDATA[ A vector database stores data as lists of numbers called embeddings, which lets it search by meaning instead of matching exact keywords. It's the piece behind a lot of recommendation systems, RAG apps ]]>
                </description>
                <link>https://www.freecodecamp.org/news/learn-vector-databases-for-beginners-embeddings-similarity-search-chromadb/</link>
                <guid isPermaLink="false">6aea06ca2d9e47407b2ddc76</guid>
                
                    <category>
                        <![CDATA[ vector database ]]>
                    </category>
                
                    <category>
                        <![CDATA[ youtube ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Beau Carnes ]]>
                </dc:creator>
                <pubDate>Tue, 03 Nov 2026 16:07:38 +0000</pubDate>
                <media:content url="https://i.ytimg.com/vi/lR931Iz8Z6U/maxresdefault.jpg" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>A vector database stores data as lists of numbers called embeddings, which lets it search by meaning instead of matching exact keywords. It's the piece behind a lot of recommendation systems, RAG apps, and AI agents.</p>
<p>We just published a comprehensive course on the freeCodeCamp.org YouTube channel that will teach you how vector databases work, starting with the math and ending with a working semantic search project. This course is from codeEdx, whose instructor has worked as a software engineer for 15 years and as a lead AI engineer for the last four. No prior knowledge is required.</p>
<p>In this course, you will learn:</p>
<ul>
<li><strong>Vectors &amp; Similarity Math</strong><br>Learn what a vector is and how cosine similarity and Euclidean distance measure how close two pieces of data are.</li>
<li><strong>Tokens &amp; Tokenization</strong><br>See why AI models can't read raw text and how a tokenizer splits a word like "indivisible" into more than one token.</li>
<li><strong>Embeddings &amp; Embedding Models</strong><br>Understand how text becomes vectors that capture meaning, including the classic king minus man plus woman equals queen example.</li>
<li><strong>Generating Embeddings with Gemini</strong><br>Follow a live demo using Google's Gemini embedding model, then visualize the results in 3D with principal component analysis (PCA).</li>
<li><strong>Inside a Vector Database</strong><br>Explore the storage, indexing, and query processing layers, and how IVF and HNSW indexes avoid slow brute force search.</li>
<li><strong>Semantic Search with ChromaDB</strong><br>Create a Chroma client and collection, store your embeddings, and run queries that return results based on meaning.</li>
</ul>
<p>Watch the full course on <a href="https://youtu.be/lR931Iz8Z6U">the freeCodeCamp.org YouTube channel</a> (1.5-hour watch).</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/lR931Iz8Z6U" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Detect Financial Fraud Patterns
with Knowledge Graphs: A Production
Guide ]]>
                </title>
                <description>
                    <![CDATA[ Most anti-fraud architectures in enterprise payment workflows evaluate transactions in isolation. When an invoice arrives, an automated rules engine checks it against static thresholds. It checks whet ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-detect-financial-fraud-patterns-with-knowledge-graphs-a-production-guide/</link>
                <guid isPermaLink="false">6ae4a260781c67f55b49b87b</guid>
                
                    <category>
                        <![CDATA[ knowledge graph ]]>
                    </category>
                
                    <category>
                        <![CDATA[ finance ]]>
                    </category>
                
                    <category>
                        <![CDATA[ graph database ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Kamal Kishore ]]>
                </dc:creator>
                <pubDate>Fri, 30 Oct 2026 13:57:52 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/223107d3-1947-4c50-aabe-a590e72a2fa9.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most anti-fraud architectures in enterprise payment workflows evaluate transactions in isolation.</p>
<p>When an invoice arrives, an automated rules engine checks it against static thresholds. It checks whether the dollar amount falls within normal limits, whether the vendor is on an approved vendor list, and whether the submitting IP address looks familiar. If the individual attributes look plausible, the payment clears.</p>
<p>The problem with this approach is structural. Sophisticated fraud schemes like money mule rings, circular invoicing loops, and vendor collision rarely produce transactions that alert individual thresholds. A shell company invoicing $4,800 to stay under a $5,000 dual sign-off threshold looks completely fine in isolation. But when 25 seemingly independent vendors all route disbursements to the same bank account, the coordinated pattern starts to become clear.</p>
<p>You won't find the warning in the attributes of an isolated transaction. It lives in the topology of the payment network.</p>
<p>In a relational database, tracing these multi-entity relationships requires recursive cell joins across tables of vendors, invoices, payments, and bank accounts. But as tables grow into millions of rows, deep path traversals quickly degrade query performance. Graph databases solve this by storing relationships as first-class, index-free adjacency pointers, turning multi-hop traversals into constant-time lookups.</p>
<p>In this tutorial, you'll explore the architecture of an end-to-end relational fraud detection pipeline built on Neo4j. You'll see how to model financial entities as a graph, precompute derived edges to optimize query latency, and write parameterized Cypher queries to uncover five core procurement fraud schemes.</p>
<p>The complete reference implementation is available in the <a href="https://github.com/kamalct/fraud-detection-knowledge-graphs">fraud-detection-knowledge-graphs</a> repository.</p>
<h3 id="heading-what-well-cover">What We'll Cover:</h3>
<ul>
<li><a href="#heading-why-tabular-rules-miss-relational-fraud">Why Tabular Rules Miss Relational Fraud</a></li>
<li><a href="#heading-prerequisites">Prerequisites</a></li>
<li><a href="#heading-how-to-model-the-financial-graph-and-ingestion-schema">How to Model the Financial Graph and Ingestion Schema</a></li>
<li><a href="#heading-how-to-detect-five-fraud-patterns-with-cypher-queries">How to Detect Five Fraud Patterns with Cypher Queries</a></li>
<li><a href="#heading-how-to-run-the-end-to-end-pipeline-and-validate-results">How to Run the End-to-End Pipeline and Validate Results</a></li>
<li><a href="#heading-production-architecture-how-to-scale-the-graph-pipeline">Production Architecture: How to Scale the Graph Pipeline</a></li>
<li><a href="#heading-key-architectural-lessons-and-takeaways">Key Architectural Lessons and Takeaways</a></li>
<li><a href="#heading-conclusion">Conclusion</a></li>
</ul>
<h2 id="heading-why-tabular-rules-miss-relational-fraud">Why Tabular Rules Miss Relational Fraud</h2>
<p>In accounts payable pipelines, risk checks typically operate on isolated records:</p>
<pre><code class="language-python"># Transaction-level view — nothing suspicious
transactions = [
    {"vendor": "Alpha Corp", "amount": 5200.00, "date": "2025-08-01"},
    {"vendor": "Beta LLC", "amount": 4800.00, "date": "2025-08-02"},
    {"vendor": "Gamma Inc", "amount": 5100.00, "date": "2025-08-03"},
]
# All amounts within normal range ($1K–$15K). All vendors valid.
# Dates are sequential. Nothing triggers any rule.
</code></pre>
<p>When you map those same records to an interconnected graph, the hidden structure becomes clear right away:</p>
<pre><code class="language-python"># Graph view — all three vendors pay to the same account
connections = {
    "ACC-MULE-001": ["Alpha Corp", "Beta LLC", "Gamma Inc"],
    "ACC-0042":     ["Delta Corp", "Epsilon LLC"],
    "ACC-0099":     ["Zeta GmbH"],
}
# ACC-MULE-001 receives from 25+ vendors. That's a money mule ring.
# The convergence is the red flag.
</code></pre>
<p>The underlying data hasn't changed. But when you evaluate transactions within an entity graph, the coordinated funnel stands out.</p>
<p>In a relational database, connecting these dots requires multiple joins across separate table. In a knowledge graph, the connections are stored directly as edges connecting nodes, which allows you to query them in a single traversal.</p>
<table>
<thead>
<tr>
<th>Pattern</th>
<th>What It Is</th>
<th>What Traditional Tools Don't Catch</th>
</tr>
</thead>
<tbody><tr>
<td>Duplicate Invoices</td>
<td>Same invoice and vendor, near-identical amount, close dates</td>
<td>Exact match misses slight variations</td>
</tr>
<tr>
<td>Circular Rings</td>
<td>Vendors invoicing each other in a closed loop</td>
<td>Each invoice looks legitimate alone</td>
</tr>
<tr>
<td>Money Mule Rings</td>
<td>Many vendors funneling to one controlled account</td>
<td>No single payment is anomalous</td>
</tr>
<tr>
<td>Vendor Collusion</td>
<td>Independent-looking vendors sharing underlying attributes</td>
<td>No cluster detection across entities</td>
</tr>
<tr>
<td>Payment Anomalies</td>
<td>Invoice approved by someone outside their department's authority</td>
<td>Role-based checks pass, context checks don't exist</td>
</tr>
</tbody></table>
<p>A knowledge graph doesn't replace rules engines or ML models. But it does fill the gap they can't reach: relationships.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along with this tutorial, you'll need:</p>
<ul>
<li>Python 3.10 or higher installed on your machine.</li>
<li>Docker and Docker Compose installed to run a local Neo4j instance.</li>
<li>Basic familiarity with Python and database concepts.</li>
</ul>
<h2 id="heading-how-to-model-the-financial-graph-and-ingestion-schema">How to Model the Financial Graph and Ingestion Schema</h2>
<p>Before running fraud queries, you need to structure your data into a cohesive property graph. The reference dataset models six entity types commonly found in financial systems:</p>
<table>
<thead>
<tr>
<th>Entity</th>
<th>Key Properties</th>
<th>Role</th>
</tr>
</thead>
<tbody><tr>
<td>Vendor</td>
<td>tax_id, name, country, city</td>
<td>Who issues invoices</td>
</tr>
<tr>
<td>Invoice</td>
<td>invoice_id, amount, date, category</td>
<td>What was billed</td>
</tr>
<tr>
<td>Payment</td>
<td>payment_id, amount, date</td>
<td>What was paid</td>
</tr>
<tr>
<td>BankAccount</td>
<td>account_id, routing_number, bank_name</td>
<td>Where money went</td>
</tr>
<tr>
<td>Employee</td>
<td>employee_id, name, department</td>
<td>Who approved</td>
</tr>
<tr>
<td>Organization</td>
<td>org_id, name, departments</td>
<td>Structural hierarchy</td>
</tr>
</tbody></table>
<p>The base relationships form a bipartite payment network:</p>
<pre><code class="language-plaintext">(Employee)-[:APPROVED]-&gt;(Payment)-[:LINKED_TO]-&gt;(Invoice)-[:ISSUED_BY]-&gt;(Vendor)
                                   |
                                   v
                            (Payment)-[:PAID_TO]-&gt;(BankAccount)
</code></pre>
<h3 id="heading-step-1-how-to-generate-the-dataset-with-known-injected-patterns">Step 1: How to Generate the Dataset with Known Injected Patterns</h3>
<p>Real-world financial datasets are difficult to access because of privacy and regulatory restrictions. Using synthetic data allows you to inject known fraud patterns and systematically verify that your detection queries find them.</p>
<p>The dataset generator creates approximately 500 entities with five known patterns embedded:</p>
<ul>
<li>12 duplicate invoice pairs</li>
<li>1 circular invoice ring (3 vendors)</li>
<li>1 money mule ring (25 vendors for 1 account)</li>
<li>1 vendor collusion cluster (4 vendors)</li>
<li>3 payment anomalies</li>
</ul>
<pre><code class="language-python">from dataset.generate import FraudDatasetGenerator

gen = FraudDatasetGenerator(
    num_vendors=100,
    num_employees=30,
    num_organizations=5,
    seed=42,
)
output_dir = gen.save("dataset/output")
</code></pre>
<p>Setting a fixed <code>seed=42</code> makes the dataset fully reproducible. Running the generator produces the exact same fraud patterns each time, giving you a reliable benchmark for testing queries:</p>
<pre><code class="language-plaintext">Vendors: 100
Invoices: 220 (200 base + 20 fraud)
Payments: 331 (300 base + 31 fraud)
Bank Accounts: 51 (50 + 1 mule)
Employees: 30
Organizations: 5
Fraud Patterns: 5 types
</code></pre>
<p>The generator also exports a ground-truth manifest <code>ground_truth.json</code> that logs every injected anomaly, and this serves as your automated validation oracle.</p>
<h3 id="heading-step-2-how-to-connect-to-neo4j-and-create-constraints">Step 2: How to Connect to Neo4j and Create Constraints</h3>
<p>You can spin up a local Neo4j 5.20 container with APOC procedures enabled using Docker Compose:</p>
<pre><code class="language-bash">docker compose up -d
</code></pre>
<p>Next, initialize the database driver and establish uniqueness constraints:</p>
<pre><code class="language-python">from graph.connection import FraudGraph

with FraudGraph() as graph:
    graph.create_constraints()
</code></pre>
<p>These constraints are critical for query performance. In Neo4j, unique property constraints automatically build underlying B-tree indexes. Without them, every MERGE operation forces an unindexed full scan across all nodes in the database:</p>
<pre><code class="language-python">class FraudGraph:
    def __init__(self, uri=None, user=None, password=None):
        self.uri = uri or os.environ.get('NEO4J_URI', 'bolt://localhost:7687')
        self.user = user or os.environ.get('NEO4J_USER', 'neo4j')
        self.password = password or os.environ.get('NEO4J_PASSWORD', 'password')
        self.driver = None

    def connect(self):
        self.driver = GraphDatabase.driver(
            self.uri, auth=(self.user, self.password),
            max_connection_pool_size=50,
            connection_acquisition_timeout=30,
        )
        with self.driver.session() as session:
            session.run('RETURN 1')
        return self

    def __enter__(self):
        self.connect()
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.close()

    def create_constraints(self):
        with self.driver.session() as session:
            session.run("CREATE CONSTRAINT vendor_tax_id IF NOT EXISTS FOR (v:Vendor) REQUIRE v.tax_id IS UNIQUE")
            session.run("CREATE CONSTRAINT invoice_id IF NOT EXISTS FOR (i:Invoice) REQUIRE i.invoice_id IS UNIQUE")
            session.run("CREATE CONSTRAINT account_id IF NOT EXISTS FOR (a:BankAccount) REQUIRE a.account_id IS UNIQUE")
            session.run("CREATE CONSTRAINT employee_id IF NOT EXISTS FOR (e:Employee) REQUIRE e.employee_id IS UNIQUE")
</code></pre>
<h3 id="heading-step-3-how-to-load-entities-into-the-graph">Step 3: How to Load Entities into the Graph</h3>
<p>The <code>FraudGraphLoader</code> uses Cypher's <code>UNWIND</code> to batch-insert entities in parameterized transactions:</p>
<pre><code class="language-python">from graph.loader import FraudGraphLoader

with FraudGraph() as graph:
    graph.create_constraints()
    loader = FraudGraphLoader(graph)
    loader.load_all(data)
</code></pre>
<p>The loading sequence respects foreign key dependencies: organizations are loaded first, followed by vendors, bank accounts, and employees, with invoices and payments loaded last.</p>
<p>Each load method follows the same pattern:</p>
<pre><code class="language-cypher">UNWIND $data AS v
MERGE (vendor:Vendor {tax_id: v.tax_id})
SET vendor.name = v.name,
    vendor.country = v.country,
    vendor.city = v.city
WITH vendor, v
MATCH (org:Organization {org_id: v.organization})
MERGE (vendor)-[:BELONGS_TO]-&gt;(org)
</code></pre>
<p><code>MERGE</code> creates or matches a node (using the constraint for O(1) lookup), <code>SET</code> attaches properties, and <code>MERGE</code> on the relationship connects nodes.</p>
<h3 id="heading-step-4-how-to-materialize-derived-relationships-for-fast-queries">Step 4: How to Materialize Derived Relationships for Fast Queries</h3>
<p>A central architectural decision in graph databases is deciding whether to compute relationships at query time or materialize them during ingestion.</p>
<p>Discovering whether two vendors share a bank account requires traversing a four-hop path:</p>
<pre><code class="language-cypher">(Vendor)&lt;-[:ISSUED_BY]-(Invoice)&lt;-[:LINKED_TO]-(Payment)-[:PAID_TO]-&gt;(BankAccount)&lt;-[:PAID_TO]-(Payment)-[:LINKED_TO]-&gt;(Invoice)-[:ISSUED_BY]-&gt;(Vendor)
</code></pre>
<p>Evaluating this four-hop path during every real-time fraud query causes significant computational overhead. Instead, you can materialize derived edges when data enters the system. This adds a slight write latency during batch ingestion, but it turns multi-hop traversals into direct, single-hop lookups during runtime detection queries.</p>
<p>The first derived relationship is <strong>SHARED_ACCOUNT_WITH,</strong> which links vendors that share a destination bank account:</p>
<pre><code class="language-cypher">MATCH (v1:Vendor)&lt;-[:ISSUED_BY]-(i1:Invoice)&lt;-[:LINKED_TO]-(p1:Payment)-[:PAID_TO]-&gt;(a:BankAccount)
MATCH (v2:Vendor)&lt;-[:ISSUED_BY]-(i2:Invoice)&lt;-[:LINKED_TO]-(p2:Payment)-[:PAID_TO]-&gt;(a)
WHERE id(v1) &lt; id(v2)
MERGE (v1)-[:SHARED_ACCOUNT_WITH]-&gt;(v2)
</code></pre>
<p>The filter <code>WHERE id(v1) &lt; id(v2)</code> ensures that the relationship is stored as a single directed edge between the internal node IDs, which prevents duplicate reverse edges.</p>
<p>You can also materialize shared geographic locations and banking routing prefixes to detect loose collusion networks:</p>
<pre><code class="language-cypher">-- Same city and country
MATCH (v1:Vendor), (v2:Vendor)
WHERE id(v1) &lt; id(v2)
  AND v1.city = v2.city
  AND v1.country = v2.country
MERGE (v1)-[:SHARED_LOCATION]-&gt;(v2)

-- Bank accounts with same routing prefix (first 3 digits)
MATCH (v1:Vendor)&lt;-[:ISSUED_BY]-(i1:Invoice)&lt;-[:LINKED_TO]-(p1:Payment)-[:PAID_TO]-&gt;(a1:BankAccount)
MATCH (v2:Vendor)&lt;-[:ISSUED_BY]-(i2:Invoice)&lt;-[:LINKED_TO]-(p2:Payment)-[:PAID_TO]-&gt;(a2:BankAccount)
WHERE id(v1) &lt; id(v2)
  AND a1.account_id &lt;&gt; a2.account_id
  AND substring(a1.routing_number, 0, 3) = substring(a2.routing_number, 0, 3)
MERGE (v1)-[:SHARED_ROUTING_PREFIX]-&gt;(v2)
</code></pre>
<p>Once ingestion completes, you can inspect the graph topology to confirm that both base and derived relationships loaded properly:</p>
<pre><code class="language-python">info = graph.get_info()
for label, count in sorted(info['nodes'].items(), key=lambda x: -x[1]):
    print(f"  {label}: {count} nodes")
for rel, count in sorted(info['relationships'].items(), key=lambda x: -x[1]):
    print(f"  {rel}: {count} relationships")
</code></pre>
<p>The output shows raw entities and the generated derived relationships:</p>
<pre><code class="language-plaintext">  Invoice: 220 nodes
  Payment: 331 nodes
  Vendor: 100 nodes
  BankAccount: 51 nodes
  Employee: 30 nodes

  SHARED_ACCOUNT_WITH: 412 relationships
  SHARED_LOCATION: 387 relationships
  LINKED_TO: 331 relationships
  PAID_TO: 331 relationships
  ISSUED_BY: 220 relationships
  APPROVED: 331 relationships
  SHARED_ROUTING_PREFIX: 52 relationships
</code></pre>
<p>These derived relationships are designed specifically for high-speed detection. In a relational database, discovering shared accounts requires a self-join across multiple tables. In a graph, it becomes a precomputed edge, turning complex traversals into direct lookups.</p>
<h2 id="heading-how-to-detect-five-fraud-patterns-with-cypher-queries">How to Detect Five Fraud Patterns with Cypher Queries</h2>
<p>Once your schema and derived relationships are established, you can construct parameterized Cypher queries that'll flag five widespread procurement fraud patterns.</p>
<h3 id="heading-pattern-1-how-to-find-duplicate-invoices">Pattern 1: How to Find Duplicate Invoices</h3>
<p>Fraudulent actors often submit duplicate invoices spaced days or weeks apart, assuming accounts payable teams will process them as routine recurring expenses.</p>
<p>Rule-based engines often catch exact matches, but they can miss invoices with slight date shifts. In a graph, you can query across all invoices issued by the same vendor within a rolling date window:</p>
<pre><code class="language-cypher">// graph/queries.py — detect_duplicates()
MATCH (v:Vendor)&lt;-[:ISSUED_BY]-(i1:Invoice),
      (v)&lt;-[:ISSUED_BY]-(i2:Invoice)
WHERE i1.invoice_id &lt; i2.invoice_id
  AND i1.amount = i2.amount
  AND i1.date &lt; i2.date
  AND duration.between(date(i1.date), date(i2.date)).days &lt;= $max_days
RETURN i1.invoice_id AS invoice_a,
       i2.invoice_id AS invoice_b,
       v.name AS vendor,
       i1.amount AS amount,
       i1.date AS date_a,
       i2.date AS date_b,
       duration.between(date(i1.date), date(i2.date)).days AS days_apart
ORDER BY days_apart
</code></pre>
<p>Key points here:</p>
<ul>
<li>The condition <code>i1.invoice_id &lt; i2.invoice_id</code> prevents returning the same pair twice like <code>(A, B)</code> and <code>(B, A)</code>.</li>
<li><code>i1.amount = i2.amount</code> is the fraud signal: same amount means likely the same invoice submitted twice.</li>
<li>By scoping the search to invoices issued by the same vendor v, the query leverages index lookups on the vendor before evaluating date intervals with the native temporal duration.between() function.</li>
</ul>
<p>You can execute this query through the Python wrapper:</p>
<pre><code class="language-python">from graph.queries import FraudQueries

queries = FraudQueries(graph)
duplicates = queries.detect_duplicates(max_days_apart=14)
print(f"Found {len(duplicates)} duplicate invoice pairs")
for dup in duplicates[:5]:
    print(f"  {dup['invoice_a']} ↔ {dup['invoice_b']} | "
          f"{dup['vendor']} | ${dup['amount']} | "
          f"{dup['days_apart']} days apart")
</code></pre>
<p>The query detects all 12 duplicate pairs injected into the synthetic dataset, achieving 100% recall.</p>
<h3 id="heading-pattern-2-how-to-uncover-circular-invoicing-rings">Pattern 2: How to Uncover Circular Invoicing Rings</h3>
<p>In circular invoicing schemes, three or more vendors bill one another in a closed loop. Each transaction appears legitimate on its own, but no actual goods or services are delivered.</p>
<p>Because you precomputed <code>SHARED_ACCOUNT_WITH</code>, cycle detection is simplified to finding three-node triangles in the vendor sub-graph:</p>
<pre><code class="language-cypher">// graph/queries.py — detect_circular_schemes()
MATCH path = (v1:Vendor)-[:SHARED_ACCOUNT_WITH]-(v2:Vendor)
              -[:SHARED_ACCOUNT_WITH]-(v3:Vendor)
              -[:SHARED_ACCOUNT_WITH]-(v1)
WHERE id(v1) &lt; id(v2) AND id(v2) &lt; id(v3)
RETURN path,
       [n IN nodes(path) | {name: n.name, tax_id: n.tax_id}] AS vendors,
       length(path) AS hops
</code></pre>
<p>Key points here:</p>
<ul>
<li>The constraint <code>id(v1) &lt; id(v2)</code> AND <code>id(v2) &lt; id(v3)</code> eliminates cyclic permutations. Without it, a single three-node ring <code>(A, B, C)</code> would be returned three separate times.</li>
<li>If you ran this query directly over raw four-hop transactional paths without materialized edges, finding a three-cycle would require traversing twelve hops, which causes high CPU usage on large datasets.</li>
</ul>
<pre><code class="language-python">circles = queries.detect_circular_schemes(min_cycle_length=3)
print(f"Found {len(circles)} circular invoice rings")
for ring in circles:
    vendor_names = [v['name'] for v in ring['vendors']]
    print(f"  Ring ({ring['hops']} hops): {' ↔ '.join(vendor_names)}")
</code></pre>
<p>The query catches the circular invoicing ring with 100% recall.</p>
<h3 id="heading-pattern-3-how-to-detect-money-mule-networks">Pattern 3: How to Detect Money Mule Networks</h3>
<p>In a money mule setup, multiple shell entities funnel money into a single account controlled by a syndicate. The individual disbursements are small, but the fan-in degree of the destination account is anomalously high.</p>
<p>You can uncover this pattern by performing degree analysis on destination bank accounts:</p>
<pre><code class="language-cypher">// graph/queries.py — detect_money_mule_rings()
MATCH (a:BankAccount)&lt;-[p_paid:PAID_TO]-(p:Payment)-[:LINKED_TO]-&gt;(i:Invoice)
                  -[:ISSUED_BY]-&gt;(v:Vendor)
WITH a, collect(DISTINCT v) AS vendors,
     sum(p.amount) AS total_received,
     count(DISTINCT v) AS unique_vendors,
     count(p) AS payment_count
WHERE unique_vendors &gt;= $min_vendors
RETURN a.account_id,
       a.routing_number,
       a.bank_name,
       unique_vendors AS vendor_count,
       payment_count,
       total_received AS total_amount,
       [vendor IN vendors | vendor.name] AS vendor_names
ORDER BY unique_vendors DESC
</code></pre>
<p>You can use the following risk guide to evaluate accounts based on incoming connections:</p>
<table>
<thead>
<tr>
<th>Vendor Count</th>
<th>Risk Level</th>
</tr>
</thead>
<tbody><tr>
<td>1–3</td>
<td>Normal</td>
</tr>
<tr>
<td>4–9</td>
<td>Investigate</td>
</tr>
<tr>
<td>10+</td>
<td>Likely money mule</td>
</tr>
</tbody></table>
<p>In production, you can add a threshold on total received amount to minimize false positives:</p>
<pre><code class="language-cypher">WHERE unique_vendors &gt;= 10 AND total_received &gt; 50000
</code></pre>
<p>Running the detection query in Python:</p>
<pre><code class="language-python">mules = queries.detect_money_mule_rings(min_vendor_count=5)
for acct in mules:
    print(f"  {acct['a.account_id']} ({acct['a.bank_name']}) | "
          f"{acct['vendor_count']} vendors | ${acct['total_amount']:.0f}")
</code></pre>
<p>The query successfully identifies the injected mule ring (ACC-MULE-001), which aggregates payments across 25 shell vendors.</p>
<h3 id="heading-pattern-4-how-to-identify-vendor-collusion-clusters">Pattern 4: How to Identify Vendor Collusion Clusters</h3>
<p>Colluding shell companies often mask their affiliation by registering under different corporate names. But they frequently leak metadata, such as shared physical office suites or identical banking branch routing prefixes.</p>
<p>To identify these clusters, you can evaluate multiple derived relationships concurrently and compute a composite structural risk score:</p>
<pre><code class="language-cypher">// graph/queries.py — detect_vendor_collusion()
MATCH (v:Vendor)
OPTIONAL MATCH (v)-[:SHARED_ACCOUNT_WITH]-&gt;(v2)
OPTIONAL MATCH (v)-[:SHARED_LOCATION]-&gt;(v3)
OPTIONAL MATCH (v)-[:SHARED_ROUTING_PREFIX]-&gt;(v4)
WITH v,
     collect(DISTINCT v2) AS shared_account_vendors,
     collect(DISTINCT v3) AS shared_location_vendors,
     collect(DISTINCT v4) AS shared_routing_vendors
WITH v,
     size(shared_account_vendors) AS sc,
     size(shared_location_vendors) AS sl,
     size(shared_routing_vendors) AS sr
WHERE (sc + sl + sr) &gt;= $min_score AND (sc + sl + sr) &gt; 0
RETURN v.name AS vendor,
       v.tax_id,
       (sc + sl + sr) AS risk_score,
       [n IN shared_account_vendors + shared_location_vendors + shared_routing_vendors | n.name] AS related_vendors,
       [x IN range(1, sc) | 'shared_account'] +
       [x IN range(1, sl) | 'shared_location'] +
       [x IN range(1, sr) | 'shared_routing'] AS flags
ORDER BY risk_score DESC
</code></pre>
<p>Key points here:</p>
<ul>
<li>Using OPTIONAL MATCH is essential here. A strict MATCH would drop vendors that exhibit shared routing and location but haven't yet shared a bank account.</li>
<li>You can tune the risk score based on your business environment. Sharing a city is common in large commercial hubs, but sharing a routing prefix and an office suite at the same time elevates the composite score past the investigative threshold.</li>
</ul>
<pre><code class="language-python">collusion = queries.detect_vendor_collusion(min_risk_score=2)
for vendor in collusion[:10]:
    print(f"  {vendor['vendor']} | Risk: {vendor['risk_score']} | "
          f"Flags: {vendor['flags']}")
</code></pre>
<p>The multi-attribute scoring query successfully flags the four-vendor collusion cluster.</p>
<h3 id="heading-pattern-5-how-to-flag-out-of-jurisdiction-payment-approvals">Pattern 5: How to Flag Out-of-Jurisdiction Payment Approvals</h3>
<p>Internal fraud often relies on an employee approving an invoice outside their departmental authority. For instance, an HR manager might approve an engineering infrastructure vendor invoice without authorization.</p>
<p>Approval systems often confirm that an approver has signing authority, but they rarely validate whether the approver's department has jurisdiction over the invoice category. A graph query can traverse the full approval path and evaluate departmental permissions:</p>
<pre><code class="language-cypher">// graph/queries.py — detect_payment_anomalies()
MATCH (inv:Invoice)&lt;-[:LINKED_TO]-(p:Payment),
      (e:Employee)-[:APPROVED]-&gt;(p),
      (e)-[:WORKS_FOR]-&gt;(org:Organization)
WITH inv, p, e, org,
     CASE
       WHEN inv.category = 'procurement' AND
            e.department IN ['finance', 'operations', 'procurement']
            THEN 'ok'
       WHEN inv.category = 'payroll' AND
            e.department IN ['hr', 'finance']
            THEN 'ok'
       WHEN inv.category = 'travel' AND
            e.department IN ['operations', 'finance', 'hr']
            THEN 'ok'
       WHEN inv.category = 'marketing' AND
            e.department IN ['marketing', 'finance']
            THEN 'ok'
       WHEN inv.category = 'consulting' AND
            e.department IN ['finance', 'operations', 'procurement']
            THEN 'ok'
       WHEN inv.category = 'services' AND
            e.department IN ['finance', 'operations', 'procurement']
            THEN 'ok'
       WHEN inv.category = 'supplies' AND
            e.department IN ['operations', 'procurement', 'finance']
            THEN 'ok'
       WHEN inv.category = 'maintenance' AND
            e.department IN ['operations', 'procurement', 'finance']
            THEN 'ok'
       WHEN inv.category = 'software' AND
            e.department IN ['engineering', 'operations', 'finance']
            THEN 'ok'
       ELSE 'ANOMALY'
     END AS approval_status
WHERE approval_status = 'ANOMALY'
RETURN inv.invoice_id, inv.amount, inv.category,
       inv.vendor_name, inv.date,
       e.name AS approver, e.employee_id,
       e.department AS approver_department
ORDER BY inv.date DESC
</code></pre>
<p>Key points here:</p>
<ul>
<li>The default-deny <code>ELSE 'ANOMALY'</code> structure ensures that any invoice category not explicitly authorized fails closed.</li>
<li>In enterprise architectures, you should materialize this policy logic directly in the graph using <code>(:Department)-[:AUTHORIZED_FOR]-&gt;(:Category)</code> nodes rather than hardcoding values in queries. This allows compliance teams to update authority matrices without deploying new code.</li>
</ul>
<pre><code class="language-python">anomalies = queries.detect_payment_anomalies()
print(f"Found {len(anomalies)} anomalous approvals")
for a in anomalies:
    print(f"  Invoice {a['inv.invoice_id']}: "
          f"{a['e.name']} ({a['e.department']}) approved "
          f"{a['inv.category']} | ${a['inv.amount']}")
</code></pre>
<p>The query surfaces all 3 injected approval anomalies with 100% recall.</p>
<h2 id="heading-how-to-run-the-end-to-end-pipeline-and-validate-results">How to Run the End-to-End Pipeline and Validate Results</h2>
<p>You can orchestrate these steps into an automated command-line pipeline:</p>
<pre><code class="language-bash">python analysis/run_detections.py
</code></pre>
<p>The end-to-end data flow operates in six distinct phases:</p>
<pre><code class="language-plaintext">Generate Dataset → Load to Neo4j → Build Derived Relationships → Run 5 Queries → Save Results → Validate
</code></pre>
<h3 id="heading-how-to-run-ingestion-and-detection">How to Run Ingestion and Detection</h3>
<p>The main pipeline script orchestrates data generation, schema creation, relationship materialization, and query execution:</p>
<pre><code class="language-python"># analysis/run_detections.py
from dataset.generate import FraudDatasetGenerator
from graph.connection import FraudGraph
from graph.loader import FraudGraphLoader
from graph.queries import FraudQueries

def run_pipeline(output_dir='analysis/output', force_regenerate=False):
    os.makedirs(output_dir, exist_ok=True)

    # Step 1: Generate or load dataset
    dataset_path = os.path.join(output_dir, 'data')
    if not os.path.exists(os.path.join(dataset_path, 'vendors.json')) or force_regenerate:
        gen = FraudDatasetGenerator(seed=42)
        gen.save(dataset_path)
    else:
        print(f'Dataset already at: {dataset_path}')

    # Load JSON files
    data = {}
    for name in ['vendors', 'invoices', 'payments', 'bank_accounts',
                  'employees', 'organizations', 'ground_truth']:
        with open(os.path.join(dataset_path, f'{name}.json')) as f:
            data[name] = json.load(f)

    # Step 2: Load into Neo4j
    graph = FraudGraph()
    graph.connect()
    graph.create_constraints()
    loader = FraudGraphLoader(graph)
    loader.load_all(data)
    loader.create_shared_account_relationships()
    loader.create_attribute_based_relationships()

    info = graph.get_info()
    print('Graph loaded:')
    for label, count in sorted(info['nodes'].items(), key=lambda x: -x[1]):
        print(f'  {label}: {count} nodes')

    # Step 3: Run all detections
    queries = FraudQueries(graph)
    results = queries.detect_all()

    # Step 4: Save results
    with open(os.path.join(output_dir, 'detection_results.json'), 'w') as f:
        json.dump(results, f, indent=2, default=str)

    for pattern, items in results.items():
        print(f'  {pattern}: {len(items)} findings')
</code></pre>
<p>The <code>detect_all()</code> method runs all five queries and returns a single dict:</p>
<pre><code class="language-python"># graph/queries.py
def detect_all(self):
    return {
        'duplicate_invoices': self.detect_duplicates(),
        'circular_schemes': self.detect_circular_schemes(),
        'money_mule_rings': self.detect_money_mule_rings(),
        'vendor_collusion': self.detect_vendor_collusion(),
        'payment_anomalies': self.detect_payment_anomalies(),
    }
</code></pre>
<h3 id="heading-how-to-validate-recall-against-injected-ground-truth">How to Validate Recall Against Injected Ground Truth</h3>
<p>The validation script compares query results against the ground-truth file to measure recall:</p>
<pre><code class="language-python"># analysis/validate.py
from collections import defaultdict

def validate(results_path, output_path=None):
    with open(results_path) as f:
        results = json.load(f)

    with open(os.path.join(os.path.dirname(results_path), 'data',
                           'ground_truth.json')) as f:
        ground_truth = json.load(f)

    gt_by_pattern = defaultdict(list)
    for pattern in ground_truth:
        gt_by_pattern[pattern['pattern']].append(pattern)
</code></pre>
<p>The verification logic evaluates detection accuracy for each pattern type:</p>
<pre><code class="language-python">for pattern_name, gt_patterns in gt_by_pattern.items():
    found = results.get(pattern_name, [])
    gt_count = len(gt_patterns)

    if pattern_name == 'duplicate_invoices':
        print(f"DUPLICATE INVOICES: DETECTED {len(found)} of {gt_count} pairs — {len(found)/max(1,gt_count):.0%} recall")

    elif pattern_name == 'money_mule_rings':
        matched = sum(1 for gt in gt_patterns
                      for r in found
                      if r.get('a.account_id') == gt['shared_account'])
        print(f"MONEY MULE RINGS: DETECTED {matched} of {gt_count} rings — {matched/max(1,gt_count):.0%} recall")
</code></pre>
<p>Running the validator against a dataset generated with <code>seed=42</code> produces these results:</p>
<pre><code class="language-plaintext">DUPLICATE INVOICES: DETECTED 12 of 12 pairs — 100% recall
CIRCULAR SCHEMES: DETECTED 1 of 1 rings — 100% recall
MONEY MULE RINGS: DETECTED 1 of 1 rings — 100% recall
VENDOR COLLUSION: 89 high-risk vendors flagged (multi-attribute scoring)
PAYMENT ANOMALIES: DETECTED 3 of 3 anomalies — 100% recall
</code></pre>
<h3 id="heading-how-to-execute-the-complete-workflow">How to Execute the Complete Workflow</h3>
<p>Here's the complete sequence of terminal commands to set up the environment, run the pipeline, and validate the results:</p>
<pre><code class="language-bash"># 1. Start Neo4j
docker compose up -d

# 2. Install dependencies
pip install -r requirements.txt

# 3. Run the full pipeline
python analysis/run_detections.py

# 4. Validate results
python analysis/validate.py

# 5. (Optional) Regenerate with fresh data and re-run
python analysis/run_detections.py --regenerate
</code></pre>
<h2 id="heading-production-architecture-how-to-scale-the-graph-pipeline">Production Architecture: How to Scale the Graph Pipeline</h2>
<p>While the reference prototype operates on hundreds of nodes, production fraud detection engines handle millions of transactions daily. Scaling this architecture requires addressing key operational bottlenecks.</p>
<pre><code class="language-plaintext">New Transaction
    ↓
Stage 1 — Deterministic queries (fast, rule-based)
    ↓  (if any pattern matches)
Stage 2 — ML scoring on graph features
    ↓
Risk Score → Alert / Approve / Escalate
</code></pre>
<h3 id="heading-how-to-implement-a-two-stage-detection-architecture">How to Implement a Two-Stage Detection Architecture</h3>
<p>Running global cycle detection queries synchronously on every incoming transaction inside an online payment path will exhaust database connection pools.</p>
<p>Instead, you should partition detection into two tiers:</p>
<ul>
<li><strong>Stage 1 (Synchronous Gating):</strong> Sub-millisecond Cypher queries that catch known patterns. They're fast because they use indexed properties, pre-built relationships, and bounded pattern lengths.</li>
<li><strong>Stage 2 (Asynchronous Graph Analytics):</strong> For transactions that don't trigger a deterministic rule, compute graph-derived features and score them:</li>
</ul>
<h3 id="heading-how-to-tune-thresholds-and-manage-false-positives">How to Tune Thresholds and Manage False Positives</h3>
<p>Detection systems inevitably produce false positives. In production, review feedback from compliance analysts should be used to continuously calibrate query thresholds:</p>
<pre><code class="language-python">#Tune thresholds based on false positive rate

queries.detect_money_mule_rings(min_vendor_count=10) # was 5
queries.detect_duplicates(max_days_apart=7) # was 14
queries.detect_vendor_collusion(min_risk_score=3) # was 2
</code></pre>
<p>You can rerun validation after each threshold update to monitor the effect on recall:</p>
<pre><code class="language-python">python analysis/validate.py --input analysis/output/detection_results.json
</code></pre>
<h3 id="heading-how-to-mitigate-supernode-expansion">How to Mitigate Supernode Expansion</h3>
<p>A common failure in graph systems is the "supernode" problem. Legitimate shared entities (for example, a major corporate landlord, a national power utility, or internal payroll accounts) can accumulate tens of thousands of incoming edges.</p>
<p>When a query attempts to traverse through a supernode without filtering, traversal latency spikes exponentially.</p>
<p>You can mitigate this with two strategies:</p>
<ul>
<li><strong>Degree Limits:</strong> Add degree pruning in traversals:</li>
</ul>
<p>MATCH (a:BankAccount)</p>
<p>WHERE size((a)&lt;-[:PAID_TO]-()) &lt; 1000</p>
<ul>
<li><strong>Node Labels:</strong> Tag known high-volume institutional entities with a :Whitelisted or :Utility label, and explicitly exclude them from collusion and money mule scans.</li>
</ul>
<h3 id="heading-how-to-use-graph-features-for-machine-learning">How to Use Graph Features for Machine Learning</h3>
<p>The most effective fraud detection systems don't treat graphs and machine learning as competing paradigms. Instead, the graph acts as an advanced feature store for predictive models:</p>
<ul>
<li><strong>Degree Centrality:</strong> Measures the connectivity of an account to identify mule convergence.</li>
<li><strong>Betweenness Centrality:</strong> Identifies entities that act as bridges between seemingly disconnected vendor groups.</li>
<li><strong>PageRank and Community Detection:</strong> Quantifies cluster association and entity importance across the network.</li>
</ul>
<p>An ML classifier relying solely on transaction amounts might see nothing unusual about a $4,900 payment. But when paired with a graph-derived feature showing the vendor has a high betweenness centrality and shares a second-degree connection with a confirmed fraud ring, the classifier flags the transaction with high precision.</p>
<h2 id="heading-key-architectural-lessons-and-takeaways">Key Architectural Lessons and Takeaways</h2>
<p>Building and tuning graph-based fraud pipelines highlights several key trade-offs:</p>
<p>First, in a relational database, schema design prioritizes third normal form (3NF) to eliminate data redundancy. In a graph, design your schema around the traversals you need to execute. If a query repeatedly traverses four hops to find correlated attributes, materialize that correlation as a direct relationship during ingestion.</p>
<p>Second, unique property constraints aren't just for data integrity. They provide the underlying hash indexes that make node lookups $O(1)$. Omitting constraints turns every graph merge into an unindexed full scan.</p>
<p>Third, you should curate the rule Inventory**.** Start with a focused set of four or five high-signal patterns. Each pattern requires threshold calibration and triage workflows. Adding dozens of unverified heuristics simultaneously creates operational noise and alert fatigue for compliance teams.</p>
<p>And finally, close the analyst feedback loop. The output of a detection query is a hypothesis, not a final verdict. When compliance analysts investigate an alert and confirm fraud, write that status back into the graph as a node property or label (for example, :ConfirmedFraud). This enriches the neighborhood topology and provides clean ground truth for training downstream machine learning models.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this tutorial, you explored why point-in-time relational checks miss complex fraud schemes, how to model accounts payable data as an interconnected graph, and how to materialize derived edges to accelerate traversal latency.</p>
<p>You also implemented five Cypher queries capable of detecting duplicate billing, circular rings, money mules, vendor collusion, and unauthorized payment approvals.</p>
<p><em>The companion code for this article is available at</em> <a href="https://github.com/kamalct/fraud-detection-knowledge-graphs"><em>github.com/kamalct/fraud-detection-knowledge-graphs</em></a><em>. All data is synthetic and fully reproducible.</em></p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
