@profitlich/template-toolkit 2.4.2 → 2.5.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.
@@ -0,0 +1,228 @@
1
+ # Plan: Exakte Spaltenlinien via Canvas (Variante E)
2
+
3
+ ## Context
4
+
5
+ Im `lines`-Modus des Dev-Grids werden Spaltenlinien über `linear-gradient` + `background-size` als Kachelmuster erzeugt. Die Kachelbreite löst auf Bruchpixel-Werte auf → die `0.5px`-Gradient-Stops landen an leicht unterschiedlichen physischen Pixeln pro Kachel. Ergebnis: Linien erscheinen wackelnd oder inkonsistent.
6
+
7
+ **Lösung:** `<canvas>`-Element mit korrekter `devicePixelRatio`-Behandlung und dem "Half-Pixel"-Trick für pixelgenaue, scharf gerenderte Linien. Funktioniert auf 1×- und Retina-Displays gleich gut.
8
+
9
+ ---
10
+
11
+ ## Betroffene Dateien
12
+
13
+ - [dev/toolbar/Toolbar.js](dev/toolbar/Toolbar.js)
14
+ - [dev/toolbar/toolbar.scss](dev/toolbar/toolbar.scss)
15
+
16
+ ---
17
+
18
+ ## Architektur
19
+
20
+ ```text
21
+ .dev-toolbar__grid (position: fixed, 100vw×100vh)
22
+ └── <canvas> ← neu, ersetzt ::after für "lines"
23
+ ::after ← bleibt für "ribbons" erhalten
24
+ ```
25
+
26
+ Das Canvas-Element wird als Kind von `#gridElement` angehängt. Es erbt dessen Fixed-Positioning und Visibility-Steuerung über `data-dev`. Für den `lines`-Modus zeichnet das Canvas; für `ribbons` wird das Canvas geleert und `::after` übernimmt.
27
+
28
+ ---
29
+
30
+ ## SCSS — `toolbar.scss`
31
+
32
+ ### 1. CSS Custom Properties im Mixin ergänzen
33
+
34
+ Im Block `body[data-dev='true']` innerhalb von `dev-toolbar-grid`:
35
+
36
+ ```scss
37
+ body[data-dev='true'] {
38
+ --dev-columns: #{$columns}; // unitless integer
39
+ --dev-gutter: #{$gutter}; // CSS-Wert (z. B. "1.5rem" oder "20px")
40
+ --dev-margin-left: #{$margin-left};
41
+ --dev-margin-right: #{$margin-right};
42
+
43
+ .dev-toolbar__grid::after { ... } // unverändert
44
+ }
45
+ ```
46
+
47
+ ### 2. `lines`-Gradient entfernen
48
+
49
+ Den gesamten Block (Zeilen 28–39) entfernen — das Canvas übernimmt die Darstellung:
50
+
51
+ ```scss
52
+ // ENTFERNEN:
53
+ body[data-dev='true'][data-dev-grid="lines"] {
54
+ .dev-toolbar__grid::after { background: ...; }
55
+ }
56
+ ```
57
+
58
+ ### 3. Canvas-Styling ergänzen
59
+
60
+ ```scss
61
+ .dev-toolbar__grid {
62
+ canvas {
63
+ display: block;
64
+ width: 100%;
65
+ height: 100%;
66
+ }
67
+ }
68
+ ```
69
+
70
+ ---
71
+
72
+ ## JavaScript — `Toolbar.js`
73
+
74
+ ### Neue private Fields
75
+
76
+ ```javascript
77
+ #gridElement = null; // bisher nur lokale Variable, jetzt gespeichert
78
+ #canvas = null;
79
+ #ctx = null;
80
+ ```
81
+
82
+ ### `constructor()` — Änderungen
83
+
84
+ 1. `gridOverlay` → `this.#gridElement` (Referenz speichern)
85
+ 2. `#initCanvas()` aufrufen nach `prepend`
86
+
87
+ ```javascript
88
+ this.#gridElement = document.createElement('div');
89
+ this.#gridElement.classList.add('dev-toolbar__grid');
90
+ document.body.prepend(this.#gridElement);
91
+ this.#initCanvas();
92
+ ```
93
+
94
+ ### Neue Methode `#initCanvas()`
95
+
96
+ ```javascript
97
+ #initCanvas() {
98
+ this.#canvas = document.createElement('canvas');
99
+ this.#gridElement.appendChild(this.#canvas);
100
+ this.#ctx = this.#canvas.getContext('2d');
101
+ }
102
+ ```
103
+
104
+ ### Neue Methode `#drawGrid()`
105
+
106
+ Wird aufgerufen bei: State-Wechsel (`#applyState`), Resize (`#onResize`).
107
+
108
+ ```javascript
109
+ #drawGrid() {
110
+ const dpr = window.devicePixelRatio || 1;
111
+ const w = window.innerWidth;
112
+ const h = window.innerHeight;
113
+
114
+ this.#canvas.width = Math.round(w * dpr);
115
+ this.#canvas.height = Math.round(h * dpr);
116
+
117
+ this.#ctx.clearRect(0, 0, this.#canvas.width, this.#canvas.height);
118
+
119
+ if (this.#state.grid !== 'lines') return;
120
+
121
+ this.#ctx.setTransform(dpr, 0, 0, dpr, 0.5, 0); // scale + half-pixel shift
122
+
123
+ this.#drawColumnLines();
124
+ }
125
+ ```
126
+
127
+ **Half-pixel-Trick:** `ctx.setTransform(dpr, 0, 0, dpr, 0.5, 0)` — der 0.5px-Shift zentriert 1px-Linien exakt auf physische Pixel bei dpr=1. Bei dpr=2 (Retina) ist 0.5 CSS-px = 1 physischer Pixel, ebenfalls scharf.
128
+
129
+ ### Neue Methode `#drawColumnLines()`
130
+
131
+ ```javascript
132
+ #drawColumnLines() {
133
+ const style = getComputedStyle(document.body);
134
+ const columns = parseInt(style.getPropertyValue('--dev-columns'));
135
+ const gutter = this.#resolveToPx(style.getPropertyValue('--dev-gutter').trim());
136
+ const marginLeft = this.#resolveToPx(style.getPropertyValue('--dev-margin-left').trim());
137
+
138
+ // Kachelbreite = (Viewport - Margins + Gutter) / Spalten
139
+ // (identisch zur background-size-Formel im SCSS)
140
+ const gridWidth = window.innerWidth - marginLeft
141
+ - this.#resolveToPx(style.getPropertyValue('--dev-margin-right').trim())
142
+ + gutter;
143
+ const tileWidth = gridWidth / columns;
144
+ const colContent = tileWidth - gutter;
145
+
146
+ this.#ctx.strokeStyle = 'rgba(0, 0, 255, 0.5)';
147
+ this.#ctx.lineWidth = 1;
148
+ const h = window.innerHeight;
149
+
150
+ for (let i = 0; i < columns; i++) {
151
+ const leftEdge = marginLeft + i * tileWidth;
152
+ const rightEdge = leftEdge + colContent;
153
+
154
+ this.#ctx.beginPath();
155
+ this.#ctx.moveTo(Math.round(leftEdge), 0);
156
+ this.#ctx.lineTo(Math.round(leftEdge), h);
157
+ this.#ctx.stroke();
158
+
159
+ this.#ctx.beginPath();
160
+ this.#ctx.moveTo(Math.round(rightEdge), 0);
161
+ this.#ctx.lineTo(Math.round(rightEdge), h);
162
+ this.#ctx.stroke();
163
+ }
164
+ }
165
+ ```
166
+
167
+ ### Neue Methode `#resolveToPx(cssValue)`
168
+
169
+ Löst beliebige CSS-Einheiten (rem, vw, calc, …) auf px auf, ohne Unit-Parsing-Logik:
170
+
171
+ ```javascript
172
+ #resolveToPx(cssValue) {
173
+ if (/^-?\d+(\.\d+)?px$/.test(cssValue)) return parseFloat(cssValue);
174
+
175
+ const probe = document.createElement('div');
176
+ probe.style.cssText = `position:fixed;visibility:hidden;width:${cssValue};top:0;left:0;`;
177
+ document.body.appendChild(probe);
178
+ const px = probe.getBoundingClientRect().width;
179
+ probe.remove();
180
+ return px;
181
+ }
182
+ ```
183
+
184
+ ### `#applyState()` — Ergänzung
185
+
186
+ ```javascript
187
+ #applyState() {
188
+ const gridActive = this.#state.grid !== 'aus';
189
+ document.body.setAttribute('data-dev', String(gridActive));
190
+ document.body.setAttribute('data-dev-grid', this.#state.grid);
191
+ this.#updateImageSize();
192
+ this.#drawGrid(); // ← neu
193
+ }
194
+ ```
195
+
196
+ ### `#onResize` — Ergänzung
197
+
198
+ ```javascript
199
+ #onResize = () => {
200
+ this.#gui.title(this.#getViewportText());
201
+ this.#updateImageSize();
202
+ this.#drawGrid(); // ← neu
203
+ }
204
+ ```
205
+
206
+ ---
207
+
208
+ ## Positionsformel (Herleitung)
209
+
210
+ Das SCSS positioniert `::after` mit `margin-left = marginLeft - gutter/2`, sodass die Kacheln mittig zwischen Spalten beginnen. Die resultierenden Canvas-Positionen sind:
211
+
212
+ | | Formel | Beispiel (12 Spalten, gutter=20px, marginLeft=40px) |
213
+ | --- | --- | --- |
214
+ | Linke Kante Spalte i | `marginLeft + i × tileWidth` | i=0: 40px, i=1: 120px, … |
215
+ | Rechte Kante Spalte i | `marginLeft + i × tileWidth + colContent` | i=0: 100px, i=1: 180px, … |
216
+
217
+ Diese Formel ist identisch zur bestehenden `background-size`-Logik, nur ohne Kachel-Grenz-Problem.
218
+
219
+ ---
220
+
221
+ ## Verifikation
222
+
223
+ 1. `ddev npm run dev` starten
224
+ 2. Dev-Toolbar öffnen (Ctrl), Grid auf "lines" schalten
225
+ 3. **Browserfenster in verschiedene Breiten ziehen** → Linien dürfen nicht wackeln
226
+ 4. **Safari (Retina) + Chrome (1×)** vergleichen → gleiche Schärfe
227
+ 5. **Ribbons-Modus** prüfen → Canvas geleert, `::after`-Gradient unverändert
228
+ 6. **Breakpoint-Wechsel** prüfen → CSS Custom Properties werden neu gelesen, Linienanzahl passt sich an
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@profitlich/template-toolkit",
3
- "version": "2.4.2",
3
+ "version": "2.5.0",
4
4
  "description": "Shared SCSS layout system, JS utilities, components and build scripts for profitlich template repos",
