6 Commits

Author SHA1 Message Date
steve 0ee95dbb09 chore: bump version to 0.4.5
Build and push Docker image / build-push (push) Has been cancelled
2026-04-13 08:28:46 -06:00
steve a2de7e2d9d feat(layout): add grid, safe zone, and MICR anchor alignment
- Draw 1/8" grid overlay on layout editor canvas
- Anchor MICR second transit symbol at 2 59/64" from left
- Clamp draggable fields to printing safe zone (11/64" sides, 13/64" top, 0.5" bottom)
- Render dashed safe-zone outline on layout canvas
2026-04-13 08:06:23 -06:00
steve c4e4a8c246 chore: bump version to 0.4.3
Build and push Docker image / build-push (push) Has been cancelled
2026-04-11 10:23:17 -06:00
steve b692791436 fix: align account access checkboxes with fixed grid layout
Use a 3-column inner grid (checkbox | label | dropdown) with fixed
64px row height for consistent alignment across all account entries.
2026-04-11 10:23:15 -06:00
steve 37d70b4d82 chore: bump version to 0.4.2
Build and push Docker image / build-push (push) Has been cancelled
2026-04-11 09:51:20 -06:00
steve 7d854d4e01 fix: clean up account access grid and separate OIDC fields in user form
Use CSS grid for uniform account checkbox layout and add a bordered
subsection with heading for the OIDC identity fields.
2026-04-11 09:51:18 -06:00
6 changed files with 110 additions and 31 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ezcheck", "name": "ezcheck",
"version": "0.4.1", "version": "0.4.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ezcheck", "name": "ezcheck",
"version": "0.4.1", "version": "0.4.5",
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"better-sqlite3": "^9.4.3", "better-sqlite3": "^9.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ezcheck", "name": "ezcheck",
"version": "0.4.1", "version": "0.4.5",
"description": "Self-hosted check printing web app", "description": "Self-hosted check printing web app",
"main": "src/app.js", "main": "src/app.js",
"scripts": { "scripts": {
+35 -5
View File
@@ -867,19 +867,49 @@ input[type="file"] {
} }
/* ── User management ── */ /* ── User management ── */
.account-checkboxes { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; } .account-checkboxes {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 6px;
margin-top: 4px;
}
.account-checkbox-label { .account-checkbox-label {
display: flex; display: grid;
grid-template-columns: 20px 1fr auto;
align-items: center; align-items: center;
gap: 5px; gap: 8px;
font-size: 12px; font-size: 12px;
background: var(--bg); background: var(--bg);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 4px; border-radius: 6px;
padding: 3px 8px; padding: 6px 10px;
cursor: pointer; cursor: pointer;
height: 64px;
} }
.account-checkbox-label:hover { border-color: var(--primary); } .account-checkbox-label:hover { border-color: var(--primary); }
.account-checkbox-label input[type="checkbox"] {
justify-self: center;
}
.account-checkbox-label span {
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
}
.account-checkbox-label select {
flex-shrink: 0;
}
.uf-oidc-section {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.uf-oidc-section h4 {
font-size: 13px;
font-weight: 600;
margin-bottom: 10px;
color: var(--text-muted);
}
/* Hide layout editor button on portrait/mobile — canvas needs landscape space */ /* Hide layout editor button on portrait/mobile — canvas needs landscape space */
@media (max-width: 768px), (orientation: portrait) { @media (max-width: 768px), (orientation: portrait) {
+4 -1
View File
@@ -149,7 +149,9 @@
<label>Account Access <span class="field-hint">(admins see all — no selection needed)</span></label> <label>Account Access <span class="field-hint">(admins see all — no selection needed)</span></label>
<div id="uf-accounts-checkboxes" class="account-checkboxes"></div> <div id="uf-accounts-checkboxes" class="account-checkboxes"></div>
</div> </div>
<div id="uf-oidc-group" class="form-row" hidden> <div id="uf-oidc-group" class="uf-oidc-section" hidden>
<h4>Single Sign-On Identity</h4>
<div class="form-row">
<div class="form-group"> <div class="form-group">
<label for="uf-oidc-sub">OIDC Subject <span class="field-hint">(sub claim from provider)</span></label> <label for="uf-oidc-sub">OIDC Subject <span class="field-hint">(sub claim from provider)</span></label>
<input type="text" id="uf-oidc-sub" autocomplete="off"> <input type="text" id="uf-oidc-sub" autocomplete="off">
@@ -159,6 +161,7 @@
<input type="text" id="uf-oidc-issuer" autocomplete="off"> <input type="text" id="uf-oidc-issuer" autocomplete="off">
</div> </div>
</div> </div>
</div>
<div id="user-form-error" class="wizard-error" hidden></div> <div id="user-form-error" class="wizard-error" hidden></div>
<div style="display:flex;gap:8px;margin-top:12px"> <div style="display:flex;gap:8px;margin-top:12px">
<button id="btn-save-user" class="btn-primary">Add User</button> <button id="btn-save-user" class="btn-primary">Add User</button>
+46 -10
View File
@@ -316,8 +316,8 @@ function renderUfAccountCheckboxes() {
const acctRole = assignment ? assignment.role : 'viewer'; const acctRole = assignment ? assignment.role : 'viewer';
return `<label class="account-checkbox-label"> return `<label class="account-checkbox-label">
<input type="checkbox" name="uf-account" value="${a.id}"${checked ? ' checked' : ''}> <input type="checkbox" name="uf-account" value="${a.id}"${checked ? ' checked' : ''}>
${escHtml(a.company1 || a.bank_name || `Account ${a.id}`)} <span>${escHtml(a.company1 || a.bank_name || `Account ${a.id}`)}</span>
<select name="uf-account-role" data-account-id="${a.id}" style="margin-left:6px;font-size:12px"> <select name="uf-account-role" data-account-id="${a.id}" style="font-size:12px">
<option value="editor"${acctRole === 'editor' ? ' selected' : ''}>Editor</option> <option value="editor"${acctRole === 'editor' ? ' selected' : ''}>Editor</option>
<option value="viewer"${acctRole === 'viewer' ? ' selected' : ''}>Viewer</option> <option value="viewer"${acctRole === 'viewer' ? ' selected' : ''}>Viewer</option>
</select> </select>
@@ -2082,6 +2082,12 @@ function populateLayoutDropdown() {
).join(''); ).join('');
} }
// Printing safe zone for user-adjustable fields (inches). MICR is exempt.
const SAFE_LEFT = 11 / 64;
const SAFE_RIGHT = 8.5 - 11 / 64;
const SAFE_TOP = 13 / 64;
const SAFE_BOTTOM = 3.5 - 0.5;
const SVG_NS = 'http://www.w3.org/2000/svg'; const SVG_NS = 'http://www.w3.org/2000/svg';
function svgEl(tag, attrs, text) { function svgEl(tag, attrs, text) {
const el = document.createElementNS(SVG_NS, tag); const el = document.createElementNS(SVG_NS, tag);
@@ -2104,11 +2110,41 @@ function renderLayoutCanvas() {
// White check background // White check background
svg.appendChild(svgEl('rect', { x:0, y:0, width:W, height:H, fill:'#fff', stroke:'#bbb', 'stroke-width':1 })); svg.appendChild(svgEl('rect', { x:0, y:0, width:W, height:H, fill:'#fff', stroke:'#bbb', 'stroke-width':1 }));
// Grid at 1/8" increments (darker every 1/4", darkest on whole inches)
for (let n8 = 1; n8 < Math.ceil(8.5 * 8); n8++) {
const x = (n8 / 8) * SCALE;
if (x >= W) break;
const isInch = n8 % 8 === 0;
const isQtr = n8 % 2 === 0;
const stroke = isInch ? '#d0d7de' : isQtr ? '#e4e8ed' : '#f0f2f5';
svg.appendChild(svgEl('line', { x1:x, y1:0, x2:x, y2:H, stroke, 'stroke-width':1 }));
}
for (let n8 = 1; n8 < Math.ceil(3.5 * 8); n8++) {
const y = (n8 / 8) * SCALE;
if (y >= H) break;
const isInch = n8 % 8 === 0;
const isQtr = n8 % 2 === 0;
const stroke = isInch ? '#d0d7de' : isQtr ? '#e4e8ed' : '#f0f2f5';
svg.appendChild(svgEl('line', { x1:0, y1:y, x2:W, y2:y, stroke, 'stroke-width':1 }));
}
// MICR reference line // MICR reference line
const micrY = (3.5 - 0.267) * SCALE; const micrY = (3.5 - 0.267) * SCALE;
svg.appendChild(svgEl('line', { x1:0, y1:micrY, x2:W, y2:micrY, stroke:'#ccc', 'stroke-width':1, 'stroke-dasharray':'4,4' })); svg.appendChild(svgEl('line', { x1:0, y1:micrY, x2:W, y2:micrY, stroke:'#ccc', 'stroke-width':1, 'stroke-dasharray':'4,4' }));
svg.appendChild(svgEl('text', { x:4, y:micrY - 3, 'font-size':9, fill:'#bbb', 'font-family':'sans-serif' }, 'MICR')); svg.appendChild(svgEl('text', { x:4, y:micrY - 3, 'font-size':9, fill:'#bbb', 'font-family':'sans-serif' }, 'MICR'));
// Safe zone outline for user-adjustable fields
svg.appendChild(svgEl('rect', {
x: SAFE_LEFT * SCALE,
y: SAFE_TOP * SCALE,
width: (SAFE_RIGHT - SAFE_LEFT) * SCALE,
height: (SAFE_BOTTOM - SAFE_TOP) * SCALE,
fill: 'none',
stroke: '#60a5fa',
'stroke-width': 1,
'stroke-dasharray': '3,3',
}));
for (const f of layoutState.fields) { for (const f of layoutState.fields) {
const g = createFieldSvgElement(f, SCALE, layoutState.selectedId === f.id); const g = createFieldSvgElement(f, SCALE, layoutState.selectedId === f.id);
svg.appendChild(g); svg.appendChild(g);
@@ -2333,11 +2369,11 @@ function onLayoutDragMove(e) {
const dy = (e.clientY - layoutDrag.mouseY) / layoutState.scale; const dy = (e.clientY - layoutDrag.mouseY) / layoutState.scale;
const f = layoutState.fields.find(x => x.id === layoutDrag.fieldId); const f = layoutState.fields.find(x => x.id === layoutDrag.fieldId);
if (!f) return; if (!f) return;
f.x_pos = clampIn(round16(layoutDrag.origX + dx), 0, 8.5); f.x_pos = clampIn(round16(layoutDrag.origX + dx), SAFE_LEFT, SAFE_RIGHT);
f.y_pos = clampIn(round16(layoutDrag.origY + dy), 0, 3.5); f.y_pos = clampIn(round16(layoutDrag.origY + dy), SAFE_TOP, SAFE_BOTTOM);
if (layoutDrag.moveEnd) { if (layoutDrag.moveEnd) {
f.x_end_pos = clampIn(round16(layoutDrag.origX2 + dx), 0, 8.5); f.x_end_pos = clampIn(round16(layoutDrag.origX2 + dx), SAFE_LEFT, SAFE_RIGHT);
f.y_end_pos = clampIn(round16(layoutDrag.origY2 + dy), 0, 3.5); f.y_end_pos = clampIn(round16(layoutDrag.origY2 + dy), SAFE_TOP, SAFE_BOTTOM);
} }
// Update just the dragged element for smooth performance // Update just the dragged element for smooth performance
const svg = document.querySelector('#layout-canvas-container svg'); const svg = document.querySelector('#layout-canvas-container svg');
@@ -2364,11 +2400,11 @@ function nudgeLayoutField(dx, dy) {
const f = layoutState.fields.find(x => x.id === layoutState.selectedId); const f = layoutState.fields.find(x => x.id === layoutState.selectedId);
if (!f) return; if (!f) return;
const S = 1 / 16; const S = 1 / 16;
f.x_pos = clampIn(round16(f.x_pos + dx * S), 0, 8.5); f.x_pos = clampIn(round16(f.x_pos + dx * S), SAFE_LEFT, SAFE_RIGHT);
f.y_pos = clampIn(round16(f.y_pos + dy * S), 0, 3.5); f.y_pos = clampIn(round16(f.y_pos + dy * S), SAFE_TOP, SAFE_BOTTOM);
if (f.field_type === 'Line' || f.field_type === 'Graph') { if (f.field_type === 'Line' || f.field_type === 'Graph') {
f.x_end_pos = clampIn(round16(f.x_end_pos + dx * S), 0, 8.5); f.x_end_pos = clampIn(round16(f.x_end_pos + dx * S), SAFE_LEFT, SAFE_RIGHT);
f.y_end_pos = clampIn(round16(f.y_end_pos + dy * S), 0, 3.5); f.y_end_pos = clampIn(round16(f.y_end_pos + dy * S), SAFE_TOP, SAFE_BOTTOM);
} }
updateLayoutSidebar(f); updateLayoutSidebar(f);
renderLayoutCanvas(); renderLayoutCanvas();
+15 -5
View File
@@ -235,17 +235,27 @@ function generateCheckPdf(account, checks, fields) {
} }
// --- MICR line --- // --- MICR line ---
// Anchor the second transit symbol (the 'A' after routing) at 2 59/64" from left.
// Routing extends left from that anchor; account + check extend right.
const micrLine = formatMicrLine(account.routing_number, account.account_number, check.check_no); const micrLine = formatMicrLine(account.routing_number, account.account_number, check.check_no);
const micrPos = pt(0.3, MICR_Y_IN); const ANCHOR_IN = 2 + 59 / 64;
if (hasMicrFont) { if (hasMicrFont) {
doc.font('MICR').fontSize(12).fillColor('#000000') doc.font('MICR').fontSize(12).fillColor('#000000');
.text(micrLine, micrPos.x, micrPos.y, { lineBreak: false });
} else { } else {
doc.font('Courier').fontSize(10).fillColor('#000000') doc.font('Courier').fontSize(10).fillColor('#000000');
.text(micrLine, micrPos.x, micrPos.y, { lineBreak: false });
} }
// Prefix = everything up to and including the second 'A' (first A + routing + second A).
const secondA = micrLine.indexOf('A', 1) + 1;
const prefix = micrLine.slice(0, secondA);
const prefixWidthPts = doc.widthOfString(prefix);
const anchorXPts = (ANCHOR_IN + offX) * POINTS_PER_INCH;
const micrXPts = anchorXPts - prefixWidthPts;
const micrYPts = (slotOriginY + MICR_Y_IN + offY) * POINTS_PER_INCH;
doc.text(micrLine, micrXPts, micrYPts, { lineBreak: false });
} // end slot loop } // end slot loop
} // end page loop } // end page loop