From 9db0b538da2e1c056cbdd3db7ffa3b8b4270d12f Mon Sep 17 00:00:00 2001 From: Antonino Cuzzola Date: Sun, 13 Sep 2026 19:27:03 +0200 Subject: [PATCH 1/2] La tipografia si legge mentre gira: niente parole sovrapposte, niente frasi che spariscono Sul sito le dieci voci tipografiche avevano ancora parole una sopra l'altra, frasi che se ne andavano e tornavano, e cose che scattavano. Nessun banco le vedeva: demo-check.py misura la tesi di ogni voce, loop-close.py la giunta del ciclo. Il nuovo scripts/type-check.py le misura su ogni fotogramma, a piu' larghezze, leggendo il DOM reso: copertura (anche della HUD), testo fuori quadro, scatti mentre si vede, e sempre una parola intera sullo schermo. Sulla pagina pubblicata boccia dieci voci su dieci; su questa, zero, a 390, 768, 1440 e 2560 px. La regola della parola intera e' quella che ha cambiato le demo. Sfalsare uscite e ingressi teneva il quadro fuori dal nero, ma le frasi sparivano lo stesso, in onda. Adesso: - TYP-01: due righe di 29 caratteri si passano il turno in una finestra che ritaglia; soste di 56 e 35 fotogrammi fermi, 15,5 contro 24,9 c/s. - TYP-04: ogni parola e' una casella con due copie che scorrono insieme; la coppia e' click/drag, larghe uguali, perche' "join" in una cella larga quanto "pose" galleggiava in un vuoto. - TYP-05 e TYP-06: "Real UI," resta, e arriva il resto. - TYP-09: la didascalia stesa non scende sotto i 34 px, dove sul telefono copriva il contatore per 130 fotogrammi su 230. - Le rotazioni in seno invece che in cubica, che a meta' corsa passava il bordo in meno di tre fotogrammi. Il motore legge il fotogramma dal timestamp di requestAnimationFrame e non da performance.now(): i fotogrammi tenuti per uno o tre refresh invece di due passano dal 3,3 allo 0,7 per cento. In CI: type-check.py a 390 e 1440 px con quattro copie guaste che deve bocciare, e la copia guasta di TYP-01 per demo-check.py riscritta sulla nuova sosta (quella vecchia non trovava piu' niente da cambiare). Co-Authored-By: Claude Opus 5 --- .github/workflows/showcase.yml | 34 +- README.md | 123 +++++-- scripts/demo-check.py | 103 ++++-- scripts/type-check.py | 257 +++++++++++++ showcase/grammatica.html | 646 ++++++++++++++++++--------------- 5 files changed, 816 insertions(+), 347 deletions(-) create mode 100755 scripts/type-check.py diff --git a/.github/workflows/showcase.yml b/.github/workflows/showcase.yml index f976771..e9218ff 100644 --- a/.github/workflows/showcase.yml +++ b/.github/workflows/showcase.yml @@ -274,7 +274,7 @@ jobs: # E lo stesso sul lato tipografico, che ha un palco diverso e quindi # una sonda diversa: qui si rendono uguali le due soste di TYP-01, cioe' # si toglie il contrasto sul quale la voce si regge. - sed 's/dwell=second?41:62/dwell=62/' \ + sed 's/var DW = {A:56, B:35, R:22};/var DW = {A:56, B:56, R:22};/' \ showcase/dist/grammatica.html > /tmp/guasta-typ.html if cmp -s showcase/dist/grammatica.html /tmp/guasta-typ.html; then echo "la copia guasta tipografica e' identica all'originale: la sed non ha trovato niente." >&2 @@ -319,6 +319,38 @@ jobs: cat /tmp/gm.log >&2; exit 1; } echo "loop-close.py boccia anche uno stacco in mezzo al ciclo." + # E la tipografia si deve poter leggere mentre gira: niente parole una + # sopra l'altra o sotto la HUD, niente fuori quadro, sempre una parola + # intera sullo schermo, niente che scatti mentre si vede. Due larghezze + # perche' sul telefono la riga va a capo e la HUD, che e' in pixel, + # occupa un sesto del palco: due dei difetti trovati c'erano solo li'. + ./scripts/type-check.py --widths 390,1440 + + # Quattro copie guaste, una per regola, e il banco deve bocciarle tutte + # nominando la voce e il motivo. Ognuna rimette un difetto che la + # pagina ha avuto davvero. + guasta_tipo() { # nome, sed, voce, larghezza, motivo atteso + sed "$2" showcase/dist/grammatica.html > "/tmp/guasta-$1.html" + if cmp -s showcase/dist/grammatica.html "/tmp/guasta-$1.html"; then + echo "la copia guasta $1 e' identica all'originale: la sed non ha trovato niente." >&2 + exit 1 + fi + rc=0; ./scripts/type-check.py "/tmp/guasta-$1.html" --only "$3" --widths "$4" > "/tmp/guasta-$1.log" 2>&1 || rc=$? + [ "$rc" != 0 ] || { echo "type-check.py promuove la copia guasta $1." >&2; cat "/tmp/guasta-$1.log" >&2; exit 1; } + grep "ROTTA $3" "/tmp/guasta-$1.log" | grep -q "$5" || { + echo "type-check.py boccia la copia guasta $1 ma non per $5 su $3:" >&2 + cat "/tmp/guasta-$1.log" >&2; exit 1; } + echo "type-check.py boccia $3 per $5, come deve." + } + # la parola chiave che cresce dal centro copre la riga sotto + guasta_tipo copertura 's/display:inline-block;transform-origin:50% 100%/display:inline-block;transform-origin:50% 50%/' TYP-02 1440 copertura + # la frase che sparisce mentre prende peso + guasta_tipo vuoto 's/T.line.style.fontWeight=String(val);/T.line.style.fontWeight=String(val); T.line.style.opacity=String(1-w);/' TYP-08 1440 'senza una parola intera' + # il colore acceso in un fotogramma + guasta_tipo scatto 's/mixc(TNEU,TINT\[0\],a)/mixc(TNEU,TINT[0],a>0.5?1:0)/' TYP-03 1440 scatti + # la didascalia stesa sopra il contatore, sul telefono + guasta_tipo hud 's/bottom: max(4cqw, 34px);/bottom: 4cqw;/' TYP-09 390 'sotto la HUD' + # 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 623967e..0f1c7e2 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ clock to run backwards. The return leg is not part of the movement and the readout says so: better a declared leg home than a tear every pass. Two demos did not need a rewind and got something truer instead: the streaming answer scrolls up and out the way the app would, and the shared-word sentence swaps back -in the opposite direction, so the loop is `join → pose → join`. +in the opposite direction, so the loop is `click → drag → click`. `loop-close.py` measures it, and finding the right question took three wrong ones. Against the demo's *typical* motion, the demos that sit still between @@ -190,26 +190,26 @@ shared ones do not move a pixel while the one that changes is replaced. **The dwell is measured, and it is the entry that matters.** How long a line stays is not chosen by eye but in characters per second of *net* dwell — the -stretch in which the sentence is already composed and still, after the last word -has landed. Large type holds 15 to 16; over 20 the line is taken away while you -are still reading it. The declared window and the net dwell are not the same -number — with a staggered entry there are six frames between them — which is -exactly why the bench measures the net figure on the rendering instead of -trusting the constant. It reads 15.5 c/s against 24.9 across the two halves of -the demo. And the corollary is the useful part: to gain reading time *without* -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. +stretch in which the sentence is complete and still, after it has arrived and +before it starts to leave. Large type holds 15 to 16; over 20 the line is taken +away while you are still reading it. In the demo two lines of 29 characters hand +over inside a window, the one leaving rising past the top edge while the next +rises in from below, and they stand still for 56 frames and for 35: the bench +counts the frames in which a line sits exactly in its place and reads 15.5 c/s +against 24.9. The corollary is the useful part: to gain reading time *without* +slowing the edit down, keep the transition short and lengthen only the dwell. +Every frame the transition 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 +— the most common technique in the reference work, and the reason is that the +edge stays still while the word crosses it, so the word is uncovered rather than +switched on. The **grain of the stagger**, letter by letter against word by word: +16 entry moments against 5 on the same words, 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. @@ -217,7 +217,9 @@ 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 +words, the width growth is stated (11 per cent here, 3.6 on the CI's Linux, +because how much a family spends on weight is its own business) 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 @@ -227,8 +229,8 @@ 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 +laid flat, the same words become a caption, and a caption 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. @@ -243,16 +245,78 @@ sized in `cqw` and not pixels, because in a real composition the type is a fraction of the frame, and the proportion between word and frame *is* the content of these entries. -One defect worth recording, because it is the same shape as others in here. -`TYP-04` first cross-faded the two swapping words in the same box, and at the -midpoint you saw `join` and `pose` overlapping — which does not read as one word -replacing another, it reads as a rendering error. The substitution is sequential -now: the old one leaves, then the new one arrives, and the two frames of empty -box between them are a beat rather than a hole. The box keeps its width the whole -time because the outgoing word stays in the flow while invisible, so the three -words that are supposed to stay put have no reason to move — and `demo-check.py` -measures that they move 0.00 px, against 77 in the half that replaces the whole -line. +One defect worth recording, because it is the same shape as others in here, and +it took three tries. `TYP-04` first cross-faded the two swapping words in the +same box, and at the midpoint you saw `join` and `pose` on top of each other — +which does not read as one word replacing another, it reads as a rendering +error. The second try made the swap sequential with the new word laid over the +old one, and `pose` is wider than `join`: two words of four letters are not the +same width, and for 118 frames out of 260 it covered `is`. Now every word lives +in a clipping box that is a grid cell as wide as the wider of its two words, with +both copies inside, and handing over is moving both up on the same stroke — one +leaves through the top edge while the other comes in through the bottom. That +made the pair itself a measurement: in a cell as wide as `pose`, `join` — 154 px +against 215 on a 100 px body — floated in a gap on both sides, in the half that +is supposed to be right. The words are `click` and `drag` now, 200.6 and 200.9 +px, and whatever another font leaves over goes into the narrower one's tracking. +The three words that are supposed to stay put move 0.00 px, against 89 in the +half that hands over every box. + +### What the sentences did in playback + +Two rounds of fixes on this family went out with every bench green, and scrolling +the site still showed words on top of words, sentences that went away and came +back, and things that jumped. None of those is a thesis failing, so +`demo-check.py` could not see them, and none is a torn loop, so `loop-close.py` +could not either. `type-check.py` looks for exactly those, on every frame of every +typography demo, at several widths, reading the rendered DOM rather than pixels — +with pixels a fade and a cut look too much alike, with properties a fade changes +opacity by a few hundredths a frame and a cut by all of it. What it checks: + +- **Covering**: no two visible units of text overlap, and none sits under the + readout. How much of a unit is visible is the part left inside every box that + clips it, times its opacity; the first version looked at opacity alone, and a + word rising from behind its box edge — eleven pixels of seventy-four uncovered + on its first frame — read as a snap from nothing to everything. +- **Frame**: no visible text outside the stage. +- **A whole word**: on every frame at least one word is on screen in full. +- **Snaps**: nothing changes by a large step in one frame while it is visible — + how much of it shows, where it is, what colour it is. + +Pointed at the page as it was deployed, it failed all ten entries. `TYP-02` +covered its second row for 59 frames and left the frame for 63, `TYP-03` switched +colour in one frame, `TYP-04` had `pose` over `is`, and every one of the ten had +stretches of up to 23 frames with nothing on screen, because each went away +between its right case and its wrong one. + +The **whole word** rule is the one worth explaining, because the obvious rule came +first and was not enough. Making sure the frame never went fully black was easy — +stagger the exits and the entrances so there is always something — and the +sentences kept leaving and coming back anyway, in waves: in `TYP-01` a fifth of +the sentence was on screen for half a second, twice a loop, and in `TYP-04` the +most visible word was at 23 per cent for a frame. What the eye reads as "the sentence is gone" is not a +black frame, it is having nothing whole to read. So where a demo needs a line to +change, the line now hands over instead of leaving: two lines in one window in +`TYP-01`, two copies in one box per word in `TYP-04`. Where a demo is about how a +line *arrives*, and arriving needs it to be missing first, part of the sentence +stays: in `TYP-05` and `TYP-06`, "Real UI," holds while the rest comes and goes. + +Two smaller ones came out of the same pass. On a phone the readout, which is in +pixels, covers a sixth of the stage's height, and `TYP-09`'s caption laid flat sat +on the frame counter for 130 frames out of 230 — it never sits lower than 34 px now. +And every demo's frame was read from `performance.now()` inside the animation +callback, which runs after the refresh by an amount that changes every time — 2.1 +ms on median and up to 3.7, measured in headless Chrome with the typography on +screen. With 30 fps demos on a 60 Hz screen, when the boundary between two frames +falls near a refresh that delay decides which side it is on: 3.3 per cent of +frames stayed up for one refresh or three instead of two. The frame is read from +the callback's own timestamp now, which falls on the refresh, and the same count +is 0.7. + +Its negative controls are four copies of the built page, each broken in one of +those ways — a keyword scaled from its centre, a line faded out while it gains +weight, a colour switched in one frame, and the caption back at `4cqw` — and the +script has to fail each and name the entry. ## Speed is a number in `catalog.json` @@ -341,6 +405,7 @@ npx remotion render PromptInput out/prompt-input.mp4 # from video/ ./scripts/fixture-screenshot.sh # build the scene focus-sharpness must fail ./scripts/demo-check.py [page.html] # do the catalogue demos still show their thesis ./scripts/loop-close.py [page.html] # does every demo loop close, or tear every pass +./scripts/type-check.py [page.html] # does the type cover, leave the frame, vanish or snap ./scripts/contrast-floor.py [scene.mp4] # is the attenuated content still readable ./scripts/tempo.py [long.mp4 short.mp4] # does shortening a scene retime it or just trim it ./scripts/fixture-tempo.sh # render the two retimed fixtures diff --git a/scripts/demo-check.py b/scripts/demo-check.py index 0e4624b..483b290 100755 --- a/scripts/demo-check.py +++ b/scripts/demo-check.py @@ -321,30 +321,51 @@ def ty(code, f): tcache[k] = pg.evaluate(PROBE_TYPE, [code, f]) return tcache[k] - # TYP-01 - la sosta, cioe' i fotogrammi in cui la frase e' su e ferma. + # LE FINESTRE SI LEGGONO DALLA PAGINA. Ogni voce a due meta' dichiara + # `half` e `dur`, e i controlli tipografici guardano [0, half) e + # [half, dur) invece di fotogrammi scritti a mano qui: riscrivendo i tempi + # delle voci, i numeri incollati nel banco erano rimasti quelli vecchi e + # misuravano meta' ciclo sbagliata. + def hd(code): + return pg.evaluate("""(c)=>{const h=[...document.querySelectorAll('.mv')]; + const i=h.findIndex(m=>(m.querySelector('.code')||{}).textContent.trim()===c); + const mv=document.querySelectorAll('.stage')[i].__it.mv; return [mv.half, mv.dur];}""", code) + + # TYP-01 - la sosta, cioe' i fotogrammi in cui una frase e' ferma al suo + # posto nella finestra. # Caso peggiore: due meta' con la stessa sosta non dimostrerebbero niente, # perche' la voce parla di quanto una sosta puo' accorciarsi prima che la # riga venga tolta mentre la si legge. + # Le due frasi si passano il turno scorrendo, quindi "ferma" e' la copia + # con scarto zero dal bordo della finestra. Non "ferma rispetto al + # fotogramma prima": la rotazione e' in seno e i suoi estremi si muovono di + # meno di un pixel, e contati come fermi allungavano la sosta di uno. + def rest(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 win=st.querySelector('.twin'), wr=win.getBoundingClientRect(); + return [...win.querySelectorAll('.tcopy')].map(cp=>({ + off: cp.getBoundingClientRect().top - wr.top, + text: [...cp.querySelectorAll('.tw')].map(w=>w.textContent).join(' ')})); + }""", [code, f]) def dwell(code, lo, hi): - prev, run, best = None, 0, 0 + run, best, text = 0, 0, "" for f in range(lo, hi): - s0 = ty(code, f) - up = all(sp["op"] > 0.985 for sp in s0["spans"]) - still = prev is not None and all( - abs(a["x"] - b["x"]) < 0.4 and abs(a["y"] - b["y"]) < 0.4 - for a, b in zip(s0["spans"], prev["spans"]) - ) - run = run + 1 if (up and still) else 0 - best = max(best, run) - prev = s0 - return best - chars = len("Real UI, not a drawing of UI.") - d1, d2 = dwell("TYP-01", 0, 104), dwell("TYP-01", 104, 187) - c1 = chars / (d1 / 30) if d1 else 0 - c2 = chars / (d2 / 30) if d2 else 0 + still = [c for c in rest(code, f) if abs(c["off"]) < 0.05] + run = run + 1 if still else 0 + if run > best: + best, text = run, still[0]["text"] + return best, text + h1, D1 = hd("TYP-01") + (d1, t1), (d2, t2) = dwell("TYP-01", 0, h1), dwell("TYP-01", h1, D1) + c1 = len(t1) / (d1 / 30) if d1 else 0 + c2 = len(t2) / (d2 / 30) if d2 else 0 check("TYP-01", d1 > d2 * 1.4 and 14 < c1 < 17 and 22 < c2 < 30, - "sosta netta %df = %.1f c/s nella prima meta', %df = %.1f c/s nella seconda" - % (d1, c1, d2, c2)) + "sosta netta %df = %.1f c/s nella prima meta', %df = %.1f c/s nella seconda (%d e %d caratteri)" + % (d1, c1, d2, c2, len(t1), len(t2))) # TYP-02 - una parola sola, enorme. def hratio(code, f): @@ -373,15 +394,31 @@ def lit(code, f): # TYP-04 - le parole che restano non si muovono. # Gli indici 0, 2 e 3 sono le tre parole in comune fra le due frasi; l'1 e' # quella che cambia, e quella deve muoversi. + # Ogni parola e' una casella con due copie che scorrono in verticale: la + # casella non si muove mai, quindi quello che si misura e' lo scarto dal + # proprio posto delle copie che si vedono almeno in parte. Misurando la + # casella il banco avrebbe letto ferme anche le parole che rotolano. def drift(code, lo, hi): - base = ty(code, lo)["spans"] worst = 0 - for f in range(lo, hi, 3): - sp = ty(code, f)["spans"] - for i in (0, 2, 3): - worst = max(worst, abs(sp[i]["x"] - base[i]["x"])) + for f in range(lo, hi, 2): + offs = 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')].map(box=>{ + const br=box.getBoundingClientRect(); + return Math.max(0, ...[...box.children].map(ch=>{ + const r=ch.getBoundingClientRect(); + const seen=Math.min(r.bottom,br.bottom)-Math.max(r.top,br.top) > 0; + return seen ? Math.abs(new DOMMatrixReadOnly(getComputedStyle(ch).transform).m42) : 0; + })); + }); + }""", [code, f]) + worst = max(worst, offs[0], offs[2], offs[3]) return worst - w1, w2 = drift("TYP-04", 40, 118), drift("TYP-04", 170, 248) + h4, D4 = hd("TYP-04") + w1, w2 = drift("TYP-04", 0, h4), drift("TYP-04", h4, D4) 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)) @@ -406,7 +443,7 @@ def offset(code, lo, hi): return inn.getBoundingClientRect().top - box.getBoundingClientRect().top; }""", [code, f])) return min(vals), max(vals) - o1 = offset("TYP-05", 8, 50) + o1 = offset("TYP-05", 4, 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. @@ -441,7 +478,8 @@ def entries(code, lo, hi): out.add(f) prev = cur return len(out) - e1, e2 = entries("TYP-06", 4, 60), entries("TYP-06", 129, 185) + h6, D6 = hd("TYP-06") + e1, e2 = entries("TYP-06", 0, h6), entries("TYP-06", h6, D6) check("TYP-06", e1 > e2 * 2.5, "momenti d'ingresso distinti: %d lettera per lettera, %d parola per parola" % (e1, e2)) @@ -488,16 +526,19 @@ def side(code, f): 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)) + # La tesi e' l'ASSE, quindi si misura l'orientamento. La posizione della + # didascalia stesa non e' piu' al centro: sta in basso a sinistra, perche' + # e' li' che la rotazione sul perno non attraversa la frase. + s1, s2 = side("TYP-09", 40), side("TYP-09", 155) + check("TYP-09", s1["tall"] and s1["x"] < 0.1 and (not s2["tall"]), + "compagna: verticale sul fianco al %.0f%% della larghezza, poi stesa in orizzontale" + % (s1["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) + f1, f2 = fore("TYP-10", 90), fore("TYP-10", 190) 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)) diff --git a/scripts/type-check.py b/scripts/type-check.py new file mode 100755 index 0000000..82e720e --- /dev/null +++ b/scripts/type-check.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python3 +"""Le voci tipografiche si leggono, o si coprono, spariscono e scattano? + +PERCHE' ESISTE. Due volte di fila i banchi erano verdi e scorrendo il sito si +vedevano parole una sopra l'altra, frasi che sparivano e ricomparivano, cose +che scattavano. Nessun banco guardava quelle tre cose: demo-check.py misura la +tesi di ogni voce, loop-close.py la giunta del ciclo, e fra le due passavano +tutti i difetti che si vedono in riproduzione. + +COSA MISURA, su ogni fotogramma di ogni voce tipografica, a piu' larghezze: + + COPERTURA. Due unita' di testo visibili - parole, lettere, la didascalia + ruotata - non si sovrappongono, e nessuna passa sotto il testo della HUD. + TYP-02 copriva la seconda riga con la parola ingrandita per 59 fotogrammi; + TYP-04 metteva "pose" sopra "is" per 118 su 260, perche' due parole di + quattro lettere non sono larghe uguali; e sul telefono la didascalia stesa + di TYP-09 finiva sopra il contatore dei fotogrammi. + + QUADRO. Nessun testo visibile esce dal palco. La parola ingrandita di TYP-02 + usciva da tutti e due i lati per 63 fotogrammi. + + PAROLA INTERA. In ogni fotogramma almeno una parola della riga si vede + tutta. Non basta che il quadro non sia nero: la prima correzione lo + garantiva, sfalsando uscite e ingressi, e le frasi continuavano a sparire + e tornare - in onda: in TYP-01 un quinto della frase sullo schermo per mezzo + secondo, due volte a giro, e in TYP-04 la parola piu' visibile al 23%. Quello + che l'occhio legge come "la frase se n'e' andata" e' non avere niente di + intero da leggere. + + SCATTO. Nessuna proprieta' salta in un fotogramma mentre si vede: quanto se + ne vede, posizione, colore. TYP-03 cambiava colore in un fotogramma. + +COME LEGGE. Le proprieta' rese del DOM, non i pixel. Coi pixel una +dissolvenza e uno stacco si somigliano troppo per distinguerli; con le +proprieta' una dissolvenza cambia l'opacita' di pochi centesimi per +fotogramma e uno stacco di tutta. + +QUANTO SI VEDE di un'unita' e' la frazione che resta dentro tutte le caselle +che la ritagliano, per la sua opacita'. La prima versione guardava solo +l'opacita', e una parola che sale da dietro il bordo della sua casella - +scoperta di undici pixel su settantaquattro al primo fotogramma - risultava +uno scatto da zero a uno. + +LE COPIE IDENTICHE contano come una. Quando una casella passa il turno a una +parola uguale, le due copie stanno nella stessa casella, e nel fotogramma in +cui si scambiano il ruolo sono gli stessi glifi nello stesso posto: si +sommano, e lo scambio non e' uno scatto perche' non si vede. + +Uso: ./scripts/type-check.py [pagina.html] [--widths 390,1440] [--only TYP-02,TYP-09] +""" +import argparse +import pathlib +import re +import sys + +from playwright.sync_api import sync_playwright + +ROOT = pathlib.Path(__file__).resolve().parent.parent + +ap = argparse.ArgumentParser() +ap.add_argument("page", nargs="?") +ap.add_argument("--widths", default="390,1440") +ap.add_argument("--only", default="") +args = ap.parse_args() + +if args.page: + PAGE = pathlib.Path(args.page).resolve() +else: + PAGE = ROOT / "showcase" / "dist" / "grammatica.html" + if not PAGE.exists(): + PAGE = ROOT / "showcase" / "grammatica.html" +ONLY = {c.strip() for c in args.only.split(",") if c.strip()} + +# Sopra questa quota di se' un'unita' conta come visibile. +VISIBILE = 0.2 +# Frazione della piu' piccola delle due parti oltre la quale e' copertura. +COPRE = 0.06 +# Una parola e' intera quando se ne vede almeno questa quota. +INTERA = 0.9 +# Un salto di visibilita' in un fotogramma oltre il quale e' uno scatto. +SALTO_VISIBILE = 0.4 +# Uno spostamento in un fotogramma, in frazione della larghezza del palco. +SALTO_POSIZIONE = 0.05 +# Una differenza di colore in un fotogramma, somma delle tre componenti. +SALTO_COLORE = 90 + +PROBE = r"""([code, f]) => { + const h=[...document.querySelectorAll('.mv')]; + const i=h.findIndex(m=>(m.querySelector('.code')||{}).textContent.trim()===code); + const st=document.querySelectorAll('.stage')[i], it=st.__it; + it.manual=f; it.last=-1; it.mv.draw(f,it.S); + const sr=st.getBoundingClientRect(); + const rel=r=>({l:r.left-sr.left, r:r.right-sr.left, t:r.top-sr.top, b:r.bottom-sr.top}); + function eop(e){ let o=1; for(let n=e; n && n!==st; n=n.parentElement){ o*=parseFloat(getComputedStyle(n).opacity); } return o; } + // Le caselle che ritagliano, fino al palco tipografico escluso: .tstage + // ritaglia tutto al quadro, e contarlo nasconderebbe proprio il testo che + // ne esce. + function clip(e){ + let c={l:-1e9, r:1e9, t:-1e9, b:1e9}; + for(let n=e.parentElement; n && n!==st && !n.classList.contains('tstage'); n=n.parentElement){ + const cs=getComputedStyle(n); + if(cs.overflowX!=='visible' || cs.overflowY!=='visible'){ + const q=rel(n.getBoundingClientRect()); + c={l:Math.max(c.l,q.l), r:Math.min(c.r,q.r), t:Math.max(c.t,q.t), b:Math.min(c.b,q.b)}; + } + } + return c; + } + function part(e){ + const r=rel(e.getBoundingClientRect()), c=clip(e); + const p={l:Math.max(r.l,c.l), r:Math.min(r.r,c.r), t:Math.max(r.t,c.t), b:Math.min(r.b,c.b)}; + const full=Math.max(1e-6,(r.r-r.l)*(r.b-r.t)); + const area=(p.r>p.l && p.b>p.t) ? (p.r-p.l)*(p.b-p.t) : 0; + return {box:p, area, frac:area/full, op:eop(e), color:getComputedStyle(e).color}; + } + function unit(id, word, letter, els){ + const ps=els.map(part); + let vis=0, wx=0, wy=0, wa=0; + ps.forEach(p=>{ vis+=p.frac*p.op; const w=p.area*p.op; wx+=(p.box.l+p.box.r)/2*w; wy+=(p.box.t+p.box.b)/2*w; wa+=w; }); + const best=ps.reduce((a,b)=>a.frac*a.op>=b.frac*b.op?a:b); + return {id, word, letter, text:els[0].textContent, vis:Math.min(1,vis), + parts:ps.filter(p=>p.frac*p.op>0.2 && p.area>4).map(p=>p.box), + x:wa?wx/wa:0, y:wa?wy/wa:0, color:best.color}; + } + const units=[]; + st.querySelectorAll('.tline .tw').forEach((w,k)=>{ + const leaves=[...w.querySelectorAll('*')].filter(c=>c.children.length===0 && c.textContent.trim().length); + const letters=leaves.length>1 && leaves.every(c=>c.textContent.length===1); + if(!leaves.length){ units.push(unit('w'+k, k, false, [w])); return; } + const groups=new Map(); + leaves.forEach((c,j)=>{ const key=letters?'l'+j:'t'+c.textContent; if(!groups.has(key)) groups.set(key,[]); groups.get(key).push(c); }); + groups.forEach((els,key)=>units.push(unit('w'+k+key, k, letters, els))); + }); + const s=st.querySelector('.tside'); + if(s) units.push(unit('side', -1, false, [s])); + const hud=[...st.querySelectorAll('.hud > span')].filter(e=>e.textContent.trim()).map(e=>{ + const rg=document.createRange(); rg.selectNodeContents(e); + const r=rel(rg.getBoundingClientRect()), q=rel(e.getBoundingClientRect()); + return {text:e.textContent.trim(), box:{l:Math.max(r.l,q.l), r:Math.min(r.r,q.r), t:Math.max(r.t,q.t), b:Math.min(r.b,q.b)}}; + }); + return {w:sr.width, h:sr.height, units, hud}; +}""" + + +def inter(a, b): + return max(0, min(a["r"], b["r"]) - max(a["l"], b["l"])) * max(0, min(a["b"], b["b"]) - max(a["t"], b["t"])) + + +def area(a): + return max(0, a["r"] - a["l"]) * max(0, a["b"] - a["t"]) + + +def rgb(c): + m = re.findall(r"[\d.]+", c) + return tuple(float(v) for v in m[:3]) if len(m) >= 3 else (0.0, 0.0, 0.0) + + +def whole(units): + """La parola piu' intera del fotogramma. Una parola fatta di lettere e' + intera quanto la meno visibile delle sue lettere.""" + words = {} + for u in units: + if u["word"] < 0: + continue + if u["letter"]: + words.setdefault(u["word"], []).append(u["vis"]) + else: + words.setdefault((u["word"], u["id"]), []).append(u["vis"]) + return max([min(v) for v in words.values()] + [0]) + + +fails = [] + +with sync_playwright() as pw: + try: + br = pw.chromium.launch(headless=True) + except Exception: + br = pw.chromium.launch(headless=True, channel="chrome") + + for W in [int(x) for x in args.widths.split(",") if x]: + pg = br.new_context(viewport={"width": W, "height": 900}).new_page() + pg.goto(PAGE.as_uri(), wait_until="load") + pg.wait_for_timeout(1200) + codes = [c for c in pg.eval_on_selector_all(".mvhead .code", "e=>e.map(x=>x.textContent.trim())") + if c.startswith("TYP") and (not ONLY or c in ONLY)] + print("larghezza %d px" % W) + for code in codes: + dur = pg.evaluate("(c)=>{const h=[...document.querySelectorAll('.mv')];" + "const i=h.findIndex(m=>(m.querySelector('.code')||{}).textContent.trim()===c);" + "return document.querySelectorAll('.stage')[i].__it.mv.dur;}", code) + frames = [pg.evaluate(PROBE, [code, f]) for f in range(dur)] + SW, SH = frames[0]["w"], frames[0]["h"] + cop, hud, fuori, vuoti, scatti = [], [], [], [], [] + low = (1.0, 0) + for f, fr in enumerate(frames): + U = [u for u in fr["units"] if u["vis"] > VISIBILE and u["parts"]] + for i in range(len(U)): + for j in range(i + 1, len(U)): + a, b = U[i], U[j] + # Le lettere di una stessa parola si toccano per via della + # spaziatura negativa: e' composizione, non copertura. + if a["letter"] and b["letter"] and a["word"] == b["word"]: + continue + if any(inter(p, q) / max(1, min(area(p), area(q))) > COPRE + for p in a["parts"] for q in b["parts"]): + cop.append((f, a["text"].strip(), b["text"].strip())) + for u in U: + for p in u["parts"]: + for t in fr["hud"]: + if inter(p, t["box"]) > 2: + hud.append((f, u["text"].strip(), t["text"][:12])) + if p["l"] < -1 or p["r"] > SW + 1 or p["t"] < -1 or p["b"] > SH + 1: + fuori.append((f, u["text"].strip())) + wv = whole(fr["units"]) + if wv < low[0]: + low = (wv, f) + if wv < INTERA: + vuoti.append(f) + A = {u["id"]: u for u in fr["units"]} + B = {u["id"]: u for u in frames[(f + 1) % dur]["units"]} + for k in A: + if k not in B: + continue + a, b = A[k], B[k] + if abs(a["vis"] - b["vis"]) > SALTO_VISIBILE and max(a["vis"], b["vis"]) > 0.3: + scatti.append((f, "visibilita'", a["text"].strip())) + if min(a["vis"], b["vis"]) > 0.3: + if max(abs(a["x"] - b["x"]), abs(a["y"] - b["y"])) > SALTO_POSIZIONE * SW: + scatti.append((f, "posizione", a["text"].strip())) + if sum(abs(x - y) for x, y in zip(rgb(a["color"]), rgb(b["color"]))) > SALTO_COLORE: + scatti.append((f, "colore", a["text"].strip())) + bad = [] + if cop: + bad.append("copertura in %d fotogrammi (f%d: \"%s\" su \"%s\")" % (len({c[0] for c in cop}), cop[0][0], cop[0][1], cop[0][2])) + if hud: + bad.append("sotto la HUD in %d fotogrammi (f%d: \"%s\" su \"%s\")" % (len({c[0] for c in hud}), hud[0][0], hud[0][1], hud[0][2])) + if fuori: + bad.append("fuori quadro in %d fotogrammi (f%d: \"%s\")" % (len({c[0] for c in fuori}), fuori[0][0], fuori[0][1])) + if vuoti: + bad.append("senza una parola intera per %d fotogrammi (da f%d; al minimo %.0f%% a f%d)" % (len(vuoti), vuoti[0], low[0] * 100, low[1])) + if scatti: + bad.append("%d scatti (f%d: %s di \"%s\")" % (len(scatti), scatti[0][0], scatti[0][1], scatti[0][2])) + if bad: + print(" ROTTA %s %s" % (code, "; ".join(bad))) + fails.append("%s a %d px" % (code, W)) + else: + print(" ok %s %d fotogrammi: niente coperture ne' fuori quadro, sempre una parola intera (al minimo %.0f%%), nessuno scatto" + % (code, dur, low[0] * 100)) + pg.close() + br.close() + +print() +if fails: + print("voci tipografiche che non si leggono: " + ", ".join(fails)) + raise SystemExit(1) +print("VERDETTO: nessuna parola ne copre un'altra o la HUD, nessuna esce dal quadro,") +print("c'e' sempre una parola intera da leggere e niente scatta mentre si vede.") diff --git a/showcase/grammatica.html b/showcase/grammatica.html index 292fe61..12d3c40 100644 --- a/showcase/grammatica.html +++ b/showcase/grammatica.html @@ -119,7 +119,7 @@ justify-content: center; padding: 0 5cqw; overflow: hidden; } .tground { position: absolute; inset: 0; } .tline { position: relative; display: flex; flex-wrap: wrap; align-items: baseline; - justify-content: center; column-gap: 0.22em; row-gap: 0.04em; + justify-content: center; column-gap: 0.22em; row-gap: 0.12em; font-weight: 800; letter-spacing: -0.035em; line-height: 0.94; text-align: center; font-size: 7.4cqw; color: var(--text); } .tw { display: inline-block; white-space: pre; } @@ -127,10 +127,37 @@ overflow visibile sui discendenti sarebbe il difetto - "p" e "g" verrebbero tagliate - quindi la casella e' piu' alta della riga e il testo ci sta dentro con il suo respiro. */ - .tclip { display: inline-block; overflow: hidden; padding: 0.14em 0 0.2em; } + .tclip { display: inline-block; overflow: hidden; padding: 0.18em 0.08em 0.24em; + margin: 0 -0.08em; } .tclip > span { display: inline-block; } - /* La riga che accompagna, ruotata: sta sul fianco e non compete. */ - .tside { position: absolute; left: 3cqw; top: 50%; transform-origin: 0 50%; + /* La casella in cui una parola passa il turno a un'altra: le due copie + stanno nella STESSA cella di una griglia, che prende la larghezza della + piu' larga - con l'assoluto sopra una parola nel flusso la cella era + larga quanto "join", e "pose" ci sbordava sopra "is" - e la casella + ritaglia, cosi' quella che esce e quella che entra non si vedono mai + una sopra l'altra. */ + .troll { display: inline-grid; justify-items: center; overflow: hidden; + padding: 0.18em 0.08em 0.24em; margin: 0 -0.08em; } + .troll > span { grid-area: 1 / 1; display: inline-block; } + /* La finestra in cui una RIGA passa il turno a un'altra. Anche qui le due + stanno nella stessa cella, alta quanto la piu' alta delle due, e ognuna + scorre di tutta la propria altezza: il padding e' la' perche' ascendenti + e discendenti restino dentro la riga a cui appartengono, invece di + affacciarsi nella finestra quando la riga e' gia' fuori. Ogni riga e' + spezzata in due a mano: lasciata andare a capo da sola, la prima + stava su una e la seconda su due, e a meta' corsa per un paio di + fotogrammi nessuna delle due aveva una riga intera nella finestra. */ + .twin { display: grid; width: 100%; overflow: hidden; } + .tcopy { grid-area: 1 / 1; display: flex; flex-direction: column; justify-content: center; + gap: 0.12em; padding: 0.2em 0; font-size: 7.4cqw; } + /* La riga che accompagna, ruotata: sta sul fianco e non compete. Il perno + e' l'angolo in basso a sinistra, cosi' passando da verticale a + orizzontale spazza solo l'angolo del quadro e non attraversa la frase. + Mai piu' in basso di 34 px: la HUD e' in pixel, non in cqw, e sul + telefono occupa un sesto dell'altezza del palco - a 4cqw la didascalia + stesa finiva sopra il contatore dei fotogrammi per 130 fotogrammi su + 230. */ + .tside { position: absolute; left: 3cqw; bottom: max(4cqw, 34px); transform-origin: 0 100%; font-size: 1.5cqw; font-weight: 600; letter-spacing: 0.22em; text-transform: uppercase; color: var(--faint); white-space: nowrap; } /* Il piano: la frase vive nella stessa prospettiva della lastra. */ @@ -397,10 +424,11 @@

