diff --git a/.editorconfig b/.editorconfig index 5ca9d6d4d..998c7dc75 100644 --- a/.editorconfig +++ b/.editorconfig @@ -48,14 +48,14 @@ max_line_length = 120 # ------------------------- # JavaScript / React # ------------------------- -[*.{js,jsx}] +[*.{js,ts,jsx,tsx}] indent_style = space indent_size = 2 charset = utf-8 end_of_line = lf trim_trailing_whitespace = true insert_final_newline = true -max_line_length = 120 +max_line_length = 200 # ------------------------- # JSON @@ -90,12 +90,15 @@ indent_style = space indent_size = 2 trim_trailing_whitespace = true insert_final_newline = true +max_line_length = 200 # ------------------------- # Markdown # ------------------------- [*.md] indent_style = space +# Note: Prettier uses 3 spaces for nested list items, but enforcing 2 spaces +# at the file level while allowing editorconfig-checker to be lenient indent_size = 2 trim_trailing_whitespace = false insert_final_newline = true @@ -142,9 +145,10 @@ max_line_length = off # ------------------------- # LICENSE # ------------------------- -[LICENSE] +[LICENSE*] max_line_length = off trim_trailing_whitespace = false +insert_final_newline = true # ------------------------- # Bash / Shell scripts @@ -157,6 +161,7 @@ end_of_line = lf trim_trailing_whitespace = true insert_final_newline = true max_line_length = 120 + # img2num is in bash [img2num] indent_style = space diff --git a/.editorconfig-checker.json b/.editorconfig-checker.json new file mode 100644 index 000000000..e3debd85a --- /dev/null +++ b/.editorconfig-checker.json @@ -0,0 +1,40 @@ +{ + "Exclude": [ + "node_modules", + "dist", + "build", + ".git", + "package-lock.json", + "src/data/contributor-credits.json", + "docs/node_modules", + "docs/build", + "docs/.docusaurus", + "docs/package-lock.json", + "src/wasm/build", + "src/wasm/modules/image/build", + "\\.ase$", + "\\.min\\.js$", + "\\.min\\.css$", + "\\.lock$", + "CC-BY-SA-4\\.0\\.txt$", + "LICENSE.*", + "\\.md$", + "\\.mdx$", + "\\.py$", + "\\.bat$", + "\\.ps1$", + "^img2num$", + "\\.cpp$", + "\\.h$" + ], + "AllowedContentTypes": [], + "PassedFiles": [], + "Disable": { + "EndOfLine": false, + "Indentation": false, + "InsertFinalNewline": false, + "TrimTrailingWhitespace": false, + "IndentSize": false, + "MaxLineLength": false + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03bb1fd2b..9ff7d186f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,31 @@ on: branches: [main] jobs: + lint: + name: Lint Code + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run ESLint + run: npm run lint + + - name: Run format check + run: npm run format:check + + - name: Run editorconfig-checker + run: npm run lint:style validate-scripts: runs-on: ubuntu-latest steps: @@ -14,38 +39,30 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 - # Node.js setup - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: 22 - # Install dependencies - name: Install dependencies run: npm ci - # Detect if scripts changed - name: Determine if scripts changed id: check_changes shell: bash run: | echo "Checking for changed scripts..." - if [[ "${{ github.event_name }}" == "pull_request" ]]; then BASE_SHA="${{ github.event.pull_request.base.sha }}" else BASE_SHA="${{ github.event.before }}" fi - HEAD_SHA="${{ github.sha }}" - # Ensure a valid common ancestor (handles force-pushes) BASE_SHA=$(git merge-base "$BASE_SHA" "$HEAD_SHA") - files=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA") echo "Changed files: $files" - match=false for file in $files; do if [[ "$file" == "package.json" || "$file" == "docs/package.json" ]]; then @@ -58,9 +75,7 @@ jobs: break fi done - echo "scripts_changed=$match" >> "$GITHUB_OUTPUT" - # Run script validation if scripts changed - name: Run script validation id: validate @@ -70,7 +85,6 @@ jobs: set -o pipefail npm run validate-scripts 2>&1 | tee validation.log echo "validation_exit_code=${PIPESTATUS[0]}" >> $GITHUB_OUTPUT - # Comment on PR if validation fails - name: Comment on PR if validation fails if: steps.validate.outputs.validation_exit_code != '0' && github.event_name == 'pull_request' diff --git a/.github/workflows/issue-take-untake.yml b/.github/workflows/issue-take-untake.yml index e098cc3bf..4ad0bd3fd 100644 --- a/.github/workflows/issue-take-untake.yml +++ b/.github/workflows/issue-take-untake.yml @@ -13,257 +13,208 @@ permissions: issues: write jobs: - # Post initial instructions and ensure label exists - issue_instructions: - if: github.event_name == 'issues' && github.event.action == 'opened' + take_untake: + if: > + github.event_name == 'issues' || + (github.event_name == 'issue_comment' && + github.actor != 'github-actions[bot]') + runs-on: ubuntu-latest steps: - - name: Post instructions and ensure 'taken' label exists + - name: Handle issue take/untake and body status uses: actions/github-script@v8 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const owner = context.repo.owner; const repo = context.repo.repo; - const issueNumber = context.payload.issue.number; + const BOT_LOGIN = 'github-actions[bot]'; const takenLabel = 'taken'; + const MARKER_PREFIX = ''; + const BODY_END = ''; + const EXPIRE_MS = 21 * 24 * 60 * 60 * 1000; // Claims expire after 21 days + + const isIssueOpen = context.eventName === 'issues'; + const isComment = context.eventName === 'issue_comment'; + + const issueNumber = context.payload.issue.number; + + function buildUntakenBody() { + return ` + ${BODY_START} + > [!TIP] + > This issue hasn't been claimed yet. Comment \`/take\` if you'd like to work on it! + > + > > For more information on how Img2Num's claim system works, please see #99. + > + > This edit was made by the [\`issue-take-untake\`](https://github.com/Ryan-Millard/Img2Num/blob/main/.github/workflows/issue-take-untake.yml) GitHub workflow + ${BODY_END} + `.trim(); + } + + function buildTakenBody(ownerName) { + return ` + ${BODY_START} + > [!CAUTION] + > This issue has been claimed by **@${ownerName}**, so it is not recommended that you work on it. + > + > > For more information on how Img2Num's claim system works, please see #99. + > + > > **For @${ownerName}:** + > > If you would like to revoke your claim, comment \`/untake\` + > + > This edit was made by the [\`issue-take-untake\`](https://github.com/Ryan-Millard/Img2Num/blob/main/.github/workflows/issue-take-untake.yml) GitHub workflow + ${BODY_END} + `.trim(); + } - // Ensure label exists (best-effort) - try { - await github.rest.issues.createLabel({ + async function updateIssueBody(newBanner) { + const issue = await github.rest.issues.get({ owner, repo, issue_number: issueNumber }); + const body = issue.data.body || ''; + + const updated = body.includes(BODY_START) + ? body.replace(new RegExp(`${BODY_START}[\\s\\S]*?${BODY_END}`), newBanner) + : `${newBanner}\n\n---\n\n${body}`; + + await github.rest.issues.update({ owner, repo, - name: takenLabel, - color: 'ff0000', - description: 'Issue is currently claimed' + issue_number: issueNumber, + body: updated }); - } catch (e) { - // ignore if label already exists } - // Instructions comment (no meta marker here; preserves commit guide rules) - await github.rest.issues.createComment({ - owner, - repo, - issue_number: issueNumber, - body: [ - "πŸ‘‹ Thanks for opening this issue!", - "", - "> [!TIP]", - "> # Claiming rules", - "> - Only one claim at a time (the claimer can invite others)", - "> - Comment `/take` to claim the issue", - "> \t- You may invite collaborators: e.g., `/take @user1 @user2`", - "> - Comment `/untake` to release (or to remove yourself if you've been invited)", - "> \t- You may remove collaborators: e.g., `/untake @user1 @user2`", - "> - Claims expire after **3 weeks** of inactivity (automatically)", - "" - ].join("\n") - }); + if (isIssueOpen) { + try { + await github.rest.issues.createLabel({ + owner, + repo, + name: takenLabel, + color: 'ff0000', + description: 'Issue is currently claimed' + }); + } catch (e) {} + + await updateIssueBody(buildUntakenBody()); + return; + } + + if (!isComment) return; - # Handle /take and /untake comments (single bot comment per action) - take_untake: - if: github.event_name == 'issue_comment' - runs-on: ubuntu-latest - steps: - - name: Handle /take and /untake - uses: actions/github-script@v8 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - if (!github) throw new Error("github object is undefined"); const comment = context.payload.comment; - if (!comment) return; const commenter = comment.user.login; - const body = (comment.body || '').trim(); - const issueNumber = context.payload.issue.number; - const owner = context.repo.owner; - const repo = context.repo.repo; - const takenLabel = 'taken'; - const BOT_LOGIN = 'github-actions[bot]'; - const MARKER_PREFIX = '', start); - const jsonText = latestBotComment.body.slice(start + MARKER_PREFIX.length, end).trim(); - meta = JSON.parse(jsonText); - } catch (e) { + const body = latestMeta.body; + const start = body.indexOf(MARKER_PREFIX); + const end = body.indexOf('-->', start); + if (start !== -1 && end !== -1) { + meta = JSON.parse( + body.slice(start + MARKER_PREFIX.length, end).trim() + ); + } + } catch { meta = { owner: null, members: [], ts: 0 }; } } - const now = Date.now(); + async function persistMeta(metaObj, claimed) { + // Find existing meta comments + const commentsResp = await github.rest.issues.listComments({ + owner, + repo, + issue_number: issueNumber, + per_page: 100 + }); + + const metaComments = commentsResp.data.filter( + c => c.user?.login === BOT_LOGIN && c.body?.includes(MARKER_PREFIX) + ); - // If meta is stale, expire it on interaction - if (meta && meta.owner && meta.ts && (now - meta.ts) > EXPIRE_MS) { - if (hasTakenLabel) { + // Delete all previous meta comments (keep state clean) + for (const c of metaComments) { try { - await github.rest.issues.removeLabel({ owner, repo, issue_number: issueNumber, name: takenLabel }); - } catch (e) { /* ignore */ } + await github.rest.issues.deleteComment({ + owner, + repo, + comment_id: c.id + }); + } catch {} } - // single human-visible expiry comment - await github.rest.issues.createComment({ owner, repo, issue_number: issueNumber, body: `⚠️ The claim by @${meta.owner} has expired after 3 weeks of inactivity. The issue is now free to be taken.` }); - // create a new bot meta comment that resets state (we do NOT edit old comments) - const resetMeta = { owner: null, members: [], ts: 0 }; + + // Create the new meta comment await github.rest.issues.createComment({ owner, repo, issue_number: issueNumber, - body: `${MARKER_PREFIX}${JSON.stringify(resetMeta)} -->\n\nThis issue is currently unclaimed.` + body: `${MARKER_PREFIX}${JSON.stringify(metaObj)} -->` }); - // update local meta - meta = resetMeta; - } - // Persist meta by creating a new bot comment containing both the marker and a human message. - // This function creates exactly ONE comment and handles the label add/remove. - async function persistMetaAndComment(metaObj, ensureLabel, humanMessage) { - const botBody = `${MARKER_PREFIX}${JSON.stringify(metaObj)} -->\n\n${humanMessage}`; - await github.rest.issues.createComment({ owner, repo, issue_number: issueNumber, body: botBody }); - - if (ensureLabel) { - try { - await github.rest.issues.addLabels({ owner, repo, issue_number: issueNumber, labels: [takenLabel] }); - } catch (e) { /* ignore */ } + if (claimed) { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: issueNumber, + labels: [takenLabel] + }); + await updateIssueBody(buildTakenBody(metaObj.owner)); } else { try { - await github.rest.issues.removeLabel({ owner, repo, issue_number: issueNumber, name: takenLabel }); - } catch (e) { /* ignore */ } + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: issueNumber, + name: takenLabel + }); + } catch {} + await updateIssueBody(buildUntakenBody()); } } - // parse mentions in the /take comment - const mentions = [...(body.matchAll(/@([A-Za-z0-9-]+)/g))].map(m => m[1]); - const newMentions = mentions.filter(u => u !== commenter); - - // ---------- /take ---------- - if (isTake) { - if (!meta.owner) { - // claim the issue - meta.owner = commenter; - meta.members = Array.from(new Set([...(meta.members || []), ...newMentions])); - meta.ts = Date.now(); - - const humanMsg = meta.members.length > 0 - ? `βœ… This issue has been taken by @${meta.owner}, with ${meta.members.map(m => `@${m}`).join(', ')} invited to collaborate.` - : `βœ… This issue has been taken by @${meta.owner}. Comment \`/untake\` to release it.`; + // Normalize expired claims before handling commands + if (meta.owner && meta.ts && Date.now() - meta.ts > EXPIRE_MS) { + meta = { owner: null, members: [], ts: 0 }; + } - await persistMetaAndComment(meta, true, humanMsg); - return; - } - // already claimed - if (meta.owner === commenter) { - // owner re-issuing /take: allow adding new collaborators if they @mention them - if (newMentions.length === 0) { - // single short reply (no meta change) - await quickReply(`ℹ️ You have already taken this issue.`); - } else { - meta.members = Array.from(new Set([...(meta.members || []), ...newMentions])); - meta.ts = Date.now(); // refresh timestamp when owner interacts - const humanMsg = `πŸ”— @${commenter} has invited ${newMentions.map(u => '@'+u).join(', ')} to this issue.`; - await persistMetaAndComment(meta, true, humanMsg); - } - } else { - // someone else attempted to take -> single reply - await quickReply(`⚠️ Sorry, this issue is already claimed by @${meta.owner}.`); - } + if (isTake && !meta.owner) { + meta.owner = commenter; + meta.ts = Date.now(); + await persistMeta(meta, true); return; } - // ---------- /untake ---------- - if (isUntake) { - if (!meta.owner) { - await quickReply(`ℹ️ This issue is not currently claimed.`); - return; - } - - const mentioned = mentions[0]; // only one makes sense here - - // OWNER removing a collaborator - if (meta.owner === commenter && mentioned) { - if (!meta.members.includes(mentioned)) { - await quickReply(`ℹ️ @${mentioned} is not a collaborator on this issue.`); - return; - } - - meta.members = meta.members.filter(m => m !== mentioned); - meta.ts = Date.now(); - - await persistMetaAndComment( - meta, - true, - `🚫 @${commenter} has removed @${mentioned} from this issue.` - ); - return; - } - - // OWNER unclaiming entire issue - if (meta.owner === commenter && !mentioned) { - meta = { owner: null, members: [], ts: 0 }; - - await persistMetaAndComment( - meta, - false, - `🚫 @${commenter} has unclaimed this issue. It is now free for others.` - ); - return; - } - - // MEMBER removing themselves - if (meta.members.includes(commenter) && !mentioned) { - meta.members = meta.members.filter(m => m !== commenter); - meta.ts = Date.now(); - - await persistMetaAndComment( - meta, - true, - `🚫 @${commenter} has removed themselves from this issue. It remains claimed by @${meta.owner}.` - ); - return; - } - - // Anything else is invalid - await quickReply( - `⚠️ You cannot modify this claim. Only the owner can remove collaborators, and members may remove themselves.` - ); + if (isUntake && meta.owner === commenter) { + meta = { owner: null, members: [], ts: 0 }; + await persistMeta(meta, false); return; } - # Weekly cleanup job (runs on schedule) β€” creates new comments only (no edits) cleanup: if: github.event_name == 'schedule' runs-on: ubuntu-latest steps: - - name: Expire stale claims (weekly) + - name: Expire stale claims uses: actions/github-script@v8 with: github-token: ${{ secrets.GITHUB_TOKEN }} @@ -271,12 +222,25 @@ jobs: const owner = context.repo.owner; const repo = context.repo.repo; const takenLabel = 'taken'; - const BOT_LOGIN = 'github-actions[bot]'; const MARKER_PREFIX = ''; + const BODY_END = ''; + const EXPIRE_MS = 21 * 24 * 60 * 60 * 1000; + + function buildUntakenBody() { + return ` + ${BODY_START} + > [!TIP] + > This issue hasn't been claimed yet. Comment \`/take\` if you'd like to work on it! + > + > > For more information on how Img2Num's claim system works, please see #99. + > + > This edit was made by the [\`issue-take-untake\`](https://github.com/Ryan-Millard/Img2Num/blob/main/.github/workflows/issue-take-untake.yml) GitHub workflow + ${BODY_END} + `.trim(); + } - // list issues with the `taken` label (open issues only by default) - const issuesResp = await github.rest.issues.listForRepo({ + const issues = await github.rest.issues.listForRepo({ owner, repo, labels: takenLabel, @@ -284,32 +248,54 @@ jobs: per_page: 100 }); - for (const issue of (issuesResp.data || [])) { - const issueNumber = issue.number; - const commentsResp = await github.rest.issues.listComments({ owner, repo, issue_number: issueNumber, per_page: 200 }); - const comments = commentsResp.data || []; - const botCommentsWithMarker = comments - .filter(c => c.user && c.user.login === BOT_LOGIN && (c.body || '').includes(MARKER_PREFIX)) - .sort((a,b) => new Date(a.created_at) - new Date(b.created_at)); - if (!botCommentsWithMarker.length) continue; - const latestBotComment = botCommentsWithMarker[botCommentsWithMarker.length - 1]; - try { - const start = latestBotComment.body.indexOf(MARKER_PREFIX); - const end = latestBotComment.body.indexOf('-->', start); - const jsonText = latestBotComment.body.slice(start + MARKER_PREFIX.length, end).trim(); - const meta = JSON.parse(jsonText); - if (meta && meta.owner && meta.ts && (Date.now() - meta.ts) > EXPIRE_MS) { - // expire it - try { - await github.rest.issues.removeLabel({ owner, repo, issue_number: issueNumber, name: takenLabel }); - } catch (e) { /* ignore */ } - // create human-visible expiry comment - await github.rest.issues.createComment({ owner, repo, issue_number: issueNumber, body: `⚠️ The claim by @${meta.owner} has expired after 3 weeks of inactivity. The issue is now free to be taken.` }); - // create a new bot meta comment resetting state (do NOT edit existing comments) - const newMeta = { owner: null, members: [], ts: 0 }; - await github.rest.issues.createComment({ owner, repo, issue_number: issueNumber, body: `${MARKER_PREFIX}${JSON.stringify(newMeta)} -->\n\nThis issue is currently unclaimed.` }); + for (const issue of issues.data) { + const comments = await github.rest.issues.listComments({ + owner, + repo, + issue_number: issue.number, + per_page: 200 + }); + + const metaComment = comments.data + .filter(c => c.body?.includes(MARKER_PREFIX)) + .sort((a, b) => new Date(a.created_at) - new Date(b.created_at)) + .pop(); + + + if (!metaComment) continue; + + try { + const body = metaComment.body; + const start = body.indexOf(MARKER_PREFIX); + const end = body.indexOf('-->', start); + if (start === -1 || end === -1) continue; + + const meta = JSON.parse( + body.slice(start + MARKER_PREFIX.length, end).trim() + ); + + if (meta.ts && Date.now() - meta.ts > EXPIRE_MS) { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: issue.number, + name: takenLabel + }); + + const currentBody = issue.body || ''; + const updated = currentBody.includes(BODY_START) + ? currentBody.replace( + new RegExp(`${BODY_START}[\\s\\S]*?${BODY_END}`), + buildUntakenBody() + ) + : `${buildUntakenBody()}\n\n---\n\n${currentBody}`; + + await github.rest.issues.update({ + owner, + repo, + issue_number: issue.number, + body: updated + }); } - } catch (e) { - // ignore parse errors for now - } + } catch {} } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 67955cdab..ac515c97c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,3 +3,33 @@ Want to contribute to Img2Num? There are a few things you need to know. We wrote a [contribution guide](https://ryan-millard.github.io/Img2Num/info/docs/guidelines/contributing) to help you get started. + +## Quick Links + +- **[Coding Style Guidelines](https://ryan-millard.github.io/Img2Num/info/docs/guidelines/coding-style)** - Code quality and linting standards +- **[CI/CD Workflows](https://ryan-millard.github.io/Img2Num/info/docs/reference/tools/ci-workflows)** - Understanding our automated checks +- **[Commit Guidelines](https://ryan-millard.github.io/Img2Num/info/docs/guidelines/commits)** - How to write good commit messages +- **[Pull Request Guidelines](https://ryan-millard.github.io/Img2Num/info/docs/guidelines/pull-requests)** - Creating and submitting PRs + +## Code Quality + +Before submitting a PR, ensure your code passes all checks: + +```bash +npm ci # Install dependencies +npm run lint # Check JavaScript/React code +npm run lint:style # Check code style +npm run format:check # Verify formatting +``` + +For detailed information about linting, formatting, and fixing issues, see our [Coding Style Guidelines](https://ryan-millard.github.io/Img2Num/info/docs/guidelines/coding-style). + +## Questions? + +If you have questions or need help: + +- Open a [discussion](https://github.com/Ryan-Millard/Img2Num/discussions) +- Create an [issue](https://github.com/Ryan-Millard/Img2Num/issues) +- Check existing PRs for examples + +Thank you for improving Img2Num! πŸŽ¨πŸš€ diff --git a/README.md b/README.md index e50799b5a..39e480381 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ + > [!CAUTION] > ⚠️⚠️⚠️ **Breaking Change in [PR #93](https://github.com/Ryan-Millard/Img2Num/pull/93)** ⚠️⚠️⚠️ > @@ -10,8 +11,6 @@
- - [![Site Badge](https://img.shields.io/badge/site-online-blue.svg)](https://ryan-millard.github.io/Img2Num/) [![Docs Badge](https://img.shields.io/badge/docs-online-blue.svg)](https://ryan-millard.github.io/Img2Num/info/) [![License Badge](https://img.shields.io/badge/license-AGPLv3-blue.svg)](LICENSE) @@ -22,11 +21,8 @@ [![Contributors](https://img.shields.io/github/contributors/Ryan-Millard/Img2Num)](https://ryan-millard.github.io/Img2Num/credits) [![GitHub issues](https://img.shields.io/github/issues/Ryan-Millard/Img2Num)](https://github.com/Ryan-Millard/Img2Num/issues) - -
- **Img2Num** converts photos into printable, browser-colourable **colour-by-number** templates using a fast WebAssembly (C++) image pipeline. > A fast, offline, serverless application that runs at near-native speeds, enabling in-browser colouring or printing of the image. @@ -69,12 +65,12 @@ ### What are you waiting for? + Try it out now by [clicking here](https://ryan-millard.github.io/Img2Num/)! ## What this repository contains (short) -
![React](https://img.shields.io/badge/React-19-blue?logo=react&logoColor=61DAFB) ![C++](https://img.shields.io/badge/C++-Modern-blue?logo=c%2B%2B&logoColor=00599C) @@ -84,10 +80,8 @@ Try it out now by [clicking here](https://ryan-millard.github.io/Img2Num/)! ![Vite](https://img.shields.io/badge/Vite-7-purple?logo=vite) ![CSS](https://img.shields.io/badge/CSS-Modern-blue?logo=css3) -
-
![Prettier](https://img.shields.io/badge/Prettier-Code%20Formatter-brightgreen?logo=prettier&logoColor=F7B93E) ![ESLint](https://img.shields.io/badge/ESLint-Linted-yellow?logo=eslint) @@ -97,12 +91,10 @@ Try it out now by [clicking here](https://ryan-millard.github.io/Img2Num/)! ![EditorConfig](https://img.shields.io/badge/EditorConfig-Style-blue?logo=editorconfig) ![Clang-Format](https://img.shields.io/badge/Clang%20Format-Formatted-blue?logo=clang) - -
-* A React frontend that handles image input, preview and in-browser colouring. -* A WebAssembly module (C++ β†’ Emscripten) that performs image processing and colour quantisation. +- A React frontend that handles image input, preview and in-browser colouring. +- A WebAssembly module (C++ β†’ Emscripten) that performs image processing and colour quantisation. This README is intentionally short β€” full installation steps, guides and references live in the docs site (see **Essential links** below). @@ -110,11 +102,11 @@ This README is intentionally short β€” full installation steps, guides and refer Visit the docs site for full guides, API references and troubleshooting: -* Quick start - [https://ryan-millard.github.io/Img2Num/info/docs/getting-started/](https://ryan-millard.github.io/Img2Num/info/docs/introduction/getting-started) -* Guidelines - [https://ryan-millard.github.io/Img2Num/info/docs/category/-guidelines/](https://ryan-millard.github.io/Img2Num/info/docs/category/-guidelines) -* Documentation - [https://ryan-millard.github.io/Img2Num/info/docs/](https://ryan-millard.github.io/Img2Num/info/docs/) -* Reference & Advanced Guides - [https://ryan-millard.github.io/Img2Num/info/docs/reference/](https://ryan-millard.github.io/Img2Num/info/docs/reference/) -* Changelog - [https://ryan-millard.github.io/Img2Num/info/docs/changelog/](https://ryan-millard.github.io/Img2Num/info/changelog) +- Quick start - [https://ryan-millard.github.io/Img2Num/info/docs/getting-started/](https://ryan-millard.github.io/Img2Num/info/docs/introduction/getting-started) +- Guidelines - [https://ryan-millard.github.io/Img2Num/info/docs/category/-guidelines/](https://ryan-millard.github.io/Img2Num/info/docs/category/-guidelines) +- Documentation - [https://ryan-millard.github.io/Img2Num/info/docs/](https://ryan-millard.github.io/Img2Num/info/docs/) +- Reference & Advanced Guides - [https://ryan-millard.github.io/Img2Num/info/docs/reference/](https://ryan-millard.github.io/Img2Num/info/docs/reference/) +- Changelog - [https://ryan-millard.github.io/Img2Num/info/docs/changelog/](https://ryan-millard.github.io/Img2Num/info/changelog) (These replace long, duplicate instructions in this README to keep maintenance easier.) @@ -124,9 +116,9 @@ We welcome contributions. Please read [CONTRIBUTING.md](https://ryan-millard.git **A few important points:** -* **Add tests** with your PR β€” new features and bug fixes **must** include tests where appropriate. PRs without tests are unlikely to be approved. -* Follow the repository's [coding style rules](https://ryan-millard.github.io/Img2Num/info/docs/guidelines/coding-style) and [commit message rules](https://ryan-millard.github.io/Img2Num/info/docs/guidelines/commits). -* Use the issue and PR templates when filing issues or submitting code. Your PR will be rejected if you don't. +- **Add tests** with your PR β€” new features and bug fixes **must** include tests where appropriate. PRs without tests are unlikely to be approved. +- Follow the repository's [coding style rules](https://ryan-millard.github.io/Img2Num/info/docs/guidelines/coding-style) and [commit message rules](https://ryan-millard.github.io/Img2Num/info/docs/guidelines/commits). +- Use the issue and PR templates when filing issues or submitting code. Your PR will be rejected if you don't. If you're unsure what to change, open an issue first and we can discuss scope. @@ -136,18 +128,19 @@ If you're unsure what to change, open an issue first and we can discuss scope. ## What we intentionally keep out of this README -* Long, step‑by‑step build instructions (moved to the [docs site](https://ryan-millard.github.io/Img2Num/info/docs/)) -* Full API reference (moved to the [docs site](https://ryan-millard.github.io/Img2Num/info/docs/reference)) -* Very large images or heavy explanations β€” use the [docs site](https://ryan-millard.github.io/Img2Num/info/docs/) for in-depth content +- Long, step‑by‑step build instructions (moved to the [docs site](https://ryan-millard.github.io/Img2Num/info/docs/)) +- Full API reference (moved to the [docs site](https://ryan-millard.github.io/Img2Num/info/docs/reference)) +- Very large images or heavy explanations β€” use the [docs site](https://ryan-millard.github.io/Img2Num/info/docs/) for in-depth content ## Can't find something? + Hopefully you understand by now that if you need something, it should be on the [docs site](https://ryan-millard.github.io/Img2Num/info/docs/). If it isn't, please open a ["New Feature" issue](https://github.com/Ryan-Millard/Img2Num/issues/new?template=feature_request.yml) to request its addition to the [docs site](https://ryan-millard.github.io/Img2Num/info/docs/). ## Maintainers -* [Ryan](https://github.com/Ryan-Millard/) -* [Hayden](https://github.com/hjmillard/) (temporarily unavailable) +- [Ryan](https://github.com/Ryan-Millard/) +- [Hayden](https://github.com/hjmillard/) (temporarily unavailable) > ⚠️ **Disclaimer:** Pull request reviews may take some time as we try to keep up with contributions. > We highly encourage everyone to review each other's pull requests where possible β€” this helps the project move faster and benefits all contributors in the long run. Thank you for your support! diff --git a/docker-compose.yml b/docker-compose.yml index da81d0e6c..1dd6e0c40 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,12 +11,12 @@ services: - node_modules:/usr/src/app/node_modules - docs_node_modules:/usr/src/app/docs/node_modules environment: - CHOKIDAR_USEPOLLING: "true" - CHOKIDAR_INTERVAL: "100" + CHOKIDAR_USEPOLLING: 'true' + CHOKIDAR_INTERVAL: '100' ports: - - "5173:5173" # Vite dev - - "4173:4173" # Vite preview - - "3000:3000" # Docusaurus dev + - '5173:5173' # Vite dev + - '4173:4173' # Vite preview + - '3000:3000' # Docusaurus dev stdin_open: true tty: true diff --git a/docs/changelogSidebarGenerator.js b/docs/changelogSidebarGenerator.js index fec8c5ef6..652b75d2b 100644 --- a/docs/changelogSidebarGenerator.js +++ b/docs/changelogSidebarGenerator.js @@ -4,10 +4,7 @@ * 1. By date (descending) * 2. By semver version (descending) */ -export async function changelogSidebarGenerator({ - defaultSidebarItemsGenerator, - ...args -}) { +export async function changelogSidebarGenerator({ defaultSidebarItemsGenerator, ...args }) { const items = await defaultSidebarItemsGenerator(args); const isReleaseDoc = (item) => @@ -28,9 +25,7 @@ export async function changelogSidebarGenerator({ const [dateStr, versionStr] = filename.replace(/\.md$/, '').split('_'); const date = new Date(dateStr).getTime() || 0; - const [major = 0, minor = 0, patch = 0] = (versionStr || '0.0.0') - .split('.') - .map((n) => Number(n)); + const [major = 0, minor = 0, patch = 0] = (versionStr || '0.0.0').split('.').map((n) => Number(n)); return { date, major, minor, patch }; }; @@ -45,9 +40,5 @@ export async function changelogSidebarGenerator({ return B.patch - A.patch; }); - return [ - ...(indexItem ? [indexItem] : []), - ...sortedReleaseItems, - ...(mainChangelogItem ? [mainChangelogItem] : []), - ]; -}; + return [...(indexItem ? [indexItem] : []), ...sortedReleaseItems, ...(mainChangelogItem ? [mainChangelogItem] : [])]; +} diff --git a/docs/docs/guidelines/CONTRIBUTING.md b/docs/docs/guidelines/CONTRIBUTING.md index b768ae4ae..986f719c1 100644 --- a/docs/docs/guidelines/CONTRIBUTING.md +++ b/docs/docs/guidelines/CONTRIBUTING.md @@ -23,6 +23,12 @@ _When reporting issues, please:_ - Attach screenshots or logs if applicable. - Specify your environment (OS, Node.js version, browser). +## Claiming Issues + +- To claim an issue, comment: `/take`. This will assign the issue to you and add the `taken` label. +- To release an issue, comment: `/untake`. This will unassign the issue from you and remove the `taken` label. +- Issues labeled `taken` are currently owned and being worked on. + ## Development Setup The [Getting Started](../introduction/getting-started.md) section shows how to clone and run the application for the first time. diff --git a/docs/docs/guidelines/_category_.json b/docs/docs/guidelines/_category_.json index c7b516dd8..fcc255f35 100644 --- a/docs/docs/guidelines/_category_.json +++ b/docs/docs/guidelines/_category_.json @@ -2,6 +2,6 @@ "label": "Guidelines", "position": 4, "link": { - "type": "generated-index", + "type": "generated-index" } } diff --git a/docs/docs/guidelines/coding-style.md b/docs/docs/guidelines/coding-style.md index 6cee45e86..f790ccdb4 100644 --- a/docs/docs/guidelines/coding-style.md +++ b/docs/docs/guidelines/coding-style.md @@ -5,6 +5,7 @@ sidebar_position: 3 --- ## 🌐General Rules + - **Follow `.editorconfig`** exactly: - Indent: **2 spaces** - Charset: **UTF-8** @@ -18,6 +19,7 @@ sidebar_position: 3 - **Do not manually override formatting** outside Prettier/clang-format unless necessary. ## βš› JavaScript / React + - Indent: 2 spaces - Max line length: 120 - Single quotes `'...'` @@ -30,6 +32,7 @@ sidebar_position: 3 - Globals: browser ## πŸ’» C / C++ + - Indent: 2 spaces - Max line length: 120 - **Brace style: Allman** (opening brace on a new line) @@ -55,6 +58,7 @@ namespace exampleNamespace #endif // EXAMPLE_FUNCTION_H ``` +
@@ -67,18 +71,20 @@ namespace exampleNamespace namespace exampleNamespace { - void exampleFunction(uint8_t x) - { - if (internalLibrary::isPrime(x)) - { - std::cout << "Prime" << std::endl; - return; - } +void exampleFunction(uint8_t x) +{ +if (internalLibrary::isPrime(x)) +{ +std::cout << "Prime" << std::endl; +return; +} std::cout << "Non-prime" << std::endl; - } + } -``` +} + +````
## 🌐 HTML / CSS / Markdown / YAML @@ -98,3 +104,71 @@ namespace exampleNamespace - Do not trim trailing whitespace - No final newline - Max line length: off + +## πŸ” Linting and Style Checks + +### Running Lints Locally + +Before submitting a pull request, run these commands to ensure your code meets our quality standards: + +```bash +# Install dependencies +npm ci + +# Run ESLint (JavaScript/React) +npm run lint + +# Auto-fix ESLint issues +npm run lint:fix + +# Check code style (indentation, line endings, etc.) +npm run lint:style +```` + +### What the Linters Check + +**ESLint** validates: + +- JavaScript/React syntax and best practices +- Potential bugs and code smells +- Consistent code style +- React Hooks rules + +**editorconfig-checker** validates: + +- Indentation style (spaces vs tabs) +- Line ending consistency (LF) +- Trailing whitespace +- Final newline in files +- Line length limits (120 characters) + +### Fixing Lint Issues + +1. **Auto-fixable**: Run `npm run lint:fix` to automatically fix most ESLint issues + +2. **Manual fixes** required for: + - Complex indentation errors + - Line length violations (refactor long lines) + - Missing final newlines + +3. **Editor setup**: Install EditorConfig plugin for your editor: + - **VS Code**: "EditorConfig for VS Code" extension + - **Other editors**: See [EditorConfig.org](https://editorconfig.org/) + +### Special Cases + +- **Makefiles**: Must use tabs (not spaces) for indentation +- **Binary files**: Linting does not apply to images, compiled files, etc. + +### CI Integration + +All pull requests automatically run linting checks. If the lint job fails: + +1. Review the CI logs +2. Fix issues locally using the commands above +3. Commit and push your fixes +4. The CI will automatically re-run + +:::tip Current Status +The project has some existing lint violations being addressed incrementally. Focus on keeping your changes lint-clean. Don't feel obligated to fix unrelated issues! +::: diff --git a/docs/docs/guidelines/commits.md b/docs/docs/guidelines/commits.md index fc7eeb945..cfeffc956 100644 --- a/docs/docs/guidelines/commits.md +++ b/docs/docs/guidelines/commits.md @@ -5,21 +5,23 @@ sidebar_position: 4 --- ## πŸ“ Commits + :::info Format: `(): ` Optional body below. Reference issues: `Fixes #123` ::: ## Types -| Type | Description | -|----------|--------------------------------------------------| -| **feat** | A new feature | -| **fix** | A bug fix | -| **docs** | Documentation only | -| **style**| Formatting, linting, whitespace changes only | -| **refactor** | Code changes without affecting functionality | -| **test** | Adding or updating tests | -| **chore**| Maintenance tasks (dependencies, build tools) | + +| Type | Description | +| ------------ | --------------------------------------------- | +| **feat** | A new feature | +| **fix** | A bug fix | +| **docs** | Documentation only | +| **style** | Formatting, linting, whitespace changes only | +| **refactor** | Code changes without affecting functionality | +| **test** | Adding or updating tests | +| **chore** | Maintenance tasks (dependencies, build tools) | :::important @@ -27,12 +29,13 @@ Commits should be **atomic**, addressing one logical change per commit. Always c ::: - ## Examples + - Commits must be **atomic** (one logical change per commit) - Always check `.editorconfig` before committing ### [Version bump](https://github.com/Ryan-Millard/Img2Num/commit/426ac4f655343b06429b5f976e794b448f1afa0f) + ```bash chore(deps-dev): Bump prettier from 3.7.1 to 3.7.3 in the all-npm group @@ -57,11 +60,13 @@ Signed-off-by: dependabot[bot] ``` ### [Additional WASM image processing function](https://github.com/Ryan-Millard/Img2Num/commit/1b85d2d1fc358f10d1a122d988c6a94b275bac9a) + ```bash feat(Merge Small Regions): Detect & merge regions in processed images that are difficult to click ``` ### [React Helmet & index.html wrapper + SEO images](https://github.com/Ryan-Millard/Img2Num/commit/3d090919dae749d884a0413b07a8897d0478e8eb) + ```bash feat(HTML head tags): Add proper head tags & favicon.svg @@ -71,21 +76,25 @@ feat(HTML head tags): Add proper head tags & favicon.svg ``` ### [Basic SEO - sitemap & robots.txt](https://github.com/Ryan-Millard/Img2Num/commit/de420c0bd2a323341b425776c7e5096f8f6a726d) + ```bash create(robots.txt, automatic sitemap): Basic robots.txt & vite-plugin-sitemap ``` ### [Change React's routing system & add redirect on route not found](https://github.com/Ryan-Millard/Img2Num/commit/02f70f268c9605b50882368958df9b097e173be0) + ```bash update(main.jsx, 404.html): Switch to BrowserRouter and redirect on 404 ``` ### [Fallback for users who can't run JavaScript](https://github.com/Ryan-Millard/Img2Num/commit/f07c6c60e67f36be955ad850e96f4d234c4a5264) + ```bash update(index.html): Add noscript fallback for users with js disabled ``` ### [Remove dynamic code from Credits page to allow it to be statically generated](https://github.com/Ryan-Millard/Img2Num/commit/645aea4375d33f6c3223da78efb55ecda21c9f4f) + ```bash refactor(Credits Page): Contributors card now fully static ``` diff --git a/docs/docs/guidelines/issues.md b/docs/docs/guidelines/issues.md index 590653638..fbb772b97 100644 --- a/docs/docs/guidelines/issues.md +++ b/docs/docs/guidelines/issues.md @@ -7,13 +7,14 @@ sidebar_position: 5 To keep issues clear, actionable, and easy to triage, we provide templates for contributors. Always use the appropriate template when creating a new issue. ### Templates -| Template | Description | Labels | -|------------------------------------------|---------------------------------------------------------------------------------------------------------|--------| -| **Bug Report** | Report a bug in the code or functionality. Fill out all fields to help maintainers reproduce the issue. | bug | -| **New Feature** | Request a new feature or enhancement. Provide clear motivation and expected behavior. | feature | -| **Good First Issue** | Labelled issues ideal for new contributors. Great starting point to get familiar with the project. | good first issue | -| **Refactor or Code Quality Improvement** | Request or suggest refactoring of existing code for maintainability or performance. | refactor | -| **Blank Issue** | Use when the other templates don't fit your issue's description (try not to use this one). | misc | + +| Template | Description | Labels | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------- | ---------------- | +| **Bug Report** | Report a bug in the code or functionality. Fill out all fields to help maintainers reproduce the issue. | bug | +| **New Feature** | Request a new feature or enhancement. Provide clear motivation and expected behavior. | feature | +| **Good First Issue** | Labelled issues ideal for new contributors. Great starting point to get familiar with the project. | good first issue | +| **Refactor or Code Quality Improvement** | Request or suggest refactoring of existing code for maintainability or performance. | refactor | +| **Blank Issue** | Use when the other templates don't fit your issue's description (try not to use this one). | misc | ### Guidelines diff --git a/docs/docs/guidelines/pull-requests.md b/docs/docs/guidelines/pull-requests.md index dc0f84e52..94f58e4e1 100644 --- a/docs/docs/guidelines/pull-requests.md +++ b/docs/docs/guidelines/pull-requests.md @@ -5,13 +5,17 @@ sidebar_position: 6 --- ## Fork the repo & create a feature branch: + ```bash title="Switch to a new branch" git checkout -b feat/your-feature ``` + ## Make commits following guidelines + These can be found in the [previous section](../commits). ## Push to your fork: + :::danger Danger: **Merge Conflicts** Keep your branch **up-to-date with [main](https://github.com/Ryan-Millard/Img2Num/tree/main)** to avoid conflicts @@ -27,7 +31,9 @@ git push origin feat/your-feature ``` ## Open PR against `main`: + Make sure it has: + - A clear title - A summary of changes & motivation - References to related issues: `Fixes #123` diff --git a/docs/docs/index.md b/docs/docs/index.md index 06134baec..4cc39815f 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -36,7 +36,8 @@ For issues or contributions, visit our If you spot something wrong in the documentation or elsewhere, please help the community by opening an issue for it! Issue links: + - [Bug Report](https://github.com/Ryan-Millard/Img2Num/issues/new?template=bug_report.yml) - [Refactor / Code Quality Improvement](https://github.com/Ryan-Millard/Img2Num/issues/new?template=refactor.yml) - [Blank Issue](https://github.com/Ryan-Millard/Img2Num/issues/new) -::: + ::: diff --git a/docs/docs/introduction/getting-started.md b/docs/docs/introduction/getting-started.md index e33f1224f..d441f7dc5 100644 --- a/docs/docs/introduction/getting-started.md +++ b/docs/docs/introduction/getting-started.md @@ -7,9 +7,9 @@ sidebar_position: 2 import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; -import DockerHomepage from './img/docker-desktop-homepage.jpg'; -import DockerSettings from './img/docker-desktop-settings-button-location.jpg'; -import DockerWslSetup from './img/docker-desktop-wsl-integration-setup.jpg'; +import DockerHomepage from './img/docker-desktop-homepage.jpg'; +import DockerSettings from './img/docker-desktop-settings-button-location.jpg'; +import DockerWslSetup from './img/docker-desktop-wsl-integration-setup.jpg'; import DockerResources from './img/docker-desktop-resources-button-location.jpg'; import DockerWslButton from './img/docker-desktop-wsl-integration-button-location.jpg'; @@ -33,7 +33,7 @@ Before you start installing anything, make sure you have the below installed. -#### Installing Docker +### Installing Docker The section below will guide you through installing Docker on your operating system. @@ -53,7 +53,7 @@ The section below will guide you through installing Docker on your operating sys wsl --install ``` This will install Ubuntu by default. You can use other distributions if you prefer. - See [Microsoft's documentation](https://learn.microsoft.com/en-us/windows/wsl/install) to + See [Microsoft's documentation](https://learn.microsoft.com/en-us/windows/wsl/install) to find out more about installing WSL. @@ -121,42 +121,45 @@ The section below will guide you through installing Docker on your operating sys :::danger Docker not working? Make sure to keep Docker Desktop open while you're using Docker because it needs to be open to run containers. ::: + - ### Installing Docker on macOS +### Installing Docker on macOS - 1. Download and install **Docker Desktop** from [https://www.docker.com/products/docker-desktop](https://www.docker.com/products/docker-desktop). +1. Download and install **Docker Desktop** from [https://www.docker.com/products/docker-desktop](https://www.docker.com/products/docker-desktop). - 2. Open Docker Desktop and ensure it is running. +2. Open Docker Desktop and ensure it is running. - 3. Verify installation in Terminal: - ```bash - docker --version - docker compose version - ``` +3. Verify installation in Terminal: + ```bash + docker --version + docker compose version + ``` - ### Installing Docker on Linux +### Installing Docker on Linux + +1. Install Docker and Docker Compose via your package manager. For Ubuntu/Debian: + + ```bash + sudo apt update + sudo apt install -y docker.io docker-compose + sudo systemctl enable --now docker + sudo usermod -aG docker $USER + ``` - 1. Install Docker and Docker Compose via your package manager. For Ubuntu/Debian: - ```bash - sudo apt update - sudo apt install -y docker.io docker-compose - sudo systemctl enable --now docker - sudo usermod -aG docker $USER - ``` - > You may need to log out and back in for the group change to take effect. + > You may need to log out and back in for the group change to take effect. - 2. Verify installation: - ```bash - docker --version - docker compose version - ``` +2. Verify installation: + ```bash + docker --version + docker compose version + ``` @@ -358,6 +361,7 @@ You can choose to only install the dependencies for one portion of the app, but ``` + @@ -556,5 +560,6 @@ This section will help you run both the main application and the documentation s ### Further Information + You may want to have a look at the [Project Scripts section](../../project-scripts/overview) now, but make sure that you understand and agree with Img2Num's [License](../../license) and [guidelines](../../category/guidelines) first. diff --git a/docs/docs/project-scripts/help-scripts/index.md b/docs/docs/project-scripts/help-scripts/index.md index 0ae61e86a..bedc94d49 100644 --- a/docs/docs/project-scripts/help-scripts/index.md +++ b/docs/docs/project-scripts/help-scripts/index.md @@ -26,6 +26,7 @@ npm run help This launches an interactive CLI that shows all scripts in `package.json` grouped by category and allows fuzzy search. **Features:** + - Lists scripts in groups: Development, Build, Cleaning, Formatting, Linting, Other - Allows fuzzy search for script names - `a + enter` lists all scripts @@ -38,9 +39,10 @@ This launches an interactive CLI that shows all scripts in `package.json` groupe CLI Args Example :::info + - These scripts are meant to manage the documentation site. - Use `start` to run a local dev server for docs. - Use `build` and `deploy` to publish to GitHub Pages. - `swizzle` allows customizing theme components safely. - `write-translations` and `write-heading-ids` are useful for internationalization and stable MDX anchors. -::: + ::: diff --git a/docs/docs/project-scripts/help-scripts/scripts-guide.md b/docs/docs/project-scripts/help-scripts/scripts-guide.md index 8809acccf..9107ce4a3 100644 --- a/docs/docs/project-scripts/help-scripts/scripts-guide.md +++ b/docs/docs/project-scripts/help-scripts/scripts-guide.md @@ -14,12 +14,12 @@ The `help` CLIs were refactored to read this metadata and a GitHub Actions workf ## What's in this page -* `scriptsInfo` schema and examples -* How to add or update scripts -* `validate-scripts` usage and CI integration -* Refactored `help` CLI and fuzzy search usage -* Location of shared libraries and utilities -* Troubleshooting & tips +- `scriptsInfo` schema and examples +- How to add or update scripts +- `validate-scripts` usage and CI integration +- Refactored `help` CLI and fuzzy search usage +- Location of shared libraries and utilities +- Troubleshooting & tips ## `scriptsInfo` β€” schema & examples @@ -37,9 +37,7 @@ A minimal example: }, "help": { "group": "dev", - "desc": [ - "Interactive help for available npm scripts.", - ], + "desc": ["Interactive help for available npm scripts."], "args": [] } }, @@ -51,56 +49,58 @@ A minimal example: ``` :::note Notes on the schema used in this PR: -* `scriptsInfo` is an object whose keys exactly match the `scripts` keys in `package.json`. -* Each script entry may include: - - * `group` β€” logical grouping used in the help CLI (e.g. `dev`, `build`, `docs`). - * `desc` β€” a string or an array of strings (multi-line descriptions supported). - * `args` β€” an array describing positional or named args the script accepts. -* The validator enforces a strict 1:1 mapping; every `scripts` entry must have a corresponding `scriptsInfo` entry and vice versa. - * This ensures the scripts are properly documented. -::: + +- `scriptsInfo` is an object whose keys exactly match the `scripts` keys in `package.json`. +- Each script entry may include: + - `group` β€” logical grouping used in the help CLI (e.g. `dev`, `build`, `docs`). + - `desc` β€” a string or an array of strings (multi-line descriptions supported). + - `args` β€” an array describing positional or named args the script accepts. + +- The validator enforces a strict 1:1 mapping; every `scripts` entry must have a corresponding `scriptsInfo` entry and vice versa. + - This ensures the scripts are properly documented. + ::: ## Adding or updating scripts 1. Add your script command under `scripts` in `package.json`. 2. Add a matching entry in `scriptsInfo` using the schema above. - ```json - "": { - "group": "", - "desc": "", - "args": [ - "", - "", - ... - "" - ] - } - ``` + ```json + "": { + "group": "", + "desc": "", + "args": [ + "", + "", + ... + "" + ] + } + ``` 3. Run the validator locally before pushing: - ```bash - npm run validate-scripts - ``` - :::caution If the validator exits non-zero - Fix any missing or mismatched `scriptsInfo` entries. - ::: + ```bash + npm run validate-scripts + ``` + + :::caution If the validator exits non-zero + Fix any missing or mismatched `scriptsInfo` entries. + ::: ## `validate-scripts` tool & CI The repository includes `scripts/validate-scripts.js` (and an npm script `validate-scripts`) which: -* Loads `./package.json` and `./docs/package.json`. -* Flattens the `scriptsInfo` structure (if grouped) and compares the set of keys with the `scripts` object. -* Exits with non-zero status if there are missing or extra entries, printing helpful error messages. +- Loads `./package.json` and `./docs/package.json`. +- Flattens the `scriptsInfo` structure (if grouped) and compares the set of keys with the `scripts` object. +- Exits with non-zero status if there are missing or extra entries, printing helpful error messages. ### CI integration A GitHub Actions workflow (`.github/workflows/ci.yml`) was added to: -* Detect if changes in a push/PR touched script-related files. -* Run `npm ci` and then `npm run validate-scripts` when relevant. -* Capture validator output and β€” on failures β€” call a reusable commenter workflow that posts the validation output back to the PR as a comment. +- Detect if changes in a push/PR touched script-related files. +- Run `npm ci` and then `npm run validate-scripts` when relevant. +- Capture validator output and β€” on failures β€” call a reusable commenter workflow that posts the validation output back to the PR as a comment. This ensures documentation for scripts can't be accidentally left out of a PR. @@ -109,58 +109,60 @@ This ensures documentation for scripts can't be accidentally left out of a PR. ## Refactored `help` CLI :::info The old `help.js` was refactored to: -* Read `scripts` and `scriptsInfo` from `package.json` (the source of truth). -* Delegate the fuzzy listing/search UI to a shared orchestrator (`scripts/lib/cli-fuzzy.js`). -* Support multi-line descriptions and CLI args rendering. -::: + +- Read `scripts` and `scriptsInfo` from `package.json` (the source of truth). +- Delegate the fuzzy listing/search UI to a shared orchestrator (`scripts/lib/cli-fuzzy.js`). +- Support multi-line descriptions and CLI args rendering. + ::: ### Usage Examples ```bash title="Run the help CLI from the repo root" npm run help ``` + ```bash title="Arguments (immediately passed to fuzzy search)" npm run help -- help build ``` ### CLI features -* Fuzzy search of script names and descriptions. -* Grouped presentation of scripts. -* Colorized & formatted output for readability. +- Fuzzy search of script names and descriptions. +- Grouped presentation of scripts. +- Colorized & formatted output for readability. ## Shared utilities Shared helper modules live under `scripts/lib/` and are used by both the root app and `docs` app: -* `cli-fuzzy.js` β€” orchestrates rendering of fuzzy-search-based interactive help. -* `colors.js` β€” terminal color helpers and formatting. -* `read-packageJson-scripts.js` β€” small helper to safely read and return `scripts` + `scriptsInfo` from package manifests. -* `validate-scripts.js` β€” the validator described above (also present at `scripts/validate-scripts.js`). +- `cli-fuzzy.js` β€” orchestrates rendering of fuzzy-search-based interactive help. +- `colors.js` β€” terminal color helpers and formatting. +- `read-packageJson-scripts.js` β€” small helper to safely read and return `scripts` + `scriptsInfo` from package manifests. +- `validate-scripts.js` β€” the validator described above (also present at `scripts/validate-scripts.js`). When modifying or adding helpers, keep in mind both root and `docs` apps import these relative utilities. ## Troubleshooting & common failures -* **Validator fails saying a script is missing from `scriptsInfo`**: add the scriptsInfo entry with the correct key. -* **Validator finds an extra `scriptsInfo` key**: remove or rename the `scripts` or `scriptsInfo` entry to match. -* **CI workflow errors about `actions/checkout` runner**: update the `actions/checkout@v3` action ref if actionlint or GitHub Actions suggests a newer pinned version (the existing workflow contains a small actionlint note β€” consider updating to the latest stable minor release if needed). +- **Validator fails saying a script is missing from `scriptsInfo`**: add the scriptsInfo entry with the correct key. +- **Validator finds an extra `scriptsInfo` key**: remove or rename the `scripts` or `scriptsInfo` entry to match. +- **CI workflow errors about `actions/checkout` runner**: update the `actions/checkout@v3` action ref if actionlint or GitHub Actions suggests a newer pinned version (the existing workflow contains a small actionlint note β€” consider updating to the latest stable minor release if needed). ## Contributing & making changes -* Update both `package.json` (or `docs/package.json`) `scripts` and `scriptsInfo` together. -* Run `npm run validate-scripts` locally before opening a PR. -* If you touch scripts, remember CI will run the validator and may annotate your PR with the output if validation fails. +- Update both `package.json` (or `docs/package.json`) `scripts` and `scriptsInfo` together. +- Run `npm run validate-scripts` locally before opening a PR. +- If you touch scripts, remember CI will run the validator and may annotate your PR with the output if validation fails. ## Files touched in the PR (helpful reference) -* `.github/workflows/ci.yml` -* `.github/workflows/commenter.yml` (reusable commenter) -* `package.json` -* `docs/package.json` -* `scripts/help.js` -* `docs/scripts/help.js` -* `scripts/lib/cli-fuzzy.js` -* `scripts/lib/colors.js` -* `scripts/lib/read-packageJson-scripts.js` -* `scripts/validate-scripts.js` +- `.github/workflows/ci.yml` +- `.github/workflows/commenter.yml` (reusable commenter) +- `package.json` +- `docs/package.json` +- `scripts/help.js` +- `docs/scripts/help.js` +- `scripts/lib/cli-fuzzy.js` +- `scripts/lib/colors.js` +- `scripts/lib/read-packageJson-scripts.js` +- `scripts/validate-scripts.js` diff --git a/docs/docs/reference/react/_category_.json b/docs/docs/reference/react/_category_.json index e30043238..9314ef0b4 100644 --- a/docs/docs/reference/react/_category_.json +++ b/docs/docs/reference/react/_category_.json @@ -6,5 +6,5 @@ "title": "React Components & Hooks", "description": "Reference for React components, hooks, and context providers in Img2Num, including WASM integration.", "slug": "/reference/react" - }, + } } diff --git a/docs/docs/reference/react/components/GlassCard/index.md b/docs/docs/reference/react/components/GlassCard/index.md index e5c653c5a..0bdf36570 100644 --- a/docs/docs/reference/react/components/GlassCard/index.md +++ b/docs/docs/reference/react/components/GlassCard/index.md @@ -16,6 +16,7 @@ We are actively looking for contributors to help document this component and its https://github.com/Ryan-Millard/Img2Num/issues/178 That issue outlines exactly what is needed, including: + - Component overview - Usage examples - Props reference diff --git a/docs/docs/reference/react/components/GlassSwitch/_category_.json b/docs/docs/reference/react/components/GlassSwitch/_category_.json new file mode 100644 index 000000000..037e4a6c0 --- /dev/null +++ b/docs/docs/reference/react/components/GlassSwitch/_category_.json @@ -0,0 +1,8 @@ +{ + "label": "GlassSwitch", + "position": 4, + "link": { + "type": "generated-index", + "description": "Documentation for the GlassSwitch component" + } +} diff --git a/docs/docs/reference/react/components/GlassSwitch/index.md b/docs/docs/reference/react/components/GlassSwitch/index.md new file mode 100644 index 000000000..c92a42281 --- /dev/null +++ b/docs/docs/reference/react/components/GlassSwitch/index.md @@ -0,0 +1,398 @@ +--- +title: GlassSwitch +description: A customizable glass-morphism toggle switch component with accessibility support +--- + +## Overview + +`GlassSwitch` is a reusable, accessible toggle switch component with a modern glass-morphism design. It provides smooth animations, flexible customization options, and full keyboard navigation support. + +### Key Features + +- ✨ **Glass-morphism design** - Beautiful translucent styling +- 🎨 **Customizable thumb content** - Use icons, text, or any React element +- β™Ώ **Fully accessible** - WCAG compliant with proper ARIA attributes +- ⌨️ **Keyboard navigation** - Tab to focus, Enter/Space to toggle +- 🎭 **Smooth animations** - 0.3s ease transitions +- πŸ›‘οΈ **Type-safe** - PropTypes validation included + +:::tip Best Use Cases +Perfect for: + +- **Theme toggles** - Light/dark mode switching +- **Feature flags** - Enable/disable features +- **Settings switches** - User preferences and configurations +- **Boolean states** - Any on/off toggle requirement + +See the [Examples](#examples) section for practical implementations. +::: + +## Installation & Dependencies + +This component is part of the Img2Num component library. It depends on: + +| Dependency | Purpose | +| --------------------- | -------------------------------------------- | +| `@components/Tooltip` | Displays helpful tooltips on hover | +| `prop-types` | Runtime prop type validation | +| `lucide-react` | (Optional) For icon support in thumb content | + +## Quick Start + +### Basic Usage + +The simplest implementation requires just three props: `isOn`, `onChange`, and `ariaLabel`. + +```jsx +import GlassSwitch from '@components/GlassSwitch'; +import { useState } from 'react'; + +export default function Settings() { + const [isEnabled, setIsEnabled] = useState(false); + + return ( +
+ +
+ ); +} +``` + +### With Custom Icons + +Add visual indicators using custom thumb content (icons, emojis, or any React element). + +```jsx +import GlassSwitch from '@components/GlassSwitch'; +import { Bell, BellOff } from 'lucide-react'; +import { useState } from 'react'; + +export default function NotificationToggle() { + const [notificationsOn, setNotificationsOn] = useState(false); + + return ( + setNotificationsOn(!notificationsOn)} + ariaLabel="Toggle notifications" + thumbContent={notificationsOn ? : } + /> + ); +} +``` + +## API Reference + +### Props + +| Prop | Type | Required | Default | Description | +| -------------- | ------------ | -------- | -------- | --------------------------------------------------- | +| `isOn` | `boolean` | βœ… Yes | - | Controls the switch state (true = on, false = off) | +| `onChange` | `function` | βœ… Yes | - | Callback fired when the switch is toggled | +| `ariaLabel` | `string` | βœ… Yes | - | Accessible label for screen readers and tooltips | +| `thumbContent` | `React.node` | No | fallback | Custom content inside the thumb (icons, text, etc.) | +| `disabled` | `boolean` | No | `false` | Disables the switch and prevents interaction | + +#### Prop Usage Guide + +**`isOn` (required)** - Boolean state controller + +```jsx +const [isOn, setIsOn] = useState(false); +``` + +**`onChange` (required)** - Toggle handler + +```jsx +onChange={() => setIsOn(!isOn)} +// or with custom logic +onChange={handleToggle} +``` + +**`ariaLabel` (required)** - Accessibility label (also shown in tooltip) + +```jsx +ariaLabel = 'Toggle dark mode'; +ariaLabel = 'Enable push notifications'; +``` + +**`thumbContent` (optional)** - Custom thumb visuals + +```jsx +// Icons +thumbContent={} + +// Emojis +thumbContent="πŸŒ™" + +// Conditional +thumbContent={isDark ? : } + +// Omit for CSS-based fallback (on/off colored thumb) +``` + +**`disabled` (optional)** - Prevent interaction + +```jsx +disabled={!isPremiumUser} +disabled={isLoading} +``` + +### CSS Classes & Styling + +The component uses CSS modules for scoped styling: + +| Class | Applied To | Purpose | +| ------------------------- | ---------------- | ------------------------------------------------ | +| `switch` | Button element | Main switch container and glass effect | +| `thumb` | Thumb span | Sliding thumb element | +| `checked` | Button (when on) | Added when `isOn={true}` to trigger animation | +| `fallbackThumbContentOn` | Thumb (default) | On-state style (green tone) when no thumbContent | +| `fallbackThumbContentOff` | Thumb (default) | Off-state style (gray tone) when no thumbContent | + +**Global Classes:** + +- `.glass` - Provides glass-morphism effects (backdrop blur, transparency) + +**CSS Custom Properties:** + +- `--size: 32px` - Controls switch dimensions (width = 2Γ— size) + +:::info Customization +To override styles, target these classes in your own CSS or use inline styles on the wrapper element. +::: + +## Accessibility + +GlassSwitch follows WCAG guidelines for accessible toggle switches: + +### Semantic HTML + +- βœ… Uses `role="switch"` for proper assistive technology support +- βœ… Renders as ` + : } + ariaLabel={`switch to ${isDark ? 'light' : 'dark'} mode`} + /> ); } ``` ## Visual behavior -| Current Theme | Icon Displayed | Next Theme on Click | -| ------------- | -------------- | ------------------- | -| Light | πŸŒ™ Moon | Dark | -| Dark | β˜€οΈ Sun | Light | +| Current Theme | Icon Displayed | aria-label | Next Theme on Toggle | +| ------------- | -------------- | ---------------------- | -------------------- | +| Light | πŸŒ™ Moon | "switch to dark mode" | Dark | +| Dark | β˜€οΈ Sun | "switch to light mode" | Light | ## Testing diff --git a/docs/docs/reference/react/components/ThemeSwitch/tests.md b/docs/docs/reference/react/components/ThemeSwitch/tests.md index 4b022ee59..ddd823be4 100644 --- a/docs/docs/reference/react/components/ThemeSwitch/tests.md +++ b/docs/docs/reference/react/components/ThemeSwitch/tests.md @@ -2,8 +2,7 @@ title: ThemeSwitch Tests --- - -The ThemeSwitch component has 7 updated tests covering rendering, icon display, theme toggling, hook integration, and edge cases. +The ThemeSwitch component has 7 tests covering rendering, icon display, theme toggling, hook integration, and edge cases. ## Test file location @@ -26,48 +25,44 @@ npm test -- --watch ThemeSwitch.test.jsx ## Test organization -### 1. Rendering (2 tests) +### 1. Rendering & accessibility (2 tests) -* Renders a button with the correct aria-label based on the theme -* Button has type="button" attribute and CSS class `themeButton` +- Renders a switch with the correct aria-label based on the theme (light β†’ "switch to dark mode", dark β†’ "switch to light mode") +- Uses `type="button"` and `role="switch"` with proper `aria-checked` ### 2. Icon display based on theme (2 tests) -* Shows Moon icon for light theme -* Shows Sun icon for dark theme -* Applies `icon` CSS class correctly +- Shows Moon icon for light theme +- Shows Sun icon for dark theme ### 3. Theme toggling functionality (2 tests) -* Calls `toggleTheme` when button is clicked -* Is keyboard accessible (focusable) +- Calls `toggleTheme` when clicked +- Is keyboard accessible (focusable) ### 4. Integration with useTheme hook (1 test) -* Uses `toggleTheme` function from hook +- Uses the `toggleTheme` function provided by the hook ### 5. Edge cases (1 test) -* Defaults to Moon icon when theme is undefined or falsy +- Defaults to Moon icon when theme is undefined or falsy ## Mocking strategy -* **useTheme hook**: mocked with `vi.spyOn` to control theme and toggle function -* **CSS modules**: mocked to check `className` usage -* **Lucide icons**: mocked with test spans for Moon/Sun icons -* **Tooltip**: mocked as a simple wrapper component +- **useTheme hook**: mocked with `vi.spyOn` to control theme and toggle function +- **Lucide icons**: mocked with test spans for Moon/Sun icons (for easy querying) ## Example test snippets -### Rendering button with light theme +### Rendering switch with light theme ```javascript vi.spyOn(useThemeModule, 'useTheme').mockReturnValue({ theme: 'light', toggleTheme: mockToggleTheme }); render(); -const button = screen.getByRole('button', { name: 'Switch to Dark Mode' }); +const button = screen.getByRole('switch', { name: 'switch to dark mode' }); expect(button).toBeInTheDocument(); expect(button).toHaveAttribute('type', 'button'); -expect(button).toHaveClass('mocked-theme-button-class'); ``` ### Testing icon display @@ -75,14 +70,13 @@ expect(button).toHaveClass('mocked-theme-button-class'); ```javascript const moonIcon = screen.getByTestId('moon-icon'); expect(moonIcon).toBeInTheDocument(); -expect(moonIcon).toHaveClass('mocked-icon-class'); expect(screen.queryByTestId('sun-icon')).not.toBeInTheDocument(); ``` ### Testing toggleTheme ```javascript -const button = screen.getByRole('button', { name: 'Switch to Dark Mode' }); +const button = screen.getByRole('switch', { name: 'switch to dark mode' }); fireEvent.click(button); expect(mockToggleTheme).toHaveBeenCalledTimes(1); ``` @@ -97,19 +91,19 @@ expect(screen.getByTestId('moon-icon')).toBeInTheDocument(); ## Test utilities -* **Vitest** - Test framework -* **React Testing Library** - Component rendering & queries -* **vi.fn() / vi.spyOn()** - Mocking -* **fireEvent** - User interactions +- **Vitest** - Test framework +- **React Testing Library** - Component rendering & queries +- **vi.fn() / vi.spyOn()** - Mocking +- **fireEvent** - User interactions ## Coverage -* Component rendering (light/dark themes) -* User interactions (click, keyboard) -* Edge cases (undefined/falsy theme) -* Integration with hook -* Accessibility (aria-labels, focus) -* CSS class application +- Component rendering (light/dark themes) +- User interactions (click, keyboard) +- Edge cases (undefined/falsy theme) +- Integration with hook +- Accessibility (aria-labels, focus) +- CSS class application ## Best practices @@ -121,5 +115,5 @@ expect(screen.getByTestId('moon-icon')).toBeInTheDocument(); ## Related -* [ThemeSwitch Component](../) -* [useTheme Hook](../../../hooks/useTheme) +- [ThemeSwitch Component](../) +- [useTheme Hook](../../../hooks/useTheme) diff --git a/docs/docs/reference/react/components/Tooltip/index.md b/docs/docs/reference/react/components/Tooltip/index.md index 0e597e19a..b895205ec 100644 --- a/docs/docs/reference/react/components/Tooltip/index.md +++ b/docs/docs/reference/react/components/Tooltip/index.md @@ -3,24 +3,27 @@ title: Tooltip --- **What this file covers (quick):** + - How to use the `Tooltip` component - Props & defaults - Accessibility - Short implementation caveats ## Dependencies + - [`react-tooltip`](https://www.npmjs.com/package/react-tooltip). ## Basic usage + ```jsx -import Tooltip from '@components/Tooltip' +import Tooltip from '@components/Tooltip'; export default function Example() { return ( - ) + ); } ``` @@ -29,29 +32,33 @@ If you pass a non-element child (plain text or multiple nodes), the component wr so keyboard users can discover the tooltip. The actual tooltip element is rendered by `react-tooltip` and appended to `document.body` as a portal. ## Props + | Prop | Type | Required | Default | Notes | -| -------------------- | -------: | -------: | --------: | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| -------------------- | -------: | -------: | --------: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `content` | `string` | Yes | | Text shown inside the tooltip. Keep it short - tooltips are for hints. | | `children` | `node` | Yes | | Element that triggers the tooltip. Prefer a single React element (` ``` + ```jsx title="Internal navigation (react-router Link)" Profile ``` + ```jsx title="External link (attach tooltip to the itself β€” target & rel recommended)" - GitHub + + GitHub + ``` + ```jsx title="Plain text / complex non-focusable nodes (gets wrapped in a focusable span)" - - Some inline text or an icon-only element - +Some inline text or an icon-only element ``` ## Testing tips + - `react-tooltip` mounts the tooltip node into `document.body`. -In the `jsdom` environment `screen` queries will still find it. + In the `jsdom` environment `screen` queries will still find it. - Portal timing & animations can make tests flaky. -Wrap assertions in `await waitFor()` or use `findBy*` queries which retry until the element appears. Example patterns: + Wrap assertions in `await waitFor()` or use `findBy*` queries which retry until the element appears. Example patterns: ```js title="Hover" - await user.hover(screen.getByText('Hover me')) - expect(await screen.findByText('Hello tooltip')).toBeVisible() + await user.hover(screen.getByText('Hover me')); + expect(await screen.findByText('Hello tooltip')).toBeVisible(); ``` + ```js title="Focus" - await user.tab() - expect(await screen.findByText('Hello tooltip')).toBeVisible() + await user.tab(); + expect(await screen.findByText('Hello tooltip')).toBeVisible(); ``` + ```js title="Hide with waitFor to accommodate transition" - await waitFor(() => expect(screen.queryByText('Hello tooltip')).not.toBeInTheDocument()) + await waitFor(() => expect(screen.queryByText('Hello tooltip')).not.toBeInTheDocument()); ``` + - In tests prefer passing an actual element as `children` (button, Link, or anchor) -so the library attributes are attached directly and keyboard focus works reliably. -If you need to assert behavior for plain text triggers, test the wrapped `` behavior explicitly. + so the library attributes are attached directly and keyboard focus works reliably. + If you need to assert behavior for plain text triggers, test the wrapped `` behavior explicitly. - If you see intermittent failures due to CSS transitions, disable transitions in your test setup -(for example, add a small global CSS rule to turn off transitions during tests) - this makes timing deterministic. + (for example, add a small global CSS rule to turn off transitions during tests) - this makes timing deterministic. ## Summary + - The `Tooltip` prefers to attach attributes directly to a single React child (preserves semantics for ``, ` + ); } // Example test it('defaults to system preference when no localStorage value exists (prefers dark)', () => { - window.matchMedia = vi.fn().mockImplementation(() => ({ matches: true, addListener: vi.fn(), removeListener: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn() })); + window.matchMedia = vi.fn().mockImplementation(() => ({ + matches: true, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })); render(); @@ -79,16 +87,16 @@ it('defaults to system preference when no localStorage value exists (prefers dar ## Utilities used -* **Vitest** β€” test runner (`vi`, `describe`, `it`, etc.) -* **@testing-library/react** β€” render, queries, and `fireEvent` -* **vi.fn() / vi.spyOn()** β€” mocking and spying +- **Vitest** β€” test runner (`vi`, `describe`, `it`, etc.) +- **@testing-library/react** β€” render, queries, and `fireEvent` +- **vi.fn() / vi.spyOn()** β€” mocking and spying ## Best practices demonstrated -* Mock platform APIs (e.g. `matchMedia`) instead of relying on environment defaults. -* Reset `localStorage` and DOM side-effects between tests. -* Prefer small helper/test components for testing hooks that manipulate the DOM. +- Mock platform APIs (e.g. `matchMedia`) instead of relying on environment defaults. +- Reset `localStorage` and DOM side-effects between tests. +- Prefer small helper/test components for testing hooks that manipulate the DOM. ## Related -* [ThemeSwitch Component](../../../components/ThemeSwitch) β€” uses `useTheme` to render UI. +- [ThemeSwitch Component](../../../components/ThemeSwitch) β€” uses `useTheme` to render UI. diff --git a/docs/docs/reference/tools/_category_.json b/docs/docs/reference/tools/_category_.json index 6ca07d415..fa1cc9cf3 100644 --- a/docs/docs/reference/tools/_category_.json +++ b/docs/docs/reference/tools/_category_.json @@ -6,5 +6,5 @@ "title": "Developer Tools & Scripts", "description": "Reference for development scripts, CLI utilities, and testing tools used in the Img2Num project.", "slug": "/reference/tools" - }, + } } diff --git a/docs/docs/reference/tools/ci-workflows.md b/docs/docs/reference/tools/ci-workflows.md new file mode 100644 index 000000000..b51edd955 --- /dev/null +++ b/docs/docs/reference/tools/ci-workflows.md @@ -0,0 +1,151 @@ +--- +sidebar_position: 2 +title: CI/CD Workflows +description: Documentation for GitHub Actions workflows used in Img2Num's continuous integration and deployment pipeline +keywords: [CI, CD, GitHub Actions, workflows, automation, testing, linting] +--- + +# CI/CD Workflows + +Img2Num uses GitHub Actions for continuous integration and deployment. This page documents the workflows that run automatically on pull requests and pushes to main. + +## Workflows Overview + +### Lint Workflow + +**File**: `.github/workflows/ci.yml` (lint job) + +**Triggers**: + +- Pull requests to `main` +- Pushes to `main` + +**Purpose**: Ensures all code meets our style and quality standards before merging. + +**Steps**: + +1. Checkout code +2. Setup Node.js 22 with npm caching +3. Install dependencies (`npm ci`) +4. Run ESLint (`npm run lint`) +5. Run editorconfig-checker (`npm run lint:style`) + +**What it checks**: + +- JavaScript/React code quality (ESLint) +- Code style consistency (indentation, line endings, etc.) +- EditorConfig compliance + +**Failure conditions**: The workflow fails if any linting errors are detected. + +--- + +### Script Validation Workflow + +**File**: `.github/workflows/ci.yml` (validate-scripts job) + +**Triggers**: + +- Pull requests to `main` (when scripts are modified) +- Pushes to `main` (when scripts are modified) + +**Purpose**: Validates that every npm script has corresponding documentation in `package.json`'s `scriptsInfo` section. + +**Smart detection**: Only runs when: + +- `package.json` or `docs/package.json` changes include `scriptsInfo` modifications +- Files under `scripts/` or `docs/scripts/` are modified + +**Steps**: + +1. Detect if script-related files changed +2. If yes, run `npm run validate-scripts` +3. Comment on PR if validation fails (with error details) + +--- + +## Running CI Checks Locally + +To verify your changes will pass CI before pushing: + +```bash +# Run all lint checks +npm ci +npm run lint +npm run lint:style + +# Validate scripts (if you modified scripts) +npm run validate-scripts +``` + +## Troubleshooting CI Failures + +### Lint Job Fails + +**Symptoms**: `npm run lint` or `npm run lint:style` fails in CI + +**Solutions**: + +1. Run the failing command locally: + ```bash + npm run lint # for ESLint errors + npm run lint:style # for style errors + ``` +2. Review the error messages +3. Fix issues (use `npm run lint:fix` for auto-fixable ESLint issues) +4. Commit and push fixes + +### Script Validation Fails + +**Symptoms**: `validate-scripts` job fails + +**Solutions**: + +1. Ensure every script in `package.json` has an entry in `scriptsInfo` +2. Run `npm run validate-scripts` locally to see specific errors +3. Add missing `scriptsInfo` entries or remove unused scripts +4. See [Project Scripts](../../project-scripts/overview.md) for more details + +## Workflow Configuration + +### Node.js Version + +All workflows use **Node.js 22** to match the project's runtime environment. + +### Caching + +npm dependencies are cached to speed up workflow runs: + +```yaml +- uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' +``` + +### Conditional Execution + +The `validate-scripts` job only runs when necessary, saving CI resources: + +- Checks git diff for relevant file changes +- Uses `if: steps.check_changes.outputs.scripts_changed == 'true'` + +## Best Practices + +1. **Run checks locally first**: Don't rely on CI to catch issues +2. **Keep commits lint-clean**: Fix linting issues before pushing +3. **Review CI logs**: If CI fails, read the full error output +4. **Ask for help**: If stuck, open a [discussion](https://github.com/Ryan-Millard/Img2Num/discussions) or comment on your PR + +## Future Enhancements + +Planned improvements to CI workflows: + +- Unit test automation +- Build verification +- Deployment automation +- Code coverage reporting + +:::info Contributing to CI +If you want to improve or add new workflows, please open an issue first to discuss your ideas with the maintainers. +::: diff --git a/docs/docs/reference/wasm/_category_.json b/docs/docs/reference/wasm/_category_.json index 437f513cc..359b54014 100644 --- a/docs/docs/reference/wasm/_category_.json +++ b/docs/docs/reference/wasm/_category_.json @@ -6,5 +6,5 @@ "title": "WebAssembly (WASM)", "description": "Documentation for all WebAssembly (WASM) modules in Img2Num, including FFT and image processing functions.", "slug": "/reference/wasm" - }, + } } diff --git a/docs/docs/reference/wasm/development-workflow.md b/docs/docs/reference/wasm/development-workflow.md index 6adad1dbe..0f8549fd4 100644 --- a/docs/docs/reference/wasm/development-workflow.md +++ b/docs/docs/reference/wasm/development-workflow.md @@ -10,9 +10,9 @@ This repository configures a Vite plugin (`watch-cpp-and-build-wasm`) that watch Important points: -* The watcher registers `src/wasm/**/*.{cpp,h}` with Vite's watcher so edits trigger rebuilds. -* The build uses the root `src/wasm/CMakeLists.txt` which iterates through the modules and calls each module's `CMakeLists.txt`. -* For faster local iteration use `npm run dev:debug` β€” this runs `make debug` and launches the dev server. +- The watcher registers `src/wasm/**/*.{cpp,h}` with Vite's watcher so edits trigger rebuilds. +- The build uses the root `src/wasm/CMakeLists.txt` which iterates through the modules and calls each module's `CMakeLists.txt`. +- For faster local iteration use `npm run dev:debug` β€” this runs `make debug` and launches the dev server. ## Root CMakeLists.txt contract @@ -94,18 +94,19 @@ message(STATUS "Module '${MODULE_NAME}' configured (export: create${CAP_MODULE_N ``` :::note + 1. Place your C++ headers in include/ and sources in src/ 2. Use exported functions via ccall/cwrap in JS 3. Adjust memory flags if your module needs more/less WASM memory 4. For module-specific Emscripten options, add them before target_link_options -::: + ::: This template compiles all `.cpp` files under `src/` into `build/index.js` + `build/index.wasm` using simple flags. Tailor flags and link-time options to your needs. ## Debugging tips -* Build with `debug` target to keep symbols and turn on `ASSERTIONS`. -* Use `EMSCRIPTEN_KEEP_UNWANTED_CODE` only when you need to preserve functions β€” avoid it in production. -* Use `console.log` in Emscripten glue JS β€” Emscripten prints useful warnings if symbols are missing. +- Build with `debug` target to keep symbols and turn on `ASSERTIONS`. +- Use `EMSCRIPTEN_KEEP_UNWANTED_CODE` only when you need to preserve functions β€” avoid it in production. +- Use `console.log` in Emscripten glue JS β€” Emscripten prints useful warnings if symbols are missing. --- diff --git a/docs/docs/reference/wasm/how-to-add-a-module.md b/docs/docs/reference/wasm/how-to-add-a-module.md index b01422930..4cf42e5fb 100644 --- a/docs/docs/reference/wasm/how-to-add-a-module.md +++ b/docs/docs/reference/wasm/how-to-add-a-module.md @@ -7,15 +7,15 @@ sidebar_position: 6 # Step-by-step: add a new module 1. Create a new directory: `src/wasm/modules//`. - - Replace `` with your actual module name (lowercase, e.g., `audio`, `filters`). + - Replace `` with your actual module name (lowercase, e.g., `audio`, `filters`). 2. Add `src/`, `include/` directories and a `CMakeLists.txt` file. 3. Make sure the module's `CMakeLists.txt` writes output to `build/` with `index.js` and `index.wasm` (the repo's alias generator expects `modules/{name}/build`). 4. `vite.config.js` will automatically find the module and create an alias `@wasm-` on next `vite` start (or rebuild of the config). Example usage: - ```js - import init from '@wasm-/index.js'; - await init(); - ``` + ```js + import init from '@wasm-/index.js'; + await init(); + ``` 5. Commit the `CMakeLists.txt` and source files; do not commit `build/` artifacts unless you want to vendor the WASM for static hosting without building. diff --git a/docs/docs/reference/wasm/modules/_category_.json b/docs/docs/reference/wasm/modules/_category_.json index f975aa69b..15e4d317b 100644 --- a/docs/docs/reference/wasm/modules/_category_.json +++ b/docs/docs/reference/wasm/modules/_category_.json @@ -5,5 +5,5 @@ "title": "WebAssembly Modules", "description": "Documentation for individual WebAssembly (WASM) modules in Img2Num.", "slug": "/reference/wasm/modules" - }, + } } diff --git a/docs/docs/reference/wasm/modules/image/_category_.json b/docs/docs/reference/wasm/modules/image/_category_.json index 50f7996f5..8bb84fe11 100644 --- a/docs/docs/reference/wasm/modules/image/_category_.json +++ b/docs/docs/reference/wasm/modules/image/_category_.json @@ -6,5 +6,5 @@ "title": "WebAssembly Image Module", "description": "Documentation for the Image WebAssembly (WASM) module in Img2Num.", "slug": "/reference/wasm/modules/image" - }, + } } diff --git a/docs/docs/reference/wasm/modules/image/fft_iterative/_category_.json b/docs/docs/reference/wasm/modules/image/fft_iterative/_category_.json index 30d3f1481..25c1a6bd1 100644 --- a/docs/docs/reference/wasm/modules/image/fft_iterative/_category_.json +++ b/docs/docs/reference/wasm/modules/image/fft_iterative/_category_.json @@ -6,5 +6,5 @@ "title": "Iterative Radix-2 Cooley-Tukey Fast Fourier Transform", "description": "Documentation for the Iterative Radix-2 Cooley-Tukey Fast Fourier Transform in the Image WebAssembly (WASM) module in Img2Num.", "slug": "/reference/wasm/modules/image/fft_iterative" - }, + } } diff --git a/docs/docs/reference/wasm/modules/image/fft_iterative/api.md b/docs/docs/reference/wasm/modules/image/fft_iterative/api.md index 921518ee2..e6c64ec6d 100644 --- a/docs/docs/reference/wasm/modules/image/fft_iterative/api.md +++ b/docs/docs/reference/wasm/modules/image/fft_iterative/api.md @@ -21,11 +21,11 @@ As a result: - `iterative_fft` and `fft_copy` may **resize the input vector** - `iterative_fft_2d` and `iterative_fft_2d_copy` may **resize the buffer and change the effective width/height** -If you require strict *same-size in / same-size out* behavior, ensure your input dimensions are already powers of two before calling these functions. +If you require strict _same-size in / same-size out_ behavior, ensure your input dimensions are already powers of two before calling these functions. ::: | Function | Signature | Purpose | -| ----------------------- | -----------------------------------------------------------------------------------------------------------------------: | -------------------------------------------------------------------------------- | +| ----------------------- | -----------------------------------------------------------------------------------------------------------------------: | -------------------------------------------------------------------------------------- | | `is_power_of_two` | `bool is_power_of_two(size_t n)` | Check if `n` is a power of two. | | `next_power_of_two` | `size_t next_power_of_two(size_t n)` | Return the next power of two $$\ge n$$. | | `bit_reverse_permute` | `void bit_reverse_permute(std::vector &a)` | In-place bit-reversal permutation (required for DIT iterative FFT). | @@ -36,6 +36,7 @@ If you require strict *same-size in / same-size out* behavior, ensure your input | `iterative_fft_2d_copy` | `std::vector iterative_fft_2d_copy(const std::vector &input, size_t width, size_t height, bool inverse = false)` | Returns a new vector with the 2D FFT result. | :::info Notes + - Sign convention: forward uses `-2Ο€i`, inverse uses `+2Ο€i` and divides by `N`. - Implementation uses `std::polar` to create stage primitive roots. -::: + ::: diff --git a/docs/docs/reference/wasm/modules/image/fft_iterative/explained.md b/docs/docs/reference/wasm/modules/image/fft_iterative/explained.md index 3db73f79c..7a1745c04 100644 --- a/docs/docs/reference/wasm/modules/image/fft_iterative/explained.md +++ b/docs/docs/reference/wasm/modules/image/fft_iterative/explained.md @@ -41,21 +41,22 @@ An example: a cellphone from 2013. ## Bit-Reversal Permutation ### Purpose + Before applying the iterative butterfly operations, the input array must be **reordered so that the indices correspond to the bit-reversed order** of their original index. - **Example:** For $N=8$, binary indices: - | Original Index (decimal) | Original Index (binary) | Bit-Reversed Index (binary) | Bit-Reversed Index (decimal) | - | ------------------------ | ----------------------- | --------------------------- | ---------------------------- | - | 0 | 000 | 000 | 0 | - | 1 | 001 | 100 | 4 | - | 2 | 010 | 010 | 2 | - | 3 | 011 | 110 | 6 | - | 4 | 100 | 001 | 1 | - | 5 | 101 | 101 | 5 | - | 6 | 110 | 011 | 3 | - | 7 | 111 | 111 | 7 | + | Original Index (decimal) | Original Index (binary) | Bit-Reversed Index (binary) | Bit-Reversed Index (decimal) | + | ------------------------ | ----------------------- | --------------------------- | ---------------------------- | + | 0 | 000 | 000 | 0 | + | 1 | 001 | 100 | 4 | + | 2 | 010 | 010 | 2 | + | 3 | 011 | 110 | 6 | + | 4 | 100 | 001 | 1 | + | 5 | 101 | 101 | 5 | + | 6 | 110 | 011 | 3 | + | 7 | 111 | 111 | 7 | - Reordering ensures that the iterative algorithm can **process butterflies in a linear pass** without recursion. - Each butterfly stage combines elements separated by certain distances; bit-reversal guarantees that the data for each stage are contiguous in memory. @@ -92,6 +93,7 @@ $$ - Half of the elements are combined with the other half using the corresponding twiddle factor. ### Visualization + ```mermaid %% N=8 FFT Butterfly Diagram (Textbook-Accurate, Domain-Neutral) flowchart LR diff --git a/docs/docs/reference/wasm/modules/image/fft_iterative/implementation.md b/docs/docs/reference/wasm/modules/image/fft_iterative/implementation.md index df977c692..4002c4a20 100644 --- a/docs/docs/reference/wasm/modules/image/fft_iterative/implementation.md +++ b/docs/docs/reference/wasm/modules/image/fft_iterative/implementation.md @@ -80,11 +80,11 @@ This yields the true inverse DFT. ## Padding & 2D transforms -* The code auto-pads input to the next power of two using `next_power_of_two` and `pad_to_pow_two`. -* 2D transforms are implemented by running the 1D FFT across rows, then across columns (separable property). See `iterative_fft_2d(...)`. +- The code auto-pads input to the next power of two using `next_power_of_two` and `pad_to_pow_two`. +- 2D transforms are implemented by running the 1D FFT across rows, then across columns (separable property). See `iterative_fft_2d(...)`. ## Practical optimization notes -* Precompute stage roots if you want to micro-optimise repeated transforms of the same size. -* Keep the transform in-place to minimise allocations and improve cache locality. -* Use `double` for better precision; `float` can be used for speed but expect more numerical error for large N. +- Precompute stage roots if you want to micro-optimise repeated transforms of the same size. +- Keep the transform in-place to minimise allocations and improve cache locality. +- Use `double` for better precision; `float` can be used for speed but expect more numerical error for large N. diff --git a/docs/docs/reference/wasm/modules/image/fft_iterative/overview.md b/docs/docs/reference/wasm/modules/image/fft_iterative/overview.md index dbffa939f..37dcc2878 100644 --- a/docs/docs/reference/wasm/modules/image/fft_iterative/overview.md +++ b/docs/docs/reference/wasm/modules/image/fft_iterative/overview.md @@ -27,8 +27,8 @@ If you need the mathematical background before diving in, see the prerequisite p ## Pages in this mini-guide -* **Overview** (this page) -* **Implementation details** β€” step-by-step mapping between theory and the actual C++ code. -* **API & reference** β€” brief function signatures and purpose for quick lookup. +- **Overview** (this page) +- **Implementation details** β€” step-by-step mapping between theory and the actual C++ code. +- **API & reference** β€” brief function signatures and purpose for quick lookup. Jump to implementation: [Implementation details](../implementation/) diff --git a/docs/docs/reference/wasm/modules/image/fft_iterative/prerequisite-theory/continuous-fourier-transform/index.md b/docs/docs/reference/wasm/modules/image/fft_iterative/prerequisite-theory/continuous-fourier-transform/index.md index 296a144ae..2b56cbc32 100644 --- a/docs/docs/reference/wasm/modules/image/fft_iterative/prerequisite-theory/continuous-fourier-transform/index.md +++ b/docs/docs/reference/wasm/modules/image/fft_iterative/prerequisite-theory/continuous-fourier-transform/index.md @@ -15,20 +15,23 @@ If you take the Fourier series and let the period $T \to \infty$, the discrete h It represents general, non-periodic continuous-time signals. ## CFT for a continuous sequence + $$ X(f) = \int_{-\infty}^{\infty} x(t)\, e^{-j 2\pi f t} \, dt $$ ## Inverse CFT for a continuous sequence + $$ x(t) = \int_{-\infty}^{\infty} X(f)\, e^{j 2\pi f t} \, df $$ :::note + - The CFT assumes a continuous-time signal $x(t)$ defined for all $t$. - Not directly computable on a digital computer because it requires infinite, continuous data. - The forward and inverse transforms differ in the sign of the complex exponential (which determines rotation direction) andβ€”in discrete implementationsβ€”by a normalization factor (e.g. $\frac{1}{N}$ for the inverse DFT in the engineering convention). Both differences are required so the inverse undoes the forward transform. -::: + ::: --- @@ -41,6 +44,7 @@ Note how the time-domain Gaussian width inversely affects the spread in the freq In practice, we compute approximate transforms digitally using the DFT/FFT, which discretizes both time and frequency. #### Figure 1: Gaussian pulse in time domain (top) and magnitude of its CFT (bottom) + Gaussian pulse CFT :::danger You may have missed this diff --git a/docs/docs/reference/wasm/modules/image/fft_iterative/prerequisite-theory/discrete-time-signals-and-the-dft/index.md b/docs/docs/reference/wasm/modules/image/fft_iterative/prerequisite-theory/discrete-time-signals-and-the-dft/index.md index f70da2de9..735337e40 100644 --- a/docs/docs/reference/wasm/modules/image/fft_iterative/prerequisite-theory/discrete-time-signals-and-the-dft/index.md +++ b/docs/docs/reference/wasm/modules/image/fft_iterative/prerequisite-theory/discrete-time-signals-and-the-dft/index.md @@ -25,8 +25,8 @@ X[k] = \sum_{n=0}^{N-1} x[n] e^{-j 2 \pi \frac{k n}{N}}, \quad &k = 0, \dots, N- \end{align*} $$ -* $k$ is the **bin index** corresponding to a discrete frequency. -* $X[k]$ is generally **complex-valued**, representing both amplitude and phase of the frequency component. +- $k$ is the **bin index** corresponding to a discrete frequency. +- $X[k]$ is generally **complex-valued**, representing both amplitude and phase of the frequency component. ## Inverse DFT @@ -57,13 +57,13 @@ $$ f_k = k \frac{f_s}{N} \quad k = 0, 1, \dots, N-1 $$ -* Frequencies above $\frac{f_s}{2}$ correspond to **negative frequencies** (aliases): +- Frequencies above $\frac{f_s}{2}$ correspond to **negative frequencies** (aliases): $$ f_k - f_s \quad k > \frac{N}{2} $$ -* For **real-valued signals**, the DFT is **conjugate symmetric**: +- For **real-valued signals**, the DFT is **conjugate symmetric**: $$ X[N-k] = \overline{X[k]} \quad (\text{complex conjugate symmetry}) @@ -72,9 +72,10 @@ $$ This means we only need to examine the first half of the spectrum for amplitude information. :::tip Important Notes + 1. The DFT **assumes periodicity**: it treats the finite sequence $x[n]$ as one period of an infinitely repeating discrete signal. -This is why windowing and zero-padding are important β€” they reduce artifacts caused by discontinuities at the boundaries. + This is why windowing and zero-padding are important β€” they reduce artifacts caused by discontinuities at the boundaries. 2. The **frequency resolution** depends on the number of samples and sampling rate: $\Delta f = \frac{f_s}{N}$. -This is the spacing between adjacent frequency bins. + This is the spacing between adjacent frequency bins. 3. The DFT and IDFT differ in **exponential sign** (rotation direction) and a **normalization factor**, which ensures perfect reconstruction. -::: + ::: diff --git a/docs/docs/reference/wasm/modules/image/fft_iterative/prerequisite-theory/fourier-series--the-bridge/index.md b/docs/docs/reference/wasm/modules/image/fft_iterative/prerequisite-theory/fourier-series--the-bridge/index.md index c699618d7..72a5b305f 100644 --- a/docs/docs/reference/wasm/modules/image/fft_iterative/prerequisite-theory/fourier-series--the-bridge/index.md +++ b/docs/docs/reference/wasm/modules/image/fft_iterative/prerequisite-theory/fourier-series--the-bridge/index.md @@ -23,6 +23,7 @@ In other words, a periodic time-domain signal can be perfectly represented by ad Conversely, the Fourier series maps the time-domain periodic signal to discrete points in the frequency domain β€” its harmonic frequencies. #### Figure 1: Sum Continuous (High-Resolution) with Actual Jittered Samples Overlaid + Jittered sum
View Figure 1's Code @@ -36,6 +37,7 @@ Conversely, the Fourier series maps the time-domain periodic signal to discrete (below) illustrates each underlying harmonic as a continuous sine wave in the time domain. #### Figure 2: Underlying Harmonics - Individual Continuous Harmonic Components + Individual Harmonics
View Figure 2's Code @@ -43,6 +45,7 @@ Conversely, the Fourier series maps the time-domain periodic signal to discrete
#### Figure 3: Combined Time-Domain Harmonics with Jittered Samples and Frequency Spectrum + Harmonic comparison in time and frequency domain
View Figure 3's Code @@ -60,20 +63,24 @@ _The bottom panel in highlighting discrete peaks at the harmonic frequencies corresponding to the sinusoidal components in the time domain._ ## Block Equation (Fourier Series) + A periodic signal $x(t)$ with period $T$ can be expressed as a sum of complex exponentials (or equivalently, sinusoids) at integer multiples of the fundamental frequency $f_0 = \frac{1}{T}$: + $$ x(t) = \sum_{k=-\infty}^{\infty} X_k e^{j 2 \pi k f_0 t}, \quad \text{where } k \in \mathbb{Z} \text{, } X_k \in \mathbb{C} $$ + - $k$ indexes the harmonics - e.g., $k=1$ is the first harmonic $(f_0)$, $k=2$ is the second harmonic $(2f_0)$, etc. - Hence $k f_0$ inside the exponent - :::note - $k=0$ corresponds to the DC component ($0 \text{ Hz}$), which is not considered a harmonic. + :::note + $k=0$ corresponds to the DC component ($0 \text{ Hz}$), which is not considered a harmonic. $k < 0$ corresponds to negative frequency components (complex conjugates), which is important for understanding FFT symmetry. ::: + - $X_k$ are the **complex Fourier coefficients** representing the amplitude and phase of each harmonic. - To interpret them: - Write $X_k = a + jb$ with $a = \Re(X_k)$ and $b = \Im(X_k), \quad \Re\text{: Real, } \Im\text{: Imaginary}$ @@ -88,6 +95,7 @@ $$ When you see a Fourier coefficient $X_k = a + jb$, it's easy to forget what the real and imaginary parts mean. Here's a clear way to visualize it: Using **Euler's formula** ($e^{i \theta} = \cos\theta + i \sin\theta$), we know that: + - **Real part ($a = \Re(X_k)$)** β†’ corresponds to the **cosine** component - **Imag part ($b = \Im(X_k)$)** β†’ corresponds to the **sine** component @@ -100,6 +108,7 @@ Think of $X_k$ as a **vector in the complex plane**:
### Key points: + 1. **Amplitude (magnitude)**: $$ |X_k| = \sqrt{a^2 + b^2} @@ -117,6 +126,7 @@ Think of $X_k$ as a **vector in the complex plane**: - $\tan\theta = \frac{b}{a}$ β†’ angle of the vector ### Why this matters: + The real and imaginary parts tell you **how much cosine and sine** of that frequency are in the signal. Combining them using Pythagoras gives the **amplitude**, and the angle gives the **phase**, which shifts the waveform in time. This is exactly how $X_k$ encodes both **strength** and **timing** of each harmonic. @@ -132,9 +142,11 @@ Every periodic signal can be β€œbuilt” by adding together these harmonics. The ### Real Fourier Series Form The Fourier series can also be expressed using **only real-valued sine and cosine functions**: + $$ x(t) = a_0 + \sum_{k=1}^{\infty} [ a_k \cos(2 \pi k f_0 t) + b_k \sin(2 \pi k f_0 t) ] $$ + - $a_0$ is the **DC component** (average value of the signal). - $a_k$ and $b_k$ are the **real Fourier coefficients** representing the amplitudes of cosine and sine components at the $k^\text{th}$ harmonic. - This form is equivalent to the complex exponential form: @@ -143,13 +155,14 @@ $$ $$ so the information about amplitude and phase is fully captured. - ## Fourier Coefficients (Complex Amplitudes) The coefficients $X_k$ quantify the contribution of each harmonic $k$ in the signal: + $$ X_k = \frac{1}{T} \int_{0}^{T} x(t) \cdot e^{-j 2 \pi k f_0 t} dt $$ + - This integral measures **how much of the frequency $k f_0$** exists in the signal. - You can integrate over **any interval of length $T$**, because the signal is periodic. - In practice: @@ -162,10 +175,12 @@ Imagine projecting your signal onto each sine/cosine component β€” the integral :::tip $a_k$ and $b_k$ can be converted to amplitude and phase using: + $$ \begin{align*} A_k &= \sqrt{a_k^2 + b_k^2}\\ \phi_k &= \tan^{-1}(\frac{ b_k }{ a_k }) \end{align*} $$ + ::: diff --git a/docs/docs/reference/wasm/modules/image/fft_iterative/prerequisite-theory/how-fourier-transforms-work/index.md b/docs/docs/reference/wasm/modules/image/fft_iterative/prerequisite-theory/how-fourier-transforms-work/index.md index 05b6e889e..fbf72e220 100644 --- a/docs/docs/reference/wasm/modules/image/fft_iterative/prerequisite-theory/how-fourier-transforms-work/index.md +++ b/docs/docs/reference/wasm/modules/image/fft_iterative/prerequisite-theory/how-fourier-transforms-work/index.md @@ -26,6 +26,7 @@ core equations and a breakdown of their similarities and differences. ### Definition + - **CFT:** $X(f) = \int_{-\infty}^{\infty} x(t)\, e^{-j 2\pi f t}\, dt$ - **DFT:** @@ -34,24 +35,28 @@ core equations and a breakdown of their similarities and differences. **Similarity:** Both use complex exponentials to express the signal’s frequency content. ### Domain of Input + - **CFT:** Continuous signal $x(t)$ - **DFT:** Discrete sequence $x[n]$ of length $N$ **Similarity:** Both operate on time-domain signals (continuous or sampled). ### Domain of Output + - **CFT:** Continuous function $X(f)$ - **DFT:** Discrete frequency bins $X[k]$ **Similarity:** Both output complex-valued frequency components. ### Exponential Kernel + - **CFT:** $e^{-j 2\pi f t}$ - **DFT:** $e^{-j \frac{2\pi}{N} k n}$ **Similarity:** Both use complex exponentials (phasors) as basis functions. ### Inverse Transform + - **CFT:** $x(t) = \int_{-\infty}^{\infty} X(f)\, e^{j 2\pi f t}\, df$ - **DFT:** @@ -60,16 +65,20 @@ core equations and a breakdown of their similarities and differences. **Similarity:** Both perfectly reconstruct the original signal (given correct conditions). ### Linearity + Both transforms are linear. ### Parseval’s Theorem + Energy is preserved between time and frequency domains (with appropriate normalization). ### Purpose + Both analyse the frequency content of signals. ### Periodicity (Key Concept) -- **DFT:** Assumes that $x[n]$ is *periodically extended* with period $N$. + +- **DFT:** Assumes that $x[n]$ is _periodically extended_ with period $N$. - **CFT:** No periodicity assumption. This is why windowing and spectral leakage matter in the discrete case. @@ -79,17 +88,17 @@ This is why windowing and spectral leakage matter in the discrete case. -| Aspect | Continuous Fourier Transform (CFT) | Discrete Fourier Transform (DFT) | Similarities | -| ---------------------- | ------------------------------------------------------------------ | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -| **Definition** | $X(f)=\int_{-\infty}^{\infty}x(t)e^{-j2\pi f t}dt$ | $X[k]=\sum_{n=0}^{N-1}x[n]e^{-j\frac{2\pi}{N}kn}$ | Both use complex exponentials to analyse frequency content. | -| **Input Domain** | Continuous-time signal $x(t)$ | Finite-length discrete sequence $x[n]$ | Both work with time-domain signals. | -| **Output Domain** | Continuous $X(f)$ | Discrete $X[k]$ | Both output complex spectra. | -| **Kernel** | $e^{-j2\pi f t}$ | $e^{-j \frac{2\pi}{N}kn}$ | Both use phasors as basis functions. | -| **Inverse Transform** | Integral | Summation with $1/N$ factor | Both perfectly reconstruct the signal (given conditions). | -| **Linearity** | Linear operator | Linear operator | Both obey superposition. | -| **Parseval** | Energy preserved | Energy preserved (within normalization) | Both conserve signal energy. | -| **Periodicity** | No implicit periodicity | Assumes periodic extension with period $N$ | β€” | -| **Purpose** | Analyse continuous spectra | Analyse discrete/finite spectra | Both decompose signals into frequency components. | +| Aspect | Continuous Fourier Transform (CFT) | Discrete Fourier Transform (DFT) | Similarities | +| --------------------- | -------------------------------------------------- | ------------------------------------------------- | ----------------------------------------------------------- | +| **Definition** | $X(f)=\int_{-\infty}^{\infty}x(t)e^{-j2\pi f t}dt$ | $X[k]=\sum_{n=0}^{N-1}x[n]e^{-j\frac{2\pi}{N}kn}$ | Both use complex exponentials to analyse frequency content. | +| **Input Domain** | Continuous-time signal $x(t)$ | Finite-length discrete sequence $x[n]$ | Both work with time-domain signals. | +| **Output Domain** | Continuous $X(f)$ | Discrete $X[k]$ | Both output complex spectra. | +| **Kernel** | $e^{-j2\pi f t}$ | $e^{-j \frac{2\pi}{N}kn}$ | Both use phasors as basis functions. | +| **Inverse Transform** | Integral | Summation with $1/N$ factor | Both perfectly reconstruct the signal (given conditions). | +| **Linearity** | Linear operator | Linear operator | Both obey superposition. | +| **Parseval** | Energy preserved | Energy preserved (within normalization) | Both conserve signal energy. | +| **Periodicity** | No implicit periodicity | Assumes periodic extension with period $N$ | β€” | +| **Purpose** | Analyse continuous spectra | Analyse discrete/finite spectra | Both decompose signals into frequency components. | @@ -99,8 +108,8 @@ This is why windowing and spectral leakage matter in the discrete case. Both the CFT and DFT use **complex exponentials** of the form: -- $e^{-j 2\pi f t}$ for continuous signals -- $e^{-j \frac{2\pi}{N} k n}$ for discrete signals +- $e^{-j 2\pi f t}$ for continuous signals +- $e^{-j \frac{2\pi}{N} k n}$ for discrete signals Even though these look different, the DFT kernel is simply the CFT kernel **sampled at discrete times and discrete frequencies**. @@ -168,8 +177,8 @@ This makes Fourier analysis algebraically simple because phasors rotate in the c A complex exponential $e^{j\theta}$ lies on the **unit circle** in the complex plane. Fourier coefficients naturally contain: -- **magnitude** (amplitude of sinusoid) -- **phase** (angle) +- **magnitude** (amplitude of sinusoid) +- **phase** (angle) which match perfectly with polar form. diff --git a/docs/docs/reference/wasm/modules/image/fft_iterative/prerequisite-theory/index.md b/docs/docs/reference/wasm/modules/image/fft_iterative/prerequisite-theory/index.md index c3fa4e6c0..4415a8f9a 100644 --- a/docs/docs/reference/wasm/modules/image/fft_iterative/prerequisite-theory/index.md +++ b/docs/docs/reference/wasm/modules/image/fft_iterative/prerequisite-theory/index.md @@ -7,6 +7,7 @@ sidebar_position: 1 :::info Prerequisites This section assumes familiarity with the below: + - `Complex numbers` (including Euler’s formula) - Basic `trigonometry and calculus` - Introductory concepts in `signals` diff --git a/docs/docs/reference/wasm/modules/image/fft_iterative/prerequisite-theory/introduction/index.md b/docs/docs/reference/wasm/modules/image/fft_iterative/prerequisite-theory/introduction/index.md index dac67de0a..e6fb8c39c 100644 --- a/docs/docs/reference/wasm/modules/image/fft_iterative/prerequisite-theory/introduction/index.md +++ b/docs/docs/reference/wasm/modules/image/fft_iterative/prerequisite-theory/introduction/index.md @@ -12,24 +12,27 @@ import continuousSinusoid from '!!raw-loader!./python_scripts/continuous-sinusoi import discreteSinusoid from '!!raw-loader!./python_scripts/discrete-sinusoid.py'; Fourier techniques let us express any signal as a **sum of its constituent "pure" sinusoids** (frequencies), meaning: + - **"Pure" sinusoids**: any signal is composed of simple sinusoids, e.g.: -$$ -\sin(2\pi f_1 t),\quad \sin(2\pi f_2 t) -$$ + $$ + \sin(2\pi f_1 t),\quad \sin(2\pi f_2 t) + $$ - **Sum of constituent frequencies**: any signal equals the sum of its "pure" constituent sinusoids, e.g: -$$ -x(t)=\sin(2\pi f_1 t)+\sin(2\pi f_2 t) -$$ + $$ + x(t)=\sin(2\pi f_1 t)+\sin(2\pi f_2 t) + $$ This gives two equivalent ways of describing a signal: + - **Time domain** β€” how the signal changes over time - **Frequency domain** β€” how much of each frequency is present -The *Fourier Transform* converts between these representations. +The _Fourier Transform_ converts between these representations. ## Sinusoids ### Continuous Sinusoids + For the purpose of illustration, we’ll treat the finely sampled sinusoid in [Figure 1](#figure-1-a-densely-sampled-sinusoid-to-approximate-a-continuous-signal-plotted-in-time-and-frequency-domains) as a continuous signal. @@ -37,7 +40,7 @@ as a continuous signal. Below, [Figure 1](#figure-1-a-densely-sampled-sinusoid-to-approximate-a-continuous-signal-plotted-in-time-and-frequency-domains) shows the exact same sinusoid plotted in both the time and frequency domain. Notice how the time domain graph looks like the typical wave shape of a sine graph that you are likely familiar with, -whereas the frequency domain graph contains only *one non-zero point*, $(5, 1)$. This is because the signal contains +whereas the frequency domain graph contains only _one non-zero point_, $(5, 1)$. This is because the signal contains only a single pure tone (see _f0_ in [Figure 1's Python Code](#figure-1-details)). :::info @@ -45,6 +48,7 @@ Continuous sinusoidal data are best-suited for **CFTs** (Continuous Fourier Tran ::: #### Figure 1: A densely sampled sinusoid to approximate a continuous signal plotted in time and frequency domains + sinusoid_time_and_frequency
View Figure 1's Code @@ -52,6 +56,7 @@ Continuous sinusoidal data are best-suited for **CFTs** (Continuous Fourier Tran
### Discrete Sinusoids + In this repo we operate on **digital** data (images/arrays), which are sampled, finite, and stored in memory since real digital systems (microcontrollers, sensors, images) cannot store continuous signals. They store samples, collected at discrete time intervals. @@ -66,6 +71,7 @@ Discrete sinusoidal data are best-suited for **DFTs** (Discrete Fourier Transfor ::: #### Figure 2: A realistically sampled sinusoid plotted in time and frequency domains + discrete_sinusoid_time_and_frequency
View Figure 2's Code @@ -74,10 +80,11 @@ Discrete sinusoidal data are best-suited for **DFTs** (Discrete Fourier Transfor Notice, once again, how the time domain graph looks like the typical wave shape of a sine graph that you are likely familiar with (even if it is a bit broken up due to poorly-timed sampling), -whereas the frequency domain graph contains only *one non-zero point*, $(5, 1)$. This is because the signal contains +whereas the frequency domain graph contains only _one non-zero point_, $(5, 1)$. This is because the signal contains only a single pure tone (see _f0_ in [Figure 2's Python Code](#figure-2-a-realistically-sampled-sinusoid-plotted-in-time-and-frequency-domains) above). ### Realistic Sinusoids + **[Figure 1](#figure-1-a-densely-sampled-sinusoid-to-approximate-a-continuous-signal-plotted-in-time-and-frequency-domains) shows the ideal scenario**: a clean sinusoid sampled densely enough to look continuous. @@ -86,6 +93,7 @@ shows the type of data we actually work with in real programs**: discrete, finit **Both figures ([1](#figure-1-a-densely-sampled-sinusoid-to-approximate-a-continuous-signal-plotted-in-time-and-frequency-domains) and [2](#figure-2-a-realistically-sampled-sinusoid-plotted-in-time-and-frequency-domains)) represent the same sinusoid**: + $$ x(t)=\sin(2\pi f_0 t), \quad f_0=5 Hz $$ @@ -98,27 +106,34 @@ imprecise intervals (due to slight natural variations) rather than continuously better suited for the domain conversions in this context. ## Why use an FFT over a DFT or a CFT? + In practice, we only deal with two major Fourier transforms: + - the Continuous Fourier Transform (CFT) - the Discrete Fourier Transform (DFT) The Fast Fourier Transform (FFT) is not a different kind of transform - it's just a faster way to compute the DFT. ### FFT = "DFT's practical implementation" + The FFT is just a fast algorithm for computing the DFT: they are mathematically identical. The only reason FFTs appear everywhere is because they are essentially just **fast** DFTs: + - **DFT** = mathematical definition - **FFT** = optimized algorithm that computes a DFT In computation, all data are discrete and finite, so only the DFT (via FFT) matters. ## Fourier Transforms + ### Why use them? + Images, audio, and signals are discrete. Any filtering or transformation done in the frequency domain operates on discrete data, and must therefore use the DFT (via FFT). This is the foundation for FFT-based convolution, image kernels, Gaussian blurs, etc. ### Why not use something simpler to understand? + Fourier Transforms can be avoided, but avoiding them often results in more significant drawbacks - motivating their use. :::warning Time Complexity @@ -128,6 +143,7 @@ One of the most important concerns about not using them is the resultant time co Discussing all of the drawbacks is out of scope for this documentation, but it is worth mentioning one for the sake of your understanding: #### Gaussian blurs on user-selected images + :::info This is important because it covers one of the **core processing techniques** used in Img2Num's pre-processing pipeline before images are sent through K-means clustering. @@ -143,13 +159,13 @@ This means the runtime scales linearly with the kernel size. Using the FFT, convolution can be done in the frequency domain: -| Action | Time Complexity | -| ---------------------------------- | ----------------- | -| Compute FFTs of image & kernel | $O(N^2 \log N)$ | -| Multiply FFTs of image & kernel | $O(N^2)$ | +| Action | Time Complexity | +| ---------------------------------- | --------------- | +| Compute FFTs of image & kernel | $O(N^2 \log N)$ | +| Multiply FFTs of image & kernel | $O(N^2)$ | | Perform inverse FFT of image | $O(N^2 \log N)$ | -| | | -| Total (independent of kernel size) | $O(N^2 \log N)$ | +| | | +| Total (independent of kernel size) | $O(N^2 \log N)$ | FFT-based convolution is preferred for large kernels or images, where direct convolution becomes slow, while separable convolution is usually faster for small kernels due to lower overhead. @@ -158,12 +174,12 @@ while separable convolution is usually faster for small kernels due to lower ove For extremely small kernels, direct or separable convolution may still be faster due to lower overhead. Consider a $512 \times 512$ image with a small Gaussian kernel, say $5 \times 5$. + - **Direct separable convolution:** $O(N^2 K) = 512^2 \cdot 5 \approx 1.31 \times 10^6$ operations. - **FFT-based convolution:** $O(N^2 \log N) = 512^2 \cdot \log_2 512 \approx 3.84 \times 10^6$ operations. Here, **direct convolution is ~3Γ— faster** because the kernel is small and the FFT overhead dominates. - This is, however, rarely the case for Img2Num's Gaussian blur since users are unlikely to upload such small images and the benefit of using an FFT is evident when a large image is uploaded. ::: diff --git a/docs/docs/reference/wasm/modules/image/fft_iterative/prerequisite-theory/keywords.md b/docs/docs/reference/wasm/modules/image/fft_iterative/prerequisite-theory/keywords.md index 648aa4110..df7b3d686 100644 --- a/docs/docs/reference/wasm/modules/image/fft_iterative/prerequisite-theory/keywords.md +++ b/docs/docs/reference/wasm/modules/image/fft_iterative/prerequisite-theory/keywords.md @@ -8,6 +8,7 @@ import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; ## Keywords + - **Aliasing**: High-frequency components above the Nyquist frequency fold into lower frequencies when sampled. - **Angular frequency** ($\omega$): $\omega = 2\pi f$ radians per second. - **Circular Convolution**: Convolution implied by the DFT due to assumed periodicity. @@ -17,7 +18,7 @@ import TabItem from '@theme/TabItem'; - **DFT**: Discrete Fourier Transform - **FFT**: Fast Fourier Transform (efficient algorithm for computing the DFT) - **Frequency-domain signal**: $X(f)$ (continuous) or $X[k]$ (discrete) -- **Fundamental Frequency** ($f_0$): The lowest frequency component of a periodic signal. +- **Fundamental Frequency** ($f_0$): The lowest frequency component of a periodic signal. Every other frequency component (harmonics) is an integer multiple of $f_0$: $$ f_k = k \cdot f_0, \quad k = 1,2,3,\dots @@ -48,11 +49,12 @@ this area of the project's code, please make sure to familiarise yourself with i ::: @@ -63,24 +65,26 @@ this area of the project's code, please make sure to familiarise yourself with i - Common in **signal processing, numerical libraries (NumPy, MATLAB)**. #### Continuous Fourier Transform (CFT) + - **Forward:** -$$ -X(f) = \int_{-\infty}^{\infty} x(t) \, e^{-j 2\pi f t} \, dt -$$ + $$ + X(f) = \int_{-\infty}^{\infty} x(t) \, e^{-j 2\pi f t} \, dt + $$ - **Inverse:** -$$ -x(t) = \int_{-\infty}^{\infty} X(f) \, e^{+j 2\pi f t} \, df -$$ + $$ + x(t) = \int_{-\infty}^{\infty} X(f) \, e^{+j 2\pi f t} \, df + $$ #### Discrete Fourier Transform (N-point DFT) + - **Forward:** -$$ -X[k] = \sum_{n=0}^{N-1} x[n] \, e^{-j \frac{2\pi}{N} k n}, \quad k=0,\dots,N-1 -$$ + $$ + X[k] = \sum_{n=0}^{N-1} x[n] \, e^{-j \frac{2\pi}{N} k n}, \quad k=0,\dots,N-1 + $$ - **Inverse:** -$$ -x[n] = \frac{1}{N} \sum_{k=0}^{N-1} X[k] \, e^{+j \frac{2\pi}{N} k n}, \quad n=0,\dots,N-1 -$$ + $$ + x[n] = \frac{1}{N} \sum_{k=0}^{N-1} X[k] \, e^{+j \frac{2\pi}{N} k n}, \quad n=0,\dots,N-1 + $$ :::note This is the most commonly used convention, so you might see it more frequently. @@ -94,28 +98,30 @@ Img2Num, NumPy, and MATLAB use the engineering convention. ### Science / Physics Convention -- Forward transform uses **positive exponent**, inverse may include **$1/2\pi$ factor**. +- Forward transform uses **positive exponent**, inverse may include **$1/2\pi$ factor**. - Often seen in **physics, math textbooks**, and continuous analysis. #### Continuous Fourier Transform (CFT) + - **Forward:** -$$ -X(f) = \int_{-\infty}^{\infty} x(t) \, e^{+j 2\pi f t} \, dt -$$ + $$ + X(f) = \int_{-\infty}^{\infty} x(t) \, e^{+j 2\pi f t} \, dt + $$ - **Inverse:** -$$ -x(t) = \int_{-\infty}^{\infty} X(f) \, e^{-j 2\pi f t} \, df -$$ + $$ + x(t) = \int_{-\infty}^{\infty} X(f) \, e^{-j 2\pi f t} \, df + $$ #### Discrete Fourier Transform (N-point DFT) + - **Forward:** -$$ -X[k] = \sum_{n=0}^{N-1} x[n] \, e^{+j \frac{2\pi}{N} k n}, \quad k=0,\dots,N-1 -$$ + $$ + X[k] = \sum_{n=0}^{N-1} x[n] \, e^{+j \frac{2\pi}{N} k n}, \quad k=0,\dots,N-1 + $$ - **Inverse:** -$$ -x[n] = \frac{1}{N} \sum_{k=0}^{N-1} X[k] \, e^{-j \frac{2\pi}{N} k n}, \quad n=0,\dots,N-1 -$$ + $$ + x[n] = \frac{1}{N} \sum_{k=0}^{N-1} X[k] \, e^{-j \frac{2\pi}{N} k n}, \quad n=0,\dots,N-1 + $$ :::note Science convention flips the sign of the exponent in forward/inverse transforms compared to the engineering convention. Some libraries (scientific computing) may follow this. @@ -126,6 +132,6 @@ Science convention flips the sign of the exponent in forward/inverse transforms :::danger Don't get confused -Both are simply *conventions*. It does not matter which one you follow (unless the convention is already established wherever you are working) +Both are simply _conventions_. It does not matter which one you follow (unless the convention is already established wherever you are working) because they both achieve the same outcomes. ::: diff --git a/docs/docs/reference/wasm/modules/image/fft_iterative/prerequisite-theory/why-img2num-uses-the-dft/index.md b/docs/docs/reference/wasm/modules/image/fft_iterative/prerequisite-theory/why-img2num-uses-the-dft/index.md index c3aa9bd82..50a974162 100644 --- a/docs/docs/reference/wasm/modules/image/fft_iterative/prerequisite-theory/why-img2num-uses-the-dft/index.md +++ b/docs/docs/reference/wasm/modules/image/fft_iterative/prerequisite-theory/why-img2num-uses-the-dft/index.md @@ -11,8 +11,9 @@ sidebar_position: 8 5. **Compatibility with image processing conventions** β€” 2D DFT is separable: apply 1D DFT across rows then columns (or vice versa); per-channel processing is straightforward. :::note Practical consequences to document in the repo + - Zero-padding to reduce circular convolution effects. - Windowing to reduce spectral leakage. - `fft shift` / `ifft shift` to center the zero frequency for visualization. - Explain per-channel transforms for multi-channel images (RGB). -::: + ::: diff --git a/docs/docs/reference/wasm/modules/image/mergeSmallRegionsInPlace/complexity-and-memory.md b/docs/docs/reference/wasm/modules/image/mergeSmallRegionsInPlace/complexity-and-memory.md index 1375201e6..699e4b03b 100644 --- a/docs/docs/reference/wasm/modules/image/mergeSmallRegionsInPlace/complexity-and-memory.md +++ b/docs/docs/reference/wasm/modules/image/mergeSmallRegionsInPlace/complexity-and-memory.md @@ -11,12 +11,15 @@ $$ $$ ## Time + The flood-fill visits each pixel once and tests 4 neighbours ($$4 \cdot N$$), so the dominant cost is $$O(N)$$ with a small constant (neighbour checks and byte comparisons). The merge pass is another similar $$O(N)$$ scan, so overall $$O(N)$$. ## Memory + The implementation allocates a `labels` array of $N$ integers and a `regions` vector whose size equals number of components (at most $$N$$). So memory is $$O(N)$$ additional to the image buffer. ## Cache behaviour + BFS queue can cause random access inside large components; using a scanline two-pass connected-component algorithm (or union-find) can improve cache locality and throughput on big images. diff --git a/docs/docs/reference/wasm/modules/image/mergeSmallRegionsInPlace/connected-components.md b/docs/docs/reference/wasm/modules/image/mergeSmallRegionsInPlace/connected-components.md index 4ebc33f45..c258c712d 100644 --- a/docs/docs/reference/wasm/modules/image/mergeSmallRegionsInPlace/connected-components.md +++ b/docs/docs/reference/wasm/modules/image/mergeSmallRegionsInPlace/connected-components.md @@ -29,26 +29,27 @@ and their RGBA values are exactly equal. - - - - - - - - - - - + + + + + + + + + + + + 2. While flood-filling, `Region` metadata is collected (`size`, `minX`, `maxX`, `minY`, `maxY`) and labeled according to an index (`labels`). 3. After all components are labeled and regions metadata computed, -iterate pixels again. For a pixel whose region is considered *small* -(fails `isBigEnough(minArea,minWidth,minHeight)`), -check its four immediate neighbors. If any neighbor belongs to a *big* region, -copy that neighbor's RGBA into the small pixel (effectively assigning the small pixel to the big region; -over time, the small region is consumed by *bigger neighboring regions*). + iterate pixels again. For a pixel whose region is considered _small_ + (fails `isBigEnough(minArea,minWidth,minHeight)`), + check its four immediate neighbors. If any neighbor belongs to a _big_ region, + copy that neighbor's RGBA into the small pixel (effectively assigning the small pixel to the big region; + over time, the small region is consumed by _bigger neighboring regions_). :::note This merges only small-region pixels which are adjacent to large regions. @@ -62,6 +63,7 @@ the implementation stops at first qualifying neighbour. The algorithm computes **connected components** on a planar grid using 4-connectivity. Formally, we can describe the image as a function: + $$ \begin{align*} I &: \mathbb{Z}^2 \to \mathcal{C} \\ @@ -70,8 +72,9 @@ I &: \mathbb{Z}^2 \to \mathcal{C} \\ $$ :::important -- $I$ is the image function. -- $\mathbb{Z}^2$ is the set of all integer pairs $(x, y)$ representing pixel coordinates. + +- $I$ is the image function. +- $\mathbb{Z}^2$ is the set of all integer pairs $(x, y)$ representing pixel coordinates. - $\mathcal{C}$ is the set of all possible RGBA values: $$ \mathcal{C} = \{ (R, G, B, A) \mid R,G,B,A \in [0,255] \} @@ -79,7 +82,7 @@ $$ - $I(x, y) \in \mathcal{C}$ is the color of the pixel at coordinates $(x, y)$. > In simple terms, each pixel at position $(x, y)$ has a color given by $I(x, y)$. -::: +> ::: Two pixels, $$p=(x,y)$$ and $$q=(x',y')$$, are **4-adjacent** if $$|x-x'| + |y-y'| = 1$$. A connected component is a maximal set of pixels, $$S$$, such that any two pixels in $$S$$ are connected by a path of 4-adjacent pixels with identical colors. @@ -89,10 +92,13 @@ A flood-fill (BFS / DFS) computes these components exactly. ### Bounding box & geometric heuristics For each component we compute an axis-aligned bounding box with integer coordinates: + $$ [minX,maxX]\times[minY,maxY] $$ + The bounding-box width and height are: + $$ \begin{align*} W &= maxX - minX + 1 \\ @@ -106,7 +112,7 @@ to understand how this is used. ::: The area (component size) is simply the number of pixels in the component, $$|S|$$. -The heuristics used to classify *small* vs *big* regions rely on thresholds on both area and bounding box dimensions. +The heuristics used to classify _small_ vs _big_ regions rely on thresholds on both area and bounding box dimensions. This avoids keeping long thin noise (e.g. a long 1-pixel-wide arm) even if its area is above `minArea`. ### Why 4-connectivity, not 8? diff --git a/docs/docs/reference/wasm/modules/image/mergeSmallRegionsInPlace/faq.md b/docs/docs/reference/wasm/modules/image/mergeSmallRegionsInPlace/faq.md index 8f9b6aa3a..7ee2c88d1 100644 --- a/docs/docs/reference/wasm/modules/image/mergeSmallRegionsInPlace/faq.md +++ b/docs/docs/reference/wasm/modules/image/mergeSmallRegionsInPlace/faq.md @@ -13,23 +13,29 @@ description: Frequently asked questions related to the mergeSmallRegionsInPlace This function removes **tiny connected regions** in an RGBA image by merging them into neighboring, sufficiently large regions **in-place**. It is intended for post-processing steps after: + - k-means color quantization - segmentation Or as a pre-processing step before: + - image-to-vector (SVG) pipelines Small regions often appear as visual noise and make downstream geometry extraction harder. ## What exactly is a β€œregion” in this context? -A **region** is a *4-connected component* of pixels where: + +A **region** is a _4-connected component_ of pixels where: + - Each pixel is connected via **up, down, left, or right** - All pixels have **exactly the same RGBA values** Diagonal adjacency **does not** count. ## Why use 4-connectivity instead of 8-connectivity? + 4-connectivity: + - Matches most raster algorithms (flood-fill, contour tracing) - Avoids diagonal β€œcorner-touch” artifacts - Produces cleaner, grid-aligned regions @@ -134,16 +140,18 @@ $$ (4,4) idx*4=96 - Each pixel = 4 bytes (R,G,B,A) - Byte offset = idx(x,y,width) * 4 + +Each pixel = 4 bytes (R,G,B,A) +Byte offset = idx(x,y,width) \* 4
:::note Assumptions this layout has + - Row-major order - No padding between rows -::: + ::: ## What does `sameColor(...)` check? @@ -183,11 +191,13 @@ A region must satisfy **all three** conditions: - `height() >= minHeight` Where: + - `size` = number of pixels - `width()` = bounding box width - `height()` = bounding box height This prevents: + - Thin lines - Long but narrow artifacts - Small blobs @@ -195,6 +205,7 @@ This prevents: ## Why use a bounding box instead of checking shape quality? Bounding boxes are: + - Fast to compute - Memory cheap - Conservative @@ -208,6 +219,7 @@ There is a TODO noting that **internal gaps** could reduce effective width/heigh Yes. Example: + - A hollow ring - A U-shaped region - A region with internal gaps @@ -219,11 +231,13 @@ This implementation prioritizes speed and simplicity over perfect geometric vali ## Why are regions merged pixel-by-pixel instead of as a whole? Because: + - The function operates **in-place** - It avoids reallocating buffers - It keeps memory usage predictable Each pixel independently: + - Checks its neighbors - Copies the color of a valid region @@ -232,6 +246,7 @@ This makes the merge phase linear and simple. ## Why only check immediate neighbors during merging? Only **4 immediate neighbors** are checked because: + - The merge should respect spatial adjacency - Copying from distant pixels could create visual artifacts @@ -244,6 +259,7 @@ Nothing. Pixels in that region remain unchanged. This avoids: + - Arbitrary color assignment - Unexpected long-range merges @@ -254,6 +270,7 @@ If this is undesirable, a second pass or fallback strategy can be added. No. Once the merge phase starts: + - `regions` is treated as read-only - Labels may change per pixel - Region sizes are **not recomputed** @@ -270,12 +287,14 @@ Overall complexity is **linear**: Where $$ n = \text{width} \times \text{height} $$ Each pixel is: + - Visited once in flood-fill - Checked against at most 4 neighbors ## What is the memory overhead? Additional memory used: + - `labels`: one `int` per pixel - `regions`: one entry per connected component - BFS queue (temporary) @@ -285,6 +304,7 @@ No additional image buffers are allocated. ## Why use BFS (`std::queue`) instead of DFS? BFS: + - Avoids deep recursion - Prevents stack overflow on large regions - Has predictable memory usage @@ -296,11 +316,13 @@ DFS would require either recursion (unsafe) or an explicit stack (no advantage h Not easily in its current form. Reasons: + - Flood-fill has data dependencies - Label assignment is sequential - Merge step mutates shared data Parallel versions would require: + - Tiled processing - Boundary reconciliation - More complex region merging logic @@ -308,6 +330,7 @@ Parallel versions would require: ## Is this suitable for SVG generation pipelines? Yes β€” especially as a **cleanup step** before: + - Boundary tracing - Polygon extraction - Path simplification @@ -317,6 +340,7 @@ Removing small regions early greatly simplifies vector geometry later. ## What are common improvements or extensions? Possible enhancements include: + - Detecting holes inside regions @@ -335,7 +359,7 @@ Possible enhancements include: - Multi-pass merging -- Choosing the *largest neighboring region* instead of the first valid one +- Choosing the _largest neighboring region_ instead of the first valid one - Detecting localised width/height - regions often have large tentacle-like protrusions. The current design favors **clarity and predictability** over heuristics. @@ -348,9 +372,10 @@ Given the same input image and parameters, the output is always identical. No randomness is involved. -## When should I *not* use this? +## When should I _not_ use this? Avoid this function if: + - You need exact topology preservation - You rely on diagonal connectivity - You require sub-pixel or fuzzy color matching diff --git a/docs/docs/reference/wasm/modules/image/mergeSmallRegionsInPlace/limitations-and-pitfalls.md b/docs/docs/reference/wasm/modules/image/mergeSmallRegionsInPlace/limitations-and-pitfalls.md index 45e7e2dcd..b439295c6 100644 --- a/docs/docs/reference/wasm/modules/image/mergeSmallRegionsInPlace/limitations-and-pitfalls.md +++ b/docs/docs/reference/wasm/modules/image/mergeSmallRegionsInPlace/limitations-and-pitfalls.md @@ -7,6 +7,7 @@ description: Limitations and pitfalls commonly encountered when using the mergeS --- ## Exact RGBA equality + The algorithm treats colors as equal only when all 4 bytes match exactly. If you need fuzzy color merging (e.g. colors within a Euclidean distance in RGB), you must replace `sameColor` with a colour-distance test. :::note Extending functionality @@ -14,27 +15,33 @@ This could be extended in the future by allowing callers to pass a custom equali ::: ## Only 4-connectivity + Components connected only diagonally will be considered separate. If your semantics require 8-connectivity, adapt the neighbour set. ## Local merge heuristic + A small region is colored using the first adjacent large neighbour encountered. If a small island touches multiple large regions, the chosen one depends on neighbour scan order (right, left, down, up in the reference code). This can occasionally lead to confusion as it is not an `intelligent check`. ## Holes / concavities + The bounding-box test may be fooled by shapes with large bounding boxes but containing many holes. **The TODO in the source code remains valid**: you could compute convex-hull, morphological closing, or compute the ratio `size / (width*height)` (occupancy) to detect sparse shapes. + ```cpp title="The TODO comment" // TODO: check for gaps inside regions - its possible their dimensions are fine, // but inner gaps reduce effective width and height ``` ## Order sensitivity -Since pixels are recolored in place and have their `labels` updated when a merge is done, + +Since pixels are recolored in place and have their `labels` updated when a merge is done, subsequent small pixels that were adjacent to that pixel may now see a different neighbour label; this actually helps the merge flood (small pixels adjacent to a merged pixel can be recoloured to the same large region), but it means the behaviour is implementation-order dependent. ## Performance + Allocation of `std::vector` and `std::queue` can be optimized for very large images. diff --git a/docs/docs/reference/wasm/modules/image/mergeSmallRegionsInPlace/overview.md b/docs/docs/reference/wasm/modules/image/mergeSmallRegionsInPlace/overview.md index 2f6842a8b..091155af7 100644 --- a/docs/docs/reference/wasm/modules/image/mergeSmallRegionsInPlace/overview.md +++ b/docs/docs/reference/wasm/modules/image/mergeSmallRegionsInPlace/overview.md @@ -23,7 +23,7 @@ The merge rule is important as it avoids leaving gaps in the image (a problem fa - With an RGBA image stored in a tightly-packed `uint8_t*` pixels buffer (4 bytes per pixel, row-major). - To remove very small connected components while preserving large components. -- You are OK with replacing a small-region pixel by the color of an *adjacent* large region. +- You are OK with replacing a small-region pixel by the color of an _adjacent_ large region. :::important Not suitable when you need to preserve small but semantically important details (e.g. text strokes), @@ -58,10 +58,10 @@ $$ ### Preconditions -* `pixels != nullptr` and `width>0` and `height>0`. -* The buffer length must be at least `width * height * 4` bytes. +- `pixels != nullptr` and `width>0` and `height>0`. +- The buffer length must be at least `width * height * 4` bytes. ### Postconditions -* The `pixels` buffer may be modified in-place: small regions will have their pixels recolored to match an adjacent large region (if found). -* The function uses an internal label map and region metadata; it does not return labels. +- The `pixels` buffer may be modified in-place: small regions will have their pixels recolored to match an adjacent large region (if found). +- The function uses an internal label map and region metadata; it does not return labels. diff --git a/docs/docs/reference/wasm/modules/image/mergeSmallRegionsInPlace/testing-and-debugging-suggestions.md b/docs/docs/reference/wasm/modules/image/mergeSmallRegionsInPlace/testing-and-debugging-suggestions.md index 19b6b8550..9fcd85c2a 100644 --- a/docs/docs/reference/wasm/modules/image/mergeSmallRegionsInPlace/testing-and-debugging-suggestions.md +++ b/docs/docs/reference/wasm/modules/image/mergeSmallRegionsInPlace/testing-and-debugging-suggestions.md @@ -7,17 +7,22 @@ description: Testing and debugging suggestions for the mergeSmallRegionsInPlace --- ## Synthetic test images + Create synthetic test images that exercise corner cases: + - Single pixel islands - Long 1-pixel-wide arms (test minWidth/minHeight) - Two large regions separated by thin small islands - Diagonal-touching shapes (to test 4 vs 8 connectivity) ## Visualization of labels map + Visualize the `labels` map (map labels to colors) to ensure connected components are being formed as expected. ## Unit tests + Assert that the number of pixels of a known large blob is unchanged; assert that small isolated pixels have been recolored. ## Instrumentation + Collect histogram of region sizes to choose appropriate `minArea`. diff --git a/docs/docs/reference/wasm/modules/image/mergeSmallRegionsInPlace/variants-and-improvements.md b/docs/docs/reference/wasm/modules/image/mergeSmallRegionsInPlace/variants-and-improvements.md index be81f8985..2ae390964 100644 --- a/docs/docs/reference/wasm/modules/image/mergeSmallRegionsInPlace/variants-and-improvements.md +++ b/docs/docs/reference/wasm/modules/image/mergeSmallRegionsInPlace/variants-and-improvements.md @@ -55,4 +55,3 @@ If `solidity` is low (i.e., bounding box large but region sparse), treat as smal ## Parallel labeling Use segmented image tiling with boundary stitching for multicore scaling or specialized GPU approaches. - diff --git a/docs/docs/reference/wasm/modules/image/mergeSmallRegionsInPlace/walkthrough-and-usage.md b/docs/docs/reference/wasm/modules/image/mergeSmallRegionsInPlace/walkthrough-and-usage.md index 9a423ef10..63b4b9a08 100644 --- a/docs/docs/reference/wasm/modules/image/mergeSmallRegionsInPlace/walkthrough-and-usage.md +++ b/docs/docs/reference/wasm/modules/image/mergeSmallRegionsInPlace/walkthrough-and-usage.md @@ -19,20 +19,21 @@ for the full source listing. Here we explain the important parts. - `struct Pixel { int x,y; };` β€” small POD for BFS queue. - `inline int idx(int x, int y, int width)` β€” maps (x,y) to a linear index into `labels` (not into pixel bytes; bytes index uses `*4`). -This makes it simpler to index 2D data in a 1D array. + This makes it simpler to index 2D data in a 1D array. - `sameColor(...)` β€” compares 4 bytes (in RGBA form) at two pixel coordinates for exact equality. - :::caution It uses *exact* equality - Compression or anti-aliased edges will produce many colors that are visually similar but not equal. - ::: + :::caution It uses _exact_ equality + Compression or anti-aliased edges will produce many colors that are visually similar but not equal. + ::: - `struct Region` β€” collects `size`, `minX`, `maxX`, `minY`, `maxY`, provides convenience `width()`, `height()`, and `isBigEnough(...)`. - **Flood-fill labeling loop** β€” For every unlabeled pixel, perform a BFS: - + 1. Push initial pixel @@ -67,13 +68,15 @@ This makes it simpler to index 2D data in a 1D array. L --> M[Push neighbour to queue] M --> F - ``` - - + ``` +
+ + + ``` - **Merge phase**: iterate every pixel; if its region is too small (`isBigEnough(...) == false`), -check the 4 immediate neighbours; if any neighbour is in a different label `nl` and `regions[nl].isBigEnough(...)` -is true, copy the neighbour's color bytes into the small pixel and set its label to `nl`. + check the 4 immediate neighbours; if any neighbour is in a different label `nl` and `regions[nl].isBigEnough(...)` + is true, copy the neighbour's color bytes into the small pixel and set its label to `nl`. :::note The merge phase uses the `labels` array to pick neighbour region IDs and the `regions` metadata to determine which regions are "big". ::: @@ -98,7 +101,7 @@ int main() { return 0; } ``` + :::tip Tweak the thresholds to match your use-case. ::: - diff --git a/docs/docs/reference/wasm/modules/image/overview.md b/docs/docs/reference/wasm/modules/image/overview.md index c55f6a01a..929ad47b5 100644 --- a/docs/docs/reference/wasm/modules/image/overview.md +++ b/docs/docs/reference/wasm/modules/image/overview.md @@ -9,6 +9,7 @@ sidebar_position: 1 The **Image** WASM module provides core image-processing functionality for Img2Num. It exposes C++ image utilities, pixel structures, FFT operations, K-means clustering, and region-merging algorithms. ## Structure + ``` src/wasm/modules/image/ β”œβ”€β”€ CMakeLists.txt @@ -32,16 +33,19 @@ src/wasm/modules/image/ ``` ## Description + Each header corresponds to a major subsystem: + - `Pixel.h` / `RGBPixel.h` / `RGBAPixel.h` β€” Pixel representations. - - **RGBPixel & RGBAPixel** inherit from **Pixel**. + - **RGBPixel & RGBAPixel** inherit from **Pixel**. - `Image.h` β€” Core image class. - - Internally uses a **Pixel type**. + - Internally uses a **Pixel type**. - `PixelConverters` β€” Functions for converting between pixel formats. - `fft_iterative` β€” Fast Fourier Transform utilities. - - Used by **Gaussian Blur** inside image_utils.h. + - Used by **Gaussian Blur** inside image_utils.h. - `kmeans` β€” K-means clustering used for quantization. - `mergeSmallRegionsInPlace` β€” Post-processing step for cleanup after K-means. ## Exports + `exported.h` defines **EXPORTED**, a macro used to declare a public API function that can be accessed by external code. diff --git a/docs/docs/reference/wasm/overview.md b/docs/docs/reference/wasm/overview.md index 003ffe03b..b49615e88 100644 --- a/docs/docs/reference/wasm/overview.md +++ b/docs/docs/reference/wasm/overview.md @@ -28,9 +28,9 @@ This repo ships native C++ image-processing code compiled to WebAssembly (WASM) The important pieces are: -* [`src/wasm/`](https://github.com/Ryan-Millard/Img2Num/tree/main/src/wasm) β€” centralized place for a **root CMakeLists.txt** and a `modules/` directory containing one or more WASM modules (example: `image`). -* [`vite.config.js`](https://github.com/Ryan-Millard/Img2Num/blob/main/vite.config.js) β€” contains alias generation so you can `import` built WASM outputs using `@wasm-{module-name}` and a dev-time watcher that triggers rebuilds when `.cpp` / `.h` change. -* [`package.json`](https://github.com/Ryan-Millard/Img2Num/blob/main/package.json) scripts β€” `npm run build-wasm`, `npm run build-wasm:debug`, `npm run clean-wasm`, which delegate to the CMakeLists.txt in `src/wasm/`. +- [`src/wasm/`](https://github.com/Ryan-Millard/Img2Num/tree/main/src/wasm) β€” centralized place for a **root CMakeLists.txt** and a `modules/` directory containing one or more WASM modules (example: `image`). +- [`vite.config.js`](https://github.com/Ryan-Millard/Img2Num/blob/main/vite.config.js) β€” contains alias generation so you can `import` built WASM outputs using `@wasm-{module-name}` and a dev-time watcher that triggers rebuilds when `.cpp` / `.h` change. +- [`package.json`](https://github.com/Ryan-Millard/Img2Num/blob/main/package.json) scripts β€” `npm run build-wasm`, `npm run build-wasm:debug`, `npm run clean-wasm`, which delegate to the CMakeLists.txt in `src/wasm/`. The design goals: @@ -52,4 +52,3 @@ npm run build-wasm:debug ```bash title="Remove all generated WASM build outputs" npm run clean-wasm ``` - diff --git a/docs/docs/reference/wasm/setup-and-dependencies.md b/docs/docs/reference/wasm/setup-and-dependencies.md index 6d21a22f9..bf0c5731e 100644 --- a/docs/docs/reference/wasm/setup-and-dependencies.md +++ b/docs/docs/reference/wasm/setup-and-dependencies.md @@ -115,6 +115,7 @@ The repo ships npm scripts that use a cross-platform Node.js build script with C ``` The build script: + 1. Verifies Emscripten is installed 2. Runs `emcmake cmake` to configure the build 3. Runs `cmake --build` to compile all WASM modules diff --git a/docs/docs/reference/wasm/troubleshooting-and-optimizations.md b/docs/docs/reference/wasm/troubleshooting-and-optimizations.md index 8edc79f68..03ab59605 100644 --- a/docs/docs/reference/wasm/troubleshooting-and-optimizations.md +++ b/docs/docs/reference/wasm/troubleshooting-and-optimizations.md @@ -8,41 +8,41 @@ sidebar_position: 5 ## The browser fails to load `.wasm` with `404`/`incorrect MIME type` -* Vite copies `.wasm` to the `dist` when `assetsInclude` includes `**/*.wasm`. -When you serve the built app, ensure the files were published and the `base` in `vite.config.js` is correct (this repo uses `/Img2Num/`). +- Vite copies `.wasm` to the `dist` when `assetsInclude` includes `**/*.wasm`. + When you serve the built app, ensure the files were published and the `base` in `vite.config.js` is correct (this repo uses `/Img2Num/`). ## Hot reload doesn't pick up changes -* Confirm the plugin added the files to `server.watcher`. If not, open `vite.config.js` and verify `fg.sync('src/wasm/**/*.{cpp,h}')` returns your files. -* Ensure the watcher is not ignoring the paths (see `server.watch.ignored` in `vite.config.js`). +- Confirm the plugin added the files to `server.watcher`. If not, open `vite.config.js` and verify `fg.sync('src/wasm/**/*.{cpp,h}')` returns your files. +- Ensure the watcher is not ignoring the paths (see `server.watch.ignored` in `vite.config.js`). ## Large `.wasm` size -* Use `-O3` with `--closure 1` or `-Os` depending on your performance/size tradeoffs. -* Strip debug symbols for production builds (`-s DEMANGLE_SUPPORT=0` and remove `-g`). -* Use `-s ALLOW_MEMORY_GROWTH=1` only if necessary; fixed memory can be slightly smaller. -* Audit exported functions β€” export only what you need via `EXPORTED_FUNCTIONS` or `EMSCRIPTEN_BINDINGS`. +- Use `-O3` with `--closure 1` or `-Os` depending on your performance/size tradeoffs. +- Strip debug symbols for production builds (`-s DEMANGLE_SUPPORT=0` and remove `-g`). +- Use `-s ALLOW_MEMORY_GROWTH=1` only if necessary; fixed memory can be slightly smaller. +- Audit exported functions β€” export only what you need via `EXPORTED_FUNCTIONS` or `EMSCRIPTEN_BINDINGS`. ## Optimizations checklist 1. Build release with `-O3` and closure compiler. Example flag additions: - ``` - emcc ... \ - -O3 \ - --closure 1 \ - -s ALLOW_MEMORY_GROWTH=0 \ - -s MODULARIZE=1 \ - -s EXPORT_NAME="createModule" - ``` + ```bash + emcc ... \ + -O3 \ + --closure 1 \ + -s ALLOW_MEMORY_GROWTH=0 \ + -s MODULARIZE=1 \ + -s EXPORT_NAME="createModule" + ``` 2. Use `MODULARIZE` and `EXPORT_NAME` to control loader behavior and reduce global pollution. 3. Use **lazy instantiation**: only instantiate the WASM module in components that need it. 4. Consider splitting functionality into multiple modules to avoid shipping large monolithic WASM files. ## CI & reproducible builds -* In CI, install a pinned emsdk version and call `npm run build-wasm` (or `make -C src/wasm build`). -* Cache the emsdk install between CI runs to save time. +- In CI, install a pinned emsdk version and call `npm run build-wasm` (or `make -C src/wasm build`). +- Cache the emsdk install between CI runs to save time. ## If builds hang or fail on Windows -* Run builds inside WSL2 or use a cross-platform Docker image that has emsdk preinstalled. +- Run builds inside WSL2 or use a cross-platform Docker image that has emsdk preinstalled. diff --git a/docs/docs/reference/wasm/using-wasm-in-react.md b/docs/docs/reference/wasm/using-wasm-in-react.md index 2b684f667..d979a1907 100644 --- a/docs/docs/reference/wasm/using-wasm-in-react.md +++ b/docs/docs/reference/wasm/using-wasm-in-react.md @@ -25,9 +25,9 @@ Emscripten-generated builds often export a JS wrapper (`index.js`) that bootstra ## Best practices -* Always `await` the module initializer before calling exported functions. -* Keep the Emscripten API surface small β€” expose only functions you need via `extern "C"` and a small header such as `exported.h`. -* Return simple typed arrays or pointers + lengths; avoid passing large JS objects across the bridge. +- Always `await` the module initializer before calling exported functions. +- Keep the Emscripten API surface small β€” expose only functions you need via `extern "C"` and a small header such as `exported.h`. +- Return simple typed arrays or pointers + lengths; avoid passing large JS objects across the bridge. ## Example: small wrapper hook @@ -40,8 +40,12 @@ export default function useImageWasm() { const [module, setModule] = useState(null); useEffect(() => { let mounted = true; - init().then((m) => { if (mounted) setModule(m); }); - return () => { mounted = false; }; + init().then((m) => { + if (mounted) setModule(m); + }); + return () => { + mounted = false; + }; }, []); return module; } diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index bf5d69d22..c01fa0246 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -15,17 +15,15 @@ import rehypeKatex from 'rehype-katex'; const require = createRequire(import.meta.url); require('dotenv').config(); -const hasAlgoliaEnvDefined = process.env.ALGOLIA_APP_ID - && process.env.ALGOLIA_API_KEY - && process.env.ALGOLIA_INDEX_NAME; -const algolia = - hasAlgoliaEnvDefined +const hasAlgoliaEnvDefined = + process.env.ALGOLIA_APP_ID && process.env.ALGOLIA_API_KEY && process.env.ALGOLIA_INDEX_NAME; +const algolia = hasAlgoliaEnvDefined ? { - appId: process.env.ALGOLIA_APP_ID, - apiKey: process.env.ALGOLIA_API_KEY, - indexName: process.env.ALGOLIA_INDEX_NAME, - contextualSearch: false, - } + appId: process.env.ALGOLIA_APP_ID, + apiKey: process.env.ALGOLIA_API_KEY, + indexName: process.env.ALGOLIA_INDEX_NAME, + contextualSearch: false, + } : undefined; const algoliaHeadTag = { name: 'algolia-site-verification', @@ -139,8 +137,7 @@ const config = { { href: 'https://cdn.jsdelivr.net/npm/katex@0.13.24/dist/katex.min.css', type: 'text/css', - integrity: - 'sha384-odtC+0UGzzFL/6PNoE8rX/SPcQDXBJ+uRepguP4QkPCm2LBxH3FA3y+fKSiJ+AmM', + integrity: 'sha384-odtC+0UGzzFL/6PNoE8rX/SPcQDXBJ+uRepguP4QkPCm2LBxH3FA3y+fKSiJ+AmM', crossorigin: 'anonymous', }, ], @@ -154,9 +151,7 @@ const config = { respectPrefersColorScheme: true, }, - metadata: [ - algoliaHeadTag, - ], + metadata: [algoliaHeadTag], algolia, diff --git a/docs/scripts/help.js b/docs/scripts/help.js index cb2469fed..d10aa5ac7 100644 --- a/docs/scripts/help.js +++ b/docs/scripts/help.js @@ -1,13 +1,12 @@ -import { runFuzzyCli } from "../../scripts/lib/cli-fuzzy.js"; -import { readPackageJsonScripts } from "../../scripts/lib/read-packageJson-scripts.js"; +import { runFuzzyCli } from '../../scripts/lib/cli-fuzzy.js'; +import { readPackageJsonScripts } from '../../scripts/lib/read-packageJson-scripts.js'; -const title = -`Img2Num Docs CLI Scripts +const title = `Img2Num Docs CLI Scripts Also see: https://ryan-millard.github.io/Img2Num/info/docs/category/-project-scripts `; try { - const { flat: items, basicItems } = readPackageJsonScripts(new URL("../package.json", import.meta.url)); + const { flat: items, basicItems } = readPackageJsonScripts(new URL('../package.json', import.meta.url)); // Grab all CLI args after `npm run help --` const initialSearch = process.argv.slice(2); @@ -19,6 +18,6 @@ try { initialSearch, }); } catch (error) { - console.error("Failed to read docs package.json scripts:", error.message); + console.error('Failed to read docs package.json scripts:', error.message); process.exit(1); } diff --git a/docs/sidebars.js b/docs/sidebars.js index c5d679f30..c63c39d74 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -4,15 +4,12 @@ /** * Creating a sidebar enables you to: - - create an ordered group of docs - - render a sidebar for each doc of that group - - provide next/previous navigation - - The sidebars can be generated from the filesystem, or explicitly defined here. - - Create as many sidebars as you want. - - @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} + * - create an ordered group of docs + * - render a sidebar for each doc of that group + * - provide next/previous navigation + * The sidebars can be generated from the filesystem, or explicitly defined here. + * Create as many sidebars as you want. + * @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */ const sidebars = { // By default, Docusaurus generates a sidebar from the docs folder structure diff --git a/docs/src/components/ColorSwatch.jsx b/docs/src/components/ColorSwatch.jsx index 7ba25439a..856713404 100644 --- a/docs/src/components/ColorSwatch.jsx +++ b/docs/src/components/ColorSwatch.jsx @@ -15,4 +15,3 @@ export default function ColorSwatch({ color, size = 24 }) { /> ); } - diff --git a/index.html b/index.html index b6a212a63..0ea77efbe 100644 --- a/index.html +++ b/index.html @@ -64,12 +64,14 @@ diff --git a/package-lock.json b/package-lock.json index 3b4b91a64..751b83efa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "imagetracerjs": "^1.2.6", "lucide-react": "^0.562.0", + "prop-types": "^15.8.1", "react": "^19.2.3", "react-dom": "^19.2.3", "react-helmet": "^6.1.0", @@ -18,7 +19,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.2", - "@tanstack/react-query": "^5.90.14", + "@tanstack/react-query": "^5.90.12", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", diff --git a/package.json b/package.json index 66ca30a54..b831b3171 100644 --- a/package.json +++ b/package.json @@ -66,13 +66,22 @@ }, "Formatting": { "format": { - "desc": "Format all files with Prettier and clang-format" + "desc": "Format all files with Prettier and clang-format (modifies files)" + }, + "format:check": { + "desc": "Check all files with Prettier and clang-format without modifying them (files untouched)" }, "format-js": { - "desc": "Format all non-C++ files with Prettier" + "desc": "Format all non-C++ files with Prettier (modifies files)" + }, + "format-js:check": { + "desc": "Check all non-C++ files with Prettier without modifying them (files untouched)" }, "format-wasm": { - "desc": "Format all C++ files with clang-format" + "desc": "Format all C++ files with clang-format (modifies files)" + }, + "format-wasm:check": { + "desc": "Check all C++ files with clang-format without modifying them (files untouched)" } }, "Linting": { @@ -137,8 +146,11 @@ "clean-js": "rimraf dist", "clean-wasm": "node scripts/build-wasm.js --clean", "format": "npm run format-js && npm run format-wasm", + "format:check": "npm run format-js:check && npm run format-wasm:check", "format-js": "prettier --write .", + "format-js:check": "prettier --check .", "format-wasm": "node scripts/format-wasm.js", + "format-wasm:check": "node scripts/format-wasm.js --check", "lint": "eslint .", "lint:fix": "eslint . --fix", "lint:style": "editorconfig-checker", @@ -151,6 +163,7 @@ "dependencies": { "imagetracerjs": "^1.2.6", "lucide-react": "^0.562.0", + "prop-types": "^15.8.1", "react": "^19.2.3", "react-dom": "^19.2.3", "react-helmet": "^6.1.0", @@ -159,7 +172,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.2", - "@tanstack/react-query": "^5.90.14", + "@tanstack/react-query": "^5.90.12", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", diff --git a/scripts/build-wasm.js b/scripts/build-wasm.js index e871c243b..e3e66c2b0 100644 --- a/scripts/build-wasm.js +++ b/scripts/build-wasm.js @@ -107,7 +107,7 @@ function safeRemoveDir(dir) { if (error.message) { console.error(` Message: ${error.message}`); } - console.log("You may need to forcefully remove it."); + console.log('You may need to forcefully remove it.'); return false; } } @@ -206,12 +206,7 @@ function build() { const buildType = isDebug ? 'Debug' : 'Release'; const emcmake = isWindows ? 'emcmake.bat' : 'emcmake'; - run(emcmake, [ - 'cmake', - '-S', WASM_DIR, - '-B', BUILD_DIR, - `-DCMAKE_BUILD_TYPE=${buildType}`, - ]); + run(emcmake, ['cmake', '-S', WASM_DIR, '-B', BUILD_DIR, `-DCMAKE_BUILD_TYPE=${buildType}`]); // Build run('cmake', ['--build', BUILD_DIR, '--parallel', '--config', buildType]); diff --git a/scripts/format-wasm.js b/scripts/format-wasm.js index c625e3e65..d53d331ff 100644 --- a/scripts/format-wasm.js +++ b/scripts/format-wasm.js @@ -9,13 +9,21 @@ if (!files.length) { process.exit(0); } +const checkOnly = process.argv.includes('--check'); + files.forEach((file) => { try { - execSync(`npx clang-format -i "${file}"`, { stdio: 'inherit' }); + if (checkOnly) { + execSync(`clang-format --dry-run --Werror "${file}"`, { stdio: 'inherit' }); + return; + } + + execSync(`clang-format -i "${file}"`, { stdio: 'inherit' }); console.log(`Formatted: ${file}`); } catch (err) { console.error(`Error formatting ${file}:`, err.message); + process.exit(1); } }); -console.log('C++ formatting complete.'); +console.log(`C++ ${checkOnly ? 'format check' : 'formatting'} complete.`); diff --git a/scripts/handle-changelog.js b/scripts/handle-changelog.js index 6c2282705..2412c7e3a 100644 --- a/scripts/handle-changelog.js +++ b/scripts/handle-changelog.js @@ -1,37 +1,37 @@ #!/usr/bin/env node -import fs from "fs"; -import path from "path"; -import { execSync } from "node:child_process"; +import fs from 'fs'; +import path from 'path'; +import { execSync } from 'node:child_process'; // Run standard-version try { // Stage the changelog folder - execSync("npx standard-version", { stdio: "inherit" }); + execSync('npx standard-version', { stdio: 'inherit' }); - console.log("[release] release created successfully"); + console.log('[release] release created successfully'); } catch (err) { - console.error("[release] Error:", err.message); + console.error('[release] Error:', err.message); process.exit(1); } -const changelogPath = "CHANGELOG.md"; -const outputDir = "docs/changelog"; +const changelogPath = 'CHANGELOG.md'; +const outputDir = 'docs/changelog'; if (!fs.existsSync(changelogPath)) { - console.log("[changelog] No CHANGELOG.md found. Skipping."); + console.log('[changelog] No CHANGELOG.md found. Skipping.'); process.exit(0); } -const content = fs.readFileSync(changelogPath, "utf8"); +const content = fs.readFileSync(changelogPath, 'utf8'); // Write to docs/changelog/complete-changelog.md -const completeChangelogPath = path.join(outputDir, "complete-changelog.md"); +const completeChangelogPath = path.join(outputDir, 'complete-changelog.md'); let completeChangelogMdHeader = `--- title: Complete Changelog --- `; -fs.writeFileSync(completeChangelogPath, completeChangelogMdHeader + content, "utf8"); +fs.writeFileSync(completeChangelogPath, completeChangelogMdHeader + content, 'utf8'); const lines = content.split(/\r?\n/); @@ -46,13 +46,13 @@ for (const line of lines) { if (capture) break; // stop at next release capture = true; version = releaseMatch[1]; - date = releaseMatch[2] + date = releaseMatch[2]; } if (capture) releaseLines.push(line); } if (!version) { - console.log("[changelog] No release section detected. Skipping."); + console.log('[changelog] No release section detected. Skipping.'); process.exit(0); } @@ -69,20 +69,19 @@ id: ${fileName} # Release ${version} `; -const fileLines = frontmatter + releaseLines.join("\n"); -fs.writeFileSync(outPath, fileLines, "utf8"); +const fileLines = frontmatter + releaseLines.join('\n'); +fs.writeFileSync(outPath, fileLines, 'utf8'); console.log(`[changelog] Extracted release ${version} -> ${outPath}`); // Stage the file so it also gets committed try { // Stage the changelog folder - execSync(`git add ${outPath} ${completeChangelogPath}`, { stdio: "inherit" }); - execSync(`git commit -m "chore(changelog): add ${version} release notes"`, { stdio: "inherit" }); + execSync(`git add ${outPath} ${completeChangelogPath}`, { stdio: 'inherit' }); + execSync(`git commit -m "chore(changelog): add ${version} release notes"`, { stdio: 'inherit' }); - console.log("[git] docs/changelog added and commit amended successfully."); + console.log('[git] docs/changelog added and commit amended successfully.'); } catch (err) { - console.error("[git] Error:", err.message); + console.error('[git] Error:', err.message); process.exit(1); } - diff --git a/scripts/help.js b/scripts/help.js index c1879a526..a1e372a74 100644 --- a/scripts/help.js +++ b/scripts/help.js @@ -1,13 +1,12 @@ -import { runFuzzyCli } from "./lib/cli-fuzzy.js"; -import { readPackageJsonScripts } from "./lib/read-packageJson-scripts.js"; +import { runFuzzyCli } from './lib/cli-fuzzy.js'; +import { readPackageJsonScripts } from './lib/read-packageJson-scripts.js'; -const title = -`Img2Num CLI Scripts +const title = `Img2Num CLI Scripts Also see: https://ryan-millard.github.io/Img2Num/info/docs/category/-project-scripts `; try { - const { flat: items, basicItems } = readPackageJsonScripts(new URL("../package.json", import.meta.url)); + const { flat: items, basicItems } = readPackageJsonScripts(new URL('../package.json', import.meta.url)); // Grab all CLI args after `npm run help --` const initialSearch = process.argv.slice(2); @@ -19,6 +18,6 @@ try { initialSearch, }); } catch (error) { - console.error("Failed to read root package.json scripts:", error.message); + console.error('Failed to read root package.json scripts:', error.message); process.exit(1); } diff --git a/scripts/lib/cli-fuzzy.js b/scripts/lib/cli-fuzzy.js index c2345576c..8aba52c32 100644 --- a/scripts/lib/cli-fuzzy.js +++ b/scripts/lib/cli-fuzzy.js @@ -1,6 +1,6 @@ -import readline from "readline"; -import fuzzy from "fuzzy"; -import { Colors, colorText } from "./colors.js"; +import readline from 'readline'; +import fuzzy from 'fuzzy'; +import { Colors, colorText } from './colors.js'; /** * Start an interactive fuzzy-search CLI for the provided script items. @@ -36,7 +36,7 @@ export function runFuzzyCli({ items, basicItems, title, initialSearch = [] }) { // Run initial search terms if provided if (initialSearch.length > 0) { - initialSearch.forEach(term => runSearch(term, items)); + initialSearch.forEach((term) => runSearch(term, items)); } startInteractive(items, initialSearch.length > 0); @@ -44,7 +44,7 @@ export function runFuzzyCli({ items, basicItems, title, initialSearch = [] }) { const HEADER_LINE_WIDTH = 80; const HEADER_INSTRUCTIONS = "Type 'a' to list all, 'q' to quit."; -const HEADER_LINE = colorText("─".repeat(HEADER_LINE_WIDTH), Colors.BLUE); +const HEADER_LINE = colorText('─'.repeat(HEADER_LINE_WIDTH), Colors.BLUE); /** * Prints a styled header block containing the provided title and header instructions. * @param {string} title - The header title displayed between decorative horizontal lines. @@ -63,11 +63,11 @@ function printHeader(title) { * @param {string[]} basicItems - Ordered list of script names to include in the basic section. */ function printBasics(items, basicItems) { - console.log("\nBasic scripts:"); + console.log('\nBasic scripts:'); for (const name of basicItems) { if (items[name]) printItem(name, items[name]); } - console.log(""); + console.log(''); } /** @@ -88,12 +88,12 @@ function startInteractive(items, skipIfInitialSearch = false) { output: process.stdout, completer(line) { const names = Object.keys(items); - const hits = fuzzy.filter(line, names).map(x => x.original); + const hits = fuzzy.filter(line, names).map((x) => x.original); return [hits, line]; }, }); - rl.setPrompt(colorText("> ", Colors.CYAN)); + rl.setPrompt(colorText('> ', Colors.CYAN)); // If initialSearch was provided, and we just want one-shot results, skip the interactive prompt if (skipIfInitialSearch) { @@ -102,18 +102,18 @@ function startInteractive(items, skipIfInitialSearch = false) { rl.prompt(); - rl.on("line", line => { + rl.on('line', (line) => { const input = line.trim(); - if (input === "q") return rl.close(); - if (input === "a") return printAll(items, rl); + if (input === 'q') return rl.close(); + if (input === 'a') return printAll(items, rl); runSearch(input, items); rl.prompt(); }); - rl.on("close", () => { - console.log(colorText("Exiting.", Colors.MAGENTA)); + rl.on('close', () => { + console.log(colorText('Exiting.', Colors.MAGENTA)); process.exit(0); }); } @@ -127,9 +127,9 @@ function startInteractive(items, skipIfInitialSearch = false) { * @param {Object.} items - Mapping of item names to metadata used when printing matches. */ function runSearch(input, items) { - const matches = fuzzy.filter(input, Object.keys(items)).map(x => x.original); + const matches = fuzzy.filter(input, Object.keys(items)).map((x) => x.original); if (!matches.length) { - console.log(colorText("No matches.", Colors.RED)); + console.log(colorText('No matches.', Colors.RED)); return; } @@ -141,7 +141,9 @@ function runSearch(input, items) { /** * Print all scripts grouped by their `info.group` and re-prompt the given readline interface. * - * Groups items by the `group` property on each info object (uses "Other" when absent), prints a blue header for each group, lists each script using `printItem`, and then calls `rl.prompt()` to resume the interactive prompt. + * Groups items by the `group` property on each info object (uses "Other" when absent), + * prints a blue header for each group, lists each script using `printItem`, and then + * calls `rl.prompt()` to resume the interactive prompt. * * @param {Object} items - Mapping of script names to their info objects. * @param {import('readline').Interface} rl - Readline interface used to re-prompt after listing. @@ -150,7 +152,7 @@ function printAll(items, rl) { const groups = {}; for (const [name, info] of Object.entries(items)) { - const group = info.group || "Other"; + const group = info.group || 'Other'; if (!groups[group]) groups[group] = []; groups[group].push([name, info]); } @@ -175,8 +177,8 @@ function printAll(items, rl) { * @param {string} [info.command] - Optional command string displayed as a cyan-prefixed line. */ function printItem(name, info) { - console.log(`\n\t${colorText(name, Colors.YELLOW)}${info.group ? ` (${info.group})` : ""}`); - const description = Array.isArray(info.desc) ? info.desc.join(" ") : info.desc; + console.log(`\n\t${colorText(name, Colors.YELLOW)}${info.group ? ` (${info.group})` : ''}`); + const description = Array.isArray(info.desc) ? info.desc.join(' ') : info.desc; if (description) { console.log(`\t\t- ${colorText(description, Colors.YELLOW)}`); } @@ -190,4 +192,4 @@ function printItem(name, info) { if (info.command) { console.log(`\t\t\t\t> ${colorText(info.command, Colors.CYAN)}`); } -} \ No newline at end of file +} diff --git a/scripts/lib/colors.js b/scripts/lib/colors.js index a0f3cf11f..71694d189 100644 --- a/scripts/lib/colors.js +++ b/scripts/lib/colors.js @@ -4,44 +4,44 @@ const supportsColor = process.stdout.isTTY; // Define allowed color names as an enum export const Colors = Object.freeze({ - RESET: "reset", - BOLD: "bold", - DIM: "dim", - RED: "red", - GREEN: "green", - YELLOW: "yellow", - BLUE: "blue", - MAGENTA: "magenta", - CYAN: "cyan", - WHITE: "white", - BG_RED: "bgRed", - BG_GREEN: "bgGreen", - BG_YELLOW: "bgYellow", - BG_BLUE: "bgBlue", - BG_MAGENTA: "bgMagenta", - BG_CYAN: "bgCyan", - BG_WHITE: "bgWhite", + RESET: 'reset', + BOLD: 'bold', + DIM: 'dim', + RED: 'red', + GREEN: 'green', + YELLOW: 'yellow', + BLUE: 'blue', + MAGENTA: 'magenta', + CYAN: 'cyan', + WHITE: 'white', + BG_RED: 'bgRed', + BG_GREEN: 'bgGreen', + BG_YELLOW: 'bgYellow', + BG_BLUE: 'bgBlue', + BG_MAGENTA: 'bgMagenta', + BG_CYAN: 'bgCyan', + BG_WHITE: 'bgWhite', }); // Mapping from enum to ANSI codes const codes = Object.freeze({ - reset: "\x1b[0m", - bold: "\x1b[1m", - dim: "\x1b[2m", - red: "\x1b[31m", - green: "\x1b[32m", - yellow: "\x1b[33m", - blue: "\x1b[34m", - magenta: "\x1b[35m", - cyan: "\x1b[36m", - white: "\x1b[37m", - bgRed: "\x1b[41m", - bgGreen: "\x1b[42m", - bgYellow: "\x1b[43m", - bgBlue: "\x1b[44m", - bgMagenta: "\x1b[45m", - bgCyan: "\x1b[46m", - bgWhite: "\x1b[47m", + reset: '\x1b[0m', + bold: '\x1b[1m', + dim: '\x1b[2m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + magenta: '\x1b[35m', + cyan: '\x1b[36m', + white: '\x1b[37m', + bgRed: '\x1b[41m', + bgGreen: '\x1b[42m', + bgYellow: '\x1b[43m', + bgBlue: '\x1b[44m', + bgMagenta: '\x1b[45m', + bgCyan: '\x1b[46m', + bgWhite: '\x1b[47m', }); /** @@ -64,4 +64,4 @@ export function colorText(text, colorEnum) { */ export function logColor(text, colorEnum) { console.log(colorText(text, colorEnum)); -} \ No newline at end of file +} diff --git a/scripts/lib/read-packageJson-scripts.js b/scripts/lib/read-packageJson-scripts.js index c4e51f29d..16ebfbe2f 100644 --- a/scripts/lib/read-packageJson-scripts.js +++ b/scripts/lib/read-packageJson-scripts.js @@ -1,11 +1,13 @@ -import fs from "fs"; +import fs from 'fs'; /** * Load and normalize script metadata from a package-style JSON file. * * @param {string} fileUrl - Path to a JSON file that contains `scriptsInfo` and `scripts` top-level properties. * @returns {{flat: Record, basicItems: any[]}} An object with: - * - `flat`: a mapping of script name to its CLI metadata (description defaults to `""`, args defaults to `[]`, command falls back to `"No command defined"`, and `group` is the originating group key). + * - `flat`: a mapping of script name to its CLI metadata (description defaults to `""`, + * args defaults to `[]`, command falls back to `"No command defined"`, and `group` + * is the originating group key). * - `basicItems`: the array from `scriptsInfo._meta.basic` or an empty array when not present. */ export function readPackageJsonScripts(fileUrl) { @@ -20,13 +22,13 @@ export function readPackageJsonScripts(fileUrl) { for (const [group, entries] of Object.entries(groups)) { for (const [name, desc] of Object.entries(entries)) { flat[name] = { - desc: desc.desc || "", // take the actual string description + desc: desc.desc || '', // take the actual string description args: desc.args || [], // optional, if you want to show CLI args - command: scripts[name] || "No command defined", + command: scripts[name] || 'No command defined', group, }; } } return { flat, basicItems }; -} \ No newline at end of file +} diff --git a/scripts/validate-scripts.js b/scripts/validate-scripts.js index 04c9f4e10..43866799a 100644 --- a/scripts/validate-scripts.js +++ b/scripts/validate-scripts.js @@ -1,6 +1,6 @@ #!/usr/bin/env node -import fs from "fs"; -import path from "path"; +import fs from 'fs'; +import path from 'path'; /** * Load and parse a package.json (or other JSON) file from disk. @@ -11,7 +11,7 @@ import path from "path"; */ function loadPackageJson(filePath) { try { - return JSON.parse(fs.readFileSync(filePath, "utf-8")); + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); } catch (error) { console.error(`❌ Failed to load ${filePath}: ${error.message}`); process.exit(1); @@ -27,7 +27,7 @@ function loadPackageJson(filePath) { function flattenScriptsInfo(scriptsInfo) { const flat = {}; for (const [group, entries] of Object.entries(scriptsInfo)) { - if (group === "_meta") continue; + if (group === '_meta') continue; for (const [name] of Object.entries(entries)) { flat[name] = true; } @@ -38,7 +38,9 @@ function flattenScriptsInfo(scriptsInfo) { /** * Validates that the "scripts" keys in a package.json match the flattened entries in "scriptsInfo". * - * If "scripts" or "scriptsInfo" is missing, or any script is undocumented or any description refers to a non-existent script, logs errors and exits the process with code 1. On success, logs a confirmation message. + * If "scripts" or "scriptsInfo" is missing, or any script is undocumented or any + * description refers to a non-existent script, logs errors and exits the process with + * code 1. On success, logs a confirmation message. * * @param {string} pkgPath - Path to the package.json file to validate. */ @@ -78,7 +80,7 @@ function validateScripts(pkgPath) { } // Validate main project -validateScripts(path.resolve("./package.json")); +validateScripts(path.resolve('./package.json')); // Validate docs project -validateScripts(path.resolve("./docs/package.json")); \ No newline at end of file +validateScripts(path.resolve('./docs/package.json')); diff --git a/src/components/GlassSwitch.jsx b/src/components/GlassSwitch.jsx new file mode 100644 index 000000000..8ce342d5a --- /dev/null +++ b/src/components/GlassSwitch.jsx @@ -0,0 +1,31 @@ +import styles from './GlassSwitch.module.css'; +import Tooltip from '@components/Tooltip'; +import PropTypes from 'prop-types'; + +const GlassSwitch = ({ onChange, isOn, ariaLabel, thumbContent, disabled = false }) => { + const fallbackContent = isOn ? styles.fallbackThumbContentOn : styles.fallbackThumbContentOff; + return ( + + + + ); +}; + +GlassSwitch.propTypes = { + isOn: PropTypes.bool.isRequired, + onChange: PropTypes.func.isRequired, + ariaLabel: PropTypes.string.isRequired, + thumbContent: PropTypes.node, + disabled: PropTypes.bool, +}; + +export default GlassSwitch; diff --git a/src/components/GlassSwitch.module.css b/src/components/GlassSwitch.module.css new file mode 100644 index 000000000..91b035763 --- /dev/null +++ b/src/components/GlassSwitch.module.css @@ -0,0 +1,54 @@ +.switch { + --size: 30px; + display: flex; + align-items: center; + width: calc(var(--size) * 2); + height: var(--size); + padding: 0; + border-radius: 9999px; + position: relative; + cursor: pointer; + margin-right: 15px; +} + +/* WCAG 2.4.7 Compliant Focus Indicator */ +.switch:focus-visible { + outline: 2px solid var(--color-primary, #4f46e5); + outline-offset: 2px; + box-shadow: 0 0 0 4px rgba(79, 70, 229, 0.1); +} + +/* Fallback for browsers that don't support :focus-visible */ +.switch:focus { + outline: 2px solid var(--color-primary, #4f46e5); + outline-offset: 2px; +} + +/* Remove default outline from focus-visible for cleaner look */ +.switch:focus:not(:focus-visible) { + outline: none; + box-shadow: none; +} + +.thumb { + position: absolute; + left: 0; + bottom: 1; + width: calc(var(--size) - 4px); + height: calc(var(--size) - 4px); + border-radius: 50%; + background: var(--color-surface); + color: var(--color-text); + transition: transform 0.3s ease; +} + +.checked .thumb { + transform: translateX(var(--size)); +} +.fallbackThumbContentOff { + background-color: rgb(110, 110, 110); +} + +.fallbackThumbContentOn { + background-color: var(--color-success); +} diff --git a/src/components/GlassSwitch.test.jsx b/src/components/GlassSwitch.test.jsx new file mode 100644 index 000000000..e0cc84ed7 --- /dev/null +++ b/src/components/GlassSwitch.test.jsx @@ -0,0 +1,116 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import GlassSwitch from './GlassSwitch'; + +// Mock the Tooltip component +vi.mock('@components/Tooltip', () => ({ + __esModule: true, + default: ({ children }) =>
{children}
, +})); + +// Mock the CSS module +vi.mock('./GlassSwitch.module.css', () => ({ + default: { + switch: 'mocked-switch-class', + thumb: 'mocked-thumb-class', + checked: 'mocked-checked-class', + fallbackThumbContentOn: 'mocked-fallback-on', + fallbackThumbContentOff: 'mocked-fallback-off', + }, +})); + +describe('GlassSwitch', () => { + it('renders a switch button', () => { + render( {}} ariaLabel="Toggle" />); + expect(screen.getByRole('switch')).toBeInTheDocument(); + }); + + it('sets aria-checked to true when checked', () => { + render( {}} ariaLabel="Toggle" />); + expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true'); + }); + + it('sets aria-checked to false when unchecked', () => { + render( {}} ariaLabel="Toggle" />); + expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false'); + }); + + it('calls onChange when clicked', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + + render(); + await user.click(screen.getByRole('switch')); + + expect(onChange).toHaveBeenCalledOnce(); + }); + + it('is keyboard accessible', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + + render(); + const button = screen.getByRole('switch'); + + button.focus(); + expect(button).toHaveFocus(); + + await user.keyboard('{Enter}'); + + expect(onChange).toHaveBeenCalled(); + }); + + it('applies correct CSS classes when checked', () => { + render( {}} ariaLabel="Toggle" />); + const switchButton = screen.getByRole('switch'); + + expect(switchButton).toHaveClass('mocked-switch-class'); + expect(switchButton).toHaveClass('mocked-checked-class'); + }); + + it('does not apply checked class when unchecked', () => { + render( {}} ariaLabel="Toggle" />); + const switchButton = screen.getByRole('switch'); + + expect(switchButton).toHaveClass('mocked-switch-class'); + expect(switchButton).not.toHaveClass('mocked-checked-class'); + }); + + it('renders with thumbContent when provided', () => { + const thumbContent = Custom; + render( {}} ariaLabel="Toggle" thumbContent={thumbContent} />); + + expect(screen.getByTestId('custom-thumb')).toBeInTheDocument(); + }); + + it('uses fallback off styling when no thumbContent and isOff', () => { + render( {}} ariaLabel="Toggle" />); + const thumb = screen.getByRole('switch').querySelector('span'); + expect(thumb).toHaveClass('mocked-thumb-class'); + expect(thumb).toHaveClass('mocked-fallback-off'); + expect(thumb?.textContent).toBe(''); + }); + + it('uses fallback on styling when no thumbContent and isOn', () => { + render( {}} ariaLabel="Toggle" />); + const thumb = screen.getByRole('switch').querySelector('span'); + expect(thumb).toHaveClass('mocked-thumb-class'); + expect(thumb).toHaveClass('mocked-fallback-on'); + expect(thumb?.textContent).toBe(''); + }); + + it('can be disabled', () => { + const onChange = vi.fn(); + render(); + const switchButton = screen.getByRole('switch'); + + expect(switchButton).toBeDisabled(); + }); + + it('sets correct aria-label', () => { + render( {}} ariaLabel="My Custom Label" />); + + expect(screen.getByLabelText('My Custom Label')).toBeInTheDocument(); + }); +}); diff --git a/src/components/NavBar.jsx b/src/components/NavBar.jsx index 6975ea1d9..51207da57 100644 --- a/src/components/NavBar.jsx +++ b/src/components/NavBar.jsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import React, { useState } from 'react'; import { Home, Users, Info, Github, SquareArrowOutUpRight, Menu, X } from 'lucide-react'; import { Link, useLocation } from 'react-router-dom'; import styles from './NavBar.module.css'; @@ -14,7 +14,12 @@ const INTERNAL_LINKS = [ const EXTERNAL_LINKS = [ { href: 'https://ryan-millard.github.io/Img2Num/info/', label: 'Docs', icon: Info, tooltip: 'View documentation' }, - { href: 'https://github.com/Ryan-Millard/Img2Num', label: 'GitHub', icon: Github, tooltip: 'Open the project on GitHub' }, + { + href: 'https://github.com/Ryan-Millard/Img2Num', + label: 'GitHub', + icon: Github, + tooltip: 'Open the project on GitHub', + }, ]; export default function NavBar() { @@ -26,13 +31,7 @@ export default function NavBar() { return ( <> {/* Backdrop to capture dismiss clicks on mobile - rendered outside nav for proper full-screen coverage */} - {isOpen && ( -