diff --git a/.github/workflows/showcase.yml b/.github/workflows/showcase.yml index 5bd19a7..f976771 100644 --- a/.github/workflows/showcase.yml +++ b/.github/workflows/showcase.yml @@ -303,6 +303,22 @@ jobs: cat /tmp/gl.log >&2; exit 1; } echo "loop-close.py boccia un ciclo che non si chiude, come deve." + # E deve bocciare anche uno stacco IN MEZZO al ciclo: si rimette CHR-02 + # a ripartire dal vuoto nella seconda meta', cioe' com'era prima + # dell'orologio virtuale. E' il difetto che si vedeva scorrendo il sito. + sed 's/var second=f>=125, q=vtime(second?f-125:f,125,86,14);/var second=f>=125, q=second?f-125:f;/' \ + showcase/dist/grammatica.html > /tmp/guasta-mezzo.html + if cmp -s showcase/dist/grammatica.html /tmp/guasta-mezzo.html; then + echo "la copia guasta di mezzo e' identica all'originale: la sed non ha trovato niente." >&2 + exit 1 + fi + rc=0; ./scripts/loop-close.py /tmp/guasta-mezzo.html > /tmp/gm.log 2>&1 || rc=$? + [ "$rc" != 0 ] || { echo "loop-close.py promuove una meta' che riparte da capo." >&2; cat /tmp/gm.log >&2; exit 1; } + grep -q 'CHR-02' /tmp/gm.log || { + echo "loop-close.py boccia la copia guasta ma non nomina CHR-02:" >&2 + cat /tmp/gm.log >&2; exit 1; } + echo "loop-close.py boccia anche uno stacco in mezzo al ciclo." + # I render restano scaricabili dalla run anche quando il deploy non parte, # cosi' su una PR si guarda il video invece di fidarsi del diff. - uses: actions/upload-artifact@v4 diff --git a/README.md b/README.md index 3ee3c13..623967e 100644 --- a/README.md +++ b/README.md @@ -140,13 +140,27 @@ counted twice. The question that works is simpler: **is the last frame one step away from the first, or somewhere else entirely?** Compare the wrap against a normal one-frame step taken at the same place in the loop. -It measures the wrap and *not* the cuts in the middle, and that is a real limit -rather than an oversight. No metric I tried separates a cut from a **fade**. -Counting pixels over a threshold, a linear fade spikes halfway through — that is -where most pixels cross at once — so the bench flagged a line of text leaving the -frame, which is the most continuous thing on the page. With a mean difference the -spike goes away and the measure becomes so sensitive it flags everything. The -mid-loop cut in `CHR-02` was found by looking, not by measuring. +It measures the cut in the middle too, and getting there took giving up on being +clever. Half the entries show the right case and then the wrong one, and in the +handover between the two they can restart from scratch — the same cut, somewhere +else in the loop. Hunting for it did not work: a cut and a **fade** look too much +alike. Counting pixels over a threshold, a linear fade spikes halfway through — +that is where most pixels cross at once — so the bench flagged a line of text +leaving the frame, which is the most continuous thing on the page. With a mean +difference the spike goes away and the measure becomes so sensitive it flags +everything. + +The answer was not a smarter metric, it was to stop guessing: every two-part demo +**declares** the frame it changes on, and the bench looks there. It is the same +bargain `seamAfter` strikes in `catalog.json` — the scene says where the join is +and the bench measures it — and it holds for the same reason: a declared join can +be measured exactly, a guessed one cannot. + +Pointed at the declared frames it found exactly one survivor, and it was the one +being reported: `CUR-03` changed 547 pixels in a single frame against zero in its +neighbours, because the answer to the click sat there until the half ran out and +then vanished. It resolves now instead — the work finishes, which is what the app +would do — and that is not a rewind: nobody sees the line write itself backwards. One more defect, and it was the one that showed. `TYP-02` grew its keyword by changing its font size inside a single flex line — and changing the size changes @@ -158,7 +172,7 @@ asserted. It is also closer to the references, where the enormous word is alone. ## Typography, where the sentence is the film -Four entries with no interface on screen at all. They come from three reference +Ten entries with no interface on screen at all. They come from three reference films the type direction was read off — a SaaS promo, a Spotify spot, an Apple Business Essentials piece — and their common DNA: the typography *is* the film, one phrase per beat, always centred, heavy sans with tight tracking, scale @@ -187,6 +201,40 @@ slowing the cut down, tighten the entry stagger and lengthen only the dwell. Widening the stagger looks like generosity and is theft, because every frame it takes comes out of the only stretch where anybody is reading. +Six more went in after the first four were looked at, and they split into how a +line *arrives* and where it *sits*. + +Arriving: a **mask** that uncovers each word from behind the edge of its own box +— the most common technique in the reference work, and the reason is that a mask +does not move the text, the word is already in place and only gets revealed. The +**grain of the stagger**, letter by letter against word by word: 23 entry moments +against 7 on the same sentence, which is the difference between a line that pours +and a line that lands in blocks. **Tracking** closing from 0.225em, the only +entrance that brings nothing in from off-frame — the sentence is all there and +only stops holding its breath. And **weight** landing from 300 to 800. + +Two of those corrected the entry that described them, which is the point of +measuring. The weight one claimed weight was the axis that changes a word's ink +without changing the room it takes; the render disagreed in the first frame, +because the long line fitted on one row at 300 and wrapped at 800. It is now two +words, the width growth is stated (12 per cent) rather than denied, and the +lesson is written down: on a long line you either keep it short or lock the +width. Tracking had the same shape of problem one floor down — wide spacing +pushed the line onto three rows and tight spacing onto two, so the composition +redid itself mid-animation. Tracking *is* a layout property, which is the entry; +the demo is two words so the effect does not eat the composition. + +Sitting: **the companion on another axis** — the small rotated line running up +the side in spaced capitals, which is what the references put next to every main +sentence. It does not compete because it is not on the axis the eye is following; +set flat underneath, the same words become a subtitle, and a subtitle is a second +thing to read. And **the sentence on the plane**, which is the entry that ties +this family to the rest of the repo: the type lives in the slab's own +perspective instead of sitting on the frame. If the film is an inclined object +seen by a camera, a sentence lying flat on the glass comes from a different film. +The bench measures it the cheapest way there is — on the plane the two ends of +the line differ (0.909), flat they are identical (1.000). + The page grew a second kind of stage for these. Type demos do not mount the slab — mounting it and then covering it would render a kanban behind a word for nothing, and the stage's perspective would falsify the type size. Same engine, diff --git a/scripts/demo-check.py b/scripts/demo-check.py index 5aca09b..0e4624b 100755 --- a/scripts/demo-check.py +++ b/scripts/demo-check.py @@ -385,6 +385,122 @@ def drift(code, lo, hi): check("TYP-04", w1 < 1.0 and w2 > 20, "spostamento delle tre parole in comune: %.2f px sostituendo una parola, %.0f px sostituendo la riga" % (w1, w2)) + # TYP-05 - scoprire non e' accendere. + # Il caso peggiore: se la parola comparisse invece di essere scoperta, la + # sua altezza resa sarebbe piena dal primo fotogramma e cambierebbe solo + # l'opacita'. + # La casella che ritaglia non cambia mai altezza - e' il suo mestiere - + # quindi non e' li' che si vede la differenza. Quello che si misura e' di + # quanto la parola sia SPOSTATA dentro la casella: scoprendola parte fuori + # e sale, in dissolvenza sta ferma al suo posto dal primo fotogramma. + def offset(code, lo, hi): + vals = [] + for f in range(lo, hi, 2): + vals.append(pg.evaluate("""([c,f])=>{ + const h=[...document.querySelectorAll('.mv')]; + const i=h.findIndex(m=>(m.querySelector('.code')||{}).textContent.trim()===c); + const st=document.querySelectorAll('.stage')[i], it=st.__it; + it.manual=f; it.last=-1; it.mv.draw(f,it.S); + const box=st.querySelectorAll('.tline > .tclip')[3]; + const inn=box.firstElementChild; + return inn.getBoundingClientRect().top - box.getBoundingClientRect().top; + }""", [code, f])) + return min(vals), max(vals) + o1 = offset("TYP-05", 8, 50) + o2 = offset("TYP-05", 123, 165) + # L'ESCURSIONE, non il valore: la casella ha un padding in cima, quindi la + # parola sta comunque una decina di pixel sotto il suo bordo anche da ferma. + # Quello che distingue lo scoprire dall'accendere e' di quanto si MUOVE. + r1, r2 = o1[1] - o1[0], o2[1] - o2[0] + check("TYP-05", r1 > 40 and r2 < 3, + "escursione della parola dentro la sua casella: %.0f px scoprendola, %.0f px in dissolvenza" + % (r1, r2)) + + # TYP-06 - il grano dello scaglionamento. + def moments(code, lo, hi): + seen, first = set(), {} + for f in range(lo, hi): + st = ty(code, f) + for j, sp in enumerate(st["spans"]): + pass + return seen, first + def entries(code, lo, hi): + # I fotogrammi distinti in cui QUALCOSA diventa visibile per la prima + # volta: e' il numero di momenti d'ingresso, che e' la tesi della voce. + prev = None + out = set() + for f in range(lo, hi): + cur = pg.evaluate("""([c,f])=>{ + const h=[...document.querySelectorAll('.mv')]; + const i=h.findIndex(m=>(m.querySelector('.code')||{}).textContent.trim()===c); + const st=document.querySelectorAll('.stage')[i], it=st.__it; + it.manual=f; it.last=-1; it.mv.draw(f,it.S); + return [...st.querySelectorAll('.tline .tw > span')].map(e=>parseFloat(getComputedStyle(e).opacity)>0.06?1:0); + }""", [code, f]) + if prev is not None and any(c and not p for c, p in zip(cur, prev)): + out.add(f) + prev = cur + return len(out) + e1, e2 = entries("TYP-06", 4, 60), entries("TYP-06", 129, 185) + check("TYP-06", e1 > e2 * 2.5, + "momenti d'ingresso distinti: %d lettera per lettera, %d parola per parola" % (e1, e2)) + + # TYP-07 - la spaziatura si chiude, quindi la riga si stringe. + # La larghezza di UNA parola, non il riquadro della riga: se la riga va a + # capo il riquadro misura la riga piu' larga e la spaziatura che si chiude + # risulta allargarla. La parola porta dentro la propria spaziatura e non ha + # quel problema. + def linew(code, f): + return ty(code, f)["spans"][1]["w"] + w_a, w_b = linew("TYP-07", 18), linew("TYP-07", 70) + w_c, w_d = linew("TYP-07", 128), linew("TYP-07", 180) + check("TYP-07", w_a > w_b * 1.12 and abs(w_c - w_d) < 2, + "larghezza della parola: da %.0f a %.0f px chiudendo la spaziatura, %.0f contro %.0f senza" + % (w_a, w_b, w_c, w_d)) + + # TYP-08 - il peso sale, e la larghezza con lui. + def wgt(code, f): + return pg.evaluate("""([c,f])=>{ + const h=[...document.querySelectorAll('.mv')]; + const i=h.findIndex(m=>(m.querySelector('.code')||{}).textContent.trim()===c); + const st=document.querySelectorAll('.stage')[i], it=st.__it; + it.manual=f; it.last=-1; it.mv.draw(f,it.S); + const l=st.querySelector('.tline'), s=st.querySelector('.tline > .tw'); + return [parseFloat(getComputedStyle(l).fontWeight), s.getBoundingClientRect().width]; + }""", [code, f]) + p_a, p_b = wgt("TYP-08", 16), wgt("TYP-08", 66) + p_c = wgt("TYP-08", 130) + # QUANTO cresca la larghezza e' affare della famiglia, non della voce: su + # questa macchina l'11 per cento, sul Linux della CI il 3,6. Il banco chiede + # che cresca, non di quanto - una soglia sul valore misurerebbe il font + # installato sulla macchina invece del comportamento del peso. + check("TYP-08", p_a[0] < 380 and p_b[0] > 760 and p_b[1] > p_a[1] * 1.025 and p_c[0] > 760, + "peso da %.0f a %.0f, e la prima parola da %.0f a %.0f px; nella seconda meta' resta a %.0f" + % (p_a[0], p_b[0], p_a[1], p_b[1], p_c[0])) + + # TYP-09 - la compagna sta su un altro asse. + def side(code, f): + return pg.evaluate("""([c,f])=>{ + const h=[...document.querySelectorAll('.mv')]; + const i=h.findIndex(m=>(m.querySelector('.code')||{}).textContent.trim()===c); + const st=document.querySelectorAll('.stage')[i], it=st.__it, sr=st.getBoundingClientRect(); + it.manual=f; it.last=-1; it.mv.draw(f,it.S); + const e=st.querySelector('.tside'), r=e.getBoundingClientRect(); + return {tall: r.height > r.width, x:(r.left+r.width/2-sr.left)/sr.width}; + }""", [code, f]) + s1, s2 = side("TYP-09", 70), side("TYP-09", 185) + check("TYP-09", s1["tall"] and s1["x"] < 0.2 and (not s2["tall"]) and abs(s2["x"] - 0.5) < 0.1, + "compagna: verticale al %.0f%% della larghezza, contro orizzontale al %.0f%%" + % (s1["x"] * 100, s2["x"] * 100)) + + # TYP-10 - sul piano i due capi della riga non sono uguali. + def fore(code, f): + sp = ty(code, f)["spans"] + return sp[0]["h"] / sp[-1]["h"] + f1, f2 = fore("TYP-10", 90), fore("TYP-10", 215) + check("TYP-10", abs(f1 - 1) > 0.05 and abs(f2 - 1) < 0.01, + "rapporto fra il primo e l'ultimo capo della riga: %.3f sul piano, %.3f da piatta" % (f1, f2)) + br.close() print() diff --git a/scripts/loop-close.py b/scripts/loop-close.py index 61f21f3..2d5d620 100755 --- a/scripts/loop-close.py +++ b/scripts/loop-close.py @@ -43,20 +43,23 @@ che si accende poche decine. Una soglia in pixel assoluti boccerebbe le prime e promuoverebbe le seconde. -QUESTO BANCO MISURA SOLO LA GIUNTA DEL CICLO, e non gli stacchi in mezzo. Ci ho -provato, e non sono riuscito a tarare la cosa: uno stacco a meta' ciclo e' lo -stesso difetto in un altro punto - CHR-02 faceva sparire una board intera in un -fotogramma - ma nessuna delle misure che ho provato distingue uno stacco da una -DISSOLVENZA. Contando i pixel sopra una soglia, una dissolvenza lineare produce -un picco a meta' strada, dove la maggior parte dei pixel attraversa la soglia -tutti insieme, e il banco segnalava come stacco l'uscita di campo di una riga di +E ANCHE LA GIUNTA IN MEZZO, ma solo perche' la demo la dichiara. Meta' delle +voci mostrano il caso giusto e poi quello sbagliato, e nel passaggio fra i due +possono ripartire da capo azzerando tutto: e' lo stesso stacco, in un altro +punto del ciclo. + +Cercarlo da soli non ha funzionato. Uno stacco e una DISSOLVENZA si somigliano +troppo: contando i pixel sopra una soglia, una dissolvenza lineare produce un +picco a meta' strada - dove la maggior parte dei pixel attraversa la soglia tutti +insieme - e il banco segnalava come stacco l'uscita di campo di una riga di testo, che e' la cosa piu' continua che ci sia. Con la differenza media il picco -sparisce ma la misura diventa cosi' sensibile che segnala tutto, e il pavimento -che separerebbe le due cose non l'ho trovato. +sparisce ma la misura diventa cosi' sensibile che segnala tutto. -Quindi la giunta del ciclo la misura, e gli stacchi in mezzo restano da trovare -a mano. E' meno di quello che serve, ed e' meglio di un verdetto che non regge: -lo stacco interno di CHR-02 e' stato trovato guardando, non misurando. +La soluzione non e' stata una misura piu' furba, e' stata smettere di indovinare: +ogni demo a due meta' DICHIARA il fotogramma in cui cambia, con `half`, e il +banco va a guardare li'. E' lo stesso patto di `seamAfter` in catalog.json - la +scena dice dove sta la giunta e il banco la misura - e vale la stessa ragione: +una giunta dichiarata si puo' misurare esattamente, una da indovinare no. Uso: ./scripts/loop-close.py [pagina.html] """ @@ -111,7 +114,7 @@ def shot(st, i, f): def d(a, b): return sum(1 for x, y in zip(a, b) if abs(x - y) > DIFF) - print("%-8s %6s %10s %10s %9s" % ("voce", "dur", "passo ai capi", "giunta", "rapporto")) + print("%-8s %6s %10s %10s %9s %8s %9s" % ("voce", "dur", "passo capi", "giunta", "rapp.", "in mezzo", "rapp.")) for i, c in enumerate(codes): st = stages[i] st.scroll_into_view_if_needed() @@ -125,18 +128,31 @@ def d(a, b): ratio = wrap / passo if passo else (999.0 if wrap > IGNORA else 0.0) bad = wrap > IGNORA and ratio > SOGLIA - print("%-8s %6d %10d %10d %8.1fx%s" % (c, dur, passo, wrap, ratio, " STRAPPA" if bad else "")) - if bad: + + # La giunta interna, dove la demo dice che c'e'. + half = pg.evaluate("(i)=>document.querySelectorAll('.stage')[i].__it.mv.half || 0", i) + mid, rmid, bad_mid = 0, 0.0, False + if half: + mid = d(shot(st, i, half - 1), shot(st, i, half)) + vic = max(d(shot(st, i, half - 4), shot(st, i, half - 3)), + d(shot(st, i, half + 2), shot(st, i, half + 3))) + rmid = mid / vic if vic else (999.0 if mid > IGNORA else 0.0) + bad_mid = mid > IGNORA and rmid > SOGLIA + + nota = " STRAPPA" if bad else (" STACCA a f%d" % half if bad_mid else "") + print("%-8s %6d %10d %10d %8.1fx %8d %8.1fx%s" + % (c, dur, passo, wrap, ratio, mid, rmid, nota)) + if bad or bad_mid: fails.append((c, wrap, ratio)) br.close() print() if fails: - print("cicli che strappano a ogni giro:", ", ".join(c for c, _, _ in fails), file=sys.stderr) - print("L'ultimo fotogramma di queste demo non somiglia al primo, quindi a ogni", file=sys.stderr) - print("giro c'e' uno stacco. Non legge come una demo che riparte, legge come", file=sys.stderr) - print("un sito che scatta.", file=sys.stderr) + print("cicli con uno stacco:", ", ".join(c for c, _, _ in fails), file=sys.stderr) + print("O l'ultimo fotogramma non somiglia al primo, o la seconda meta' riparte", file=sys.stderr) + print("da capo invece di continuare la prima. In tutti e due i casi non legge", file=sys.stderr) + print("come una demo che va avanti, legge come un sito che scatta.", file=sys.stderr) raise SystemExit(1) print("VERDETTO: tutte le dimostrazioni chiudono il proprio ciclo.") print("L'ultimo fotogramma vale quanto un fotogramma qualsiasi rispetto al primo.") diff --git a/showcase/grammatica.html b/showcase/grammatica.html index 051441b..292fe61 100644 --- a/showcase/grammatica.html +++ b/showcase/grammatica.html @@ -5,7 +5,7 @@ The grammar — scenes - +