Thirty-six movements,
no cuts.

rest-point.shA scene still moving where the next one has to attach. Its thresholds were absolute to begin with, tuned on this machine, and every one of them failed the move to Linuxedges under 30% of mid-scene click-gap.shA press and its consequence fused onto one frame, or so far apart that it stops reading as a consequence and starts reading as lagf271 and f276, five frames loop-close.pyA demo whose loop tears every pass, or whose second half restarts from scratch instead of continuing the first. Hunting for the mid-loop cut did not work — a cut and a fade look alike to every metric tried — so each two-part demo declares the frame it changes on and the bench looks there, which is the bargain seamAfter already strikes for the scenes11 torn, now 0 + type-check.pyType that covers other type or the readout, leaves the frame, snaps while it is visible, or goes away altogether. The first fix of the typography family made sure the frame never went black, and the sentences kept leaving and coming back anyway, in waves — in TYP-01 a fifth of the sentence was on screen for half a second, twice a loop. What reads as the sentence being gone is having nothing whole to read, so that is the rule: on every frame at least one word is on screen in full10 of 10 failing on the previous page, 0 now, at four widths tempo.pyA scene that was shortened rather than sped up. If the beats scale, frame f of the short render is frame f/k of the long one; compared that way the two differ by 287 px against 5379 without normalising. The residual is not slop — it is the signature of the thresholds that deliberately did not scale18.7× advantage, 4 required fixture-trim.shMeasures nothing. Truncates a scene to the short duration instead of retiming it, which is what lowering the duration produced before any of this existed120 frames, beats left in place contrast-floor.pyContent attenuated so far that it stops being a plane behind and becomes dirt on the background. Two wrong versions before this one: the first measured the composer placeholder, which is deliberately faint and sits at 2.93:1 before any attenuation at all; the second measured the right content in a place whose position depends on how the messages wrap, and on Linux the crop landed on empty background4.17:1, 3.84 on Linux, against 1.71 - demo-check.pyA demonstration on this page that has stopped demonstrating its own entry. The difference between showing a thesis and not showing it is almost always temporal, and a screenshot cannot see it, so this one scrubs15 entries, frame by frame + demo-check.pyA demonstration on this page that has stopped demonstrating its own entry. The difference between showing a thesis and not showing it is almost always temporal, and a screenshot cannot see it, so this one scrubs25 entries, frame by frame fixture-screenshot.shMeasures nothing. It builds the scene the bench above has to fail, and without it that bench is a promisefirst frame magnified 2.26× @@ -650,6 +678,25 @@

