From e6dd4c7f4cf31713c895051172738d468a7bfd69 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 15:41:53 +0000 Subject: [PATCH] feat(qr-code): render QR codes as dots with a dark theme Restyle the shared QrCode component (used in the Backstage view and the share-link dialog) to render each module as a circle instead of a square, and switch to light dots on a dark card to match Ontime's dark theme. The QR is generated manually from qrcode's create() output rather than its default SVG renderer, so we can draw dots for data modules while keeping the three finder patterns (position detection eyes) as solid rounded squares - scanners rely on their crisp edges to lock onto the code. Verified decoding still works reliably with jsQR at typical in-app sizes. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0128gEVvzXHyJFNNU9n8gUyQ --- .../components/qr-code/QrCode.module.scss | 2 +- .../src/common/components/qr-code/QrCode.tsx | 73 +++++++++++++++++-- 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/apps/client/src/common/components/qr-code/QrCode.module.scss b/apps/client/src/common/components/qr-code/QrCode.module.scss index 39a30da51..112abceff 100644 --- a/apps/client/src/common/components/qr-code/QrCode.module.scss +++ b/apps/client/src/common/components/qr-code/QrCode.module.scss @@ -2,7 +2,7 @@ .square { border-radius: $component-border-radius-sm; - background-color: $viewer-color; + background-color: $ui-black; display: flex; align-items: center; justify-content: center; diff --git a/apps/client/src/common/components/qr-code/QrCode.tsx b/apps/client/src/common/components/qr-code/QrCode.tsx index 45813ece8..d8931c28b 100644 --- a/apps/client/src/common/components/qr-code/QrCode.tsx +++ b/apps/client/src/common/components/qr-code/QrCode.tsx @@ -1,4 +1,4 @@ -import { toString } from 'qrcode'; +import { create } from 'qrcode'; import { Suspense, use } from 'react'; import { cx } from '../../utils/styleUtils'; @@ -10,13 +10,21 @@ interface QRCodeProps { size: number; } +// Quiet zone around the code, in modules +const margin = 2; +// Dot diameter as a fraction of a module. Close to 1 keeps the code reliably scannable +// while still reading as dots rather than solid squares +const dotScale = 0.94; +// Light dots on a dark card, to match Ontime's dark theme +const dotColor = '#f6f6f6'; +const backgroundColor = '#101010'; +// Position detection patterns (the three corner "eyes") are drawn as solid squares +// instead of dots, since scanners rely on their crisp edges to find the code +const finderPatternSize = 7; + export default function QRCode({ value, size }: QRCodeProps) { 'use memo'; - const svgPromise = toString(value, { - width: size, - margin: 2, - color: { dark: '#101010', light: '#cfcfcf' }, - }); + const svgPromise = toDottedSvgString(value, size); return ( }> @@ -41,3 +49,56 @@ function QrSvg({ svgPromise, size }: { svgPromise: Promise; size: number /> ); } + +/** + * Encodes the given value and renders it as a dotted QR code (SVG string): + * data modules become circles, while the three finder patterns stay solid for reliable scanning. + */ +async function toDottedSvgString(value: string, size: number): Promise { + const { modules } = create(value); + const moduleCount = modules.size; + const viewBoxSize = moduleCount + margin * 2; + const dotRadius = dotScale / 2; + + let dots = ''; + for (let row = 0; row < moduleCount; row++) { + for (let col = 0; col < moduleCount; col++) { + if (isInFinderPattern(row, col, moduleCount) || !modules.get(row, col)) { + continue; + } + const x = col + margin + 0.5; + const y = row + margin + 0.5; + dots += ``; + } + } + + const finderPatterns = + finderPattern(0, 0) + + finderPattern(0, moduleCount - finderPatternSize) + + finderPattern(moduleCount - finderPatternSize, 0); + + return ( + `` + + `` + + `${dots}${finderPatterns}` + ); +} + +function isInFinderPattern(row: number, col: number, moduleCount: number) { + const inTopRows = row < finderPatternSize; + const inBottomRows = row >= moduleCount - finderPatternSize; + const inLeftCols = col < finderPatternSize; + const inRightCols = col >= moduleCount - finderPatternSize; + return (inTopRows && inLeftCols) || (inTopRows && inRightCols) || (inBottomRows && inLeftCols); +} + +/** Renders one of the three corner "eyes" as nested rounded squares, at (row, col) in module units */ +function finderPattern(row: number, col: number) { + const x = col + margin; + const y = row + margin; + return ( + `` + + `` + + `` + ); +}