# CLAUDE.md Ein Userscript, das die phpBB3-Seiten von `cs.rin.ru` fuer Safari auf iOS in eine mobile Ansicht umbaut. Kein Build, keine Abhaengigkeiten, kein `package.json` — die `.user.js`-Datei ist das Produkt. | Datei | Greift auf | Zweck | |---|---|---| | `rin-mobile.user.js` | `*://cs.rin.ru/forum/*` | Alle Ansichten, Weiche nach Seitentyp | | `dev/rin-mobile.dev.user.js` | `*://cs.rin.ru/forum/*` | Loader, bindet per `@require file:///` das echte Script ein | | `dev/viewtopic.php` | — | Fixture: gespeicherter Thread, `per_page=15`, 1114 Seiten | | `dev/viewforum.php` | — | Fixture: Main Forum, `per_page=100`, 610 Seiten, mit Unterforen, Ankuendigungen und Stickies | | `dev/index.php` | — | Fixture: Forumwurzel, zwei Kategorien mit Forenzeilen, keine Paginierung, eingeloggt aufgenommen | | `dev/ucp.php` | — | Fixture: Login-Formular (`mode=login`), nur ausgeloggter Zustand | | `dev/search.php` | — | Fixture: Suchformular ohne Treffer, eingeloggt aufgenommen | | `dev/search2.php` | — | Fixture: Suchtreffer im Modus "Posts" (15 von 1438 Treffern, `t=63042`, `per_page=15`, 96 Seiten) | Die Fixtures sind ueber `.gitignore` (`dev/*.php`) ausgeschlossen. Nach einem frischen Klon sind sie nicht da und muessen nach der Anleitung in der README neu gespeichert werden — die Seite verlangt einen Login. Der Loader greift ausschliesslich auf die echte Seite. Die Fixtures sind Markup-Vorlagen fuer das jsdom-Harness und zum Nachlesen, keine Seiten zum Anschauen im Browser — die Layoutarbeit laeuft in Chrome auf `cs.rin.ru` selbst. Sie lokal auszuliefern verlangte `@include`-Regeln fuer `localhost`, und die passen auf Seitennamen, die dort haeufig genug vorkommen (`index.php`, alles mit `search` darin), dass der globale Teil des Stylesheets (`html, body`, `img`, jeweils `!important`) in fremde lokale Projekte geriete. Das Nachziehen waere billig, der Fehlersuchweg dorthin nicht. Fuer den Zustand nach erfolgreichem Login (`ucp.php?mode=login` ohne Formular, "Information"-Vorlage) liegt derzeit keine Fixture im Repo. `buildLoginInfo()` wurde gegen einen Mitschnitt gebaut, der nicht mehr vorhanden ist; wer den Zweig anfassen will, muss die Seite neu aufnehmen. ## Aufbau Ein IIFE, streng der Reihe nach: 1. `DEBUG`-Flag und `log`-Array 2. Weiche: `view` ist `'thread'`, `'liste'`, `'login'` oder `''` — abgeleitet aus `location.pathname + location.search` 3. Nur bei gesetztem `view`: Viewport-Meta (idempotent ueber `[data-rm]`) und der komplette Style-Block als Template-Literal 4. `replaceBody(root)` blendet alle bisherigen `body`-Kinder aus und haengt die neue Ansicht an 5. `pageState()`, `pageSheet()` und `pageNav()` — die Paginierung, von Liste und Thread benutzt, nicht vom Login 6. `buildList()`, `buildThread()` beziehungsweise `buildLogin()` lesen das Original-DOM und bauen ein `div.rm-root`; alle haengen zum Schluss ueber `addCrumbs()` die fixierte Brotkrumenleiste an, die zugleich ueber `loginState()` die Login/Logout-Pille traegt. Zwei der drei Ansichten verzweigen intern weiter, weil sich mehrere Seitenformen dieselbe URL teilen: - `buildList()` versucht zuerst `buildSearchForm()` (grosses Suchformular auf `search.php`), dann `buildSearchResults()` (Trefferliste im Modus "Posts", Karten ueber `buildHit()`); erst wenn beide `false` liefern, laeuft das gewoehnliche Zeileneinsammeln - `buildLogin()` faellt ohne Formular auf `buildLoginInfo()` zurueck ("Information"-Vorlage nach dem Absenden) - `buildThread()` legt ueber `buildThreadSearch()` die zweite Leiste an, die die Lupe in den Brotkrumen per `toggleThreadSearch()` ein- und ausblendet 7. `run()` waehlt die Ansicht und haengt bei `DEBUG` das gruene Overlay an 8. Start ueber `DOMContentLoaded`, falls `readyState === 'loading'` Auf Seiten ausserhalb der Weiche (`posting.php`, Profile, …) passiert nichts, insbesondere wird kein Stylesheet injiziert. ## Fallen, die nicht aus dem Code hervorgehen **Beide Ansichten teilen sich einen Klassennamensraum.** Das war der Grund fuer die Zusammenlegung: als getrennte Scripts matchte `rin-mobile` auch `viewtopic.php` und injizierte dort sein Stylesheet, obwohl es nichts baute. Gleich benannte Regeln kollidierten mit identischer Spezifitaet, und die Ladereihenfolge der Userscripts entschied ueber das Layout. - Geteilt: `.rm-root`, `.rm-hidden`, `.rm-dbg`, die globale `img`-Regel - Geteilt sind ausserdem die Leiste und ihr Sheet: `.rm-nav`, `.rm-sheet*`, `.rm-goto`, `.rm-grid` - Unterschiede zwischen den Ansichten ueber einen Modifier am Root loesen, nicht ueber zwei Regeln mit demselben Selektor. `.rm-root.rm-paged` haelt so die 64px hinter der fixierten Leiste frei — gesetzt von `pageNav()` selbst, weil es die Leiste nicht auf jeder Liste gibt. - Namen sind ansichtsspezifisch zu halten: `.rm-jump` und `.rm-group` gehoeren der Liste, `.rm-post` und `.rm-sig` dem Thread. - `.rm-av` braucht `!important` auf Breite und Hoehe, sonst gewinnt die globale `img { height:auto !important }`-Regel gegen die Avatarmasse. **Was nach `body` soll, muss nach `replaceBody()` angehaengt werden.** Die Funktion markiert *alle* vorhandenen `body`-Kinder mit `.rm-hidden`. Das Sheet wird deshalb erst beim ersten Antippen gebaut — das loest das Problem nebenbei mit. **In der Liste entscheidet die Dokumentreihenfolge ueber den Zeilentyp.** `buildList()` sammelt Links auf `viewforum.php` und `viewtopic.php` in einem Rutsch ein und ordnet jede `tr` nach ihrem ersten verwertbaren Link zu. Eine Forenzeile verlinkt in der Spalte „letzter Beitrag" ebenfalls `viewtopic.php` — zaehlte man Themenlinks zuerst, faellt die Zeile als Thema an, der Forenname verschwindet und nur die Beschreibung bleibt als `.rm-last` uebrig. Genau so war es vor 3.2. Brotkrumen verlinken ebenfalls Foren, stehen aber in Zeilen ohne Forenmerkmale und werden verworfen. **„Mark topics read" braucht eine eigene Bremse, „Mark forums read" nicht.** Beide sind reine Aktionslinks, keine Inhaltslinks, aber nur „Mark forums read" zeigt auf `index.php` und faellt schon beim Einsammeln durch, weil das keine der gesuchten Adressen ist. „Mark topics read" oberhalb der Themenliste zeigt dagegen mit `?mark=topics` auf dieselbe `viewforum.php`-Seite und passt damit auf den Sammel-Selektor. Sein Link liegt zwar in einer eigenen inneren `tr` ohne verschachtelte Tabelle, `a.closest('td.cat')` findet aber trotzdem die aeussere Kategoriezelle drumherum — bis 3.7 wurde daraus eine dauerhafte, nicht einklappbare `.rm-sec`-Karte oberhalb von Global Announcements statt gar keiner. `rowKind()` verwirft seit 3.8 jeden Link mit `?mark=` oder `&mark=` in der Adresse, bevor die uebrige Klassifizierung ueberhaupt beginnt. Das Repository hat keine eingeloggte `viewforum.php`-Fixture, die diesen Link mit echtem Markup zeigt — geprueft ist die Bremse nur gegen den vermuteten `mark=topics`-Parameter, per Texteinfuegung in `dev/viewforum.php`. **Der Typ einer Themenzeile steht im Symbol, nicht am Titel.** `rinDark` unterscheidet `global_read.png`, `announce_read.png`, `sticky_read.png` und `topic_read.png` in der ersten Spalte, jeweils auch als `_locked`-Variante. Die Trennzeilen der Seite (`Stickies`) tragen keinen Link und fallen beim Einsammeln durch; ihr Text ist trotzdem die beste Beschriftung und wird ueber die Vorgaengerzeilen nachgeschlagen. Achtung beim Zaehlen im Fixture: dieselben Symbole stehen unten noch einmal in der Legende, ein Grep zaehlt also mehr, als es Zeilen gibt. **Der Menuebalken der Seite verlinkt Themen.** „Forum rules" und die FAQ stehen in einer Layoutzeile ohne Tabelle oder Script darin, die Bremse in `rowKind()` greift dort also nicht. Ohne das Merkmal `a.topictitle` beziehungsweise zwei Zahlenspalten wurde daraus eine Geisterkarte am Kopf jeder Liste — mit Gruppenkoepfen faellt sie als eigener Abschnitt „Themen 1" auf. **Der Gruppenkopf entfaellt hinter einer Kategoriezeile.** Sonst stuende auf der Forenwurzel unter jeder Kategorie noch ein „FOREN n" — die Kategorie leitet die Foren darunter bereits ein. Die Karten werden deshalb erst vollstaendig gebaut und dann ausgegeben: die Anzahl im Kopf steht erst fest, wenn feststeht, welche Zeilen ueberhaupt eine Karte hergeben. **Das Seitengeruest ist selbst eine Tabelle.** `closest('tr')` liefert fuer einen Link ausserhalb einer Listenzeile die Layoutzeile, die die ganze Seite enthaelt. Ohne Bremse wurde daraus eine Karte mit dem kompletten Seitentext — Kopfzeile, Stylesheet und Inline-Scripts inklusive — als Forenbeschreibung. Zeilen mit `table`, `script`, `style` oder `form` darin sind deshalb keine Kandidaten, und die Beschreibung wird nur aus den direkten Textknoten der Titelzelle gebildet, nie aus deren gesamtem Text. **Das Theme ist `rinDark`, nicht subsilver2.** Fuer die Forenliste waren die Klassennamen aus der phpBB-Dokumentation (`forumlink`, `forumdesc`) deshalb zunaechst nur eine Vermutung; `dev/viewforum.php` bestaetigt sie inzwischen. Der Rueckfall auf die Form der Zeile bleibt trotzdem stehen — er kostet nichts und traegt die Erkennung, wenn das Theme wechselt. `rinDark` ist allerdings eine Ableitung von subsilver2 und laedt dessen `styles/subsilver2/template/forum_fn.js` mit — Klassennamen aus subsilver2 sind deshalb eine brauchbare erste Annahme, wenn eine Seite noch keine Fixture hat. **Die Brotkrumenleiste ist fixiert, nicht sticky.** `.rm-root` traegt `overflow:hidden` und wuerde damit zum Scrollport einer `position:sticky` -Leiste, in dem nie gescrollt wird — sie klebte nie. Fixierte Nachfahren klammert das `overflow` dagegen nicht, weil der Root keinen Containing Block aufspannt. Die Freihaltung oben haengt an `body.rm-crumbed`, damit ohne Brotkrumen im Markup kein leerer Streifen bleibt. **Die Login-Erkennung ist inzwischen gegen eine echte Sitzung geprueft.** `loginState()` unterscheidet `a[href*="mode=login"]` von `a[href*="mode=logout"]` im Menuebalken und liest den Namen aus dem Muster `Logout [ Name ]` im Linktext. `dev/index.php`, `dev/search.php` und `dev/search2.php` sind eingeloggt aufgenommen (`Logout [ ]`) und bestaetigen das Muster. Der Fallback bleibt trotzdem stehen: passt das Muster einmal nicht — andere Sprache, anderer Klammertyp — zeigt die Pille „Logout" ohne Namen, statt eine Ausnahme zu werfen. **`ucp.php?mode=login` rendert nach dem Absenden dieselbe URL neu, diesmal ohne Formular.** phpBB fuellt die Zwischenmeldung („You have been successfully logged in.") in dieselbe generische „Information"-Vorlage, die auch fuer andere Meldungen auf dieser Seite dient (vermutlich auch Login-Fehler, dafuer liegt aber keine Fixture vor). `buildLoginInfo()` erkennt die Vorlage deshalb an ihrer Form — eine Kopfzeile mit genau einer `th`-Zelle, gefolgt von einer `td.row1`-Nachrichtenzelle — nicht am Wortlaut, und uebernimmt die Zelle unveraendert wie das Formular selbst. Findet `buildLogin()` weder Formular noch diese Form, baut es nichts; die Originalseite bleibt sichtbar. Achtung: die Fixture, gegen die das gebaut wurde, liegt nicht mehr im Repo — der Zweig ist derzeit nicht nachpruefbar, ohne die Seite neu aufzunehmen. **`index.php` wird ueber `location.pathname` erkannt, nicht ueber `here`.** Der Login-Redirect haengt "index.php" gelegentlich als Wert eines Query-Parameters an `ucp.php` (`redirect=index.php`, siehe `dev/ucp.php`). Ein Test auf `location.pathname + location.search` wie bei den uebrigen Weichen wuerde eine solche Login-URL faelschlich als Liste einordnen, noch bevor die Pruefung auf `ucp.php` ueberhaupt greift. **Ist eine Seite nicht laenger als der Viewport, kann Safari nicht wegscrollen, um seine eigene Werkzeugleiste unten freizugeben.** Bei Liste und Thread faellt das nicht auf, weil Karten beziehungsweise Beitraege fast immer laenger sind als der Bildschirm. Der Login hat kein `.rm-nav`, das sonst Platz reserviert, und war als kurze Formularseite der erste Fall, bei dem die Seite genau so hoch wie der Viewport enden konnte — die teilweise eingefahrene Leiste blieb dann stehen und verdeckte den Submit-Button, ohne dass noch etwas zu scrollen war, um sie wegzuschieben. `env(safe-area-inset-bottom)` allein loest das nicht, das federt nur den Home-Indicator ab. Der Bodensatz unter dem Formular in `.rm-login form` ist deshalb absichtlich groesser als noetig: er erzwingt Scrollhoehe ueber den Viewport hinaus. **`buildLogin()` baut das Formular nicht nach, sondern verschiebt es.** Wie der Beitragstext im Thread (`bodyWrap.appendChild(pb)`) wandert das Original-`
` unveraendert in `.rm-root` und wird nur per CSS auf Block-Elemente umgelegt. Nachbauen wuerde bedeuten, `sid`, das doppelte `redirect`-Feld und `autologin`/`viewonline` einzeln nachzuziehen — verliert man dabei eines, scheitert der Login stumm oder landet nicht dort, wo er soll. Aus demselben Grund faengt `buildLogin()` keine Fehlermeldung eines gescheiterten Logins ab: ohne Fixture eines fehlgeschlagenen Versuchs waere die Erkennung geraten. Fehlt das Formular (`input[name="username"]`), baut die Funktion nichts und die Originalseite bleibt sichtbar. **`Page X of Y` steht als `Page 1 of 1114` im Markup.** Ein Grep im Roh-HTML nach `Page 1 of` findet nichts, die Regex auf `txt(document.body)` greift trotzdem. Nicht vorschnell fuer kaputt halten. **Seitenlinks stehen nicht nur im Paginierungsblock.** Jede Themenzeile mit mehr als einer Seite bringt ihr eigenes „Go to page: 1 … 42, 43" mit, und diese Links tragen ebenfalls `start=`. Die Liste baute ihre Seitenleiste bis 3.5 aus allen `a[href*="start="]` der Seite und kam so auf 103 Eintraege, von denen die meisten mitten in einen fremden Thread fuehrten. `pageState()` macht es richtig und liest nur den Block um den `jumpto()`-Link. **`per_page` kommt aus der `jumpto()`-Funktion der Seite** (`var per_page = '15';`). Nur damit lassen sich Seiten-URLs als `start = (Seite - 1) * per_page` selbst bauen. Die gerenderten Seitenlinks taugen nicht als Quelle — phpBB zeigt bei 1114 Seiten nur `1, 2, 3, 4, 5 … 1114`. Ohne `per_page` degradiert die Leiste auf erste Seite plus die Original-Zurueck/Weiter-Links, das Sheet verschwindet. **`postRow()` steigt bewusst mehrere `tr` hoch.** Der Beitragstext liegt in einer verschachtelten Tabelle, `closest('tr')` trifft die innere Zeile ohne Autor- und Profilspalte. **Der Umbau erbt die Scrollposition, die der Browser vorher gesetzt hat.** `replaceBody()` laeuft auf `DOMContentLoaded` und ruehrt `scrollY` nicht an; weil ein Thread auch danach laenger als der Viewport bleibt, klemmt der Browser sie nie auf 0 zurueck. Auf dem iPhone startete der Thread deshalb mitten im ersten Beitrag: iOS Safari stellt die Position einer schon besuchten Adresse von sich aus wieder her und verankert sie ausserdem beim Relayout auf `width=device-width`. Im Chrome-Debugging faellt das nicht auf, weil dort jede Adresse frisch geladen wird und es gar keine Position zu uebernehmen gibt. Zwei Bremsen dagegen: `history.scrollRestoration = 'manual'` direkt beim Script-Start — spaeter waere es zu spaet, Safari hat dann laengst gescrollt — und `holdStart()` am Ende von `run()`. Ein einzelner Reset auf `DOMContentLoaded` reichte nicht — am Geraet widerlegt: Safari legt die Position nicht in einem Rutsch fest, sondern springt nach `load` und nach dem Layout nachgeladener Bilder noch einmal nach und ueberschreibt den Reset. `holdStart()` haelt den Anfang deshalb fest — zwoelf Versuche im 50-ms-Takt plus einer auf `load` fuer den Sprung, der erst nach dem letzten Bild kommt. Die Bremse loest bei der ersten eigenen Geste des Lesers aus (`touchstart`/`wheel`/`keydown`, jeweils `once`), sonst risse sie ihn mitten im Lesen zurueck. `scroll` taugt als Signal dafuer nicht: `toStart()` loest selbst eines aus und wuerde sich sofort selbst abschalten. **Die Post-Anker der Originalseite muessen auf die Karten wandern.** Ein Beitrag traegt sein Sprungziel als `` in der Autorenzelle, und die verschwindet mit dem Original-DOM. Bis 3.14 hatte `#pXXXX` deshalb kein Ziel mehr — der „Neueste ↓"-Link aus der Themenliste landete nicht am Beitrag, sondern dort, wo der Browser beim Umbau zufaellig stand. `buildThread()` uebernimmt den Namen jetzt als `id` der `.rm-post`-Karte, und `toStart()` springt darauf statt an den Anfang, wenn die Adresse ein Fragment traegt. `scroll-margin-top` auf `.rm-post` haelt dabei die fixierte Brotkrumenleiste frei, sonst steht der Kartenkopf dahinter. **Die Lupe und die Login-Pille haengen an einer gemeinsamen `margin-left:auto` auf einem Wrapper (`.rm-auth-group`), nicht je an sich selbst.** Saesse die Regel wie zuvor direkt auf `.rm-auth`, wuerde nur die Pille an den Rand geschoben und die Lupe bliebe links davon kleben — mit sichtbarer Luecke dazwischen statt „direkt links neben der Pille". Die Lupe existiert im Markup ohnehin nur bei bestehender Sitzung; ausgeloggt bleibt der Wrapper mit der Pille als einzigem Kind und verhaelt sich exakt wie vorher. **`#topic-search` steht dreifach identisch im Thread-Markup** (Kopf, Fuss, ueber der Beitragsliste), jedes Mal mit derselben, eigentlich unzulaessigen `id`. `document.querySelector('#topic-search')` trifft einfach das erste und verschiebt es unveraendert in die zweite Leiste — alle drei tragen dieselben versteckten Felder (`t`, `sf`, `terms`), welches der drei benutzt wird, ist gleichgueltig. **`select[name="fid[]"]` ist der einzige verlaessliche Marker fuer das grosse Suchformular auf `search.php`.** Sowohl `viewforum.php` als auch `viewtopic.php` betten ein eigenes Mini-Suchformular ein (`#forum-search` / `#topic-search`, „Search this forum…" / „Search this topic…"), beide mit `action="./search.php"` und einem Feld `name="keywords"` — nach diesen Merkmalen allein liesse sich das grosse Formular nicht von den kleinen unterscheiden. Der mehrfach waehlbare Forenwaehler existiert dagegen nur im grossen Formular. **Die Vorbelegung des Forenwaehlers haengt an der harten Forum-ID 10.** `buildSearchForm()` waehlt `option[value="10"]` (Main Forum) vor und setzt `input[name="sc"][value="0"]` (Unterforen: No), weil das serverseitige Formular sonst mit leerem Waehler und „Search subforums: Yes" startet. Beides ist eine Bequemlichkeit, keine Funktion: vergibt das Forum die IDs neu oder benennt es das Hauptforum um, greift die Vorauswahl stumm nicht mehr — das Formular bleibt bedienbar, steht dann aber wieder auf der Servervorgabe. Kein Fehlerfall, aber auch nichts, was von selbst auffaellt. **Die Textfelder des Suchformulars tragen `style="width: 300px"` direkt im Markup.** Eine externe Regel ohne `!important` sticht dagegen nicht, egal wie spezifisch der Selektor ist — `buildSearchForm()` entfernt das `style`-Attribut deshalb, statt gegen es anzuschreiben. **Eine abgeschickte Suche liefert keine Themenliste, sondern einen ganz anderen Aufbau.** Die anfaengliche Annahme, Trefferzeilen tragen dieselben `topictitle`-Links wie eine Themenliste und laufen unveraendert durch `buildList()`, stimmte nicht — zumindest nicht im Modus "Posts" (`sr=posts`), der ueber die zweite Leiste im Thread entsteht, weil deren Formular kein `sr` setzt. Jeder Treffer ist stattdessen ein Beitragsauszug: Autor, Zeitstempel, Forum/Thema-Kontext, ein Stueck `postbody`. `buildSearchResults()` erkennt diesen Modus an `p.topictitle` — als `

