Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/showcase.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 56 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
116 changes: 116 additions & 0 deletions scripts/demo-check.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
54 changes: 35 additions & 19 deletions scripts/loop-close.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
"""
Expand Down Expand Up @@ -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()
Expand All @@ -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.")
Loading
Loading