Thirty-six movements,
no cuts.

}); return T.inner; }; + /* Due frasi nella stessa finestra, per passarsi il turno. Ognuna e' una + copia fatta di righe gia' spezzate - un array di parole per riga - e le + due copie stanno impilate nella stessa cella. */ + T.pair=function(a,b){ + var win=el("div","twin"); + wrap.replaceChild(win,line); + T.copies=[]; T.spans=[]; + [a,b].forEach(function(rows){ + var cp=el("div","tcopy"); + rows.forEach(function(row){ + var ln=el("div","tline"); + row.forEach(function(w){ var sp=el("span","tw",null,w); ln.appendChild(sp); T.spans.push(sp); }); + cp.appendChild(ln); + }); + win.appendChild(cp); T.copies.push(cp); + }); + T.line=T.copies[0].firstChild; + return T.copies; + }; /* La riga di fianco, ruotata di novanta gradi. */ T.side=function(txt,deg){ var e=el("div","tside",null,txt); @@ -750,7 +797,19 @@

Thirty-six movements,
no cuts.

if(mv.init) mv.init(T,stage); } -function cur(it){ return Math.floor(((performance.now()-it.t0)/1000)*FPS)%it.mv.dur; } +/* L'orario del fotogramma, non quello dell'orologio. loop() passa il + timestamp di requestAnimationFrame, che e' l'istante del refresh e cade a + passo regolare; performance.now() letto dentro il callback arriva dopo, di + un ritardo che cambia a ogni giro - misurato con la tipografia in campo, + 2,1 ms di mediana e fino a 3,7. Con i 30 fps delle demo su uno schermo a + 60 Hz, quando il confine fra due fotogrammi cade vicino al refresh quel + ritardo decide da che parte sta: il 3,3% dei fotogrammi restava a schermo + per uno o tre refresh invece di due, contro lo 0,7 col timestamp. Il modulo + positivo perche' il timestamp del primo refresh puo' precedere t0. */ +function cur(it,now){ + var n=Math.floor((((now==null?performance.now():now)-it.t0)/1000)*FPS); + return ((n%it.mv.dur)+it.mv.dur)%it.mv.dur; +} function draw(it,f){ if(f===it.last) return; it.last=f; @@ -763,12 +822,12 @@

Thirty-six movements,
no cuts.

if(e.isIntersecting){ it.last=-1; draw(it, it.manual!=null?it.manual:(RM?Math.round(it.mv.dur*0.55):cur(it))); } }); },{rootMargin:"260px 0px"}); -function loop(){ +function loop(ts){ for(var i=0;iThirty-six movements,
no cuts. }}; /* ============================================================ tipografia - Quattro voci in cui il soggetto e' la parola. La regola di questo repo - - niente stacchi - vale anche qui: le reference tipografiche di partenza - montavano a battute con stacchi netti, ma le due scelte per ultime (Apple - Creator Studio, cinque stacchi in trentacinque secondi; i tre shot Dribbble, - zero stacchi) hanno spostato la cosa sulla trasformazione continua. Queste - quattro stanno da quel lato. */ - -/* --- TYP-01: la sosta, misurata in caratteri al secondo --- */ + Dieci voci in cui il soggetto e' la parola. Valgono per loro le stesse + regole del resto del repo, e una in piu' che la prima versione violava: + + SULLO SCHERMO C'E' SEMPRE UNA PAROLA INTERA. Ogni voce mostrava il caso + giusto, faceva sparire la frase, e poi mostrava quello sbagliato: dieci + demo che si svuotavano due o tre volte per ciclo, fino a ventitre + fotogrammi di nero. Una dissolvenza al nero e' un taglio con le buone + maniere - e' scritto in CardHandoff.tsx - e qui ce n'erano venticinque. + Non bastava neanche che il quadro non fosse nero: la prima correzione + faceva uscire e rientrare le parole sfalsate, e le frasi continuavano a + sparire e tornare, in onda. Adesso il confronto avviene su una frase che + resta in campo: si trasforma da un caso all'altro (TYP-02, 03, 07, 08, 09, + 10), passa il turno a un'altra dentro una casella che ritaglia (TYP-01, + 04), oppure ne resta ferma una parte mentre arriva il resto (TYP-05, 06). + + Tutte e quattro le regole le misura type-check.py, su ogni fotogramma e a + piu' larghezze: una parola intera sempre in campo, nessuna parola sopra + un'altra o sotto la HUD, nessuna fuori dal quadro, e niente che salti in un + fotogramma mentre si vede - quanto se ne vede, dove sta, di che colore e'. */ + +/* Il tempo di un elemento sfalsato, dentro un ciclo che si ripete. */ +function cyc(f,lag,dur){ var g=(f-lag)%dur; return g<0?g+dur:g; } +/* Una rampa 0..1 su [a, a+d). */ +function ramp(g,a,d,ez){ return (ez||E.inout)(cl((g-a)/d)); } + +/* --- TYP-01: la sosta, misurata in caratteri al secondo --------------- + Due righe di 29 caratteri che si passano il turno dentro una finestra: + quella che esce sale oltre il bordo di sopra mentre l'altra sale da sotto, + nella stessa corsa, quindi fra le due non c'e' un fotogramma vuoto e non si + toccano mai. La versione di prima toglieva la riga e la rimetteva, parola + per parola: mai del tutto vuoto, ma due volte a giro, per mezzo secondo, + restava un quinto della frase, e una frase che se ne va e torna e' un + taglio con le buone maniere. + Le soste sono fotogrammi FERMI: dall'ultimo fotogramma di una rotazione al + primo della successiva. DW.A e DW.B li contano, e il ciclo si costruisce da + li'. */ var TLINE = ["Real", "UI,", "not", "a", "drawing", "of", "UI."]; -var TCHARS = TLINE.join(" ").length; - -M.dwell={dur:187, half:104, kind:"type", - init:function(T){ T.words(TLINE); }, +var TROWS = [["Real", "UI,", "not", "a"], ["drawing", "of", "UI."]]; +var TROWS2 = [["Measured,", "not"], ["guessed", "by", "eye."]]; +function chars(rows){ return [].concat.apply([],rows).join(" ").length; } +var TCHARS = chars(TROWS), TCHARS2 = chars(TROWS2); +var DW = {A:56, B:35, R:22}; +DW.a1 = DW.A - 1; /* ultimo fotogramma fermo della prima riga */ +DW.a2 = DW.a1 + DW.R + DW.B - 1; /* ultimo fotogramma fermo della seconda */ +DW.D = DW.a2 + DW.R; +DW.H = DW.a1 + DW.R / 2; + +M.dwell={dur:DW.D, half:DW.H, kind:"type", + init:function(T){ T.pair(TROWS, TROWS2); }, draw:function(f,T){ - /* Due meta': la prima tiene la frase 56 frame da ferma, la seconda 29. - Stessa frase, stesso ingresso, stessa uscita: cambia solo la sosta. */ - var second=f>=104, q=second?f-104:f, dwell=second?41:62; - var COMPOSE=24, LEAVE=18; - T.spans.forEach(function(sp,i){ - /* Le parole entrano scaglionate, ma lo scaglionamento e' STRETTO: e' il - modo di guadagnare lettura senza rallentare il montaggio. Allargarlo - ruba tempo alla sosta, che e' l'unico tratto in cui si legge davvero. */ - var inP=E.out(cl((q-i*2.2)/COMPOSE)); - var out=E.inout(cl((q-COMPOSE-dwell)/LEAVE)); - sp.style.opacity=(inP*(1-out)).toFixed(3); - sp.style.transform="translateY("+((1-inP)*0.26+out*-0.16).toFixed(3)+"em)"; - }); - /* La finestra dichiarata e la SOSTA NETTA non sono lo stesso numero, e la - differenza e' il contenuto della voce: la sosta comincia quando l'ultima - parola si e' posata, non quando la prima e' partita. Con l'ingresso - scaglionato sono sei frame di scarto, e demo-check.py misura quella netta - sul rendering invece di fidarsi di questa costante. */ - var net=dwell-6, sec=net/FPS, cps=TCHARS/sec; - var at=q>=COMPOSE&&q0 ? (1-u2)*100 : -u1*100; + var yb = u2>0 ? -u2*100 : (1-u1)*100; + T.copies[0].style.transform="translateY("+ya.toFixed(3)+"%)"; + T.copies[1].style.transform="translateY("+yb.toFixed(3)+"%)"; + var second=f>=DW.H, net=second?DW.B:DW.A, n=second?TCHARS2:TCHARS, cps=n/(net/FPS); + T.hud(n+" characters · net dwell "+net+"f → "+cps.toFixed(1)+" c/s" +(second?" over 20: the line is taken away while you are still reading it" :" large type holds 15 to 16")); }}; -/* --- TYP-02: una parola sola, enorme --- */ +/* --- TYP-02: una parola sola, enorme --------------------------------- + La parola chiave cresce DALLA BASE, con una trasformazione, su una riga + sua. Tre difetti stavano qui uno sull'altro. Col font-size la riga si + ricomponeva e la frase saltava di 147 px; con scale() dal centro la parola + cresceva anche verso il basso e copriva la seconda riga; e siccome la sua + casella era larga quanto la riga intera, il riquadro ingrandito usciva dal + quadro da tutti e due i lati. Adesso la trasformazione sta su uno span + stretto sulla parola e parte dal fondo: sale, e sotto non tocca niente. + Poi torna alla misura delle altre, invece di sparire: il caso sbagliato e' + la stessa frase senza soggetto. */ var TCLAIM = ["One", "shot.", "Forty-eight", "seconds."]; var TSHORT = ["One", "shot."]; M.big={dur:220, half:110, kind:"type", init:function(T){ T.words(TCLAIM); - /* LA PAROLA CHIAVE STA SU UNA RIGA SUA, e cresce con una TRASFORMAZIONE. - Prima cresceva cambiandole il corpo dentro una riga sola, e cambiare il - corpo cambia la larghezza della casella: la riga flex si ricomponeva, il - punto di a capo si spostava, e a meta' crescita tutta la frase saltava di - centoquarantasette pixel. Era il difetto piu' visibile della famiglia, ed - era esattamente il contrario di quello che la voce dichiara. - Una scale() non tocca il layout, quindi le altre parole non hanno proprio - modo di muoversi; e la riga propria e' anche piu' vicina alle reference, - dove la parola enorme sta da sola. */ - T.spans[0].style.flexBasis="100%"; - T.spans[0].style.textAlign="center"; - T.spans[0].style.transformOrigin="50% 60%"; + var sp=T.spans[0], txt=sp.textContent; + sp.textContent=""; sp.style.flexBasis="100%"; sp.style.textAlign="center"; + var inn=el("span",null,"display:inline-block;transform-origin:50% 100%",txt); + sp.appendChild(inn); T.key=inn; }, draw:function(f,T){ - var second=f>=110, q=second?f-110:f; - var inP=E.out(cl(q/26)), grow=E.inout(cl((q-30)/44)), out=E.inout(cl((q-96)/14)); - var k=second?1:1+1.6*grow; - T.spans.forEach(function(sp,i){ - sp.style.opacity=(inP*(1-out)).toFixed(3); - sp.style.transform=(i===0?"scale("+k.toFixed(3)+") ":"") - +"translateY("+((1-inP)*0.22).toFixed(3)+"em)"; - }); - T.hud(second - ? "every word at the same size: nothing in the line is the subject" - : "the keyword at "+k.toFixed(2)+"× the rest it scales with a transform, so the others cannot move even if they wanted to"); + var grow = f<44 ? ramp(f,0,44) : f<110 ? 1 : f<154 ? 1-ramp(f,110,44) : 0; + var k=1+1.6*grow; + T.key.style.transform="scale("+k.toFixed(3)+")"; + T.hud(f<110 + ? "the keyword at "+k.toFixed(2)+"× the rest it grows up from its own baseline, so nothing under it is touched" + : "back to the size of the others: the same sentence, and nothing in it is the subject"); }}; -/* --- TYP-03: colore solo sulla parola chiave --- */ +/* --- TYP-03: colore solo sulla parola chiave ------------------------- + Il colore si mescola in pochi fotogrammi invece di scattare: accendere una + parola in un fotogramma e' uno stacco su una parola sola. E si allarga + alle altre prima di tornare, cosi' il caso sbagliato arriva sulla stessa + frase invece che dopo un buio. */ +var TINT=[[228,123,78],[69,196,133],[99,168,238],[237,196,82]]; +var TNEU=[231,235,239]; +function mixc(a,b,t){ return "rgb("+[0,1,2].map(function(i){ return Math.round(a[i]+(b[i]-a[i])*t); }).join(",")+")"; } + M.only={dur:200, half:100, kind:"type", init:function(T){ T.words(TCLAIM); }, draw:function(f,T){ - var second=f>=100, q=second?f-100:f; - var inP=E.out(cl(q/24)), on=E.inout(cl((q-34)/26)), out=E.inout(cl((q-84)/14)); - var tint=["hsl(18 74% 60%)","hsl(150 52% 52%)","hsl(210 80% 66%)","hsl(45 90% 62%)"]; + var back=ramp(f,170,24); + var a=ramp(f,10,24)*(1-back), b=ramp(f,100,24)*(1-back); T.spans.forEach(function(sp,i){ - sp.style.opacity=(inP*(1-out)).toFixed(3); - sp.style.transform="translateY("+((1-inP)*0.2).toFixed(3)+"em)"; - var lit=second ? on>0.5 : (i===0 && on>0.5); - sp.style.color=lit ? (second?tint[i%4]:tint[0]) : ""; + sp.style.color = i===0 ? mixc(TNEU,TINT[0],a) : mixc(TNEU,TINT[i],b); }); - T.hud(second - ? "colour on every word: at that point colour is not pointing at anything" - : "one word lit, the rest left neutral the colour is the pointer, and a pointer that points everywhere is not one"); + T.hud(f<100 + ? "one word lit, the rest left neutral the colour is the pointer" + : "colour on every word: at that point colour is not pointing at anything"); }}; -/* --- TYP-04: la parola che resta --- */ -var TA = ["Every", "join", "is", "measured."]; -var TB = ["Every", "pose", "is", "measured."]; +/* --- TYP-04: la parola che resta ------------------------------------- + Ogni parola sta in una casella che ritaglia, con dentro due copie: quella + della prima frase e quella della seconda. Passare il turno e' farle salire + tutte e due della stessa corsa, il 160% dell'altezza della parola: una + esce dal bordo di sopra mentre l'altra entra da quello di sotto, e fra le + due resta sempre piu' di mezza riga d'aria. Nelle caselle delle parole in + comune le due copie sono identiche. + Prima meta': passa il turno solo la casella che cambia. Seconda: lo passano + tutte, sfalsate di 5 fotogrammi, e le tre parole in comune escono e + rientrano uguali - sono proprio quelle che dovevano restare ferme a + muoversi, ed e' il caso sbagliato. + Due versioni fa "pose" stava in assoluto sopra "join" e, piu' larga, copriva + "is" per 118 fotogrammi su 260. La versione dopo, per il caso sbagliato, + toglieva e rimetteva le parole una alla volta: per un fotogramma la piu' + visibile era al 23%. + LA COPPIA SI SCEGLIE SULLA LARGHEZZA RESA. La cella e' larga quanto la + parola piu' larga, e "join" in una cella larga quanto "pose" - 154 px contro + 215 a corpo 100 - galleggiava con un vuoto per lato, nel caso GIUSTO. "click" + e "drag" misurano 200,6 e 200,9. Quello che un altro font lascia di + differenza va nella spaziatura della piu' stretta, misurata al primo + disegno: pochi millesimi di em, invece di un buco accanto alla parola. */ +var TA = ["Every", "click", "is", "measured."]; +var TB = ["Every", "drag", "is", "measured."]; M.stays={dur:260, half:130, kind:"type", init:function(T){ T.words(TA); - /* Le due parole che si scambiano stanno nella STESSA casella, una nel flusso - e una sopra in assoluto. Quella nel flusso da' la larghezza, quindi la riga - non si ricompone e le altre tre parole non hanno motivo di spostarsi. - "join" e "pose" sono lunghe uguali di proposito: con larghezze diverse la - riga centrata si riflowa e le parole "che restano" si muovono, cioe' la - tesi della voce cade da sola. */ - var slot=T.spans[1]; - slot.textContent=""; - slot.style.position="relative"; - var oldw=el("span",null,null,TA[1]); - var neww=el("span",null,"position:absolute;left:0;top:0;opacity:0",TB[1]); - slot.appendChild(oldw); slot.appendChild(neww); - T.oldw=oldw; T.neww=neww; + T.rolls=T.spans.map(function(sp,k){ + sp.textContent=""; sp.className="tw troll"; + var a=el("span",null,null,TA[k]), b=el("span",null,null,TB[k]); + sp.appendChild(a); sp.appendChild(b); + return {a:a, b:b}; + }); }, draw:function(f,T){ - var second=f>=130, q=second?f-130:f; - var inP=E.out(cl(q/24)); - /* LA SOSTITUZIONE E' IN SEQUENZA, NON IN CROCE, e la prima versione era in - croce: a meta' dissolvenza si vedevano "join" e "pose" sovrapposte nella - stessa casella, che non legge come una parola che ne sostituisce un'altra, - legge come un errore di rendering. La vecchia esce prima, la nuova entra - dopo, e nel mezzo la casella resta vuota per due frame: quel vuoto e' una - battuta, non un buco. */ - var outW=E.inout(cl((q-58)/18)); - var inW=E.inout(cl((q-78)/18)); - var sw=E.inout(cl((q-58)/38)); - - T.spans.forEach(function(sp,i){ - if(second){ - /* Il caso da guardare: si sostituisce TUTTA la riga. Anche le tre parole - che le due frasi hanno in comune se ne vanno e ritornano, e la riga - intera scorre. Coi pixel al posto giusto legge comunque come uno - stacco con le buone maniere. */ - var slide=(sw<0.5? -sw*2 : (sw-1)*2)*1.15; - sp.style.transform="translateX("+slide.toFixed(3)+"em)"; - sp.style.opacity=(inP*(1-Math.sin(cl(sw)*Math.PI)*0.94)).toFixed(3); - } else { - sp.style.transform="none"; - sp.style.opacity=inP.toFixed(3); + if(!T.even){ + var r1=T.rolls[1], wa=r1.a.getBoundingClientRect().width, wb=r1.b.getBoundingClientRect().width; + var fs=parseFloat(getComputedStyle(r1.a).fontSize); + if(fs>0 && wa>0 && wb>0){ + var nar = wa=115, q=second?f-115:f; - var out=E.inout(cl((q-86)/22)); - T.spans.forEach(function(sp,i){ - var t=E.out(cl((q-6-i*3.4)/26)); - if(second){ - /* La stessa entrata fatta in dissolvenza: la parola non e' scoperta, - e' accesa. Il testo non si muove ma nemmeno arriva da nessuna parte. */ - T.inner[i].style.transform="none"; - T.inner[i].style.opacity=t.toFixed(3); - } else { - T.inner[i].style.transform="translateY("+((1-t)*112).toFixed(1)+"%)"; - T.inner[i].style.opacity="1"; - } - sp.style.opacity=(1-out).toFixed(3); + T.inner.forEach(function(inn,j){ + if(j=125, q=second?f-125:f; - var out=E.inout(cl((q-92)/24)), k=0; - T.spans.forEach(function(sp,w){ - for(var j=0;j=lagL && g=LT.O1+lagL && g=LT.S2+lagW && g=LT.O2+lagW && g=b.v?a:b; + c.style.opacity=s.v.toFixed(3); + c.style.transform="translateY("+s.y.toFixed(3)+"em)"; }); - T.hud(second - ? "word by word: four entry moments, and the line arrives in blocks" - : "letter by letter, 1.15 frames apart twenty-nine moments instead of seven, and the line pours instead of landing"); + T.hud(f=110, q=second?f-110:f; - var inP=E.out(cl(q/22)), set=E.inout(cl((q-14)/52)), out=E.inout(cl((q-88)/16)); - /* La spaziatura parte larga e si chiude. E' l'unica entrata che non muove - niente da fuori campo: la frase e' gia' tutta li' dal primo fotogramma, - cambia solo quanto respira. */ - var tr=second?0:(1-set)*0.26; - T.line.style.letterSpacing=(-0.035+tr).toFixed(4)+"em"; - T.spans.forEach(function(sp){ sp.style.opacity=(inP*(1-out)).toFixed(3); }); - T.hud(second - ? "no tracking move: the line is simply switched on at its final spacing" - : "letter-spacing from 0.225em to -0.035em over 52 frames nothing enters from off-frame, the sentence only stops holding its breath"); + var set = f<190 ? ramp(f,14,52) : 1-ramp(f,190,29); + T.line.style.letterSpacing=(-0.035+(1-set)*0.26).toFixed(4)+"em"; + T.hud(f<110 + ? "letter-spacing closing to -0.035em nothing enters from off-frame, the sentence only stops holding its breath" + : (f<190 ? "no tracking move: the line just sits at its final spacing" + : "opening again, so the loop can close")); }}; -/* --- TYP-08: il peso che atterra -------------------------------------- */ - - +/* --- TYP-08: il peso che atterra ------------------------------------- + Due parole: il peso cambia anche l'ingombro, e su una riga lunga + l'animazione rifarebbe la composizione. La frase non esce mai: prende + peso, resta, lo perde. */ M.weight={dur:210, half:105, kind:"type", init:function(T){ T.words(TSHORT); }, draw:function(f,T){ - var second=f>=105, q=second?f-105:f; - var inP=E.out(cl(q/20)), w=E.inout(cl((q-12)/48)), out=E.inout(cl((q-82)/16)); - /* Da 300 a 800. E il peso CAMBIA ANCHE L'INGOMBRO: a 800 le stesse parole - sono piu' larghe. La prima versione di questa voce sosteneva il contrario - e il render la smentiva subito - con la frase lunga, a 300 stava su una - riga e a 800 andava a capo. Per questo qui la frase e' corta: l'inchiostro - si vede cambiare senza che la composizione si rifaccia. Su una riga lunga - un'animazione di peso o la si tiene corta o le si blocca la larghezza. */ - var val=second?800:Math.round(300+500*w); + var w = f<180 ? ramp(f,12,48) : 1-ramp(f,180,29); + var val=Math.round(300+500*w); T.line.style.fontWeight=String(val); - T.spans.forEach(function(sp){ sp.style.opacity=(inP*(1-out)).toFixed(3); }); var w0=T.spans[0].getBoundingClientRect().width; - T.hud(second - ? "already at 800 from the first frame: the sentence arrives finished" - : "weight "+val+" of 800 the first word is "+w0.toFixed(0)+" px wide: the ink grows and so does the room it takes"); + T.hud(f<105 + ? "weight "+val+" of 800 the first word is "+w0.toFixed(0)+" px wide: the ink grows and so does the room it takes" + : (f<180 ? "already at 800: the sentence arrives finished" : "losing weight again, so the loop can close")); }}; /* --- TYP-09: la riga che accompagna, da un altro orientamento --------- - Nelle reference la frase principale non e' mai sola: di fianco corre una - riga piccola, ruotata, in maiuscoletto spaziato - una data, una categoria, - un numero d'ordine. Non compete con la frase perche' sta su un ALTRO ASSE: - l'occhio la vede senza doverla leggere prima. Messa piatta sotto la riga - principale diventa un sottotitolo, cioe' una seconda cosa da leggere. */ + La compagna gira su un perno nell'angolo in basso a sinistra: verticale + corre lungo il fianco, orizzontale si stende sotto come una didascalia. + Il perno sta li' perche' la rotazione spazza solo quell'angolo, e la frase + principale non viene mai attraversata. Nella prima versione la compagna + spariva da un lato e ricompariva dall'altro. */ M.side={dur:230, half:115, kind:"type", init:function(T){ T.words(TCLAIM); - T.side("Frame-locked · 1460 frames"); + /* Corta di proposito. Con "Frame-locked · 1460 frames" la didascalia era + lunga 330 px e ruotando la sua punta entrava nella casella di "One": + il perno nell'angolo bastava a tenerla lontana solo se era breve. Una + riga d'accompagnamento e' una data o un numero, non una frase. */ + T.side("1460 frames"); }, draw:function(f,T){ - var second=f>=115, q=second?f-115:f; - var inP=E.out(cl(q/24)), sideIn=E.out(cl((q-16)/30)), out=E.inout(cl((q-88)/18)); - T.spans.forEach(function(sp){ - sp.style.opacity=(inP*(1-out)).toFixed(3); - sp.style.transform="translateY("+((1-inP)*0.2).toFixed(3)+"em)"; - }); - var e=T.sideEl; - e.style.opacity=(sideIn*(1-out)).toFixed(3); - if(second){ - /* Il caso da guardare: la stessa riga messa piatta sotto la principale. - Da li' in poi e' un sottotitolo, e un sottotitolo si legge. */ - e.style.left="50%"; e.style.top="66%"; - e.style.transform="translateX(-50%) translateY("+((1-sideIn)*10).toFixed(1)+"px)"; - e.style.fontSize="1.9cqw"; - } else { - e.style.left="3cqw"; e.style.top="50%"; - e.style.transform="rotate(-90deg) translateX(-50%) translateY("+((1-sideIn)*-14).toFixed(1)+"px)"; - e.style.fontSize="1.5cqw"; - } - T.hud(second - ? "the same line set flat under the sentence: now it is a subtitle, and a subtitle has to be read" - : "the companion runs up the left edge at -90° it is seen without being read, because it is not on the axis the eye is following"); + var down = f<190 ? ramp(f,80,40) : 1-ramp(f,190,39); + var a=-90+90*down; + T.sideEl.style.transform="rotate("+a.toFixed(2)+"deg)"; + T.sideEl.style.opacity="1"; + T.hud(down<0.5 + ? "the companion runs up the left edge at -90° it is seen without being read, because it is not on the axis the eye is following" + : "the same line laid flat along the bottom: now it is a caption, and a caption has to be read"); }}; /* --- TYP-10: la frase sul piano -------------------------------------- - La tipografia sta nella stessa prospettiva della lastra invece che - appoggiata sul quadro. E' la voce che lega questa famiglia al resto del - repo: se il film e' un oggetto inclinato ripreso da una camera, una frase - piatta sul fotogramma viene da un altro film. */ + La tipografia sta nella prospettiva della lastra invece che appoggiata + sul quadro. Il piano si stende e torna inclinato, senza che la frase + esca: la differenza fra le due meta' e' solo la trasformazione del + genitore. */ M.plane={dur:250, half:125, kind:"type", init:function(T,stage){ T.words(TCLAIM); - /* Corpo piu' piccolo che nelle altre voci: una riga che riempie il quadro - non lascia vedere niente della prospettiva, perche' i suoi due capi - finiscono fuori. */ T.line.style.fontSize="5.2cqw"; var room=el("div","troom"), plane=el("div","tplane"); - /* La riga esistente si trasloca dentro il piano: cosi' e' lo stesso - elemento, con lo stesso corpo, e l'unica differenza fra le due meta' - e' la trasformazione del genitore. */ room.appendChild(plane); plane.appendChild(T.line); T.wrap.appendChild(room); T.plane=plane; }, draw:function(f,T){ - var second=f>=125, q=second?f-125:f; - var inP=E.out(cl(q/26)), out=E.inout(cl((q-92)/20)); - var t=E.inout(cl((q-10)/70)); - T.spans.forEach(function(sp){ sp.style.opacity=(inP*(1-out)).toFixed(3); }); - if(second){ - /* Piatta sul quadro: nessuna prospettiva, nessuna appartenenza. */ - T.plane.style.transform="translateZ(0) rotateY(0deg) rotateX(0deg)"; + var yaw, z, pitch, flat; + if(f<205){ + var settle=ramp(f,10,70); flat=ramp(f,125,45); + yaw=(-30+11*settle)*(1-flat); z=(40+120*settle)*(1-flat); pitch=3*(1-flat); } else { - /* Gli stessi angoli della lastra a fine UIMockup: yaw -9, pitch 2,5. */ - T.plane.style.transform="translateZ("+(40+120*t).toFixed(0)+"px) rotateY("+(-30+11*t).toFixed(2)+"deg) rotateX(3deg)"; + var u=ramp(f,205,44); flat=1-u; + yaw=-30*u; z=40*u; pitch=3*u; } - T.hud(second - ? "flat on the frame: the same sentence, and nothing says it belongs to the same room as the slab" - : "yaw "+(-30+11*t).toFixed(1)+"° pitch 3° on the same perspective as the slab the type is in the room, not on the glass"); + T.plane.style.transform="translateZ("+z.toFixed(1)+"px) rotateY("+yaw.toFixed(2)+"deg) rotateX("+pitch.toFixed(2)+"deg)"; + T.hud(flat<0.5 + ? "yaw "+yaw.toFixed(1)+"° on the same perspective as the slab the type is in the room, not on the glass" + : "flat on the frame: the same sentence, and nothing says it belongs to the same room as the slab"); }}; /* --- GIU-04: le tre grandezze della camera lungo tutta la catena --- */ @@ -1840,40 +1914,40 @@

Thirty-six movements,
no cuts.

d:"seam.sh applied to the boundary, with the deliberate-cut control beside it. It is the only way to tell a working join from a scene that simply is not moving."}, {fam:"typ",code:"TYP-01",t:"The dwell, in characters per second",demo:"dwell",src:"a measured rule, not a taste", - a:"How long a line stays on screen is not chosen by eye. It is chosen in characters per second of NET DWELL: the stretch in which the sentence is already composed and still, after the last word has landed and before it starts to leave.", - b:"Three windows on the same timeline — compose, dwell, leave — and only the middle one counts for reading. The entry stagger is deliberately tight: widening it looks like generosity and is theft, because every frame it takes comes out of the only stretch where anybody is actually reading.", - c:"Large type holds 15 to 16 characters per second. Over 20 the line is taken away while you are still reading it. Here the line is 29 characters and the two halves hold it for 56 and 35 frames of NET dwell, which is 15.5 and 24.9. The declared window is six frames longer than the net one in both, because the dwell only starts once the last word has landed — that gap is the whole reason this entry measures the net figure and not the constant. To gain reading time WITHOUT slowing the cut down, tighten the entry stagger and lengthen only the dwell.", - d:"demo-check.py measures the dwell on the rendering: the frames in which every word is up and nothing is moving, divided into the character count. 15.5 c/s against 24.9. The bench cannot tell you where the ceiling should be — that number came from watching people miss the end of lines — but it can tell you the two halves are not the same speed, and it fails if they are."}, + a:"How long a line stays on screen is not chosen by eye. It is chosen in characters per second of NET DWELL: the stretch in which the sentence is complete and still, after it has arrived and before it starts to leave.", + b:"Two lines of the same length hand over inside a window: the one leaving rises past the top edge while the next rises in from below, on the same stroke, so there is never a frame without a sentence and the two never touch. The roll is deliberately short, because every frame it takes comes out of the only stretch where anybody is actually reading.", + c:"Large type holds 15 to 16 characters per second. Over 20 the line is taken away while you are still reading it. Both lines here are 29 characters; the first stands still for 56 frames and the second for 35, which is 15.5 against 24.9. The roll is 22 frames on a sine, not the camera's cubic: at the middle of a cubic a row crossed the edge of the window in under three frames, which at 30 fps is a jump. Each line is broken into two rows by hand — left to wrap on its own, the first fitted on one row and the second on two, and halfway through the roll neither had a whole row inside the window. The previous version took the words away and put them back one at a time: never a black frame, but twice a loop, for half a second, a fifth of the sentence was on screen, and a sentence that leaves and comes back is a cut with good manners. To gain reading time WITHOUT slowing the edit down, keep the roll short and lengthen only the dwell.", + d:"demo-check.py counts the frames in which one of the two lines sits exactly in its place in the window, and divides that line's characters by them: 15.5 c/s against 24.9. Exactly, not still compared to the frame before — the ends of a sine move by less than a pixel, and counted as still they added a frame to each dwell. The bench cannot tell you where the ceiling should be — that number came from watching people miss the end of lines — but it can tell you the two halves are not the same speed, and it fails if they are."}, {fam:"typ",code:"TYP-02",t:"One word, enormous",demo:"big",src:"scale contrast", a:"One word per beat becomes enormous and the rest of the line stays where it was. The size difference is what says which word the sentence is about.", b:"A transform on one span, not a font size. That distinction is the entry: a font size changes the element's box and the line recomposes around it, a scale does not, and only one of the two leaves the other words where they were.", - c:"2.6 times the rest, over 44 frames, after the line has composed. The second half runs at 1.0, which is the case worth looking at. The keyword has a row of its own and grows with a transform, and both of those are repairs: it used to grow by changing its font size inside one line, which changes the box, which recomposes the line — halfway through the growth the whole sentence jumped 147 pixels, the exact opposite of what this entry claims. A scale() does not touch layout, so the others now move 0.00 px.", + c:"2.6 times the rest, over 44 frames, grown from its own baseline on a row of its own; then back to the size of the others over 44 more instead of vanishing, because the case worth looking at is the same sentence with nothing in it as the subject. Three faults were stacked here. With a font size the line recomposed and jumped 147 pixels halfway through the growth. With a scale from the centre the word grew downwards as well, and covered the second row for 59 frames. And since its box was as wide as the whole row, the enlarged box left the frame on both sides for 63. The transform now sits on a span shrink-wrapped to the word and starts from the bottom: the word grows up, and nothing under it is touched.", d:"demo-check.py compares the rendered height of the keyword against the others: 2.6 times in the first half, 1.0 in the second. When every word is the same size no word is the subject, and that is not a matter of taste — it is what the frame is failing to say."}, {fam:"typ",code:"TYP-03",t:"Colour on one word only",demo:"only",src:"the accent as a pointer", a:"Everything neutral except one word. The colour is not decoration, it is the pointer: it says read this one first.", b:"One span takes the accent, the others take nothing. Not a gradient, not a highlight box — a colour change on the glyphs, which is the cheapest thing on the list and the one that survives being scaled down.", - c:"One word out of four. The second half of the demo lights all four in different hues, which is the failure this entry exists to name.", + c:"One word out of four, mixed in over 24 frames. The second half lights all four in different hues, which is the failure this entry exists to name, and they return to neutral before the loop starts again. The colour used to switch in a single frame, and lighting a word in one frame is a cut on one word.", d:"demo-check.py counts the words whose rendered colour differs from the line's: one against four. A pointer that points at everything is not a pointer, and the count is the whole measurement."}, {fam:"typ",code:"TYP-04",t:"The word that stays",demo:"stays",src:"the join rule, applied to a sentence", a:"Two consecutive lines share most of their words. The shared ones do not move: only the word that changes is replaced. It is the join rule of this repo written as a sentence — the next state enters from the pose the previous one stopped in.", - b:"The two words that swap live in the SAME box, one in the flow and one absolutely on top of it. The one in the flow gives the width, so the centred line does not reflow and the words that stay have no reason to shift. They are the same length on purpose: with different widths the line recomposes and the words that are supposed to stay put move, which kills the thesis on its own.", - c:"One word out of four changes, over 30 frames. The other three do not move a pixel. The second half replaces the whole line and slides it out and back, which is what an edit does when it has nothing to keep.", - d:"demo-check.py measures the screen position of the three shared words across the change: under a pixel of movement in the first half against 40 or more in the second. If the words that are supposed to stay are moving, the sentence is cutting and only pretending not to."}, + b:"Every word lives in a clipping box that holds two copies: its word in the first sentence and its word in the second. Handing over is moving both up on the same stroke, 160 per cent of the word's height, so one leaves through the top edge while the other comes in through the bottom with more than half a line of air between them. The box is a grid cell as wide as the wider of its two words, which makes the pair a matter of measurement: the first version swapped 'join' for 'pose', four letters each and treated as equal, and 'pose' is 40 per cent wider — laid on top of 'join' it covered 'is' for 118 frames out of 260, and in a cell as wide as 'pose' the right case showed 'join' floating in a gap. 'click' and 'drag' render at 200.6 and 200.9 px on a 100 px body, and whatever a different font leaves over goes into the tracking of the narrower one.", + c:"One box out of four hands over, in 24 frames. The other three do not move a pixel. In the second half every box hands over, 5 frames apart, and the three shared words leave and come back identical: the words that were supposed to stay are the ones moving, which is what an edit does when it has nothing to keep. The version before this one showed that case by taking the words away and putting them back, and for one frame the most visible word on screen was at 23 per cent.", + d:"demo-check.py measures how far the visible copies of the three shared words are from their place: 0.00 px while one word is replaced, 89 while the whole line is. It reads the copies and not the boxes, because the boxes never move — measured there, words that roll would pass for words that stay. If the words that are supposed to stay are moving, the sentence is cutting and only pretending not to."}, {fam:"typ",code:"TYP-05",t:"The mask that uncovers the line",demo:"mask",src:"a clipping box per word", - a:"Each word rises from behind the edge of its own box. A mask does not move the text: the word is already in place and only gets uncovered.", - b:"One clipping box per word, and the word translated inside it. The box is taller than the line because a box cut to the line height eats the descenders — the p and the g go first, and it reads as a rendering fault rather than as a reveal.", - c:"112 per cent of the box height, 26 frames per word, 3.4 frames of stagger. The second half runs the same entry as an opacity fade, which is the case worth looking at.", - d:"demo-check.py measures how far the word travels inside its own box: 83 px while being uncovered, 0 while being faded in. Not its height — the clipping box never changes height, that is its job — and not its absolute offset either, because the box has padding at the top and the word sits ten pixels below the edge even at rest. It is the travel that separates being uncovered from being switched on."}, + a:"Each word rises from behind the edge of its own box. The edge is what makes it an entrance: the word is cut by a line that stays still until it has crossed it, instead of being switched on where it already was.", + b:"One clipping box per word, and the word translated inside it. The box is taller than the line because a box cut to the line height eats the descenders — the p and the g go first, and it reads as a rendering fault rather than as a reveal. It also reaches 0.08em past the word on each side, compensated with a negative margin: tight tracking makes the last glyph stick out of its own box, and a box cut to the word shaved it.", + c:"160 per cent of the word's height, 26 frames per word, 3.4 frames of stagger, and a sine on the way out, because at the middle of a cubic more than a fifth of the word crossed the edge in a single frame. At 112 per cent, the first version, the word at rest was not hidden: a strip of its letters stayed visible under the box and popped on every loop. 'Real UI,' stays and only the rest of the sentence comes and goes — an entrance needs the line to be missing, but when ALL of it was missing the frame went black between one entrance and the next. The second half runs the same entrance as an opacity fade, which is the case worth looking at.", + d:"demo-check.py measures how far the word travels inside its own box: 110 px while being uncovered, 0 while being faded in. Not its height — the clipping box never changes height, that is its job — and not its absolute offset either, because the box has padding at the top and the word sits ten pixels below the edge even at rest. It is the travel that separates being uncovered from being switched on."}, {fam:"typ",code:"TYP-06",t:"Letter by letter, or word by word",demo:"letters",src:"the grain of the stagger", - a:"The same line can arrive in twenty-nine moments or in seven. Letters make it pour; words make it land in blocks. Neither is better — they are different speeds of the same sentence.", + a:"The same words can arrive in sixteen moments or in five. Letters make them pour; words make them land in blocks. Neither is better — they are different speeds of the same sentence.", b:"The letters are spans inside the word's span, so the fine stagger does not touch how the line composes: the layout is still made of words. Splitting the line into characters at the top level would let it wrap mid-word.", - c:"1.15 frames between letters against 7 between words. Under about one frame per letter the stagger stops reading and becomes a fade; over three it becomes a typewriter.", - d:"demo-check.py counts the distinct frames on which something first becomes visible: 29 against 7 on the same sentence. If the two halves count the same, the grain is not doing anything."}, + c:"1.15 frames between letters against 7 between words. Under about one frame per letter the stagger stops reading and becomes a fade; over three it becomes a typewriter. As in TYP-05, 'Real UI,' stays. The travel is short, 0.1em in and 0.08em out: when the line wraps — on a phone it does — a letter rising into its row meets the one leaving the row below, and at 0.34em the 'a' sat on top of 'UI.' for three frames.", + d:"demo-check.py counts the distinct frames on which something first becomes visible: 16 against 5 on the same words. If the two halves count the same, the grain is not doing anything."}, {fam:"typ",code:"TYP-07",t:"The tracking that closes",demo:"track",src:"letter-spacing", a:"The line starts wide and closes to its final spacing. It is the only entrance that brings nothing in from off-frame: the sentence is all there from the first frame, and what changes is how much it breathes.", @@ -1889,15 +1963,15 @@

Thirty-six movements,
no cuts.

{fam:"typ",code:"TYP-09",t:"The companion, on another axis",demo:"side",src:"a rotated secondary line", a:"The main sentence is never alone in the references: a small line runs up the side, rotated, in spaced capitals — a date, a category, an order number. It does not compete, because it is on a different axis: the eye takes it in without having to read it first.", - b:"Rotated -90 degrees against its own left edge, at a fifth of the body size, with tracking opened right up. The rotation is the point and not the decoration: the same words set flat under the sentence become a subtitle, and a subtitle is a second thing to read.", - c:"-90 degrees, 1.5cqw against 7.4 for the main line, 0.22em of tracking, uppercase. The second half sets it flat and centred underneath, which is the case worth looking at.", - d:"demo-check.py measures the angle of the companion's rendered box and its distance from the main line's reading axis. Rotated it is at 90 degrees and off to the side; flat it is at zero and directly under, which is where a subtitle goes."}, + b:"Rotated -90 degrees on its own bottom-left corner, at a fifth of the body size, with tracking opened right up. The rotation is the point and not the decoration: the same words laid flat become a caption, and a caption is a second thing to read.", + c:"1.5cqw against 7.4 for the main line, 0.22em of tracking, uppercase. It turns on its corner from -90 degrees to flat over 40 frames, and back before the loop closes; flat, it is the case worth looking at. The pivot is in the corner because a rotation there only sweeps the corner, and the label is short on purpose: as 'Frame-locked · 1460 frames' its tip went through the box of 'One' while turning. A companion is a date or a number, not a sentence. It never sits lower than 34 px either — the readout at the bottom is in pixels, and on a phone the caption laid flat sat on the frame counter for 130 frames out of 230.", + d:"demo-check.py reads the orientation of the companion's rendered box: taller than wide and 2 per cent of the width in from the edge in the first half, wider than tall in the second. The entry is about the axis, so the axis is what gets measured."}, {fam:"typ",code:"TYP-10",t:"The sentence on the plane",demo:"plane",src:"the slab's own perspective", a:"The typography lives in the same perspective as the slab instead of sitting on the frame. It is the entry that ties this family to the rest of the repo: if the film is an inclined object seen by a camera, a sentence lying flat on the frame comes from a different film.", b:"The line moves inside a plane that carries the slab's yaw and pitch, in the same perspective container. It is the same element with the same body: the only difference between the two halves is the transform on its parent.", - c:"yaw -30 closing to -19, pitch 3, inside the range the slab's own poses cover — UIMockup starts at -18. At the slab's closing angles the foreshortening is real and invisible: the two ends of the line differ by a couple of per cent, which measures but does not read. The second half sets the plane flat, which is what type does when nobody puts it anywhere.", - d:"demo-check.py compares the rendered width of the first word against the last: on the plane they differ, because perspective makes the near end larger, and flat they are the same. It is the cheapest test for whether type is in the room or on the glass."}, + c:"yaw -30 closing to -19, pitch 3, inside the range the slab's own poses cover — UIMockup starts at -18. At the slab's closing angles the foreshortening is real and invisible: the two ends of the line differ by a couple of per cent, which measures but does not read. The second half lays the plane flat, which is what type does when nobody puts it anywhere, and turns it back before the loop closes; the sentence never leaves.", + d:"demo-check.py compares the rendered height of the first word against the last: 0.909 on the plane, because perspective makes the near end larger, and 1.000 flat. It is the cheapest test for whether type is in the room or on the glass."}, {fam:"giu",code:"GIU-04",t:"Monotonic derivative",demo:"chain",src:"the three boundary poses", a:"Across the whole chain yaw, pitch and push always move the same way. Not fussiness: the eye follows the derivative of the movement, and a reversal at a join reads as a cut even when the pixels of the two frames match.", From 7312df9222897fd2046b2889ad0ef077b286bcd2 Mon Sep 17 00:00:00 2001 From: Antonino Cuzzola Date: Sun, 13 Sep 2026 19:35:25 +0200 Subject: [PATCH 2/2] Col movimento ridotto le voci tipografiche mostrano un fotogramma a riposo Con prefers-reduced-motion - acceso su molti telefoni - ogni demo mostra un fotogramma solo finche' non si trascina lo scrub, e per tutte era il 55% del ciclo. Sulla tipografia cadeva a meta' di un passaggio: in TYP-01 una riga tagliata dal bordo della finestra, in TYP-05 e TYP-06 parole a mezza dissolvenza, in TYP-02 la parola chiave ancora grande sotto la scritta che la diceva tornata normale. Ogni demo puo' dichiarare il suo fotogramma fermo in `still`, e le dieci voci tipografiche lo fanno. type-check.py lo verifica col movimento ridotto emulato: il fotogramma che la pagina disegna deve essere uguale al precedente e al successivo. Sul commit prima boccia TYP-01, 02, 05 e 06; qui passano tutte. In CI una quinta copia guasta sposta il fotogramma fermo di TYP-05 in mezzo a una dissolvenza, e il banco la deve bocciare. Co-Authored-By: Claude Opus 5 --- .github/workflows/showcase.yml | 4 ++- README.md | 23 +++++++++++++---- scripts/type-check.py | 47 +++++++++++++++++++++++++++++++++- showcase/grammatica.html | 33 ++++++++++++++---------- 4 files changed, 87 insertions(+), 20 deletions(-) diff --git a/.github/workflows/showcase.yml b/.github/workflows/showcase.yml index e9218ff..0727eff 100644 --- a/.github/workflows/showcase.yml +++ b/.github/workflows/showcase.yml @@ -326,7 +326,7 @@ jobs: # occupa un sesto del palco: due dei difetti trovati c'erano solo li'. ./scripts/type-check.py --widths 390,1440 - # Quattro copie guaste, una per regola, e il banco deve bocciarle tutte + # Cinque copie guaste, una per regola, e il banco deve bocciarle tutte # nominando la voce e il motivo. Ognuna rimette un difetto che la # pagina ha avuto davvero. guasta_tipo() { # nome, sed, voce, larghezza, motivo atteso @@ -350,6 +350,8 @@ jobs: guasta_tipo scatto 's/mixc(TNEU,TINT\[0\],a)/mixc(TNEU,TINT[0],a>0.5?1:0)/' TYP-03 1440 scatti # la didascalia stesa sopra il contatore, sul telefono guasta_tipo hud 's/bottom: max(4cqw, 34px);/bottom: 4cqw;/' TYP-09 390 'sotto la HUD' + # col movimento ridotto, il fotogramma fermo a meta' di una dissolvenza + guasta_tipo fermo 's/M.mask={dur:MK.D, half:MK.F1, still:60, kind:"type",/M.mask={dur:MK.D, half:MK.F1, still:114, kind:"type",/' TYP-05 1440 'movimento ridotto' # 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. diff --git a/README.md b/README.md index 0f1c7e2..a5c7f52 100644 --- a/README.md +++ b/README.md @@ -282,6 +282,8 @@ opacity by a few hundredths a frame and a cut by all of it. What it checks: - **A whole word**: on every frame at least one word is on screen in full. - **Snaps**: nothing changes by a large step in one frame while it is visible — how much of it shows, where it is, what colour it is. +- **At rest**: with reduced motion emulated, the one frame each demo shows is + identical to the frame before and the frame after. Pointed at the page as it was deployed, it failed all ten entries. `TYP-02` covered its second row for 59 frames and left the frame for 63, `TYP-03` switched @@ -301,7 +303,7 @@ change, the line now hands over instead of leaving: two lines in one window in line *arrives*, and arriving needs it to be missing first, part of the sentence stays: in `TYP-05` and `TYP-06`, "Real UI," holds while the rest comes and goes. -Two smaller ones came out of the same pass. On a phone the readout, which is in +Three smaller ones came out of the same pass. On a phone the readout, which is in pixels, covers a sixth of the stage's height, and `TYP-09`'s caption laid flat sat on the frame counter for 130 frames out of 230 — it never sits lower than 34 px now. And every demo's frame was read from `performance.now()` inside the animation @@ -313,10 +315,21 @@ frames stayed up for one refresh or three instead of two. The frame is read from the callback's own timestamp now, which falls on the refresh, and the same count is 0.7. -Its negative controls are four copies of the built page, each broken in one of +The last one is for whoever never sees the motion. With reduced motion on — an +accessibility setting plenty of phones have enabled — every demo shows a single +frame until the scrub is dragged, and that frame was 55 per cent of the way +through the loop for all of them. On the typography it landed in the middle of a +transition: in `TYP-01` a line cut by the edge of the window, in `TYP-05` and +`TYP-06` words half faded, in `TYP-02` the keyword still large under a readout +saying it was back to normal size. A demo can declare its frame at rest now, in +`still`, and every typography entry does; the bench checks it with reduced motion +emulated, so it also measures that the page honours the setting. + +Its negative controls are five copies of the built page, each broken in one of those ways — a keyword scaled from its centre, a line faded out while it gains -weight, a colour switched in one frame, and the caption back at `4cqw` — and the -script has to fail each and name the entry. +weight, a colour switched in one frame, the caption back at `4cqw`, and a still +frame moved into the middle of a fade — and the script has to fail each and name +the entry. ## Speed is a number in `catalog.json` @@ -405,7 +418,7 @@ npx remotion render PromptInput out/prompt-input.mp4 # from video/ ./scripts/fixture-screenshot.sh # build the scene focus-sharpness must fail ./scripts/demo-check.py [page.html] # do the catalogue demos still show their thesis ./scripts/loop-close.py [page.html] # does every demo loop close, or tear every pass -./scripts/type-check.py [page.html] # does the type cover, leave the frame, vanish or snap +./scripts/type-check.py [page.html] # does the type cover, leave the frame, vanish, snap, or stop mid-move ./scripts/contrast-floor.py [scene.mp4] # is the attenuated content still readable ./scripts/tempo.py [long.mp4 short.mp4] # does shortening a scene retime it or just trim it ./scripts/fixture-tempo.sh # render the two retimed fixtures diff --git a/scripts/type-check.py b/scripts/type-check.py index 82e720e..fa62f38 100755 --- a/scripts/type-check.py +++ b/scripts/type-check.py @@ -30,6 +30,15 @@ SCATTO. Nessuna proprieta' salta in un fotogramma mentre si vede: quanto se ne vede, posizione, colore. TYP-03 cambiava colore in un fotogramma. + FERMO. Con il movimento ridotto - l'impostazione di accessibilita' che molti + telefoni hanno accesa - ogni demo mostra un fotogramma solo, e quel + fotogramma deve essere a riposo: uguale al precedente e al successivo. Era + il 55% del ciclo per tutte, e sulle voci tipografiche cadeva a meta' di un + passaggio: una riga tagliata dal bordo della finestra, parole a mezza + dissolvenza, la parola chiave ancora grande sotto la scritta che la diceva + tornata normale. Il banco lo guarda con il movimento ridotto emulato, quindi + misura anche che la pagina lo rispetti. + COME LEGGE. Le proprieta' rese del DOM, non i pixel. Coi pixel una dissolvenza e uno stacco si somigliano troppo per distinguerli; con le proprieta' una dissolvenza cambia l'opacita' di pochi centesimi per @@ -247,6 +256,41 @@ def whole(units): print(" ok %s %d fotogrammi: niente coperture ne' fuori quadro, sempre una parola intera (al minimo %.0f%%), nessuno scatto" % (code, dur, low[0] * 100)) pg.close() + + # FERMO: una sola larghezza basta, il fotogramma scelto non dipende da + # quella. La pagina lo disegna da se' quando il palco entra in vista. + ctx = br.new_context(viewport={"width": 1440, "height": 900}, reduced_motion="reduce") + pg = ctx.new_page() + pg.goto(PAGE.as_uri(), wait_until="load") + pg.wait_for_timeout(1200) + codes = [c for c in pg.eval_on_selector_all(".mvhead .code", "e=>e.map(x=>x.textContent.trim())") + if c.startswith("TYP") and (not ONLY or c in ONLY)] + print("movimento ridotto") + for code in codes: + idx = pg.evaluate("(c)=>[...document.querySelectorAll('.mv')].findIndex(m=>(m.querySelector('.code')||{}).textContent.trim()===c)", code) + pg.locator(".stage").nth(idx).scroll_into_view_if_needed() + pg.wait_for_timeout(250) + shown, dur = pg.evaluate("(i)=>{const it=document.querySelectorAll('.stage')[i].__it; return [it.last, it.mv.dur];}", idx) + around = [pg.evaluate(PROBE, [code, (shown + d) % dur]) for d in (-1, 0, 1)] + moving = [] + ref = {u["id"]: u for u in around[1]["units"]} + for fr in (around[0], around[2]): + for u in fr["units"]: + r = ref.get(u["id"]) + if r is None: + continue + if (abs(u["vis"] - r["vis"]) > 0.01 or abs(u["x"] - r["x"]) > 0.5 or abs(u["y"] - r["y"]) > 0.5 + or u["color"] != r["color"]): + moving.append(u["text"].strip()) + wv = whole(around[1]["units"]) + if shown < 0 or moving or wv < INTERA: + why = "nessun fotogramma disegnato" if shown < 0 else ( + "a f%d si muove \"%s\"" % (shown, moving[0]) if moving else "a f%d non c'e' una parola intera" % shown) + print(" ROTTA %s col movimento ridotto il fotogramma fermo non e' fermo: %s" % (code, why)) + fails.append("%s col movimento ridotto" % code) + else: + print(" ok %s col movimento ridotto mostra f%d, a riposo" % (code, shown)) + ctx.close() br.close() print() @@ -254,4 +298,5 @@ def whole(units): print("voci tipografiche che non si leggono: " + ", ".join(fails)) raise SystemExit(1) print("VERDETTO: nessuna parola ne copre un'altra o la HUD, nessuna esce dal quadro,") -print("c'e' sempre una parola intera da leggere e niente scatta mentre si vede.") +print("c'e' sempre una parola intera da leggere, niente scatta mentre si vede, e chi") +print("chiede meno movimento vede un fotogramma a riposo.") diff --git a/showcase/grammatica.html b/showcase/grammatica.html index 12d3c40..fabf1cc 100644 --- a/showcase/grammatica.html +++ b/showcase/grammatica.html @@ -424,7 +424,7 @@

Thirty-six movements,
no cuts.

rest-point.shA scene still moving where the next one has to attach. Its thresholds were absolute to begin with, tuned on this machine, and every one of them failed the move to Linuxedges under 30% of mid-scene click-gap.shA press and its consequence fused onto one frame, or so far apart that it stops reading as a consequence and starts reading as lagf271 and f276, five frames loop-close.pyA demo whose loop tears every pass, or whose second half restarts from scratch instead of continuing the first. Hunting for the mid-loop cut did not work — a cut and a fade look alike to every metric tried — so each two-part demo declares the frame it changes on and the bench looks there, which is the bargain seamAfter already strikes for the scenes11 torn, now 0 - type-check.pyType that covers other type or the readout, leaves the frame, snaps while it is visible, or goes away altogether. The first fix of the typography family made sure the frame never went black, and the sentences kept leaving and coming back anyway, in waves — in TYP-01 a fifth of the sentence was on screen for half a second, twice a loop. What reads as the sentence being gone is having nothing whole to read, so that is the rule: on every frame at least one word is on screen in full10 of 10 failing on the previous page, 0 now, at four widths + type-check.pyType that covers other type or the readout, leaves the frame, snaps while it is visible, or goes away altogether. The first fix of the typography family made sure the frame never went black, and the sentences kept leaving and coming back anyway, in waves — in TYP-01 a fifth of the sentence was on screen for half a second, twice a loop. What reads as the sentence being gone is having nothing whole to read, so that is the rule: on every frame at least one word is on screen in full. And with reduced motion, where a demo shows a single frame, that frame has to be at rest10 of 10 failing on the previous page, 0 now, at four widths tempo.pyA scene that was shortened rather than sped up. If the beats scale, frame f of the short render is frame f/k of the long one; compared that way the two differ by 287 px against 5379 without normalising. The residual is not slop — it is the signature of the thresholds that deliberately did not scale18.7× advantage, 4 required fixture-trim.shMeasures nothing. Truncates a scene to the short duration instead of retiming it, which is what lowering the duration produced before any of this existed120 frames, beats left in place contrast-floor.pyContent attenuated so far that it stops being a plane behind and becomes dirt on the background. Two wrong versions before this one: the first measured the composer placeholder, which is deliberately faint and sits at 2.93:1 before any attenuation at all; the second measured the right content in a place whose position depends on how the messages wrap, and on Linux the crop landed on empty background4.17:1, 3.84 on Linux, against 1.71 @@ -742,7 +742,7 @@

Thirty-six movements,
no cuts.

it.base=(r.width/1920)*1.04; rig.style.transform="scale("+it.base.toFixed(4)+")"; room.style.perspective=(2600*it.base).toFixed(1)+"px"; - it.last=-1; draw(it, RM?Math.round(mv.dur*0.55):cur(it)); + it.last=-1; draw(it, RM?still(mv):cur(it)); }); ro.observe(stage); @@ -806,6 +806,13 @@

Thirty-six movements,
no cuts.

ritardo decide da che parte sta: il 3,3% dei fotogrammi restava a schermo per uno o tre refresh invece di due, contro lo 0,7 col timestamp. Il modulo positivo perche' il timestamp del primo refresh puo' precedere t0. */ +/* Il fotogramma che vede chi ha chiesto meno movimento, ed e' l'unico che vede + finche' non trascina lo scrub. Il 55% del ciclo cadeva a caso: sulle voci + tipografiche a meta' di un passaggio - una riga tagliata dal bordo della + finestra, parole a mezza dissolvenza, la parola chiave ancora grande sotto + la scritta che la dice tornata normale. Chi ha un fotogramma fermo da + mostrare lo dichiara in `still`. */ +function still(mv){ return mv.still!=null ? mv.still : Math.round(mv.dur*0.55); } function cur(it,now){ var n=Math.floor((((now==null?performance.now():now)-it.t0)/1000)*FPS); return ((n%it.mv.dur)+it.mv.dur)%it.mv.dur; @@ -819,7 +826,7 @@

Thirty-six movements,
no cuts.

} var io=new IntersectionObserver(function(es){ es.forEach(function(e){ var it=e.target.__it; if(!it) return; it.vis=e.isIntersecting; - if(e.isIntersecting){ it.last=-1; draw(it, it.manual!=null?it.manual:(RM?Math.round(it.mv.dur*0.55):cur(it))); } }); + if(e.isIntersecting){ it.last=-1; draw(it, it.manual!=null?it.manual:(RM?still(it.mv):cur(it))); } }); },{rootMargin:"260px 0px"}); function loop(ts){ @@ -1330,7 +1337,7 @@

Thirty-six movements,
no cuts.

DW.D = DW.a2 + DW.R; DW.H = DW.a1 + DW.R / 2; -M.dwell={dur:DW.D, half:DW.H, kind:"type", +M.dwell={dur:DW.D, half:DW.H, still:30, kind:"type", init:function(T){ T.pair(TROWS, TROWS2); }, draw:function(f,T){ /* Seno, non la cubica della camera: la cubica a meta' corsa va al triplo @@ -1362,7 +1369,7 @@

Thirty-six movements,
no cuts.

var TCLAIM = ["One", "shot.", "Forty-eight", "seconds."]; var TSHORT = ["One", "shot."]; -M.big={dur:220, half:110, kind:"type", +M.big={dur:220, half:110, still:80, kind:"type", init:function(T){ T.words(TCLAIM); var sp=T.spans[0], txt=sp.textContent; @@ -1388,7 +1395,7 @@

Thirty-six movements,
no cuts.

var TNEU=[231,235,239]; function mixc(a,b,t){ return "rgb("+[0,1,2].map(function(i){ return Math.round(a[i]+(b[i]-a[i])*t); }).join(",")+")"; } -M.only={dur:200, half:100, kind:"type", +M.only={dur:200, half:100, still:60, kind:"type", init:function(T){ T.words(TCLAIM); }, draw:function(f,T){ var back=ramp(f,170,24); @@ -1425,7 +1432,7 @@

Thirty-six movements,
no cuts.

var TA = ["Every", "click", "is", "measured."]; var TB = ["Every", "drag", "is", "measured."]; -M.stays={dur:260, half:130, kind:"type", +M.stays={dur:260, half:130, still:110, kind:"type", init:function(T){ T.words(TA); T.rolls=T.spans.map(function(sp,k){ @@ -1478,7 +1485,7 @@

Thirty-six movements,
no cuts.

MK.F1 = MK.M1 + MK.OUT + MK.GAP; MK.D = MK.F2 + MK.OUT + MK.GAP; -M.mask={dur:MK.D, half:MK.F1, kind:"type", +M.mask={dur:MK.D, half:MK.F1, still:60, kind:"type", init:function(T){ T.words(TLINE); T.clip(); }, draw:function(f,T){ T.inner.forEach(function(inn,j){ @@ -1512,7 +1519,7 @@

Thirty-six movements,
no cuts.

LT.S2 = LT.O1 + 22; LT.D = LT.O2 + 36; -M.letters={dur:LT.D, half:LT.S2, kind:"type", +M.letters={dur:LT.D, half:LT.S2, still:60, kind:"type", init:function(T){ T.words(TLINE); T.ch=[]; @@ -1562,7 +1569,7 @@

Thirty-six movements,
no cuts.

Frase corta: a spaziatura larga quella lunga andava su tre righe e a spaziatura chiusa su due, e la composizione si rifaceva a meta' animazione. La frase non esce mai: si chiude, resta, si riapre. */ -M.track={dur:220, half:110, kind:"type", +M.track={dur:220, half:110, still:90, kind:"type", init:function(T){ T.words(TSHORT); }, draw:function(f,T){ var set = f<190 ? ramp(f,14,52) : 1-ramp(f,190,29); @@ -1577,7 +1584,7 @@

Thirty-six movements,
no cuts.

Due parole: il peso cambia anche l'ingombro, e su una riga lunga l'animazione rifarebbe la composizione. La frase non esce mai: prende peso, resta, lo perde. */ -M.weight={dur:210, half:105, kind:"type", +M.weight={dur:210, half:105, still:90, kind:"type", init:function(T){ T.words(TSHORT); }, draw:function(f,T){ var w = f<180 ? ramp(f,12,48) : 1-ramp(f,180,29); @@ -1595,7 +1602,7 @@

Thirty-six movements,
no cuts.

Il perno sta li' perche' la rotazione spazza solo quell'angolo, e la frase principale non viene mai attraversata. Nella prima versione la compagna spariva da un lato e ricompariva dall'altro. */ -M.side={dur:230, half:115, kind:"type", +M.side={dur:230, half:115, still:50, kind:"type", init:function(T){ T.words(TCLAIM); /* Corta di proposito. Con "Frame-locked · 1460 frames" la didascalia era @@ -1619,7 +1626,7 @@

Thirty-six movements,
no cuts.

sul quadro. Il piano si stende e torna inclinato, senza che la frase esca: la differenza fra le due meta' e' solo la trasformazione del genitore. */ -M.plane={dur:250, half:125, kind:"type", +M.plane={dur:250, half:125, still:100, kind:"type", init:function(T,stage){ T.words(TCLAIM); T.line.style.fontSize="5.2cqw";