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 (
+ ``
+ );
+}
+
+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 (
+ `` +
+ `` +
+ ``
+ );
+}