@7365admin1/layer-common 4.94.0 → 4.95.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.95.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 1456f7a: Vehicle QR stickers: choose a sticker design before printing - QR dot shape, corner shape, colour theme (dark on light only), sticker shape and the words under the plate. Every design is decode-tested; the QR content, size (32 mm), error correction and quiet zone are unchanged. The last choice is remembered in the browser.
8
+
3
9
  ## 4.94.0
4
10
 
5
11
  ### Minor Changes
@@ -2060,11 +2066,11 @@
2060
2066
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
2061
2067
  own rule from two endpoints the console already calls, unprojected:
2062
2068
 
2063
- GET /api/members/user/:user/app/admin the Seven365 staff membership
2064
- GET /api/roles/id/:role that membership's role document
2069
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
2070
+ GET /api/roles/id/:role that membership's role document
2065
2071
 
2066
- owner = member.type === "admin" && role.type === "admin" && role.default === true
2067
- staff = member.type === "admin" && role.type === "admin"
2072
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
2073
+ staff = member.type === "admin" && role.type === "admin"
2068
2074
 
2069
2075
  `role.default` is the marker because it is the only property of a platform
2070
2076
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -76,7 +76,34 @@
76
76
  Expired, blocklisted and inactive vehicles start unticked. Each sticker has a cut-off strip under the
77
77
  dashed line saying which unit and owner it is for - cut it off before pasting the sticker on the car.
78
78
  </p>
79
- <iframe ref="frame" title="Sticker sheet preview" :srcdoc="docHtml" class="sticker-preview" />
79
+ <div class="text-subtitle-2 mt-3 mb-1">Sticker design</div>
80
+ <p class="text-caption text-medium-emphasis mb-2">
81
+ Pick a look for the stickers. Every choice here has been tested to scan with iSecure365 and iMA365, and the
82
+ QR code stays the same size. Your choice is remembered on this computer.
83
+ </p>
84
+ <v-row dense data-test="vehicle-qr-design">
85
+ <v-col cols="12" sm="6" md="4" lg>
86
+ <v-select v-model="design.dots" :items="DOT_OPTIONS" label="QR dots" density="compact" hide-details />
87
+ </v-col>
88
+ <v-col cols="12" sm="6" md="4" lg>
89
+ <v-select v-model="design.eyes" :items="EYE_OPTIONS" label="QR corners" density="compact" hide-details />
90
+ </v-col>
91
+ <v-col cols="12" sm="6" md="4" lg>
92
+ <v-select v-model="design.theme" :items="STICKER_THEMES" item-value="id" label="Colours" density="compact"
93
+ hide-details />
94
+ </v-col>
95
+ <v-col cols="12" sm="6" md="4" lg>
96
+ <v-select v-model="design.frame" :items="FRAME_OPTIONS" label="Sticker shape" density="compact" hide-details />
97
+ </v-col>
98
+ <v-col cols="12" sm="6" md="4" lg>
99
+ <v-select v-model="design.label" :items="LABEL_OPTIONS" label="Words under the plate" density="compact"
100
+ hide-details />
101
+ </v-col>
102
+ <v-col cols="12" sm="6" md="4" lg="auto" class="d-flex align-center">
103
+ <v-btn variant="text" size="small" class="text-none" @click="resetDesign">Back to the standard look</v-btn>
104
+ </v-col>
105
+ </v-row>
106
+ <iframe ref="frame" title="Sticker sheet preview" :srcdoc="docHtml" class="sticker-preview mt-3" />
80
107
  </template>
81
108
  </v-card-text>
82
109
 
@@ -114,6 +141,18 @@ import {
114
141
  type StickerItem,
115
142
  type StickerPlateRow,
116
143
  } from "../utils/vehicle-qr-sticker";
144
+ import {
145
+ DEFAULT_STICKER_DESIGN,
146
+ DOT_OPTIONS,
147
+ EYE_OPTIONS,
148
+ FRAME_OPTIONS,
149
+ LABEL_OPTIONS,
150
+ QR_ECC,
151
+ STICKER_THEMES,
152
+ normalizeDesign,
153
+ stickerFaceSvg,
154
+ type StickerDesign,
155
+ } from "../utils/vehicle-qr-design";
117
156
 
