nti_receipt_builder 0.1.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.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +21 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +272 -0
  6. data/Rakefile +12 -0
  7. data/app/assets/javascripts/nti_receipt_builder/index.js +16 -0
  8. data/app/assets/javascripts/nti_receipt_builder/receipt_builder_controller.js +297 -0
  9. data/app/assets/javascripts/nti_receipt_builder/receipt_canvas_controller.js +279 -0
  10. data/app/assets/javascripts/nti_receipt_builder/receipt_draggable_controller.js +154 -0
  11. data/app/assets/javascripts/nti_receipt_builder/receipt_print_controller.js +24 -0
  12. data/app/assets/javascripts/nti_receipt_builder/receipt_properties_controller.js +297 -0
  13. data/app/assets/stylesheets/nti_receipt_builder.css +238 -0
  14. data/app/controllers/concerns/nti_receipt_builder/templates_controller.rb +170 -0
  15. data/app/helpers/nti_receipt_builder/render_helper.rb +21 -0
  16. data/app/models/nti_receipt_builder/template.rb +79 -0
  17. data/app/views/nti_receipt_builder/render/_order_lines_element.html.erb +36 -0
  18. data/app/views/nti_receipt_builder/render/_overflow_banner.html.erb +5 -0
  19. data/app/views/nti_receipt_builder/render/_paper.html.erb +12 -0
  20. data/app/views/nti_receipt_builder/render/_text_element.html.erb +1 -0
  21. data/app/views/nti_receipt_builder/render/_variable_element.html.erb +1 -0
  22. data/app/views/nti_receipt_builder/templates/_canvas.html.erb +33 -0
  23. data/app/views/nti_receipt_builder/templates/_elements_panel.html.erb +49 -0
  24. data/app/views/nti_receipt_builder/templates/_errors.html.erb +21 -0
  25. data/app/views/nti_receipt_builder/templates/_lock_version_field.html.erb +2 -0
  26. data/app/views/nti_receipt_builder/templates/_print_assets.html.erb +15 -0
  27. data/app/views/nti_receipt_builder/templates/_properties_panel.html.erb +8 -0
  28. data/app/views/nti_receipt_builder/templates/_settings_panel.html.erb +130 -0
  29. data/app/views/nti_receipt_builder/templates/create.turbo_stream.erb +3 -0
  30. data/app/views/nti_receipt_builder/templates/edit.html.erb +54 -0
  31. data/app/views/nti_receipt_builder/templates/new.html.erb +115 -0
  32. data/app/views/nti_receipt_builder/templates/preview.html.erb +24 -0
  33. data/app/views/nti_receipt_builder/templates/print.html.erb +31 -0
  34. data/app/views/nti_receipt_builder/templates/render_preview.turbo_stream.erb +26 -0
  35. data/app/views/nti_receipt_builder/templates/update.turbo_stream.erb +7 -0
  36. data/config/importmap.rb +4 -0
  37. data/lib/generators/nti_receipt_builder/install_generator.rb +49 -0
  38. data/lib/generators/nti_receipt_builder/templates/create_nti_receipt_templates.rb +28 -0
  39. data/lib/generators/nti_receipt_builder/templates/initializer.rb +16 -0
  40. data/lib/generators/nti_receipt_builder/templates/turbo_stream_tags.rb +36 -0
  41. data/lib/nti_receipt_builder/configuration.rb +59 -0
  42. data/lib/nti_receipt_builder/elements.rb +46 -0
  43. data/lib/nti_receipt_builder/engine.rb +32 -0
  44. data/lib/nti_receipt_builder/errors.rb +13 -0
  45. data/lib/nti_receipt_builder/layout_validator.rb +285 -0
  46. data/lib/nti_receipt_builder/presenters/collection.rb +126 -0
  47. data/lib/nti_receipt_builder/presenters/element.rb +68 -0
  48. data/lib/nti_receipt_builder/render_result.rb +18 -0
  49. data/lib/nti_receipt_builder/renderer.rb +78 -0
  50. data/lib/nti_receipt_builder/set_default.rb +24 -0
  51. data/lib/nti_receipt_builder/template_form.rb +60 -0
  52. data/lib/nti_receipt_builder/type_inference.rb +26 -0
  53. data/lib/nti_receipt_builder/variable_definition.rb +74 -0
  54. data/lib/nti_receipt_builder/variable_resolver.rb +55 -0
  55. data/lib/nti_receipt_builder/variables.rb +159 -0
  56. data/lib/nti_receipt_builder/version.rb +5 -0
  57. data/lib/nti_receipt_builder.rb +50 -0
  58. metadata +175 -0