5
5
  "type": "module",
6
6
  "exports": {
package/scripts/deploy.js CHANGED
@@ -2,27 +2,50 @@ import ftp from 'basic-ftp';
2
2
  import dotenv from 'dotenv';
3
3
  import { glob } from 'glob';
4
4
  import path from 'path';
5
+ import fs from 'fs/promises';
5
6
  import readline from 'readline';
6
7
  import cliProgress from 'cli-progress';
7
8
 
8
9
  // Helper for making sure the upload progress bars go to 100%
9
10
  const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
10
11
 
11
- export async function runDeploy(mode, uploadTasks) {
12
+ async function isNewer(client, localFile, remotePath) {
13
+ const localStat = await fs.stat(localFile);
14
+ try {
15
+ const remoteDate = await client.lastMod(remotePath);
16
+ return localStat.mtimeMs > remoteDate.getTime();
17
+ } catch {
18
+ // File doesn't exist remotely (550) or server doesn't support MDTM — upload it
19
+ return true;
20
+ }
21
+ }
22
+
23
+ async function createClient(accessOptions) {
12
24
  const client = new ftp.Client();
13
25
  client.ftp.verbose = false;
26
+ await client.access(accessOptions);
27
+ return client;
28
+ }
29
+
30
+ export async function runDeploy(mode, uploadTasks, options = {}) {
31
+ const parallel = options.parallel ?? 3;
32
+ const modeUpper = mode.toUpperCase();
33
+
34
+ const accessOptions = {
35
+ host: process.env[`FTP_HOST_${modeUpper}`],
36
+ user: process.env[`FTP_USER_${modeUpper}`],
37
+ password: process.env[`FTP_PASSWORD_${modeUpper}`],
38
+ secure: true
39
+ };
40
+
41
+ const mainClient = new ftp.Client();
42
+ mainClient.ftp.verbose = false;
14
43
  let activeProgressBar = null;
15
44
 
16
45
  try {
17
- const modeUpper = mode.toUpperCase();
18
46
  console.log(`🚀 Starting deployment for: ${modeUpper}`);
19
47
 
20
- await client.access({
21
- host: process.env[`FTP_HOST_${modeUpper}`],
22
- user: process.env[`FTP_USER_${modeUpper}`],
23
- password: process.env[`FTP_PASSWORD_${modeUpper}`],
24
- secure: true
25
- });
48
+ await mainClient.access(accessOptions);
26
49
 
27
50
  for (const task of uploadTasks) {
28
51
  console.log(`\nProcessing Task: ${task.name}`);
@@ -38,24 +61,65 @@ export async function runDeploy(mode, uploadTasks) {
38
61
  continue;
39
62
  }
40
63
 
41
- const newBar = new cliProgress.SingleBar({
64
+ // Determine which files are newer than their remote counterparts
65
+ process.stdout.write(` Checking ${files.length} files...`);
66
+ const filesToUpload = [];
67
+ for (const file of files) {
68
+ const relativeFile = path.relative(task.localBase, file);
69
+ const remotePath = path.join(task.remoteDir, relativeFile).replace(/\\/g, '/');
70
+ if (await isNewer(mainClient, file, remotePath)) {
71
+ filesToUpload.push({ file, remotePath });
72
+ }
73
+ }
74
+ process.stdout.write(` ${filesToUpload.length} changed.\n`);
75
+
76
+ if (filesToUpload.length === 0) {
77
+ console.log(' All files are up to date, skipping.');
78
+ continue;
79
+ }
80
+
81
+ const progressBar = new cliProgress.SingleBar({
42
82
  format: ' Upload |{bar}| {percentage}% {value}/{total} files {duration_formatted}',
43
83
  barCompleteChar: '█',
44
84
  barIncompleteChar: '░',
45
85
  hideCursor: true
46
86
  });
47
87
 
48
- activeProgressBar = newBar;
49
- activeProgressBar.start(files.length, 0);
88
+ activeProgressBar = progressBar;
89
+ progressBar.start(filesToUpload.length, 0);
50
90
 
51
- for (const file of files) {
52
- const relativeFile = path.relative(task.localBase, file);
53
- const remotePath = path.join(task.remoteDir, relativeFile).replace(/\\/g, '/');
54
-
55
- await client.ensureDir(path.dirname(remotePath));
56
- await client.uploadFrom(file, remotePath);
57
-
58
- activeProgressBar.increment();
91
+ if (parallel <= 1) {
92
+ for (const { file, remotePath } of filesToUpload) {
93
+ await mainClient.ensureDir(path.dirname(remotePath));
94
+ await mainClient.uploadFrom(file, remotePath);
95
+ progressBar.increment();
96
+ }
97
+ } else {
98
+ // Pre-create all required remote directories with the main client
99
+ const uniqueDirs = [...new Set(filesToUpload.map(({ remotePath }) => path.dirname(remotePath)))];
100
+ for (const dir of uniqueDirs) {
101
+ await mainClient.ensureDir(dir);
102
+ }
103
+
104
+ // Spawn parallel worker connections
105
+ const workerCount = Math.min(parallel, filesToUpload.length);
106
+ const workers = await Promise.all(
107
+ Array.from({ length: workerCount }, () => createClient(accessOptions))
108
+ );
109
+
110
+ const queue = [...filesToUpload];
111
+ await Promise.all(workers.map(async (workerClient) => {
112
+ try {
113
+ while (true) {
114
+ const item = queue.shift();
115
+ if (!item) break;
116
+ await workerClient.uploadFrom(item.file, item.remotePath);
117
+ progressBar.increment();
118
+ }
119
+ } finally {
120
+ workerClient.close();
121
+ }
122
+ }));
59
123
  }
60
124
 
61
125
  activeProgressBar.stop();
@@ -71,15 +135,16 @@ export async function runDeploy(mode, uploadTasks) {
71
135
  }
72
136
  console.error('Deployment failed:', err);
73
137
  } finally {
74
- client.close();
138
+ mainClient.close();
75
139
  }
76
140
  }
77
141
 
78
142
  /**
79
143
  * Run the deploy script.
80
144
  * @param {Array} uploadTasks - array of { name, localPattern, localBase, remoteDir, ignore? }
145
+ * @param {Object} options - { parallel: number } (default: { parallel: 3 })
81
146
  */
82
- export function run(uploadTasks) {
147
+ export function run(uploadTasks, options = {}) {
83
148
  const mode = process.argv[2];
84
149
  if (!mode || (mode !== 'staging' && mode !== 'production')) {
85
150
  console.error('Error: a mode needs to be given, either "staging" or "production"');
@@ -99,7 +164,7 @@ export function run(uploadTasks) {
99
164
  rl.close();
100
165
  if (answer.toLowerCase() === 'yes') {
101
166
  console.log('Confirmed. Starting upload...');
102
- runDeploy(mode, uploadTasks);
167
+ runDeploy(mode, uploadTasks, options);
103
168
  } else {
104
169
  console.log('❌ Deployment aborted.');
105
170
  process.exit(0);
@@ -107,6 +172,6 @@ export function run(uploadTasks) {
107
172
  });
108
173
  // In all other modes run deploy without prompt
109
174
  } else {
110
- runDeploy(mode, uploadTasks);
175
+ runDeploy(mode, uploadTasks, options);
111
176
  }
112
177
  }
@@ -197,8 +197,6 @@ $mediaqueries: (
197
197
  // Distance
198
198
  @mixin marginPadding($layout, $mode, $position, $top: 0, $right: 0, $bottom: 0, $left: 0, $important: null) {
199
199
 
200
- // @include mode($layout, padding/margin, all/top/right/bottom/left, 0, 0, 0, 0)
201
-
202
200
  @if $important == true {
203
201
  $important: '!important';
204
202
  }
@@ -225,7 +223,7 @@ $mediaqueries: (
225
223
  }
226
224
 
227
225
  @if $position == all {
228
- #{$mode}: $top $right $bottom $left $important;
226
+ #{$mode}: $top $right $bottom $left #{$important};
229
227
  }
230
228
  @if $position == top {
231
229
  #{$mode}-top: $top;