118
157
  const props = defineProps({
119
158
  modelValue: { type: Boolean, default: false },
@@ -143,6 +182,29 @@ const error = ref("");
143
182
  const docHtml = ref("");
144
183
  const frame = ref<HTMLIFrameElement | null>(null);
145
184
  const fromReissue = computed(() => props.vehicleIds.length > 0);
185
+ const siteName = ref("");
186
+
187
+ // The last design chosen in this browser. Storage can be blocked (private
188
+ // window): then it is simply the standard look each time.
189
+ const DESIGN_KEY = "vehicleQrStickerDesign";
190
+ function loadDesign(): StickerDesign {
191
+ try {
192
+ return normalizeDesign(JSON.parse(localStorage.getItem(DESIGN_KEY) || "null"));
193
+ } catch {
194
+ return { ...DEFAULT_STICKER_DESIGN };
195
+ }
196
+ }
197
+ const design = reactive<StickerDesign>(loadDesign());
198
+ watch(design, (d) => {
199
+ try {
200
+ localStorage.setItem(DESIGN_KEY, JSON.stringify(d));
201
+ } catch {
202
+ /* not saved - nothing else to do */
203
+ }
204
+ });
205
+ function resetDesign() {
206
+ Object.assign(design, DEFAULT_STICKER_DESIGN);
207
+ }
146
208
 
147
209
  const allOnPageTicked = computed(() => plates.value.length > 0 && plates.value.every((p) => selected.value.includes(p.id)));
148
210
  const someOnPageTicked = computed(() => plates.value.some((p) => selected.value.includes(p.id)));
@@ -203,6 +265,7 @@ async function goReview() {
203
265
  const { ids, dropped: over } = idsForRequest(selected.value);
204
266
  const res = await getVehicleQrStickers(props.site, ids);
205
267
  items.value = (res?.items ?? []) as StickerItem[];
268
+ siteName.value = String((res as any)?.site?.name ?? "");
206
269
  missing.value = res?.missing ?? [];
207
270
  dropped.value = over;
208
271
  ticked.value = defaultStickerIds(items.value);
@@ -214,21 +277,25 @@ async function goReview() {
214
277
  }
215
278
  }
216
279
 
217
- // The same document is the preview and the print job. Error correction Q and a
218
- // 4-module quiet zone; the QR is drawn at 32 mm so it scans from arm's length.
219
- let renderToken = 0;
220
- watch([items, ticked], async () => {
221
- const mine = ++renderToken;
222
- const chosen = items.value.filter((i) => ticked.value.includes(i.vehicleId));
223
- if (!chosen.length) {
224
- docHtml.value = "";
225
- return;
226
- }
227
- const svgs = await Promise.all(
228
- chosen.map((i) => QRCode.toString(i.qr, { type: "svg", errorCorrectionLevel: "Q", margin: 4 })),
229
- );
230
- if (mine === renderToken) docHtml.value = buildStickerDocument(chosen, svgs);
231
- });
280
+ // The same document is the preview and the print job. The QR content, error
281
+ // correction (Q), 4-module quiet zone and 32 mm size are the same on every
282
+ // design; only the look of the dots, corners, colours and frame changes.
283
+ watch(
284
+ [items, ticked, design, siteName],
285
+ () => {
286
+ const chosen = items.value.filter((i) => ticked.value.includes(i.vehicleId));
287
+ if (!chosen.length) {
288
+ docHtml.value = "";
289
+ return;
290
+ }
291
+ const look = normalizeDesign(design);
292
+ const faces = chosen.map((i) =>
293
+ stickerFaceSvg(QRCode.create(i.qr, { errorCorrectionLevel: QR_ECC as QRCode.QRCodeErrorCorrectionLevel }).modules, look),
294
+ );
295
+ docHtml.value = buildStickerDocument(chosen, faces, { design: look, siteName: siteName.value });
296
+ },
297
+ { deep: true },
298
+ );
232
299
 
233
300
  function print() {
234
301
  const win = frame.value?.contentWindow;
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "4.94.0",
5
+ "version": "4.95.0",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Vehicle QR sticker designs - the sticker face (frame + QR) as plain shapes.
3
+ *
4
+ * The QR library only gives us the dark/light module grid; we draw it here so
5
+ * the look can change while the code itself does not: same payload, same error
6
+ * correction (Q), a 4-module quiet zone and a 32 mm QR on every design. The
7
+ * shapes are kept as data (`stickerFacePaints`) so the test can draw them to
8
+ * pixels and decode them - every design must still scan. Colours are dark ink
9
+ * on light paper only; the test checks each theme's contrast.
10
+ */
11
+
12
+ export type QrMatrix = { size: number; get(row: number, col: number): number | boolean };
13
+
14
+ export type DotShape = "square" | "rounded" | "dots";
15
+ export type EyeShape = "square" | "rounded" | "circle";
16
+ export type FrameShape = "rounded" | "square" | "soft" | "arch" | "ticket";
17
+ export type LabelChoice = "app" | "verify" | "site" | "none";
18
+
19
+ export type StickerTheme = { id: string; title: string; ink: string; eye: string; paper: string };
20
+
21
+ export type StickerDesign = { dots: DotShape; eyes: EyeShape; theme: string; frame: FrameShape; label: LabelChoice };
22
+
23
+ export const DOT_OPTIONS: { value: DotShape; title: string }[] = [
24
+ { value: "square", title: "Squares (classic)" },
25
+ { value: "rounded", title: "Rounded squares" },
26
+ { value: "dots", title: "Round dots" },
27
+ ];
28
+
29
+ export const EYE_OPTIONS: { value: EyeShape; title: string }[] = [
30
+ { value: "square", title: "Square" },
31
+ { value: "rounded", title: "Rounded" },
32
+ { value: "circle", title: "Circle" },
33
+ ];
34
+
35
+ export const FRAME_OPTIONS: { value: FrameShape; title: string }[] = [
36
+ { value: "rounded", title: "Rounded corners" },
37
+ { value: "square", title: "Square corners" },
38
+ { value: "soft", title: "Extra round" },
39
+ { value: "arch", title: "Badge (round top)" },
40
+ { value: "ticket", title: "Ticket (cut corners)" },
41
+ ];
42
+
43
+ export const LABEL_OPTIONS: { value: LabelChoice; title: string }[] = [
44
+ { value: "app", title: "Scan with iSecure365 / iMA365" },
45
+ { value: "verify", title: "Scan to verify vehicle" },
46
+ { value: "site", title: "Site name" },
47
+ { value: "none", title: "No extra words" },
48
+ ];
49
+
50
+ // Dark ink on light paper only - never inverted, never pale. Checked by test.
51
+ export const STICKER_THEMES: StickerTheme[] = [
52
+ { id: "black", title: "Black on white", ink: "#000000", eye: "#000000", paper: "#ffffff" },
53
+ { id: "navy", title: "Navy blue", ink: "#0b2545", eye: "#0b2545", paper: "#ffffff" },
54
+ { id: "navy-red", title: "Navy with red corners", ink: "#0b2545", eye: "#8b1a1a", paper: "#ffffff" },
55
+ { id: "green", title: "Dark green on mint", ink: "#14462b", eye: "#14462b", paper: "#f2faf4" },
56
+ { id: "maroon", title: "Maroon on cream", ink: "#5c0f1d", eye: "#5c0f1d", paper: "#fff8ee" },
57
+ { id: "purple", title: "Deep purple", ink: "#33105c", eye: "#33105c", paper: "#ffffff" },
58
+ { id: "charcoal", title: "Charcoal on cream", ink: "#222222", eye: "#222222", paper: "#fffbea" },
59
+ ];
60
+
61
+ export const DEFAULT_STICKER_DESIGN: StickerDesign = {
62
+ dots: "square",
63
+ eyes: "square",
64
+ theme: "black",
65
+ frame: "rounded",
66
+ label: "app",
67
+ };
68
+
69
+ /** A saved or typed-in design with anything unknown replaced by the default. */
70
+ export function normalizeDesign(raw: unknown): StickerDesign {
71
+ const r = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
72
+ const pick = <T extends string>(v: unknown, list: { value: T }[], d: T): T =>
73
+ list.some((o) => o.value === v) ? (v as T) : d;
74
+ const d = DEFAULT_STICKER_DESIGN;
75
+ return {
76
+ dots: pick(r.dots, DOT_OPTIONS, d.dots),
77
+ eyes: pick(r.eyes, EYE_OPTIONS, d.eyes),
78
+ theme: STICKER_THEMES.some((t) => t.id === r.theme) ? String(r.theme) : d.theme,
79
+ frame: pick(r.frame, FRAME_OPTIONS, d.frame),
80
+ label: pick(r.label, LABEL_OPTIONS, d.label),
81
+ };
82
+ }
83
+
84
+ export function stickerTheme(id: string): StickerTheme {
85
+ return STICKER_THEMES.find((t) => t.id === id) ?? STICKER_THEMES[0];
86
+ }
87
+
88
+ /** WCAG relative luminance of "#rrggbb". */
89
+ export function stickerLuminance(hex: string): number {
90
+ const n = parseInt(hex.slice(1), 16);
91
+ const lin = (c: number) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
92
+ return 0.2126 * lin(((n >> 16) & 255) / 255) + 0.7152 * lin(((n >> 8) & 255) / 255) + 0.0722 * lin((n & 255) / 255);
93
+ }
94
+
95
+ export function stickerContrast(a: string, b: string): number {
96
+ const [hi, lo] = [stickerLuminance(a), stickerLuminance(b)].sort((x, y) => y - x);
97
+ return (hi + 0.05) / (lo + 0.05);
98
+ }
99
+
100
+ // ---- geometry (all in mm; the card is 47 x 58) ----------------------------
101
+
102
+ export const CARD_W = 47;
103
+ export const CARD_H = 58;
104
+ export const QR_MM = 32; // the whole QR including its quiet zone
105
+ export const QUIET_MODULES = 4;
106
+ /** Error correction on every design (no centre logo, so Q stays enough). */
107
+ export const QR_ECC = "Q";
108
+ const STROKE = 0.35;
109
+
110
+ /** A rectangle with per-corner radii [tl, tr, br, bl], or concave cut corners. */
111
+ export type Box = { t: "box"; x: number; y: number; w: number; h: number; r?: number[]; notch?: number };
112
+ export type Circle = { t: "circle"; cx: number; cy: number; r: number };
113
+ export type Shape = Box | Circle;
114
+ /** A filled shape; `hole` is cut out of it (rings, the frame line). */
115
+ export type Paint = { shape: Shape; hole?: Shape; fill: string };
116
+
117
+ /** Top of the QR on the card: lower on the badge so the round top keeps clear of the code. */
118
+ export function qrTop(frame: FrameShape): number {
119
+ return frame === "arch" ? 7 : 2.5;
120
+ }
121
+
122
+ function cardBox(frame: FrameShape, inset: number): Box {
123
+ const x = inset;
124
+ const w = CARD_W - inset * 2;
125
+ const h = CARD_H - inset * 2;
126
+ const r = (v: number) => Math.max(0, v - inset);
127
+ switch (frame) {
128
+ case "square":
129
+ return { t: "box", x, y: x, w, h, r: [r(0.8), r(0.8), r(0.8), r(0.8)] };
130
+ case "soft":
131
+ return { t: "box", x, y: x, w, h, r: [r(8), r(8), r(8), r(8)] };
132
+ case "arch":
133
+ return { t: "box", x, y: x, w, h, r: [w / 2, w / 2, r(3), r(3)] };
134
+ case "ticket":
135
+ return { t: "box", x, y: x, w, h, notch: 4 + inset };
136
+ default:
137
+ return { t: "box", x, y: x, w, h, r: [r(3), r(3), r(3), r(3)] };
138
+ }
139
+ }
140
+
141
+ function isFinder(n: number, row: number, col: number): boolean {
142
+ return (row < 7 && col < 7) || (row < 7 && col >= n - 7) || (row >= n - 7 && col < 7);
143
+ }
144
+
145
+ /** Every shape on the sticker face, painted in order. */
146
+ export function stickerFacePaints(matrix: QrMatrix, design: StickerDesign): Paint[] {
147
+ const theme = stickerTheme(design.theme);
148
+ const n = matrix.size;
149
+ const m = QR_MM / (n + QUIET_MODULES * 2);
150
+ const ox = (CARD_W - QR_MM) / 2 + QUIET_MODULES * m;
151
+ const oy = qrTop(design.frame) + QUIET_MODULES * m;
152
+ const paints: Paint[] = [
153
+ { shape: cardBox(design.frame, 0), fill: theme.paper },
154
+ { shape: cardBox(design.frame, 0), hole: cardBox(design.frame, STROKE), fill: theme.ink },
155
+ ];
156
+ const box = (x: number, y: number, w: number, h: number, rad = 0): Box => ({ t: "box", x, y, w, h, r: [rad, rad, rad, rad] });
157
+
158
+ for (let row = 0; row < n; row++) {
159
+ for (let col = 0; col < n; col++) {
160
+ if (!matrix.get(row, col) || isFinder(n, row, col)) continue;
161
+ const x = ox + col * m;
162
+ const y = oy + row * m;
163
+ if (design.dots === "dots") {
164
+ paints.push({ shape: { t: "circle", cx: x + m / 2, cy: y + m / 2, r: m * 0.45 }, fill: theme.ink });
165
+ } else if (design.dots === "rounded") {
166
+ paints.push({ shape: box(x, y, m, m, m * 0.3), fill: theme.ink });
167
+ } else {
168
+ // ponytail: one rect per horizontal run, so adjacent squares never show a hairline seam
169
+ let end = col;
170
+ while (end + 1 < n && matrix.get(row, end + 1) && !isFinder(n, row, end + 1)) end++;
171
+ paints.push({ shape: box(x, y, (end - col + 1) * m, m), fill: theme.ink });
172
+ col = end;
173
+ }
174
+ }
175
+ }
176
+
177
+ for (const [r0, c0] of [[0, 0], [0, n - 7], [n - 7, 0]]) {
178
+ const x = ox + c0 * m;
179
+ const y = oy + r0 * m;
180
+ if (design.eyes === "circle") {
181
+ const cx = x + 3.5 * m;
182
+ const cy = y + 3.5 * m;
183
+ paints.push({ shape: { t: "circle", cx, cy, r: 3.5 * m }, hole: { t: "circle", cx, cy, r: 2.5 * m }, fill: theme.eye });
184
+ paints.push({ shape: { t: "circle", cx, cy, r: 1.5 * m }, fill: theme.eye });
185
+ } else {
186
+ const round = design.eyes === "rounded";
187
+ paints.push({
188
+ shape: box(x, y, 7 * m, 7 * m, round ? 2 * m : 0),
189
+ hole: box(x + m, y + m, 5 * m, 5 * m, round ? 1.2 * m : 0),
190
+ fill: theme.eye,
191
+ });
192
+ paints.push({ shape: box(x + 2 * m, y + 2 * m, 3 * m, 3 * m, round ? 0.9 * m : 0), fill: theme.eye });
193
+ }
194
+ }
195
+ return paints;
196
+ }
197
+
198
+ // ---- drawing ---------------------------------------------------------------
199
+
200
+ const f = (v: number) => String(Math.round(v * 1000) / 1000);
201
+
202
+ function boxPath(b: Box): string {
203
+ const { x, y, w, h } = b;
204
+ if (b.notch) {
205
+ const k = b.notch;
206
+ return (
207
+ `M${f(x + k)} ${f(y)}H${f(x + w - k)}A${f(k)} ${f(k)} 0 0 0 ${f(x + w)} ${f(y + k)}` +
208
+ `V${f(y + h - k)}A${f(k)} ${f(k)} 0 0 0 ${f(x + w - k)} ${f(y + h)}` +
209
+ `H${f(x + k)}A${f(k)} ${f(k)} 0 0 0 ${f(x)} ${f(y + h - k)}` +
210
+ `V${f(y + k)}A${f(k)} ${f(k)} 0 0 0 ${f(x + k)} ${f(y)}Z`
211
+ );
212
+ }
213
+ const [tl, tr, br, bl] = b.r ?? [0, 0, 0, 0];
214
+ const arc = (r: number, ex: number, ey: number) => (r ? `A${f(r)} ${f(r)} 0 0 1 ${f(ex)} ${f(ey)}` : "");
215
+ return (
216
+ `M${f(x + tl)} ${f(y)}H${f(x + w - tr)}${arc(tr, x + w, y + tr)}` +
217
+ `V${f(y + h - br)}${arc(br, x + w - br, y + h)}` +
218
+ `H${f(x + bl)}${arc(bl, x, y + h - bl)}` +
219
+ `V${f(y + tl)}${arc(tl, x + tl, y)}Z`
220
+ );
221
+ }
222
+
223
+ function shapePath(s: Shape): string {
224
+ if (s.t === "box") return boxPath(s);
225
+ const { cx, cy, r } = s;
226
+ return `M${f(cx - r)} ${f(cy)}A${f(r)} ${f(r)} 0 1 0 ${f(cx + r)} ${f(cy)}A${f(r)} ${f(r)} 0 1 0 ${f(cx - r)} ${f(cy)}Z`;
227
+ }
228
+
229
+ /** The face as one SVG, 47 x 58 mm. Runs of same-colour shapes share one path. */
230
+ export function paintsToSvg(paints: Paint[]): string {
231
+ const out: string[] = [];
232
+ let fill = "";
233
+ let d = "";
234
+ const flush = () => d && out.push(`<path fill="${fill}" d="${d}"/>`);
235
+ for (const p of paints) {
236
+ if (p.hole) {
237
+ // A ring is its own evenodd path, so its hole never cuts a neighbour.
238
+ flush();
239
+ d = "";
240
+ out.push(`<path fill="${p.fill}" fill-rule="evenodd" d="${shapePath(p.shape)}${shapePath(p.hole)}"/>`);
241
+ continue;
242
+ }
243
+ if (p.fill !== fill) {
244
+ flush();
245
+ fill = p.fill;
246
+ d = "";
247
+ }
248
+ d += shapePath(p.shape);
249
+ }
250
+ flush();
251
+ return (
252
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${CARD_W} ${CARD_H}" width="${CARD_W}mm" height="${CARD_H}mm">` +
253
+ out.join("") +
254
+ `</svg>`
255
+ );
256
+ }
257
+
258
+ export function stickerFaceSvg(matrix: QrMatrix, design: StickerDesign): string {
259
+ return paintsToSvg(stickerFacePaints(matrix, design));
260
+ }
261
+
262
+ /** The words under the plate for this design ("" = none). */
263
+ export function stickerLabel(design: StickerDesign, siteName = ""): string {
264
+ if (design.label === "none") return "";
265
+ if (design.label === "verify") return "Scan to verify vehicle";
266
+ if (design.label === "site") return String(siteName ?? "").trim();
267
+ return "Scan with iSecure365 / iMA365";
268
+ }
@@ -8,6 +8,8 @@
8
8
  * pasted on the car.
9
9
  */
10
10
 
11
+ import { DEFAULT_STICKER_DESIGN, QR_MM, qrTop, stickerLabel, stickerTheme, type StickerDesign } from "./vehicle-qr-design.ts";
12
+
11
13
  /** The server's most vehicles per request (POST /api/vehicles/qr/stickers). */
12
14
  export const STICKER_MAX_PER_REQUEST = 500;
13
15
 
@@ -146,13 +148,13 @@ html, body { margin: 0; padding: 0; background: #fff; color: #111; font-family:
146
148
  .page { display: grid; grid-template-columns: repeat(${STICKER_COLUMNS}, 47mm); gap: 2mm; justify-content: center; align-content: start; width: 198mm; height: 285mm; margin: 0 auto; break-after: page; }
147
149
  .page:last-child { break-after: auto; }
148
150
  .cell { width: 47mm; break-inside: avoid; }
149
- .card { width: 47mm; height: 58mm; border: 0.3mm solid #1a1a1a; border-radius: 3mm; padding: 2mm 2mm 1.6mm; display: flex; flex-direction: column; align-items: center; justify-content: space-between; text-align: center; }
150
- .qr { width: 32mm; height: 32mm; display: block; }
151
- .qr svg { width: 100%; height: 100%; display: block; }
151
+ .card { position: relative; width: 47mm; height: 58mm; }
152
+ .face, .face svg { position: absolute; inset: 0; width: 47mm; height: 58mm; display: block; }
153
+ .words { position: absolute; left: 3mm; right: 3mm; bottom: 2mm; display: flex; flex-direction: column; align-items: center; justify-content: space-between; text-align: center; }
152
154
  .plate { font-size: 13pt; font-weight: 800; letter-spacing: 0.3mm; line-height: 1.1; }
153
- .hint { font-size: 5.8pt; color: #333; line-height: 1.2; }
154
- .brand { display: flex; align-items: center; justify-content: center; gap: 1mm; font-size: 6pt; color: #222; border-top: 0.2mm solid #ccc; width: 100%; padding-top: 1mm; }
155
- .mark { width: 3.2mm; height: 3.2mm; border-radius: 0.8mm; background: #111; color: #fff; font-weight: 800; font-size: 5pt; line-height: 3.2mm; text-align: center; }
155
+ .hint { font-size: 5.8pt; line-height: 1.2; opacity: 0.85; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
156
+ .brand { display: flex; align-items: center; justify-content: center; gap: 1mm; font-size: 6pt; border-top: 0.2mm solid #ccc; width: 100%; padding-top: 1mm; }
157
+ .mark { width: 3.2mm; height: 3.2mm; border-radius: 0.8mm; color: #fff; font-weight: 800; font-size: 5pt; line-height: 3.2mm; text-align: center; }
156
158
  .cut { margin-top: 1.2mm; border-top: 0.3mm dashed #666; padding-top: 1mm; font-size: 6.5pt; line-height: 1.25; color: #111; min-height: 8mm; }
157
159
  .cut .owner { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
158
160
  .cut .note { color: #666; font-size: 5.5pt; }
@@ -160,20 +162,29 @@ html, body { margin: 0; padding: 0; background: #fff; color: #111; font-family:
160
162
  `;
161
163
 
162
164
  /**
163
- * One full HTML document: A4 sheets of stickers. `qrSvgs` is parallel to
164
- * `items` (the QR for each, already drawn). Every value is HTML-escaped - an
165
- * owner name is typed by a person.
165
+ * One full HTML document: A4 sheets of stickers. `faceSvgs` is parallel to
166
+ * `items` (each sticker's frame + QR, already drawn for the chosen design).
167
+ * Every value is HTML-escaped - an owner or site name is typed by a person.
166
168
  */
167
- export function buildStickerDocument(items: StickerItem[], qrSvgs: string[]): string {
169
+ export function buildStickerDocument(
170
+ items: StickerItem[],
171
+ faceSvgs: string[],
172
+ opts: { design?: StickerDesign; siteName?: string } = {},
173
+ ): string {
174
+ const design = opts.design ?? DEFAULT_STICKER_DESIGN;
175
+ const { ink } = stickerTheme(design.theme);
176
+ const label = stickerLabel(design, opts.siteName);
177
+ const wordsTop = qrTop(design.frame) + QR_MM + 0.8;
168
178
  const cells = items.map((item, i) => {
169
179
  const { place, owner } = installerLine(item);
170
180
  return (
171
- `<div class="cell"><div class="card">` +
172
- `<div class="qr">${qrSvgs[i] ?? ""}</div>` +
181
+ `<div class="cell"><div class="card" style="color:${ink}">` +
182
+ `<div class="face">${faceSvgs[i] ?? ""}</div>` +
183
+ `<div class="words" style="top:${wordsTop}mm">` +
173
184
  `<div class="plate">${escapeHtml(item.plateNumber)}</div>` +
174
- `<div class="hint">Scan with iSecure365 / iMA365</div>` +
175
- `<div class="brand"><span class="mark">7</span>Powered by Seven365</div>` +
176
- `</div>` +
185
+ (label ? `<div class="hint">${escapeHtml(label)}</div>` : "") +
186
+ `<div class="brand"><span class="mark" style="background:${ink}">7</span>Powered by Seven365</div>` +
187
+ `</div></div>` +
177
188
  `<div class="cut"><div><b>${escapeHtml(place || "-")}</b></div>` +
178
189
  `<div class="owner">Owner: ${escapeHtml(owner || "-")}</div>` +
179
190
  `<div class="note">Installer: cut here, then paste on this car</div></div></div>`