feat(eidos): add QrCode — themeable QR with own zero-dep encoder

Own QR encoder in $libs/qr (ISO/IEC 18004: segment analysis →
Reed-Solomon over GF(256) → matrix layout → 8-mask penalty selection),
no npm dependency. Eidos <QrCode> renders the module matrix as one SVG
path with square/rounded/dots cell shapes; function patterns stay square
(via the encoder's `reserved` mask) so every shape scans. Optional logo
clears the centre + auto-boosts ECC to H. <QrCode.DownloadTrigger>
exports PNG/SVG, baking resolved colours into the clone first (a
serialized SVG loses the document's CSS custom properties). Fixed-tone
default colours for scannability; themeable via color/background.

morfo qr-code (scope: eidos; parts Provider/Pattern/Overlay/
DownloadTrigger) + 'path' added to MorfoElement; recipe with 11
--qr-code-* tokens; canonical demo + nav entry. 11 encoder unit tests;
verified end-to-end via jsQR round-trip + browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 4add3ed58f
commit e0579f7e24

@ -0,0 +1,2 @@
export { encode } from './qr'
export type { ErrorCorrection, QrResult, EncodeOptions } from './qr'

@ -0,0 +1,114 @@
import { describe, it, expect } from 'vitest'
import { encode, type ErrorCorrection } from './qr'
const LEVELS: ErrorCorrection[] = ['L', 'M', 'Q', 'H']
describe('qr encode — structure', () => {
it('version 1 short input is a 21×21 square matrix', () => {
const qr = encode('HELLO', { errorCorrection: 'M' })
expect(qr.version).toBe(1)
expect(qr.size).toBe(21)
expect(qr.matrix.length).toBe(21)
for (const row of qr.matrix) {
expect(row.length).toBe(21)
for (const cell of row) expect(typeof cell).toBe('boolean')
}
})
it('draws the three finder patterns', () => {
const { matrix } = encode('HELLO', { errorCorrection: 'M' })
// Top-left finder centred at (3,3): outer ring dark, light ring, dark centre.
expect(matrix[0][0]).toBe(true) // outer ring (dist 3)
expect(matrix[1][1]).toBe(false) // light ring (dist 2)
expect(matrix[3][3]).toBe(true) // centre (dist 0)
// Top-right + bottom-left finders present (their centres are dark).
expect(matrix[3][matrix.length - 4]).toBe(true)
expect(matrix[matrix.length - 4][3]).toBe(true)
})
it('draws the timing patterns (alternating on row/col 6)', () => {
const { matrix } = encode('HELLO', { errorCorrection: 'M' })
expect(matrix[6][8]).toBe(true) // even → dark
expect(matrix[6][9]).toBe(false) // odd → light
expect(matrix[8][6]).toBe(true)
expect(matrix[9][6]).toBe(false)
})
it('sets the dark module at [size-8][8]', () => {
const { matrix, size } = encode('HELLO', { errorCorrection: 'M' })
expect(matrix[size - 8][8]).toBe(true)
})
it('matches the known-good golden matrix (regression guard)', () => {
// Captured from this encoder and verified scannable by an independent
// decoder (jsQR) — pins the exact module layout for HELLO @ ECC M.
const golden = [
'111111100011101111111',
'100000100001001000001',
'101110101010101011101',
'101110101001101011101',
'101110101111101011101',
'100000101100101000001',
'111111101010101111111',
'000000001001100000000',
'101111100000101111100',
'000010011100100101111',
'110011110001010011001',
'001100000100000110000',
'000011110011010010100',
'000000001011111001011',
'111111100000101100101',
'100000101111111001001',
'101110101010100100100',
'101110101000100100100',
'101110101001010010100',
'100000100010000110101',
'111111101011010010100'
].join('\n')
const { matrix } = encode('HELLO', { errorCorrection: 'M' })
const actual = matrix.map((row) => row.map((c) => (c ? '1' : '0')).join('')).join('\n')
expect(actual).toBe(golden)
})
})
describe('qr encode — version selection + modes', () => {
it('grows the version as data grows', () => {
const small = encode('A', { errorCorrection: 'M' })
const big = encode('A'.repeat(300), { errorCorrection: 'M' })
expect(small.version).toBe(1)
expect(big.version).toBeGreaterThan(small.version)
expect(big.size).toBe(big.version * 4 + 17)
})
it('higher ECC needs a larger version for the same data', () => {
const data = 'x'.repeat(120)
const low = encode(data, { errorCorrection: 'L' })
const high = encode(data, { errorCorrection: 'H' })
expect(high.version).toBeGreaterThanOrEqual(low.version)
})
it('encodes numeric, alphanumeric, byte and unicode without throwing', () => {
for (const v of ['1234567890', 'HELLO WORLD $%*+-./:', 'https://example.com/path?x=1', 'café ☕ 你好']) {
for (const ecc of LEVELS) {
const qr = encode(v, { errorCorrection: ecc })
expect(qr.matrix.length).toBe(qr.size)
expect(qr.size).toBe(qr.version * 4 + 17)
}
}
})
it('respects minVersion', () => {
const qr = encode('A', { errorCorrection: 'M', minVersion: 5 })
expect(qr.version).toBeGreaterThanOrEqual(5)
})
it('throws when data exceeds the version range', () => {
expect(() => encode('A'.repeat(100), { errorCorrection: 'H', maxVersion: 1 })).toThrow()
})
it('empty string encodes to version 1', () => {
const qr = encode('', { errorCorrection: 'M' })
expect(qr.version).toBe(1)
})
})

@ -0,0 +1,504 @@
/**
* QR code encoder — own, zero-dependency.
*
* Implements the open QR standard (ISO/IEC 18004): segment encoding →
* Reed–Solomon error correction over GF(256) → matrix layout → mask
* selection. No npm dependency — `$libs` is zero-dep. The algorithm is the
* public standard; Nayuki's reference implementation was used to cross-check
* correctness, never imported.
*
* Supports versions 1–40, all four error-correction levels, and numeric /
* alphanumeric / byte (UTF-8) modes with single-best-mode selection.
*/
export type ErrorCorrection = 'L' | 'M' | 'Q' | 'H'
export interface QrResult {
/** Square module grid, row-major. `true` = dark module. */
matrix: boolean[][]
/**
* Function-pattern mask, row-major. `true` = the module is a finder /
* timing / alignment / format / version module (not styleable data).
* Renderers keep these square so scanners lock on regardless of cell shape.
*/
reserved: boolean[][]
/** Side length in modules (`21 + 4 * (version - 1)`). */
size: number
/** Chosen QR version (1–40). */
version: number
/** Effective error-correction level used. */
errorCorrection: ErrorCorrection
}
export interface EncodeOptions {
errorCorrection?: ErrorCorrection
/** Smallest version to try (1–40). @default 1 */
minVersion?: number
/** Largest version to try (1–40). @default 40 */
maxVersion?: number
}
// ── Error-correction level ordering + format bits ───────────────────────────
// Format-info 2-bit value per level (NOT the same as the L<M<Q<H ordering).
const ECC_ORDER: ErrorCorrection[] = ['L', 'M', 'Q', 'H']
const ECC_FORMAT_BITS: Record<ErrorCorrection, number> = { L: 1, M: 0, Q: 3, H: 2 }
const MIN_VERSION = 1
const MAX_VERSION = 40
// ── Per-(level, version) tables, indexed [eccOrdinal][version]. Index 0 unused.
// ECC codewords per block.
const ECC_CODEWORDS_PER_BLOCK: number[][] = [
// L
[-1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
// M
[-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28],
// Q
[-1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
// H
[-1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30]
]
// Number of error-correction blocks.
const NUM_ERROR_CORRECTION_BLOCKS: number[][] = [
// L
[-1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25],
// M
[-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49],
// Q
[-1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68],
// H
[-1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81]
]
/** Total number of data + ECC codewords (8-module words) for a version. */
function getNumRawDataModules(version: number): number {
let result = (16 * version + 128) * version + 64
if (version >= 2) {
const numAlign = Math.floor(version / 7) + 2
result -= (25 * numAlign - 10) * numAlign - 55
if (version >= 7) result -= 36
}
return result
}
/** Number of 8-bit data codewords (excludes ECC) for a version + level. */
function getNumDataCodewords(version: number, ecc: ErrorCorrection): number {
const o = ECC_ORDER.indexOf(ecc)
return (
Math.floor(getNumRawDataModules(version) / 8) -
ECC_CODEWORDS_PER_BLOCK[o][version] * NUM_ERROR_CORRECTION_BLOCKS[o][version]
)
}
// ── Bit buffer ──────────────────────────────────────────────────────────────
class BitBuffer {
bits: number[] = []
append(value: number, length: number): void {
for (let i = length - 1; i >= 0; i--) this.bits.push((value >>> i) & 1)
}
}
// ── Segment modes ───────────────────────────────────────────────────────────
const ALPHANUMERIC = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:'
function isNumeric(text: string): boolean {
return /^[0-9]*$/.test(text)
}
function isAlphanumeric(text: string): boolean {
for (const ch of text) if (!ALPHANUMERIC.includes(ch)) return false
return true
}
type Mode = { indicator: number; charCountBits: [number, number, number] }
const MODE_NUMERIC: Mode = { indicator: 0x1, charCountBits: [10, 12, 14] }
const MODE_ALPHANUMERIC: Mode = { indicator: 0x2, charCountBits: [9, 11, 13] }
const MODE_BYTE: Mode = { indicator: 0x4, charCountBits: [8, 16, 16] }
function charCountBits(mode: Mode, version: number): number {
const i = version <= 9 ? 0 : version <= 26 ? 1 : 2
return mode.charCountBits[i]
}
interface Segment {
mode: Mode
numChars: number
data: BitBuffer
}
function utf8Bytes(text: string): number[] {
return [...new TextEncoder().encode(text)]
}
/** Pick the single best mode for the whole string + build its segment. */
function makeSegment(text: string): Segment {
const buf = new BitBuffer()
if (isNumeric(text)) {
for (let i = 0; i < text.length; ) {
const n = Math.min(3, text.length - i)
buf.append(parseInt(text.substring(i, i + n), 10), n * 3 + 1)
i += n
}
return { mode: MODE_NUMERIC, numChars: text.length, data: buf }
}
if (isAlphanumeric(text)) {
let i = 0
for (; i + 2 <= text.length; i += 2) {
buf.append(ALPHANUMERIC.indexOf(text[i]) * 45 + ALPHANUMERIC.indexOf(text[i + 1]), 11)
}
if (i < text.length) buf.append(ALPHANUMERIC.indexOf(text[i]), 6)
return { mode: MODE_ALPHANUMERIC, numChars: text.length, data: buf }
}
const bytes = utf8Bytes(text)
for (const b of bytes) buf.append(b, 8)
return { mode: MODE_BYTE, numChars: bytes.length, data: buf }
}
// ── Reed–Solomon over GF(256), primitive 0x11D ──────────────────────────────
const GF_EXP = new Uint8Array(512)
const GF_LOG = new Uint8Array(256)
;(() => {
let x = 1
for (let i = 0; i < 255; i++) {
GF_EXP[i] = x
GF_LOG[x] = i
x <<= 1
if (x & 0x100) x ^= 0x11d
}
for (let i = 255; i < 512; i++) GF_EXP[i] = GF_EXP[i - 255]
})()
function gfMul(a: number, b: number): number {
if (a === 0 || b === 0) return 0
return GF_EXP[GF_LOG[a] + GF_LOG[b]]
}
/** Generator polynomial coefficients for `degree` ECC codewords. */
function rsGenerator(degree: number): number[] {
let poly = [1]
for (let i = 0; i < degree; i++) {
const next = new Array(poly.length + 1).fill(0)
for (let j = 0; j < poly.length; j++) {
next[j] ^= gfMul(poly[j], 1)
next[j + 1] ^= gfMul(poly[j], GF_EXP[i])
}
poly = next
}
return poly
}
function rsRemainder(data: number[], generator: number[]): number[] {
const result = new Array(generator.length - 1).fill(0)
for (const byte of data) {
const factor = byte ^ result[0]
result.shift()
result.push(0)
for (let j = 0; j < result.length; j++) result[j] ^= gfMul(generator[j + 1], factor)
}
return result
}
// ── Alignment-pattern centre positions per version ──────────────────────────
function alignmentPositions(version: number): number[] {
if (version === 1) return []
const num = Math.floor(version / 7) + 2
const size = version * 4 + 17
const step = version === 32 ? 26 : Math.ceil((size - 13) / (num * 2 - 2)) * 2
const result = [6]
for (let pos = size - 7; result.length < num; pos -= step) result.unshift(pos)
return result
}
// ── BCH error-correction for format / version info ──────────────────────────
function bch(data: number, poly: number, bits: number): number {
let rem = data
for (let i = 0; i < bits; i++) rem = (rem << 1) ^ ((rem >>> (bits - 1)) * poly)
return rem & ((1 << bits) - 1)
}
// ── Matrix construction ─────────────────────────────────────────────────────
class Matrix {
size: number
modules: boolean[][]
reserved: boolean[][]
constructor(version: number) {
this.size = version * 4 + 17
this.modules = Array.from({ length: this.size }, () => new Array(this.size).fill(false))
this.reserved = Array.from({ length: this.size }, () => new Array(this.size).fill(false))
}
set(x: number, y: number, dark: boolean, reserve = true): void {
this.modules[y][x] = dark
if (reserve) this.reserved[y][x] = true
}
drawFunctionPatterns(version: number): void {
// Timing patterns
for (let i = 0; i < this.size; i++) {
this.set(6, i, i % 2 === 0)
this.set(i, 6, i % 2 === 0)
}
// Finder patterns + separators (3 corners)
this.drawFinder(3, 3)
this.drawFinder(this.size - 4, 3)
this.drawFinder(3, this.size - 4)
// Alignment patterns
const align = alignmentPositions(version)
for (const ay of align) {
for (const ax of align) {
if ((ax === 6 && ay === 6) || (ax === 6 && ay === this.size - 7) || (ax === this.size - 7 && ay === 6)) continue
this.drawAlignment(ax, ay)
}
}
// Dark module
this.set(8, this.size - 8, true)
// Reserve format-info areas (filled later)
this.reserveFormat()
// Version info (v7+)
if (version >= 7) this.drawVersionInfo(version)
}
private drawFinder(cx: number, cy: number): void {
for (let dy = -4; dy <= 4; dy++) {
for (let dx = -4; dx <= 4; dx++) {
const x = cx + dx
const y = cy + dy
if (x < 0 || x >= this.size || y < 0 || y >= this.size) continue
const dist = Math.max(Math.abs(dx), Math.abs(dy))
this.set(x, y, dist !== 2 && dist <= 3)
}
}
}
private drawAlignment(cx: number, cy: number): void {
for (let dy = -2; dy <= 2; dy++) {
for (let dx = -2; dx <= 2; dx++) {
this.set(cx + dx, cy + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1)
}
}
}
private reserveFormat(): void {
for (let i = 0; i < 9; i++) {
this.reserved[i][8] = true
this.reserved[8][i] = true
}
for (let i = 0; i < 8; i++) {
this.reserved[this.size - 1 - i][8] = true
this.reserved[8][this.size - 1 - i] = true
}
}
private drawVersionInfo(version: number): void {
// Version info = 6 data bits + 12 BCH bits (generator 0x1F25, degree 12).
const bits = (version << 12) | bch(version, 0x1f25, 12)
for (let i = 0; i < 18; i++) {
const dark = ((bits >>> i) & 1) === 1
const a = Math.floor(i / 3)
const b = (i % 3) + this.size - 11
this.set(a, b, dark)
this.set(b, a, dark)
}
}
placeData(codewords: number[]): void {
let i = 0
for (let right = this.size - 1; right >= 1; right -= 2) {
if (right === 6) right = 5
for (let vert = 0; vert < this.size; vert++) {
for (let j = 0; j < 2; j++) {
const x = right - j
const upward = ((right + 1) & 2) === 0
const y = upward ? this.size - 1 - vert : vert
if (this.reserved[y][x]) continue
let dark = false
if (i < codewords.length * 8) dark = ((codewords[i >>> 3] >>> (7 - (i & 7))) & 1) === 1
this.modules[y][x] = dark
i++
}
}
}
}
applyMask(mask: number): void {
for (let y = 0; y < this.size; y++) {
for (let x = 0; x < this.size; x++) {
if (this.reserved[y][x]) continue
let invert = false
switch (mask) {
case 0: invert = (x + y) % 2 === 0; break
case 1: invert = y % 2 === 0; break
case 2: invert = x % 3 === 0; break
case 3: invert = (x + y) % 3 === 0; break
case 4: invert = (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0; break
case 5: invert = ((x * y) % 2) + ((x * y) % 3) === 0; break
case 6: invert = (((x * y) % 2) + ((x * y) % 3)) % 2 === 0; break
case 7: invert = (((x + y) % 2) + ((x * y) % 3)) % 2 === 0; break
}
if (invert) this.modules[y][x] = !this.modules[y][x]
}
}
}
drawFormatInfo(ecc: ErrorCorrection, mask: number): void {
const data = (ECC_FORMAT_BITS[ecc] << 3) | mask
const bits = ((data << 10) | bch(data, 0x537, 10)) ^ 0x5412
for (let i = 0; i < 15; i++) {
const dark = ((bits >>> i) & 1) === 1
// Top-left
if (i < 6) this.modules[i][8] = dark
else if (i < 8) this.modules[i + 1][8] = dark
else if (i === 8) this.modules[8][7] = dark
else this.modules[8][14 - i] = dark
// Top-right + bottom-left
if (i < 8) this.modules[8][this.size - 1 - i] = dark
else this.modules[this.size - 15 + i][8] = dark
}
this.modules[this.size - 8][8] = true
}
penalty(): number {
const n = this.size
let p = 0
const m = this.modules
// Rule 1: runs of ≥5 same-colour modules (rows + cols)
for (let y = 0; y < n; y++) {
let runColor = m[y][0]
let runLen = 1
for (let x = 1; x < n; x++) {
if (m[y][x] === runColor) { runLen++; if (runLen === 5) p += 3; else if (runLen > 5) p++ }
else { runColor = m[y][x]; runLen = 1 }
}
}
for (let x = 0; x < n; x++) {
let runColor = m[0][x]
let runLen = 1
for (let y = 1; y < n; y++) {
if (m[y][x] === runColor) { runLen++; if (runLen === 5) p += 3; else if (runLen > 5) p++ }
else { runColor = m[y][x]; runLen = 1 }
}
}
// Rule 2: 2×2 blocks of same colour
for (let y = 0; y < n - 1; y++) {
for (let x = 0; x < n - 1; x++) {
const c = m[y][x]
if (c === m[y][x + 1] && c === m[y + 1][x] && c === m[y + 1][x + 1]) p += 3
}
}
// Rule 3: finder-like patterns (1:1:3:1:1 with 4-module light run)
const pat1 = [true, false, true, true, true, false, true, false, false, false, false]
const pat2 = [false, false, false, false, true, false, true, true, true, false, true]
const lineHas = (line: boolean[]) => {
let count = 0
for (let i = 0; i + 11 <= n; i++) {
let ok1 = true, ok2 = true
for (let k = 0; k < 11; k++) { if (line[i + k] !== pat1[k]) ok1 = false; if (line[i + k] !== pat2[k]) ok2 = false }
if (ok1 || ok2) count++
}
return count
}
for (let y = 0; y < n; y++) p += lineHas(m[y]) * 40
for (let x = 0; x < n; x++) { const col = m.map((row) => row[x]); p += lineHas(col) * 40 }
// Rule 4: dark-module proportion
let dark = 0
for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) if (m[y][x]) dark++
const pct = (dark * 100) / (n * n)
p += Math.floor(Math.abs(pct - 50) / 5) * 10
return p
}
}
// ── Assemble interleaved data + ECC codewords ───────────────────────────────
function buildCodewords(dataCodewords: number[], version: number, ecc: ErrorCorrection): number[] {
const o = ECC_ORDER.indexOf(ecc)
const numBlocks = NUM_ERROR_CORRECTION_BLOCKS[o][version]
const eccLen = ECC_CODEWORDS_PER_BLOCK[o][version]
const rawCodewords = Math.floor(getNumRawDataModules(version) / 8)
const numShort = numBlocks - (rawCodewords % numBlocks)
const shortLen = Math.floor(rawCodewords / numBlocks) - eccLen
const generator = rsGenerator(eccLen)
const blocks: { data: number[]; ecc: number[] }[] = []
let k = 0
for (let b = 0; b < numBlocks; b++) {
const len = shortLen + (b < numShort ? 0 : 1)
const data = dataCodewords.slice(k, k + len)
k += len
blocks.push({ data, ecc: rsRemainder(data, generator) })
}
const result: number[] = []
// Interleave data codewords (short blocks pad with a hole at shortLen)
for (let i = 0; i <= shortLen; i++) {
for (let b = 0; b < numBlocks; b++) {
if (i < shortLen || b >= numShort) result.push(blocks[b].data[i])
}
}
// Interleave ECC codewords
for (let i = 0; i < eccLen; i++) {
for (let b = 0; b < numBlocks; b++) result.push(blocks[b].ecc[i])
}
return result
}
// ── Public API ──────────────────────────────────────────────────────────────
/** Encode `value` to a QR matrix. Throws if it doesn't fit in maxVersion. */
export function encode(value: string, options: EncodeOptions = {}): QrResult {
const ecc = options.errorCorrection ?? 'M'
const minV = Math.max(MIN_VERSION, options.minVersion ?? MIN_VERSION)
const maxV = Math.min(MAX_VERSION, options.maxVersion ?? MAX_VERSION)
const seg = makeSegment(value)
// Smallest version that fits.
let version = -1
let dataCapacityBits = 0
for (let v = minV; v <= maxV; v++) {
dataCapacityBits = getNumDataCodewords(v, ecc) * 8
const used = 4 + charCountBits(seg.mode, v) + seg.data.bits.length
if (used <= dataCapacityBits) {
version = v
break
}
}
if (version === -1) throw new Error('qr: data too long for the requested version range')
// Assemble the bit stream.
const bb = new BitBuffer()
bb.append(seg.mode.indicator, 4)
bb.append(seg.numChars, charCountBits(seg.mode, version))
bb.bits.push(...seg.data.bits)
// Terminator + bit padding to a byte boundary.
const capacity = getNumDataCodewords(version, ecc) * 8
bb.append(0, Math.min(4, capacity - bb.bits.length))
while (bb.bits.length % 8 !== 0) bb.bits.push(0)
// Byte padding.
for (let pad = 0xec; bb.bits.length < capacity; pad ^= 0xec ^ 0x11) bb.append(pad, 8)
const dataCodewords: number[] = []
for (let i = 0; i < bb.bits.length; i += 8) {
let byte = 0
for (let j = 0; j < 8; j++) byte = (byte << 1) | bb.bits[i + j]
dataCodewords.push(byte)
}
const allCodewords = buildCodewords(dataCodewords, version, ecc)
// Build matrix, try all 8 masks, keep the lowest-penalty one.
let best: Matrix | null = null
let bestPenalty = Infinity
let bestMask = 0
for (let mask = 0; mask < 8; mask++) {
const matrix = new Matrix(version)
matrix.drawFunctionPatterns(version)
matrix.placeData(allCodewords)
matrix.applyMask(mask)
matrix.drawFormatInfo(ecc, mask)
const pen = matrix.penalty()
if (pen < bestPenalty) {
bestPenalty = pen
best = matrix
bestMask = mask
}
}
const chosen = best!
void bestMask
return {
matrix: chosen.modules,
reserved: chosen.reserved,
size: chosen.size,
version,
errorCorrection: ecc
}
}

@ -0,0 +1,116 @@
# QrCode
Themeable, accessible QR code — **own zero-dependency encoder**, framework
4-layer rigor, richer than the references it learns from.
> **Design proposal** (approved 2026-06-12). This README is both the design
> record and the component doc.
## Why a new one (vs the references)
| Capability | ark-ui (zag + `uqr`) | qr-code-styling | react-qr-code | **QrCode (ours)** |
| --- | --- | --- | --- | --- |
| Parts / anatomy | ✅ Root·Frame·Pattern·Overlay·Download | ❌ imperative | ❌ single comp | ✅ |
| Encoder | `uqr` dep | qr-code-generator dep | qr.js dep | **own, zero-dep (`$libs/qr`)** |
| Error correction L/M/Q/H | ✅ | ✅ | ✅ | ✅ |
| Themeable colour (tokens) | ❌ `currentColor` only | ✅ free | fg/bg props | ✅ **via tokens + `color`** |
| Cell shapes (square/rounded/dots) | ❌ | ✅ | ❌ | ✅ |
| Logo overlay | ✅ | ✅ | partial | ✅ **+ auto quiet-zone + auto-boost ECC** |
| Download PNG/SVG | ✅ | ✅ | manual | ✅ **DownloadTrigger = `<Button>`** |
| `role=img` + label a11y | partial | ❌ | ❌ | ✅ |
| Framework morfo/recipe rigor | n/a | n/a | n/a | ✅ |
**Takeaway:** ark has the best *structure* but is visually flat and pulls an
encoder dependency; qr-code-styling is visually rich but imperative, dependency-
heavy, no a11y. Ours keeps ark's clean anatomy + API, owns the encoder (the QR
algorithm is the open ISO/IEC 18004 standard — no npm dep), and adds themeable
colour, cell shapes, a scannable logo overlay and real a11y.
## Encoder — `$libs/qr` (own, zero-dep)
`encode(value, { errorCorrection, minVersion?, maxVersion? }) → { matrix:
boolean[][], reserved: boolean[][], size, version, errorCorrection }`. A
from-scratch QR encoder (segment analysis → Reed-Solomon ECC over GF(256) →
matrix layout → mask selection over all 8 masks by penalty), pure and
zero-dependency, living in `$libs` alongside the other pure helpers. The
algorithm follows the public QR standard; `uqr`/nayuki are correctness
references only, never imported. Unit-tested against known vectors + structural
invariants; the demo harness round-trips encode→decode through `jsQR`
(versions 1–24, all ECC, all modes, all four cell shapes).
`reserved[y][x]` marks the **function modules** (finders, timing, alignment,
format/version info) — the eidos layer keeps those square even when
`cellShape` is `rounded`/`dots`, so the scanner's grid-lock pattern stays
intact and every shape remains scannable. Only the data modules take the
decorative shape.
## Scannability (verified)
- **Function patterns stay square** for all cell shapes (`reserved` mask) — what
`qr-code-styling` does, and the reason rounded/dots still decode.
- **Logo** auto-boosts ECC to `H` and clears a central ~24% region so the lost
modules fall within the error-correction budget.
- **Fixed-tone default colours** (dark-on-light) — a QR can't invert in dark
mode and stay scannable, so the default fg/bg are theme-independent; theming
is opt-in via `color`/`background` (you own the contrast).
- **Export bakes resolved colours** — a serialized SVG loses the document's CSS
custom properties, so `DownloadTrigger` resolves `getComputedStyle` fills into
the clone before writing PNG/SVG.
- **Logo in exports** — SVG export is lossless (the logo is XML and travels with
it). PNG export omits the logo: browsers do not rasterize `<foreignObject>`
content when an SVG is drawn to a canvas via `<img>` (a Chromium limitation,
not a taint — the QR itself rasterizes fine). The cleared centre region stays,
but the overlay is dropped. Use SVG to keep the logo. (A future PNG path could
composite a raster logo directly onto the canvas; deferred — the `logo` slot is
an arbitrary snippet, not a URL.)
## Anatomy (parts)
```
<QrCode value="https://…"> ← Frame (svg, role="img", aria-label)
└ Pattern ← the <path> matrix (decorative)
└ Overlay ← centred logo slot (optional)
<QrCode.DownloadTrigger> ← <Button> → PNG / SVG (optional)
```
morfo `scope: ['eidos']` (+ optional `sema` for a `commit-download` tap cue).
**No soma** — a QR is a pure display (like Avatar / Image): `value` → matrix in
a `$derived`, rendered to SVG. Download is imperative via `$adom`.
## API (root)
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `value` | `string` | `''` | data to encode (bindable) |
| `size` | `ResponsiveProp<number \| Size>` | `'md'` | rendered box size |
| `errorCorrection` | `'L' \| 'M' \| 'Q' \| 'H'` | `'M'` | redundancy (auto-boosted to `H` when a logo is present) |
| `color` | `AffirmativeColorRole \| string` | `currentColor`/token | module colour |
| `background` | `string` | token / transparent | quiet-zone + module background |
| `cellShape` | `'square' \| 'rounded' \| 'dots'` | `'square'` | module shape |
| `margin` | `number` | `4` | quiet zone in modules (spec minimum 4) |
| `logo` | `Snippet` | — | centred overlay; clears the centre + boosts ECC to `H` |
| `alt` | `string` | `value` | accessible name for the Frame |
`DownloadTrigger` props: `format: 'png' \| 'svg'` (default `'png'`), `filename`
(default `'qrcode'`) + all `<Button>` props (`variant`, `size`, `color`, …).
It renders the framework `<Button>` and calls the root's colour-baking export
via context.
Compound: `<QrCode>` + `.DownloadTrigger`. The logo is the `logo` snippet prop
(not a subcomponent); the morfo's Overlay/Pattern parts are render internals.
## v1 scope (approved)
square/rounded/dots cell shapes · themeable colour · logo Overlay with auto
quiet-zone + auto-ECC-boost · download PNG + SVG · `role=img` + label.
**Deferred to v2:** gradient fills, per-corner (finder) styling — the
marketing-grade flourishes of qr-code-styling.
## Layers
- **`$libs/qr`** — own encoder + tests.
- **morfo** `qr-code.ts` — Frame / Pattern / Overlay / DownloadTrigger contract.
- **eidos** `qr-code/` — wrapper + recipe (`--qr-code-*` tokens) + SVG render
from the matrix.
- **demo** `web/routes/uix/components/qr-code/` + nav entry.

@ -0,0 +1,17 @@
import { getContext, setContext } from 'svelte';
import type { QrCodeDownloadFormat } from './types';
const QR_CODE_KEY = Symbol('qr-code');
export type QrCodeContext = {
/** Export the rendered code (colours baked in) as PNG or SVG. */
download: (format: QrCodeDownloadFormat, filename?: string) => Promise<void>;
};
export function setQrCodeContext(ctx: QrCodeContext): void {
setContext(QR_CODE_KEY, ctx);
}
export function getQrCodeContext(): QrCodeContext | undefined {
return getContext(QR_CODE_KEY);
}

@ -0,0 +1,31 @@
// QrCode — eidos compound API. Themeable QR from the own zero-dep encoder
// (`$libs/qr`).
//
// import { QrCode } from '$uix/eidos/components/qr-code';
//
// <QrCode value="https://…" cellShape="rounded" color="#7c3aed">
// {#snippet logo()}<img src={brand} alt="" />{/snippet}
// <QrCode.DownloadTrigger format="png">Download</QrCode.DownloadTrigger>
// </QrCode>
import QrCodeComponent from './qr-code.svelte';
import DownloadTrigger from './qr-code-download-trigger.svelte';
type QrCodeNamespace = typeof QrCodeComponent & {
DownloadTrigger: typeof DownloadTrigger;
};
const QrCode = QrCodeComponent as QrCodeNamespace;
QrCode.DownloadTrigger = DownloadTrigger;
export { QrCode };
export default QrCode;
export type {
QrCodeProps,
QrCodeSize,
QrCodeCellShape,
QrCodeDownloadFormat,
QrCodeDownloadTriggerProps,
ErrorCorrection
} from './types';

@ -0,0 +1,23 @@
<script lang="ts">
/**
* `<QrCode.DownloadTrigger>` — exports the code as PNG / SVG via the
* QrCode context (the root owns the svg ref + colour-baking). Renders the
* framework `<Button>`.
*/
import { Button } from '$uix/eidos/components/button';
import { getQrCodeContext } from './context';
import type { QrCodeDownloadTriggerProps } from './types';
let { format = 'png', filename = 'qrcode', children, ...rest }: QrCodeDownloadTriggerProps = $props();
const ctx = getQrCodeContext();
</script>
<Button
type="button"
data-qr-code-download-trigger=""
onclick={() => ctx?.download(format, filename)}
{...rest}
>
{@render children?.()}
</Button>

@ -0,0 +1,31 @@
/* QrCode — the module path is generated in the wrapper; CSS owns the frame
* chrome + the logo overlay. Default fg/bg are fixed-tone (scannability — see
* the recipe note); `color` / `background` props override the tokens inline. */
[data-qr-code] {
display: block;
border-radius: var(--qr-code-radius);
max-width: 100%;
height: auto;
}
/* Logo overlay — fills the cleared centre (a foreignObject), centres the
* media, paints its own light backing so it reads over the modules. */
[data-qr-code-overlay] {
box-sizing: border-box;
inline-size: 100%;
block-size: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: var(--qr-code-overlay-bg);
border-radius: var(--qr-code-overlay-radius);
padding: var(--qr-code-overlay-padding);
}
[data-qr-code-overlay] :where(img, svg) {
inline-size: 100%;
block-size: 100%;
object-fit: contain;
}

@ -0,0 +1,156 @@
<script lang="ts">
/**
* Eidos `<QrCode>` — themeable QR from the own zero-dep encoder (`$libs/qr`).
* Renders the module matrix as a single SVG `<path>` (decorative); the svg
* is `role="img"` with `aria-label`. Finder patterns stay square for any
* `cellShape` so scanners lock on. A `logo` clears the centre + boosts ECC
* to H so it stays scannable.
*/
import './qr-code.css';
import { ActiveEidos } from '$uix/eidos';
import { encode } from '$libs/qr';
import { setQrCodeContext } from './context';
import type { QrCodeProps, QrCodeDownloadFormat } from './types';
let {
value = '',
size = 'md',
errorCorrection = 'M',
color,
background,
cellShape = 'square',
margin = 4,
alt,
logo,
children,
...rest
}: QrCodeProps = $props();
const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size, 'md'));
const px = $derived(
typeof resolvedSize === 'number' ? `${resolvedSize}px` : `var(--qr-code-size-${resolvedSize})`
);
// A logo needs max redundancy + a cleared centre to stay scannable.
const ecc = $derived(logo ? 'H' : errorCorrection);
const qr = $derived(encode(value, { errorCorrection: ecc }));
const dim = $derived(qr.size + margin * 2);
const clear = $derived(logo ? Math.max(1, Math.round(qr.size * 0.24)) : 0);
const clearLo = $derived(Math.floor((qr.size - clear) / 2));
const clearHi = $derived(clearLo + clear);
function cell(x: number, y: number, shape: string): string {
const cx = x + margin;
const cy = y + margin;
if (shape === 'dots') {
const r = 0.5;
return `M${cx + 0.5 - r},${cy + 0.5}a${r},${r} 0 1,0 ${2 * r},0a${r},${r} 0 1,0 ${-2 * r},0z`;
}
if (shape === 'rounded') {
const rr = 0.3;
const s = 1 - 2 * rr;
return `M${cx + rr},${cy}h${s}a${rr},${rr} 0 0 1 ${rr},${rr}v${s}a${rr},${rr} 0 0 1 ${-rr},${rr}h${-s}a${rr},${rr} 0 0 1 ${-rr},${-rr}v${-s}a${rr},${rr} 0 0 1 ${rr},${-rr}z`;
}
return `M${cx},${cy}h1v1h-1z`;
}
const path = $derived.by(() => {
const m = qr.matrix;
const fn = qr.reserved;
const n = qr.size;
let d = '';
for (let y = 0; y < n; y++) {
for (let x = 0; x < n; x++) {
if (!m[y][x]) continue;
if (clear && x >= clearLo && x < clearHi && y >= clearLo && y < clearHi) continue;
// Function patterns stay square so scanners lock on; data takes the shape.
d += cell(x, y, fn[y][x] ? 'square' : cellShape);
}
}
return d;
});
const overlayPos = $derived((dim - clear) / 2);
const frameStyle = $derived(
[
`width: ${px}`,
`height: ${px}`,
color ? `--qr-code-fg: ${color}` : '',
background ? `--qr-code-bg: ${background}` : ''
]
.filter(Boolean)
.join('; ')
);
let svgEl: SVGSVGElement | null = $state(null);
// Export the code (PNG / SVG). The serialized SVG loses the document's CSS
// custom properties, so we bake the resolved fills into the clone first.
async function download(format: QrCodeDownloadFormat, filename = 'qrcode'): Promise<void> {
if (!svgEl) return;
const doc = eidos.dom.getDocument(svgEl);
const win = eidos.dom.getWindow(svgEl) as Window & typeof globalThis;
const clone = svgEl.cloneNode(true) as SVGSVGElement;
const srcRect = svgEl.querySelector('rect');
const srcPath = svgEl.querySelector('path');
if (srcRect) clone.querySelector('rect')?.setAttribute('fill', win.getComputedStyle(srcRect).fill);
if (srcPath) clone.querySelector('path')?.setAttribute('fill', win.getComputedStyle(srcPath).fill);
const exportPx = Math.max(512, dim * 12);
clone.setAttribute('width', String(exportPx));
clone.setAttribute('height', String(exportPx));
const xml = new XMLSerializer().serializeToString(clone);
const click = (url: string, name: string) => {
const a = doc.createElement('a');
a.href = url;
a.download = name;
a.style.display = 'none';
doc.body.appendChild(a);
a.click();
a.remove();
eidos.timers.schedule(`qr-code-revoke-${name}`, 2000, () => win.URL.revokeObjectURL(url));
};
if (format === 'svg') {
click(
win.URL.createObjectURL(new Blob([xml], { type: 'image/svg+xml;charset=utf-8' })),
`${filename}.svg`
);
return;
}
const img = new win.Image();
img.src = 'data:image/svg+xml;base64,' + win.btoa(unescape(encodeURIComponent(xml)));
await img.decode();
const canvas = doc.createElement('canvas');
canvas.width = exportPx;
canvas.height = exportPx;
canvas.getContext('2d')?.drawImage(img, 0, 0, exportPx, exportPx);
canvas.toBlob((blob) => {
if (blob) click(win.URL.createObjectURL(blob), `${filename}.png`);
}, 'image/png');
}
setQrCodeContext({ download });
</script>
<svg
bind:this={svgEl}
data-qr-code=""
data-cell-shape={cellShape}
role="img"
aria-label={alt ?? value}
viewBox="0 0 {dim} {dim}"
shape-rendering={cellShape === 'square' ? 'crispEdges' : 'geometricPrecision'}
style={frameStyle}
{...rest}
>
<rect width={dim} height={dim} fill="var(--qr-code-bg)" />
<path d={path} fill="var(--qr-code-fg)" />
{#if logo}
<foreignObject x={overlayPos} y={overlayPos} width={clear} height={clear}>
<div data-qr-code-overlay xmlns="http://www.w3.org/1999/xhtml">{@render logo()}</div>
</foreignObject>
{/if}
</svg>
{@render children?.()}

@ -0,0 +1,46 @@
import type { Snippet } from 'svelte';
import type { ResponsiveProp, Size } from '$uix/eidos/lib/types';
import type { ErrorCorrection } from '$libs/qr';
/** QrCode size preset (maps to `--qr-code-size-*`); a raw number is px. */
export type QrCodeSize = Extract<Size, 'xs' | 'sm' | 'md' | 'lg' | 'xl'>;
/** Module shape. Finder patterns always stay square so scanners lock on. */
export type QrCodeCellShape = 'square' | 'rounded' | 'dots';
export type { ErrorCorrection };
export type QrCodeProps = {
/** Data to encode. @default '' */
value?: string;
/** Rendered box size — a preset (`'md'`) or raw px number. @default 'md' */
size?: ResponsiveProp<number | QrCodeSize>;
/** Redundancy. Auto-boosted to `'H'` when a `logo` is present. @default 'M' */
errorCorrection?: ErrorCorrection;
/** Module colour (themeable; you own the contrast). @default fixed dark token */
color?: string;
/** Quiet-zone + module background. @default fixed light token */
background?: string;
/** Module shape (finders stay square). @default 'square' */
cellShape?: QrCodeCellShape;
/** Quiet zone in modules (spec minimum 4). @default 4 */
margin?: number;
/** Accessible name for the Frame. @default value */
alt?: string;
/** Centred logo / media slot. Auto-clears the cells behind it + boosts ECC. */
logo?: Snippet;
/** Compound children (e.g. `<QrCode.DownloadTrigger>`). */
children?: Snippet;
[key: string]: unknown;
};
export type QrCodeDownloadFormat = 'png' | 'svg';
export type QrCodeDownloadTriggerProps = {
/** Export format. @default 'png' */
format?: QrCodeDownloadFormat;
/** Download filename (without extension). @default 'qrcode' */
filename?: string;
children?: Snippet;
[key: string]: unknown;
};

@ -1898,6 +1898,17 @@
--radio-group-label-font-weight: var(--font-weight-medium);
--radio-group-label-line-height: var(--leading-ui);
--radio-group-label-color: var(--color-content-primary);
--qr-code-fg: #18181b;
--qr-code-bg: #ffffff;
--qr-code-size-xs: 96px;
--qr-code-size-sm: 128px;
--qr-code-size-md: 160px;
--qr-code-size-lg: 200px;
--qr-code-size-xl: 256px;
--qr-code-radius: var(--radius-md);
--qr-code-overlay-bg: #ffffff;
--qr-code-overlay-padding: var(--space-1);
--qr-code-overlay-radius: var(--radius-sm);
--radio-cards-gap: var(--space-3);
--radio-cards-horizontal-basis: 12rem;
--radio-cards-padding-xs: var(--space-3);

@ -1937,6 +1937,23 @@ export const THEME_BASE_RECIPE_TOKENS = {
},
'_palette-text': { value: 'var(--radio-group-dot-color)', scope: 'host' }
},
// QrCode. Default fg/bg are FIXED-TONE (dark modules on a light backing),
// not theme tokens: a QR must stay dark-on-light to scan — inverting it in
// dark mode would break scanners. Theming goes through the `color` /
// `background` props (consumer owns the contrast).
'qr-code': {
fg: '#18181b',
bg: '#ffffff',
'size-xs': '96px',
'size-sm': '128px',
'size-md': '160px',
'size-lg': '200px',
'size-xl': '256px',
radius: 'var(--radius-md)',
'overlay-bg': '#ffffff',
'overlay-padding': 'var(--space-1)',
'overlay-radius': 'var(--radius-sm)'
},
// RadioCards — card-style skin over RadioGroup. Reuses RadioGroup's
// palette (`--_radio-group-palette-solid`, declared on the shared
// [data-radio-group] host) and `--radio-group-focus-ring`; adds only the

@ -0,0 +1,91 @@
import type { Morfo } from '../types';
import { v } from '../types';
/**
* QrCode — themeable QR code rendered from the own zero-dependency encoder
* (`$libs/qr`). Eidos-native display primitive (like Avatar / Image): a
* `value` → module matrix → SVG. No soma — there is no interactive state, just
* a derived render. The optional DownloadTrigger is the only action.
*
* `scope: ['eidos']`. The Frame is the root `<svg role="img">`; the Pattern is
* the module `<path>` (decorative — the Frame's `aria-label` carries the
* meaning); the Overlay is a centred logo slot; the DownloadTrigger exports the
* code as PNG / SVG.
*
* 0-event surface (like Badge): the only act is downloading, expressed by the
* consumer's handler on the DownloadTrigger.
*/
export const qrCodeMorfo = {
name: 'QrCode',
kebab: 'qr-code',
scope: ['eidos'],
apg: 'https://www.w3.org/WAI/ARIA/apg/#img',
texts: {
download: '#?components.qr-code.download|Download QR code'
},
parts: [
{
// Frame — the root `<svg>`. `role="img"` + `aria-label` make the QR a
// single labelled graphic; the module pattern inside is decorative.
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'svg',
role: 'img',
optional: false,
data: [
{ attr: 'data-cell-shape', values: ['square', 'rounded', 'dots'] }
],
aria: [
{
attr: 'aria-label',
value: v.propRef('alt'),
severity: 'recommended'
}
]
},
{
// The module matrix as one `<path>`. Decorative — aria-hidden.
name: 'Pattern',
kebab: 'pattern',
archetype: 'indicator',
kind: 'public',
defaultElement: 'path',
optional: false,
data: [],
aria: [{ attr: 'aria-hidden', value: v.literal('true') }]
},
{
// Centred logo / media slot. Decorative — the cells behind it are
// cleared (quiet zone) and ECC is auto-boosted so it stays scannable.
name: 'Overlay',
kebab: 'overlay',
archetype: 'indicator',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: [{ attr: 'aria-hidden', value: v.literal('true') }]
},
{
// Export affordance — `<button>` that downloads the code (PNG / SVG).
name: 'DownloadTrigger',
kebab: 'download-trigger',
archetype: 'trigger',
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: true,
data: [],
aria: [
{ attr: 'type', value: v.literal('button') },
{
attr: 'aria-label',
value: v.translationRef('#?components.qr-code.download|Download QR code'),
severity: 'recommended'
}
]
}
]
} as const satisfies Morfo;

@ -76,6 +76,7 @@ export type MorfoElement =
| 'footer'
| 'img'
| 'svg'
| 'path'
| 'label'
| 'form'
| 'fieldset'

@ -212,7 +212,8 @@
{ slug: '/uix/components/image-adjustments', label: 'Image adjustments' },
{ slug: '/uix/components/image-picker', label: 'Image picker' },
{ slug: '/uix/components/cropper', label: 'Cropper' },
{ slug: '/uix/components/carousel', label: 'Carousel' }
{ slug: '/uix/components/carousel', label: 'Carousel' },
{ slug: '/uix/components/qr-code', label: 'QR code' }
]
},
{

@ -0,0 +1,269 @@
<script lang="ts">
import {
QrCode,
type QrCodeSize,
type QrCodeCellShape,
type ErrorCorrection
} from '$uix/eidos/components/qr-code';
import { compileMorfo } from '$uix/morfo';
import { qrCodeMorfo } from '@/uix/morfo/components/qr-code';
type Tab = 'live' | 'examples' | 'api' | 'morfo' | 'recipe' | 'a11y';
let tab = $state<Tab>('live');
let value = $state('https://uix.dev/qr-code');
let size = $state<QrCodeSize>('lg');
let cellShape = $state<QrCodeCellShape>('square');
let errorCorrection = $state<ErrorCorrection>('M');
let margin = $state(4);
let color = $state<string | undefined>(undefined);
let withLogo = $state(false);
const sizes: QrCodeSize[] = ['xs', 'sm', 'md', 'lg', 'xl'];
const shapes: QrCodeCellShape[] = ['square', 'rounded', 'dots'];
const eccs: ErrorCorrection[] = ['L', 'M', 'Q', 'H'];
const colors: { label: string; value: string | undefined }[] = [
{ label: 'default', value: undefined },
{ label: 'violet', value: '#7c3aed' },
{ label: 'teal', value: '#0d9488' },
{ label: 'rose', value: '#e11d48' }
];
const compiled = compileMorfo(qrCodeMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const snippet = $derived(
[
"<script lang='ts'>",
" import { QrCode } from '$uix/eidos/components/qr-code';",
'</' + 'script>',
'',
'<QrCode',
` value="${value}"`,
size !== 'md' && ` size="${size}"`,
cellShape !== 'square' && ` cellShape="${cellShape}"`,
errorCorrection !== 'M' && ` errorCorrection="${errorCorrection}"`,
margin !== 4 && ` margin={${margin}}`,
color && ` color="${color}"`,
'>',
withLogo && ' {#snippet logo()}<img src={brand} alt="" />{/snippet}',
' <QrCode.DownloadTrigger format="png">Download</QrCode.DownloadTrigger>',
'</QrCode>'
]
.filter(Boolean)
.join('\n')
);
</script>
<svelte:head>
<meta name="perm-axes" content="" />
</svelte:head>
{#snippet brandLogo()}
<svg viewBox="0 0 24 24" fill={color ?? '#18181b'} aria-hidden="true">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
</svg>
{/snippet}
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Display · QR code</div>
<h1 data-uix-page-title>QR code</h1>
<p data-uix-page-lede>
Themeable QR rendered from an <strong>own zero-dependency encoder</strong> (`$libs/qr` —
ISO/IEC 18004, versions 1–40, all ECC levels). Cell shapes (square / rounded / dots) keep
the function patterns square so any scanner locks on; an optional logo clears the centre and
auto-boosts ECC to H; export to PNG / SVG. No npm encoder dependency.
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill><span data-uix-meta-key>encoder</span>own · 0-dep</span>
<span data-uix-meta-pill><span data-uix-meta-key>parts</span>{compiled.parts.order.length}</span>
<span data-uix-meta-pill><span data-uix-meta-key>versions</span>1–40</span>
<span data-uix-meta-pill><span data-uix-meta-key>ecc</span>L · M · Q · H</span>
</div>
</header>
<div data-uix-stage>
<div data-uix-stage-area style="display: flex; align-items: center; gap: 1.5rem; flex-wrap: wrap;">
<QrCode {value} {size} {cellShape} {errorCorrection} {margin} {color} logo={withLogo ? brandLogo : undefined}>
<span style="display: inline-flex; gap: 0.5rem; margin-inline-start: 0.5rem;">
<QrCode.DownloadTrigger format="png" size="sm">PNG</QrCode.DownloadTrigger>
<QrCode.DownloadTrigger format="svg" size="sm" variant="solid" color="neutral">SVG</QrCode.DownloadTrigger>
</span>
</QrCode>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>encodes</span>
<span><span data-uix-stage-trace-event>{value || '∅'}</span></span>
<span style="margin-inline-start: auto;"><span data-uix-stage-trace-key>ecc</span> {withLogo ? 'H (logo)' : errorCorrection}</span>
</div>
</div>
<div data-uix-tabs role="tablist">
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
<button data-uix-tab data-active={tab === 'examples'} onclick={() => (tab = 'examples')}>Examples</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>API</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p</span>
</button>
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
{#if tab === 'live'}
<section data-uix-section>
<h2 data-uix-section-title>Controls</h2>
<p data-uix-section-desc>Pure <span data-uix-layer-badge="eidos">eidos</span> display — no soma. <code>value</code> → matrix → SVG.</p>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>value</span>
<input type="text" bind:value />
</label>
<label data-uix-control>
<span data-uix-control-label>cellShape</span>
<span data-uix-chips role="radiogroup">
{#each shapes as s}
<button data-uix-chip data-active={cellShape === s} onclick={() => (cellShape = s)}>{s}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>size</span>
<span data-uix-chips role="radiogroup">
{#each sizes as s}
<button data-uix-chip data-active={size === s} onclick={() => (size = s)}>{s}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>errorCorrection</span>
<span data-uix-chips role="radiogroup">
{#each eccs as e}
<button data-uix-chip data-active={errorCorrection === e} onclick={() => (errorCorrection = e)}>{e}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>color</span>
<span data-uix-chips role="radiogroup">
{#each colors as c}
<button data-uix-chip data-active={color === c.value} onclick={() => (color = c.value)}>{c.label}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>margin (quiet zone)</span>
<span data-uix-chips role="radiogroup">
{#each [2, 4, 6] as m}
<button data-uix-chip data-active={margin === m} onclick={() => (margin = m)}>{m}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>logo (→ ECC H)</span>
<span data-uix-switch><input type="checkbox" bind:checked={withLogo} /><span data-uix-switch-label>{withLogo ? 'on' : 'off'}</span></span>
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>display + DownloadTrigger</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{snippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'examples'}
<section data-uix-section>
<h2 data-uix-section-title>Examples</h2>
<p data-uix-section-desc>Cell shapes, themed colour, logo — all stay scannable (function patterns square, ECC auto-boost).</p>
<div data-uix-stage-area style="display: flex; gap: 1.5rem; flex-wrap: wrap;">
<QrCode value="https://uix.dev" size="md" />
<QrCode value="https://uix.dev" size="md" cellShape="rounded" color="#7c3aed" />
<QrCode value="https://uix.dev" size="md" cellShape="dots" color="#0d9488" />
<QrCode value="https://uix.dev" size="md" cellShape="rounded" color="#e11d48" logo={brandLogo} />
</div>
</section>
{/if}
{#if tab === 'api'}
<section data-uix-section>
<h2 data-uix-section-title>API reference</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="name">value</td><td class="type">string</td><td class="default">''</td><td>Data to encode.</td></tr>
<tr><td class="name">size</td><td class="type">ResponsiveProp&lt;number | 'xs'…'xl'&gt;</td><td class="default">'md'</td><td>Box size (px or preset).</td></tr>
<tr><td class="name">cellShape</td><td class="type">'square' | 'rounded' | 'dots'</td><td class="default">'square'</td><td>Data-module shape; finders stay square.</td></tr>
<tr><td class="name">errorCorrection</td><td class="type">'L' | 'M' | 'Q' | 'H'</td><td class="default">'M'</td><td>Redundancy. Auto-boosted to 'H' with a logo.</td></tr>
<tr><td class="name">color / background</td><td class="type">string</td><td class="default">fixed tokens</td><td>Themeable (you own the contrast).</td></tr>
<tr><td class="name">margin</td><td class="type">number</td><td class="default">4</td><td>Quiet zone in modules.</td></tr>
<tr><td class="name">logo</td><td class="type">Snippet</td><td class="default empty">—</td><td>Centred overlay; clears cells + boosts ECC.</td></tr>
<tr><td class="name">alt</td><td class="type">string</td><td class="default">value</td><td>Accessible name.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title><span data-uix-layer-badge="morfo">morfo</span> · declarative contract</h2>
<p data-uix-section-desc>Source: <code>src/uix/morfo/components/qr-code.ts</code>. <code>scope:['eidos']</code> — a display primitive, no soma.</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Optional</th></tr></thead>
<tbody>
{#each partsList as part}
<tr>
<td class="name">{part.kebab}</td>
<td><code data-uix-part-marker>[{part.marker}]</code></td>
<td class="type">&lt;{part.defaultElement}&gt;</td>
<td class="type">{part.role ?? '—'}</td>
<td class="default">{part.optional ? 'yes' : 'no'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<p data-uix-section-desc>Tokens at <code>lib/recipes/base.ts</code>. Default fg/bg are <strong>fixed-tone</strong> (dark-on-light) for scannability — a QR can't invert in dark mode; theming goes through <code>color</code>/<code>background</code>.</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Token</th><th>Role</th></tr></thead>
<tbody>
<tr><td class="name">--qr-code-fg / --qr-code-bg</td><td>module colour / quiet-zone (fixed-tone)</td></tr>
<tr><td class="name">--qr-code-size-{'{'}xs…xl{'}'}</td><td>size presets</td></tr>
<tr><td class="name">--qr-code-radius</td><td>frame radius</td></tr>
<tr><td class="name">--qr-code-overlay-bg / -padding / -radius</td><td>logo backing</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Attribute</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">frame (svg)</td><td>role / aria-label</td><td class="type">"img" / alt ?? value</td></tr>
<tr><td class="name">pattern / overlay</td><td>aria-hidden</td><td class="type">"true" (decorative)</td></tr>
<tr><td class="name">download-trigger</td><td>type / aria-label</td><td class="type">"button" / "Download QR code"</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>
Loading…
Cancel
Save

Powered by TurnKey Linux.