Compare commits

...

1 Commits

Author SHA1 Message Date
Claude e6dd4c7f4c 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128gEVvzXHyJFNNU9n8gUyQ
2026-09-09 15:41:53 +00:00
2 changed files with 68 additions and 7 deletions
@@ -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;
@@ -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 (
<Suspense fallback={<QrFallback size={size} />}>
@@ -41,3 +49,56 @@ function QrSvg({ svgPromise, size }: { svgPromise: Promise<string>; 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<string> {
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 += `<circle cx="${x}" cy="${y}" r="${dotRadius}"/>`;
}
}
const finderPatterns =
finderPattern(0, 0) +
finderPattern(0, moduleCount - finderPatternSize) +
finderPattern(moduleCount - finderPatternSize, 0);
return (
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${viewBoxSize} ${viewBoxSize}">` +
`<rect width="${viewBoxSize}" height="${viewBoxSize}" rx="3" fill="${backgroundColor}"/>` +
`<g fill="${dotColor}">${dots}</g>${finderPatterns}</svg>`
);
}
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 (
`<rect x="${x}" y="${y}" width="7" height="7" rx="1.6" fill="${dotColor}"/>` +
`<rect x="${x + 1}" y="${y + 1}" width="5" height="5" rx="1.1" fill="${backgroundColor}"/>` +
`<rect x="${x + 2}" y="${y + 2}" width="3" height="3" rx="0.7" fill="${dotColor}"/>`
);
}