`, nicht als `` wie das gleichnamige Klassenpendant einer Themenzeile in `viewforum.php` — und baut daraus eigene Karten (`buildHit()`), bevor `buildList()`s allgemeines Zeileneinsammeln ueberhaupt anfaengt und die enthaltenen `viewtopic.php`-Links falsch klassifizieren wuerde. Ungeprueft bleibt der Modus "Topics" (`sr=topics`, Vorauswahl im grossen Suchformular) — dafuer liegt keine Fixture vor, dort sehen Treffer laut phpBB-Doku tatsaechlich wie topictitle-Zeilen aus und wuerden vermutlich durch den regulaeren Listenzweig laufen, das ist aber nicht verifiziert. **Ein Treffer im Modus "Posts" besteht aus drei ``, unterscheidbar nur an der Position, nicht an einer Klasse.** Titelzeile (`p.topictitle`), Autor- und Betreffzeile, dann Antworten/Views samt Beitragsauszug — alle drei tragen row1/row2 wahllos, die einzige verlaessliche Zuordnung ist `nextElementSibling` von der Titelzeile aus. **Der Beitragsauszug haengt in ``, nicht in `div`/`span.postbody` wie im echten Thread.** `buildHit()` verschiebt das `` trotzdem unveraendert per `appendChild()` in `.rm-body` — das ist reine DOM-Manipulation, keine Neuparse, darum meckert der Browser nicht ueber ein `` ausserhalb einer Tabelle. Es rendert danach aber weiterhin als `display:table-cell`, wenn `.rm-root.rm-hits .rm-body > td` das nicht ausdruecklich auf `block` zwingt. **"Replies: 5403" und "Views: 951837" stehen im Markup nur durch ein `
` getrennt, ohne Leerzeichen.** `txt()` normalisiert Whitespace, erzeugt aber keinen wo im Original-Markup keiner steht — `txt(meta)` liefert deshalb `5403Views:951837` statt zwei getrennter Werte. `buildHit()` greift beide Zahlen einzeln per Regex heraus und reicht sie an dieselbe `metaRow()` wie bei Themenkarten weiter, die den Trenner per CSS (`::before`) selbst einfuegt. **`input[type="submit"]`/`input[type="reset"]` setzen eigenes Padding und Zentrierung in iOS Safari unzuverlaessig um — auch mit `-webkit-appearance: none`.** Zuerst aufgefallen bei Suchen/Zuruecksetzen im Suchformular nebeneinander, wo die feste Flex-Aufteilung die Verzerrung besonders sichtbar machte: zu schmale Buttons, Text nicht mittig. `-webkit-appearance:none` plus `box-sizing:border-box`/`line-height:normal` (erster Versuch) reichte laut Test am Geraet nicht aus — das legt einen tieferen WebKit-Bug nahe, bei dem `input[type=submit/reset]` die native Knopfoptik trotz `appearance:none` nicht vollstaendig ablegt. Der tragfaehige Fix ist `inputToButton()`: die Original-``-Elemente werden vor dem Verschieben durch echte `