diff --git a/post1.html b/post1.html
index c149e33..148fa7a 100644
--- a/post1.html
+++ b/post1.html
@@ -129,6 +129,23 @@
padding: 9px 11px; margin-bottom: 9px; min-height: 56px;
}
.edit-info .dim { color: #6E7480; }
+ /* Ziehen ist gut fuer grob, tippen fuer genau. Ohne Eingabefelder laesst
+ sich ein Wert nicht ueber mehrere Slides hinweg exakt gleich setzen. */
+ .edit-info .fields {
+ display: grid; grid-template-columns: 42px 1fr 20px 1fr;
+ gap: 6px 7px; align-items: center; margin-top: 8px;
+ }
+ .edit-info label { color: #6E7480; font-size: 11px; }
+ .edit-info input {
+ width: 100%; box-sizing: border-box;
+ background: #14161C; border: 1px solid #343845; border-radius: 6px;
+ color: #E7EAF0; font: inherit; font-variant-numeric: tabular-nums;
+ padding: 4px 6px; text-align: right; -moz-appearance: textfield;
+ }
+ .edit-info input::-webkit-outer-spin-button,
+ .edit-info input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
+ .edit-info input:focus { outline: none; border-color: var(--accent-yellow); }
+ .edit-info .hint { color: #6E7480; margin-top: 6px; }
.std { margin-bottom: 22px; }
.std summary {
cursor: pointer; font-size: 11px; font-weight: 700; letter-spacing: .09em;
@@ -191,6 +208,17 @@
}
.zoom button:hover { background: #262932; }
.zoom .level { min-width: 58px; text-align: center; color: #8A8F9B; font-variant-numeric: tabular-nums; }
+ /* Merkzettel neben der Zoomleiste: ein Stand, den man von Hand ablegt und
+ bewusst zurueckholt. Der laufende Projektstand speichert dauernd von
+ selbst und taugt deshalb nicht als Rueckfallpunkt. */
+ .snap { display: flex; align-items: center; gap: 4px; margin-right: 10px; }
+ .snap button {
+ font-family: inherit; font-size: 13px; font-weight: 600; cursor: pointer;
+ background: #1D1F26; color: #C7CCD6; border: 1px solid #2E313B;
+ border-radius: 7px; padding: 6px 11px;
+ }
+ .snap button:hover { background: #262932; }
+ .snap button:disabled { opacity: .4; cursor: default; }
/* Der Streifen zeigt alle Slides nebeneinander und lückenlos, damit der
durchgehende Aufbau beim Arbeiten sichtbar ist. Der Rahmen reserviert die
skalierte Fläche; die Slides selbst bleiben immer echte 1080 x 1350. */
@@ -354,6 +382,35 @@
.slide[data-editmode="an"] [data-edit]:hover { outline: 3px dashed rgba(241,183,41,.75); outline-offset: 6px; }
.slide[data-editmode="an"] [data-edit].picked { outline: 4px solid var(--accent-yellow); outline-offset: 6px; }
+ /* ─── Hilfslinien beim Ziehen ────────────────────────────────────────────
+ Nur solange gezogen wird und nur auf der Slide, an der gezogen wird: das
+ Raster deckt die 1080 × 1350 ab, nicht das Canvas. Ein Raster über alle
+ Slides hinweg hilft nicht, weil jede Slide ihre eigene Bühne ist.
+ pointer-events bleiben aus, sonst reisst die Ebene den Zug ab. */
+ .guides { position: absolute; inset: 0; z-index: 9; display: none; pointer-events: none; }
+ .slide[data-guides="an"] .guides { display: block; }
+ /* 90 px = 1080/12 = 1350/15, teilt beide Kanten ohne Rest. */
+ .guides .grid {
+ position: absolute; inset: 0;
+ background-image:
+ repeating-linear-gradient(to right, var(--guide-line) 0 1px, transparent 1px 90px),
+ repeating-linear-gradient(to bottom, var(--guide-line) 0 1px, transparent 1px 90px);
+ }
+ /* Der Innenabstand aus den Regeln, damit man sieht, wann Text ihn verlässt. */
+ .guides .safe { position: absolute; inset: 92px; border: 2px dashed var(--guide-safe); }
+ .guides .ax { position: absolute; background: var(--guide-axis); }
+ .guides .ax-v { left: 50%; top: 0; bottom: 0; width: 2px; margin-left: -1px; }
+ .guides .ax-h { top: 50%; left: 0; right: 0; height: 2px; margin-top: -1px; }
+ /* Liegt die Mitte des Elements auf der Achse, wird sie kräftig: das ist die
+ Rückmeldung, auf die es beim Zentrieren ankommt. */
+ .guides .ax.on { background: var(--accent-yellow); }
+ .guides .ax-v.on { width: 4px; margin-left: -2px; }
+ .guides .ax-h.on { height: 4px; margin-top: -2px; }
+ /* Fadenkreuz des gezogenen Elements, in Slide-Koordinaten gesetzt. */
+ .guides .mid { position: absolute; background: var(--guide-mid); }
+ .guides .mid-v { top: 0; bottom: 0; width: 2px; margin-left: -1px; }
+ .guides .mid-h { left: 0; right: 0; height: 2px; margin-top: -1px; }
+
/* Jede Slide trägt nicht nur ihren eigenen Inhalt, sondern auch den ihrer
Nachbarn, verschoben um deren Position. Dadurch läuft ein nach links
geschobenes Element aus Slide 3 tatsächlich in Slide 2 hinein, statt am
@@ -738,6 +795,10 @@
--shape-accent-2: rgba(255,255,255,.14);
--pat-color: #FFFFFF; --pat-strength: .07;
--gmark-color: rgba(255,255,255,.13);
+ --guide-line: rgba(255,255,255,.16);
+ --guide-safe: rgba(255,255,255,.26);
+ --guide-axis: rgba(255,255,255,.40);
+ --guide-mid: rgba(241,183,41,.80);
}
.slide[data-variant="dunkel"] .headline .a,
.slide[data-variant="dunkel"] .title-head .a { color: #1A1A1A; }
@@ -756,6 +817,10 @@
--shape-accent-2: rgba(48,49,61,.16);
--pat-color: #30313D; --pat-strength: .07;
--gmark-color: rgba(48,49,61,.16);
+ --guide-line: rgba(48,49,61,.20);
+ --guide-safe: rgba(48,49,61,.30);
+ --guide-axis: rgba(48,49,61,.45);
+ --guide-mid: rgba(200,144,26,.75);
}
@@ -803,6 +868,12 @@
+
+
+
+
@@ -914,6 +985,10 @@
+
+
+
+
@@ -999,7 +1074,10 @@
key: "chat", type: "feature",
layouts: [{ id: "standard", label: "Standard" }],
eyebrow: "Team-Chat",
- head: 'Jedes Projekt bekommt automatisch seinen Kanal.',
+ // "automatisch seinen Kanal." misst mit Marker 933 px bei 896
+ // verfuegbaren und bricht deshalb in eine dritte Zeile. "von selbst"
+ // sagt dasselbe und bleibt mit 846 px in der Zeile.
+ head: 'Jedes Projekt bekommt von selbst seinen Kanal.',
body: "Foto von der Baustelle rein, alle sehen's sofort. Aus jeder Nachricht wird mit einem Klick eine Aufgabe.",
img: "./assets/screens/chat.png",
mockW: 1030, mockAR: 1.703,
@@ -1080,13 +1158,36 @@
const STORE = "hivegrid.projekt." + PROJECT;
const STATE_URL = "./state/" + PROJECT + ".json";
+ const STATE_V = 2;
+
function currentState() {
return {
- v: 1, variant, tilt, superSample, continuous, view, tagOut, frameStyle,
+ v: STATE_V, variant, tilt, superSample, continuous, view, tagOut, frameStyle,
current, layoutState, bgState, edits,
};
}
+ /* ─── Angleichung des Tags (einmalig, Stand 1 → 2) ───────────────────────
+ Der Tag war auf jeder Slide einzeln gezogen worden und stand deshalb
+ überall etwas anders. Er soll auf allen Feature-Slides denselben Abstand
+ zur oberen und linken Kante haben; Maß ist die erste Feature-Slide.
+ Das läuft hier und nicht über die Datei: solange ein Tab offen ist, hält
+ der seinen Stand im Speicher und schreibt jede Änderung an der Datei beim
+ nächsten Klick wieder zu. Im Ladepfad kann er das nicht. */
+ function migrateState(d) {
+ if (!d || (d.v || 1) >= 2) return false;
+ const feat = SLIDES.map((s, i) => (s.type === "feature" ? i : -1)).filter(i => i >= 0);
+ const ref = feat.length && Array.isArray(d.edits) ? (d.edits[feat[0]] || {}).badge : null;
+ if (ref) {
+ for (const i of feat.slice(1)) {
+ const e = d.edits[i] || (d.edits[i] = {});
+ e.badge = Object.assign({}, e.badge, { dx: ref.dx || 0, dy: ref.dy || 0 });
+ }
+ }
+ d.v = 2;
+ return true;
+ }
+
/* Der Stand geht in zwei Ablagen. Sofort in den localStorage, damit die
Rückmeldung nicht auf das Netz wartet, und gebündelt in
state/.json. Nur die Datei lässt sich pushen, also ist sie die
@@ -1132,10 +1233,12 @@
if (!d) {
try { d = JSON.parse(localStorage.getItem(STORE) || "null"); } catch (_) {}
}
- if (!d || d.v !== 1) return false;
+ if (!d || !(d.v === 1 || d.v === 2)) return false;
+ const migrated = migrateState(d);
// Stand aus dem Browser, aber noch keine Datei: einmalig hinueberschreiben,
// sonst bliebe ein ueber Wochen gewachsener Stand ungeteilt liegen.
- if (!fromFile) setTimeout(() => pushState(currentState()), 0);
+ // Nach einer Angleichung ebenso, damit sie nicht bei jedem Laden erneut greift.
+ if (!fromFile || migrated) setTimeout(() => pushState(currentState()), 0);
// Nur übernehmen, was zur aktuellen Slide-Anzahl passt: Änderungen am
// Aufbau dürfen keinen kaputten Stand einspielen.
if (Array.isArray(d.layoutState) && d.layoutState.length === SLIDES.length) {
@@ -1543,9 +1646,48 @@
sheet.appendChild(buildPage(j));
}
el.appendChild(sheet);
+ // Über allem, damit die Linien auch auf dem Mockup lesbar bleiben.
+ el.insertAdjacentHTML("beforeend",
+ '
' +
+ '' +
+ '
');
return el;
}
+ /* ─── Hilfslinien ────────────────────────────────────────────────────────
+ Die Linien gehören zur Slide, nicht zum Canvas: gemessen wird deshalb
+ gegen deren Rechteck. Der Maßstab kürzt sich dabei heraus, die Werte sind
+ also unabhängig vom Zoom. */
+ const SNAP = 4; // px in Slide-Koordinaten
+ let guideSlide = null;
+
+ function showGuides(slideEl) {
+ guideSlide = slideEl;
+ slideEl.dataset.guides = "an";
+ }
+
+ function hideGuides() {
+ if (!guideSlide) return;
+ delete guideSlide.dataset.guides;
+ guideSlide.querySelectorAll(".guides .on").forEach(n => n.classList.remove("on"));
+ guideSlide = null;
+ }
+
+ function updateGuides(node) {
+ if (!guideSlide) return;
+ const s = guideSlide.getBoundingClientRect();
+ const n = node.getBoundingClientRect();
+ if (!s.width || !n.width) return;
+ const k = s.width / 1080;
+ const cx = (n.left + n.width / 2 - s.left) / k;
+ const cy = (n.top + n.height / 2 - s.top) / k;
+ const g = guideSlide.querySelector(".guides");
+ g.querySelector(".mid-v").style.left = cx + "px";
+ g.querySelector(".mid-h").style.top = cy + "px";
+ g.querySelector(".ax-v").classList.toggle("on", Math.abs(cx - 540) <= SNAP);
+ g.querySelector(".ax-h").classList.toggle("on", Math.abs(cy - 675) <= SNAP);
+ }
+
/* Every asset is inlined as a data URL once, before the deck is built.
Same-origin already satisfies the canvas, but inlining removes every fetch
and timing failure mode from the export path, and it lets a layout switch
@@ -1873,6 +2015,9 @@
}
async function renderSlide(i) {
+ // Hilfslinien sind Werkzeug: koennten sie aus einem Tastendruck noch
+ // stehen, landeten sie im Bild.
+ clearTimeout(nudgeTimer); hideGuides();
const holder = deck.querySelectorAll(".holder")[i];
const el = holder.querySelector(".slide");
// In der Einzelansicht sind die übrigen Holder ausgeblendet; zum Messen und
@@ -2047,15 +2192,19 @@
const startX = e.clientX, startY = e.clientY;
const baseX = set.dx || 0, baseY = set.dy || 0;
node.style.cursor = "grabbing";
+ showGuides(holder.querySelector(".slide"));
+ updateGuides(node);
const move = ev => {
set.dx = Math.round(baseX + (ev.clientX - startX) / k);
set.dy = Math.round(baseY + (ev.clientY - startY) / k);
applyEditsAll(); // zieht alle Kopien der Seite mit
+ updateGuides(node);
syncEditPanel();
};
const up = () => {
node.style.cursor = "";
+ hideGuides();
document.removeEventListener("mousemove", move);
document.removeEventListener("mouseup", up);
saveState();
@@ -2072,6 +2221,7 @@
syncEditPanel();
}
+ let nudgeTimer = null;
function nudge(dx, dy) {
if (!picked) return;
const set = (edits[picked.slide][picked.key] = edits[picked.slide][picked.key] || {});
@@ -2079,6 +2229,17 @@
set.dy = (set.dy || 0) + dy;
applyEditsAll();
pick(picked.slide, picked.key);
+ // Pixelweise schieben braucht dieselbe Rueckmeldung wie das Ziehen, nur
+ // soll sie danach von selbst verschwinden.
+ const holder = deck.querySelectorAll(".holder")[current];
+ const node = holder && holder.querySelector('.page[data-j="' + picked.slide +
+ '"] [data-edit="' + picked.key + '"]');
+ if (node) {
+ showGuides(holder.querySelector(".slide"));
+ updateGuides(node);
+ clearTimeout(nudgeTimer);
+ nudgeTimer = setTimeout(hideGuides, 900);
+ }
saveState();
}
@@ -2112,19 +2273,98 @@
setStatus("Alle Slides auf Standard zurückgesetzt", "ok");
}
+ /* X und Y zaehlen ab der Mitte der Slide, nicht ab der Stelle, an der das
+ Element von sich aus laege. Nur so bedeuten gleiche Werte bei zwei
+ verschiedenen Elementen auch dieselbe Stelle.
+ Gemessen wird die OBERE LINKE ECKE, nicht der Mittelpunkt. Eine
+ zweizeilige und eine dreizeilige Ueberschrift sind unterschiedlich hoch;
+ bei gleichem Mittelpunkt liegen ihre Oberkanten 39 px auseinander und der
+ hoehere Block waechst nach oben ins Tag. Ab der ersten Zeile gerechnet
+ stimmt es unabhaengig von der Zeilenzahl.
+ Gemessen wird die erste sichtbare Kopie: in der Einzelansicht sind die
+ uebrigen Holder ausgeblendet und liefern ein leeres Rechteck. */
+ function centreOf(slideIdx, key) {
+ const nodes = deck.querySelectorAll(
+ '.page[data-j="' + slideIdx + '"] [data-edit="' + key + '"]');
+ for (const n of nodes) {
+ const r = n.getBoundingClientRect();
+ if (!r.width) continue;
+ const holder = n.closest(".slide");
+ const sr = holder && holder.getBoundingClientRect();
+ if (!sr || !sr.width) continue;
+ const k = sr.width / 1080;
+ return {
+ x: Math.round((r.left - sr.left) / k - 540),
+ y: Math.round((r.top - sr.top) / k - 675),
+ };
+ }
+ return null;
+ }
+
function syncEditPanel() {
const box = document.getElementById("edit-info");
- if (!picked) { box.innerHTML = 'Nichts ausgewählt'; return; }
+ if (!picked) {
+ box.dataset.for = "";
+ box.innerHTML = 'Nichts ausgewählt';
+ return;
+ }
const std = STANDARD[picked.key] || {};
const e = (edits[picked.slide] || {})[picked.key] || {};
const base = stdSize(picked.key, picked.slide);
- const val = e.size || base;
- const unit = std.prop === "font-size" ? "px Schrift" : "px breit";
- box.innerHTML =
- '' + (std.label || picked.key) + ' · Slide ' + (picked.slide + 1) + ' ' +
- (std.prop ? val + " " + unit + (e.size ? " (Standard " + base + ")" : "") + " " : "") +
- "Versatz " + (e.dx || 0) + " / " + (e.dy || 0) + " px";
+ const id = picked.slide + ":" + picked.key;
+ // Nur bei einer anderen Auswahl neu aufbauen. Beim Ziehen laeuft das hier
+ // pro Mausbewegung; ein Neuaufbau wuerde die Eingabe unter den Fingern
+ // wegreissen.
+ if (box.dataset.for !== id) {
+ box.dataset.for = id;
+ box.innerHTML =
+ '' + (std.label || picked.key) + ' · Slide ' + (picked.slide + 1) +
+ '