@@ -0,0 +1,297 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ function escapeHtml(value) {
4
+ return String(value ?? "").replace(/[&<>"']/g, (char) => ({
5
+ "&": "&amp;",
6
+ "<": "&lt;",
7
+ ">": "&gt;",
8
+ '"': "&quot;",
9
+ "'": "&#39;"
10
+ }[char]))
11
+ }
12
+
13
+ const FONT_SIZE_MIN = 6
14
+ const FONT_SIZE_MAX = 72
15
+ const LINE_HEIGHT_MIN = 0.8
16
+ const LINE_HEIGHT_MAX = 3.0
17
+ const ROW_SPACING_MIN = 0
18
+ const ROW_SPACING_MAX = 10
19
+ const Z_INDEX_MIN = 0
20
+ const Z_INDEX_MAX = 1000
21
+ const MIN_ELEMENT_DIMENSION_MM = 1.0
22
+ const TEXT_MAX_LENGTH = 500
23
+ const FALLBACK_MAX_LENGTH = 200
24
+ const PREFIX_MAX_LENGTH = 50
25
+ const SUFFIX_MAX_LENGTH = 50
26
+
27
+ // Connects to data-controller="nrb-properties"
28
+ export default class extends Controller {
29
+ static targets = ["content"]
30
+ static values = { scalarVariables: Object }
31
+
32
+ connect() {
33
+ this.boundKeydown = this.handleKeydown.bind(this)
34
+ document.addEventListener("keydown", this.boundKeydown)
35
+ }
36
+
37
+ disconnect() {
38
+ document.removeEventListener("keydown", this.boundKeydown)
39
+ }
40
+
41
+ render(element) {
42
+ this.currentElement = element
43
+
44
+ if (!element) {
45
+ this.contentTarget.innerHTML =
46
+ '<p class="text-secondary nrb-receipt-hint">Select an element on the canvas to edit its properties.</p>'
47
+ return
48
+ }
49
+
50
+ this.contentTarget.innerHTML = this.templateFor(element)
51
+ }
52
+
53
+ fieldChanged(event) {
54
+ if (!this.currentElement) return
55
+
56
+ const field = event.params.field
57
+ const valueType = event.params.valueType || "string"
58
+ const value = this.readFieldValue(event.target, valueType)
59
+ const patch = this.buildPatch(field, value)
60
+
61
+ this.dispatch("changed", {
62
+ prefix: "receipt-properties",
63
+ bubbles: true,
64
+ detail: { id: this.currentElement.id, patch }
65
+ })
66
+ }
67
+
68
+ deleteElement() {
69
+ if (!this.currentElement) return
70
+
71
+ this.dispatch("delete-requested", {
72
+ prefix: "receipt-properties",
73
+ bubbles: true,
74
+ detail: { id: this.currentElement.id }
75
+ })
76
+ }
77
+
78
+ handleKeydown(event) {
79
+ if (event.key !== "Delete" && event.key !== "Backspace") return
80
+ if (!this.currentElement) return
81
+
82
+ const active = document.activeElement
83
+ const isTextEntry = active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA" || active.isContentEditable)
84
+ if (isTextEntry) return
85
+
86
+ event.preventDefault()
87
+ this.deleteElement()
88
+ }
89
+
90
+ readFieldValue(target, valueType) {
91
+ if (valueType === "boolean") return target.checked
92
+
93
+ if (valueType === "integer") {
94
+ const num = parseInt(target.value, 10)
95
+ return Number.isNaN(num) ? 0 : num
96
+ }
97
+
98
+ if (valueType === "number") {
99
+ const num = parseFloat(target.value)
100
+ return Number.isNaN(num) ? 0 : num
101
+ }
102
+
103
+ return target.value
104
+ }
105
+
106
+ buildPatch(field, value) {
107
+ const path = field.split(".")
108
+ if (path.length === 1) return { [path[0]]: value }
109
+
110
+ const [group, key] = path
111
+ return { [group]: { [key]: value } }
112
+ }
113
+
114
+ // Template building
115
+
116
+ templateFor(element) {
117
+ return [this.commonFieldsTemplate(element), this.typeFieldsTemplate(element), this.deleteButtonTemplate()].join("")
118
+ }
119
+
120
+ commonFieldsTemplate(element) {
121
+ return `
122
+ <div class="row g-2">
123
+ <div class="col-6">${this.numberField("X (mm)", "x_mm", element.x_mm, { min: 0, step: 0.1 })}</div>
124
+ <div class="col-6">${this.numberField("Y (mm)", "y_mm", element.y_mm, { min: 0, step: 0.1 })}</div>
125
+ </div>
126
+ <div class="row g-2">
127
+ <div class="col-6">${this.numberField("Width (mm)", "width_mm", element.width_mm, { min: MIN_ELEMENT_DIMENSION_MM, step: 0.1 })}</div>
128
+ <div class="col-6">${this.numberField("Height (mm)", "height_mm", element.height_mm, { min: MIN_ELEMENT_DIMENSION_MM, step: 0.1 })}</div>
129
+ </div>
130
+ <div class="row g-2">
131
+ <div class="col-6">${this.numberField("Layer (z-index)", "z_index", element.z_index || 0, { min: Z_INDEX_MIN, max: Z_INDEX_MAX, step: 1, valueType: "integer" })}</div>
132
+ <div class="col-6 d-flex align-items-end">${this.checkboxField("Visible", "visible", element.visible !== false)}</div>
133
+ </div>
134
+ `
135
+ }
136
+
137
+ typeFieldsTemplate(element) {
138
+ if (element.type === "variable") return this.variableFieldsTemplate(element)
139
+ if (element.type === "text") return this.textFieldsTemplate(element)
140
+ return this.orderLinesFieldsTemplate(element)
141
+ }
142
+
143
+ variableFieldsTemplate(element) {
144
+ return `
145
+ <hr class="my-3">
146
+ ${this.selectField("Variable", "variable_key", element.variable_key, this.scalarVariableOptions())}
147
+ ${this.textField("Prefix", "prefix", element.prefix, { maxlength: PREFIX_MAX_LENGTH })}
148
+ ${this.textField("Suffix", "suffix", element.suffix, { maxlength: SUFFIX_MAX_LENGTH })}
149
+ ${this.textField("Fallback (if empty)", "fallback", element.fallback, { maxlength: FALLBACK_MAX_LENGTH })}
150
+ <hr class="my-3">
151
+ ${this.styleFieldsTemplate(element.styles || {})}
152
+ `
153
+ }
154
+
155
+ textFieldsTemplate(element) {
156
+ return `
157
+ <hr class="my-3">
158
+ ${this.textareaField("Text", "text", element.text, { maxlength: TEXT_MAX_LENGTH })}
159
+ <hr class="my-3">
160
+ ${this.styleFieldsTemplate(element.styles || {})}
161
+ `
162
+ }
163
+
164
+ styleFieldsTemplate(styles) {
165
+ return `
166
+ <div class="row g-2">
167
+ <div class="col-6">${this.numberField("Font Size (pt)", "styles.font_size_pt", styles.font_size_pt || 10, { min: FONT_SIZE_MIN, max: FONT_SIZE_MAX, step: 1 })}</div>
168
+ <div class="col-6">${this.selectField("Weight", "styles.font_weight", styles.font_weight || "normal", [["Normal", "normal"], ["Bold", "bold"]])}</div>
169
+ </div>
170
+ <div class="row g-2">
171
+ <div class="col-6">${this.selectField("Align", "styles.text_align", styles.text_align || "left", [["Left", "left"], ["Center", "center"], ["Right", "right"]])}</div>
172
+ <div class="col-6">${this.numberField("Line Height", "styles.line_height", styles.line_height || 1.2, { min: LINE_HEIGHT_MIN, max: LINE_HEIGHT_MAX, step: 0.1 })}</div>
173
+ </div>
174
+ `
175
+ }
176
+
177
+ orderLinesFieldsTemplate(element) {
178
+ const config = element.config || {}
179
+ const columns = config.columns || []
180
+
181
+ const columnRows = columns
182
+ .map(
183
+ (column) => `
184
+ <tr>
185
+ <td>${escapeHtml(column.label)}</td>
186
+ <td>${escapeHtml(column.key)}</td>
187
+ <td>${escapeHtml(String(column.width_mm))}mm</td>
188
+ <td>${escapeHtml(column.align)}</td>
189
+ </tr>
190
+ `
191
+ )
192
+ .join("")
193
+
194
+ return `
195
+ <hr class="my-3">
196
+ <div class="row g-2">
197
+ <div class="col-6 d-flex align-items-end">${this.checkboxField("Show header row", "config.show_header", config.show_header !== false)}</div>
198
+ <div class="col-6">${this.numberField("Font Size (pt)", "config.font_size_pt", config.font_size_pt || 8, { min: FONT_SIZE_MIN, max: FONT_SIZE_MAX, step: 1 })}</div>
199
+ </div>
200
+ ${this.numberField("Row Spacing (mm)", "config.row_spacing_mm", config.row_spacing_mm != null ? config.row_spacing_mm : 1.0, { min: ROW_SPACING_MIN, max: ROW_SPACING_MAX, step: 0.1 })}
201
+ <label class="form-label mt-2">Columns</label>
202
+ <table class="table table-sm nrb-receipt-columns-table">
203
+ <thead><tr><th>Label</th><th>Key</th><th>Width</th><th>Align</th></tr></thead>
204
+ <tbody>${columnRows}</tbody>
205
+ </table>
206
+ <p class="text-secondary nrb-receipt-hint">Column layout isn&rsquo;t editable yet &mdash; shown for reference.</p>
207
+ `
208
+ }
209
+
210
+ deleteButtonTemplate() {
211
+ return `
212
+ <hr class="my-3">
213
+ <button type="button" class="btn btn-outline-danger w-100" data-action="click->nrb-properties#deleteElement">
214
+ <span class="material-symbols-outlined pe-1">delete</span>Delete Element
215
+ </button>
216
+ `
217
+ }
218
+
219
+ // Field builders
220
+
221
+ numberField(label, field, value, { min, max, step, valueType = "number" } = {}) {
222
+ const minAttr = min != null ? `min="${min}"` : ""
223
+ const maxAttr = max != null ? `max="${max}"` : ""
224
+ const stepAttr = step != null ? `step="${step}"` : ""
225
+
226
+ return `
227
+ <div class="form-group mb-3">
228
+ <label class="form-label">${escapeHtml(label)}</label>
229
+ <input type="number" class="form-control" value="${escapeHtml(value)}" ${minAttr} ${maxAttr} ${stepAttr}
230
+ data-nrb-properties-field-param="${field}"
231
+ data-nrb-properties-value-type-param="${valueType}"
232
+ data-action="input->nrb-properties#fieldChanged">
233
+ </div>
234
+ `
235
+ }
236
+
237
+ textField(label, field, value, { maxlength } = {}) {
238
+ return `
239
+ <div class="form-group mb-3">
240
+ <label class="form-label">${escapeHtml(label)}</label>
241
+ <input type="text" class="form-control" value="${escapeHtml(value)}" maxlength="${maxlength}"
242
+ data-nrb-properties-field-param="${field}"
243
+ data-nrb-properties-value-type-param="string"
244
+ data-action="input->nrb-properties#fieldChanged">
245
+ </div>
246
+ `
247
+ }
248
+
249
+ textareaField(label, field, value, { maxlength } = {}) {
250
+ return `
251
+ <div class="form-group mb-3">
252
+ <label class="form-label">${escapeHtml(label)}</label>
253
+ <textarea class="form-control" rows="3" maxlength="${maxlength}"
254
+ data-nrb-properties-field-param="${field}"
255
+ data-nrb-properties-value-type-param="string"
256
+ data-action="input->nrb-properties#fieldChanged">${escapeHtml(value)}</textarea>
257
+ </div>
258
+ `
259
+ }
260
+
261
+ checkboxField(label, field, checked) {
262
+ return `
263
+ <div class="form-check form-switch mb-3">
264
+ <input type="checkbox" class="form-check-input" ${checked ? "checked" : ""}
265
+ data-nrb-properties-field-param="${field}"
266
+ data-nrb-properties-value-type-param="boolean"
267
+ data-action="change->nrb-properties#fieldChanged">
268
+ <label class="form-check-label">${escapeHtml(label)}</label>
269
+ </div>
270
+ `
271
+ }
272
+
273
+ selectField(label, field, selected, options) {
274
+ const optionTags = options
275
+ .map(
276
+ ([optLabel, optValue]) =>
277
+ `<option value="${escapeHtml(optValue)}" ${optValue === selected ? "selected" : ""}>${escapeHtml(optLabel)}</option>`
278
+ )
279
+ .join("")
280
+
281
+ return `
282
+ <div class="form-group mb-3">
283
+ <label class="form-label">${escapeHtml(label)}</label>
284
+ <select class="form-select"
285
+ data-nrb-properties-field-param="${field}"
286
+ data-nrb-properties-value-type-param="string"
287
+ data-action="change->nrb-properties#fieldChanged">
288
+ ${optionTags}
289
+ </select>
290
+ </div>
291
+ `
292
+ }
293
+
294
+ scalarVariableOptions() {
295
+ return Object.entries(this.scalarVariablesValue).map(([key, label]) => [label, key])
296
+ }
297
+ }
@@ -0,0 +1,238 @@
1
+ /* nti_receipt_builder — builder and print surfaces.
2
+ Assumes Tabler 1.x is present in the host layout for card/button/form chrome;
3
+ everything receipt-specific is defined here.
4
+
5
+ Geometry is deliberately absent from the shared element classes: position and size
6
+ always arrive inline from the builder JS or the print presenters, so builder-only
7
+ affordances stay scoped under `.nrb-receipt-paper-elements` and never leak into the
8
+ preview/print render partials that reuse the same bare class names. */
9
+
10
+ /* Theming: set --nrb-accent (and --nrb-accent-rgb for the translucent fills) to tint the
11
+ builder's selection and palette affordances. Both fall back to Tabler's primary. */
12
+
13
+ /* ---- Page header shared by the editor and preview pages ---- */
14
+ .nrb-receipt-topbar {
15
+ display: flex;
16
+ align-items: center;
17
+ justify-content: space-between;
18
+ gap: 1rem;
19
+ flex-wrap: wrap;
20
+ margin-bottom: 1.25rem;
21
+ }
22
+ .nrb-receipt-topbar-name {
23
+ font-size: 1.3rem; font-weight: 800;
24
+ letter-spacing: -0.01em; color: var(--tblr-dark);
25
+ margin-right: 0.75rem;
26
+ }
27
+
28
+ /* ---- 3-column editor layout: settings+elements / canvas / properties ---- */
29
+ .nrb-receipt-builder {
30
+ display: grid;
31
+ grid-template-columns: minmax(0, 18rem) minmax(0, 1fr) minmax(0, 19rem);
32
+ gap: 1.25rem;
33
+ align-items: start;
34
+ }
35
+ .nrb-receipt-builder-side { display: flex; flex-direction: column; gap: 1.25rem; }
36
+ @media (max-width: 1200px) {
37
+ .nrb-receipt-builder { grid-template-columns: 1fr; }
38
+ .nrb-receipt-builder-side { flex-direction: row; flex-wrap: wrap; }
39
+ .nrb-receipt-builder-side > .nrb-receipt-panel { flex: 1 1 16rem; }
40
+ }
41
+
42
+ /* ---- Shared glass-panel chrome for the editor's 4 panels ---- */
43
+ .nrb-receipt-panel {
44
+ position: relative;
45
+ border-radius: var(--tblr-card-border-radius, 1.25rem);
46
+ padding: 1.25rem;
47
+ background: #fff;
48
+ border: 1px solid rgba(31, 41, 55, 0.06);
49
+ box-shadow: 0 1.25rem 2.5rem -1.5rem rgba(31, 41, 55, 0.25);
50
+ }
51
+ .nrb-receipt-panel .card-title { margin-bottom: 0.85rem; }
52
+ .nrb-receipt-panel--canvas { display: flex; flex-direction: column; gap: 1rem; }
53
+ .nrb-receipt-hint { font-size: 0.82rem; margin: 0.5rem 0 0; }
54
+
55
+ /* ---- Elements palette (draggable variable/block buttons) ---- */
56
+ .nrb-receipt-palette-group + .nrb-receipt-palette-group { margin-top: 1.25rem; }
57
+ .nrb-receipt-palette-label {
58
+ display: block;
59
+ font-size: 0.7rem; font-weight: 700;
60
+ letter-spacing: 0.05em; text-transform: uppercase;
61
+ color: var(--tblr-secondary);
62
+ margin-bottom: 0.6rem;
63
+ }
64
+ .nrb-receipt-palette { display: flex; flex-wrap: wrap; gap: 0.5rem; }
65
+ .nrb-receipt-palette-btn {
66
+ display: inline-flex; align-items: center; gap: 0.4rem;
67
+ font-size: 0.8rem; font-weight: 600;
68
+ color: var(--tblr-dark);
69
+ background: rgba(var(--nrb-accent-rgb, var(--tblr-primary-rgb)), 0.07);
70
+ border: 1px solid rgba(var(--nrb-accent-rgb, var(--tblr-primary-rgb)), 0.15);
71
+ border-radius: 0.8rem;
72
+ padding: 0.4rem 0.7rem;
73
+ cursor: grab;
74
+ transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
75
+ }
76
+ .nrb-receipt-palette-btn .material-symbols-outlined {
77
+ font-size: 18px;
78
+ color: var(--nrb-accent, var(--tblr-primary));
79
+ }
80
+ .nrb-receipt-palette-btn:hover {
81
+ transform: translateY(-1px);
82
+ background: rgba(var(--nrb-accent-rgb, var(--tblr-primary-rgb)), 0.14);
83
+ box-shadow: 0 0.5rem 1rem -0.6rem rgba(var(--nrb-accent-rgb, var(--tblr-primary-rgb)), 0.6);
84
+ }
85
+ .nrb-receipt-palette-btn:active { cursor: grabbing; }
86
+
87
+ /* ---- Canvas toolbar (title + zoom control) ---- */
88
+ .nrb-receipt-toolbar {
89
+ display: flex; align-items: center; justify-content: space-between;
90
+ gap: 1rem; flex-wrap: wrap;
91
+ }
92
+ .nrb-receipt-zoom { display: flex; align-items: center; gap: 0.5rem; color: var(--tblr-secondary); }
93
+ .nrb-receipt-zoom input[type="range"] { width: 8rem; }
94
+ .nrb-receipt-zoom-label {
95
+ font-size: 0.8rem; font-weight: 700;
96
+ color: var(--tblr-dark);
97
+ min-width: 2.5rem; text-align: right;
98
+ }
99
+
100
+ /* ---- Scrollable canvas viewport + the builder-only paper surface.
101
+ `.nrb-receipt-paper-render` (preview/print) is a separate class —
102
+ the builder's `.nrb-receipt-paper` never appears in rendered output. ---- */
103
+ .nrb-receipt-viewport {
104
+ flex: 1 1 auto;
105
+ min-height: 28rem;
106
+ max-height: 70vh;
107
+ overflow: auto;
108
+ padding: 2rem;
109
+ background: rgba(31, 41, 55, 0.03);
110
+ border: 1px solid rgba(31, 41, 55, 0.06);
111
+ border-radius: 1rem;
112
+ }
113
+ .nrb-receipt-paper {
114
+ position: relative;
115
+ margin: 0 auto;
116
+ background: #fff;
117
+ border: 1px solid rgba(31, 41, 55, 0.1);
118
+ box-shadow: 0 1.25rem 2.5rem -1.5rem rgba(31, 41, 55, 0.3);
119
+ }
120
+ /* margin guides: a visual-only inset overlay, never intercepts pointer events */
121
+ .nrb-receipt-paper-margins {
122
+ position: absolute;
123
+ border: 1px dashed rgba(var(--tblr-primary-rgb), 0.35);
124
+ pointer-events: none;
125
+ }
126
+ .nrb-receipt-paper-elements { position: absolute; inset: 0; }
127
+
128
+ /* ---- Canvas element — base rule is shared with the render/print partials,
129
+ so it must stay geometry-agnostic (position/left/top/width/height
130
+ are always supplied inline, either by receipt_canvas_controller.js
131
+ or by NtiReceiptBuilder::Presenters::Element#css_style / paper_style). ---- */
132
+ .nrb-receipt-element {
133
+ position: absolute;
134
+ box-sizing: border-box;
135
+ overflow: hidden;
136
+ word-break: break-word;
137
+ }
138
+ .nrb-receipt-element-body { width: 100%; height: 100%; overflow: hidden; pointer-events: none; }
139
+
140
+ /* Builder-only interactive affordances — scoped so they never leak into
141
+ the preview/print pages, which reuse the bare `.nrb-receipt-element`. */
142
+ .nrb-receipt-paper-elements .nrb-receipt-element {
143
+ cursor: move;
144
+ touch-action: none;
145
+ user-select: none;
146
+ transition: outline-color 0.15s ease;
147
+ }
148
+ .nrb-receipt-paper-elements .nrb-receipt-element:hover {
149
+ outline: 1px dashed rgba(var(--tblr-primary-rgb), 0.4);
150
+ outline-offset: 1px;
151
+ }
152
+ .nrb-receipt-paper-elements .nrb-receipt-element--selected {
153
+ outline: 2px solid var(--nrb-accent, var(--tblr-primary));
154
+ outline-offset: 1px;
155
+ }
156
+ .nrb-receipt-paper-elements .nrb-receipt-element--hidden {
157
+ opacity: 0.4;
158
+ outline: 1px dashed rgba(31, 41, 55, 0.35);
159
+ outline-offset: 1px;
160
+ }
161
+ .nrb-receipt-paper-elements .nrb-receipt-element-dragging,
162
+ .nrb-receipt-paper-elements .nrb-receipt-element-resizing {
163
+ box-shadow: 0 0.75rem 1.5rem -0.75rem rgba(31, 41, 55, 0.45);
164
+ cursor: grabbing;
165
+ }
166
+ .nrb-receipt-resize-handle {
167
+ position: absolute;
168
+ right: -5px; bottom: -5px;
169
+ width: 0.7rem; height: 0.7rem;
170
+ border-radius: 999px;
171
+ background: var(--nrb-accent, var(--tblr-primary));
172
+ border: 2px solid #fff;
173
+ box-shadow: 0 0.15rem 0.4rem rgba(31, 41, 55, 0.35);
174
+ cursor: nwse-resize;
175
+ touch-action: none;
176
+ opacity: 0;
177
+ transition: opacity 0.15s ease;
178
+ }
179
+ .nrb-receipt-paper-elements .nrb-receipt-element:hover .nrb-receipt-resize-handle,
180
+ .nrb-receipt-paper-elements .nrb-receipt-element--selected .nrb-receipt-resize-handle {
181
+ opacity: 1;
182
+ }
183
+
184
+ /* ---- Order-lines block — shared between the builder placeholder and the
185
+ real render partial; `-empty` only ever appears in rendered output. ---- */
186
+ .nrb-receipt-order-lines { display: block; width: 100%; }
187
+ /* `table-layout: fixed` + percentage <col> widths is what makes a row span the
188
+ element edge-to-edge; row height comes from the inline line-height so rows
189
+ sit directly under one another instead of in tall centred boxes. */
190
+ .nrb-receipt-order-lines-table {
191
+ width: 100%;
192
+ table-layout: fixed;
193
+ border-collapse: collapse;
194
+ }
195
+ /* Cells wrap rather than truncate: a receipt that hides half a product name is worse than one
196
+ that runs to a second line. `overflow-wrap: break-word` only splits a word that cannot fit its
197
+ column on its own — an unbroken SKU in a narrow column — so ordinary text still breaks at
198
+ spaces. Matches `.nrb-receipt-element`, which has wrapped all along. */
199
+ .nrb-receipt-order-lines-table th,
200
+ .nrb-receipt-order-lines-table td {
201
+ padding: 0;
202
+ white-space: normal;
203
+ overflow-wrap: break-word;
204
+ vertical-align: top;
205
+ }
206
+ .nrb-receipt-order-lines-header th {
207
+ font-weight: 700;
208
+ border-bottom: 1px solid rgba(31, 41, 55, 0.18);
209
+ }
210
+ .nrb-receipt-order-lines-empty td { text-align: center; }
211
+
212
+ /* ---- Properties panel read-only column summary table ---- */
213
+ .nrb-receipt-columns-table { font-size: 0.8rem; }
214
+ .nrb-receipt-columns-table th {
215
+ color: var(--tblr-secondary);
216
+ font-weight: 700; text-transform: uppercase;
217
+ font-size: 0.68rem; letter-spacing: 0.04em;
218
+ }
219
+
220
+ /* ---- Preview / print. `.nrb-receipt-paper-render`'s on-screen elevation
221
+ (margin/border/box-shadow) is reset to nothing by print.html.erb's
222
+ inline <style>, since the printed page must be the bare receipt. ---- */
223
+ .nrb-receipt-preview-frame {
224
+ padding: 2rem;
225
+ background: rgba(31, 41, 55, 0.03);
226
+ border-radius: 1.25rem;
227
+ overflow-x: auto;
228
+ }
229
+ .nrb-receipt-paper-render {
230
+ position: relative;
231
+ margin: 0 auto;
232
+ background: #fff;
233
+ border: 1px solid rgba(31, 41, 55, 0.08);
234
+ box-shadow: 0 1.25rem 2.5rem -1.5rem rgba(31, 41, 55, 0.3);
235
+ }
236
+ .nrb-receipt-overflow-banner { margin-bottom: 1.25rem; }
237
+ .nrb-receipt-preview-modal .nrb-receipt-preview-frame { max-height: 65vh; overflow-y: auto; }
238
+