Muutosloki

Paketin CHANGELOG.md vendoroidusta kopiosta. Uudet versiot kootaan muutostiedostoista (npm run julkaise).

Nykyinen versio: 0.59.1
julkaistu 2026-10-23
Uusin julkaistu merkintä alla.
Aikajärjestys

Kaikki merkittävät muutokset @karkkainen/ui-pakettiin kirjataan tähän. Formaatti pohjautuu Keep a Changelog -ohjeeseen ja noudattaa Semanttista versiointia.

Paketilla on viisi tuotantokuluttajaa, jotka päättävät päivittämisestä tämän tiedoston perusteella. Merkitse rikkovat muutokset selvästi.

v0.59.1

2026-10-23
uusin
Korjattu
  • •AppFrame: sisältökortti on nyt relative ja overscroll-y-contain. Kortti ei ollut positioitu, joten absoluuttiset jälkeläiset (esim. listojen sr-only-tekstit) asettuivat bodyn suhteen ja venyttivät dokumentin kuoren alle. TP-PERUSTUKSEN Luettelot-sivulla dokumentti oli 1 177 px eikä 640 px, ja Firefox Androidin nopea pyyhkäisy siirsi koko kuoren ylös ja paljasti tyhjää sen alta (palvelun omistaja 23.10.2026). Palveluiden ei tarvitse tehdä mitään. Jos palvelu on korjannut tämän omassa CSS:ssään, sen voi poistaa. overscroll-y-contain katkaisee vierityksen ketjuuntumisen sivun juureen; Firefox Androidilla vedä päivittääksesi toimii yhä (palvelun omistaja 23.10.2026), Chrome Androidilla ele voi jäädä pois, kun kortti on vieritetty ylös (TP-TTK:n vertaisarvio), ja se on tahallista. _(TP-PERUSTUS; koskee: kaikki)_

v0.59.0

2026-10-23
Lisätty
  • •Breadcrumb collapse: kapealla (alle sm) polku tiivistyy keskeltä muotoon juuri › … › edellinen › nykyinen. Takaisin-reitti pysyy näkyvissä mobiilissa (TP-PERUSTUKSEN linjaus 17.10.2026); leveällä koko polku. Välikohdat piiloutuvat, kun kohtia on yli kolme, ja edellinen taso katkaistaan kapealla (koko nimi vihjeenä), jotta nykyinen sivu mahtuu. Toimii, kun lapset ovat suoraan BreadcrumbLink/BreadcrumbPage. Palvelinturvallinen (pelkkiä luokkia). Oletus ennallaan. _(TP-VASTAANOTTO; koskee: ei ketään (valinnainen))_
  • •TableFrame: rivittyvä solu <TableCell data-wrap> (white-space normal, vähintään 10rem). Kehyksen solut eivät rivity, ja pitkät tuotenimet työnsivät muut sarakkeet vaakavierityksen taakse (TP-VASTAANOTON GS1 Synkka 23.10.2026, sama TP-PERUSTUKSEN nimisarakkeissa). Sääntö on kerrosten ulkopuolella kuten muut kehyksen taulukkosäännöt. Lukko tests/frame-kerros.test.mjs. _(TP-VASTAANOTTO; koskee: ei ketään (valinnainen; TP-PERUSTUS ja TP-VASTAANOTTO hyötyvät))_

v0.58.0

2026-10-23
Lisätty
  • •Jaettu sävyvalinta (palvelun omistaja 23.10.2026: sävyt kaikkien palveluiden teemaan). ThemeProvider shades lisää esilatauksen, joka lukee tp-savy-evästeen ja asettaa <html data-vari> ennen ensimaalausta, ja AccountMenu shades (ilman arvoa) näyttää paketin sävyt (SAVYT: Petroli, Kanerva, Punainen, Neutraali) ja tallentaa valinnan jaettuun evästeeseen cookieDomainille. Uudet viennit: useShade, SAVYT, SAVY_COOKIE. Neutraali on ilman määrettä, joten palvelun ulkoasu ei muutu ennen kuin käyttäjä valitsee sävyn. Oletus pois: palvelut, jotka eivät anna shades-propsia, eivät muutu. Käyttöönotto README:n AccountMenu-kohdassa (kolme riviä); vaatii savyt.css:n. _(TP-PERUSTUS; koskee: kaikki)_

v0.57.2

2026-10-22
Korjattu
  • •SearchCombobox (ja HeaderSearch): osuman avaaminen ja Enterin oletustoiminto poistavat kohdistuksen kentästä. Kosketuslaitteella näppäimistö jäi auki avatun sivun päälle (palvelun omistaja 23.10.2026, TP-TTK tabletilla). Rivin valinta pitää kohdistuksen tarkoituksella (onMouseDown + preventDefault), joten kohdistus poistetaan erikseen vasta kun haku on tehnyt tehtävänsä. Palveluiden ei tarvitse tehdä mitään. _(TP-TTK; koskee: kaikki)_

v0.57.1

2026-10-22
Korjattu
  • •SearchCombobox (ja HeaderSearch): valinta nollautuu heti näppäilystä. Ennen se nollautui vasta, kun uudet osumat tulivat, joten Enter ennen hakuviiveen päättymistä avasi edellisen haun valitun rivin. TP-TTK:n selaintestissä "Star Wars" + Enter avasi Harry Potter -osuman. Koskee jokaista hakukenttää, jossa käyttäjä vie hiiren riville tai valitsee nuolilla ja kirjoittaa sitten uuden haun. Lisäksi viiveen aikana näkyvät edellisen haun osumat ovat himmennettyjä eikä niitä voi valita nuolella tai hoverilla, ja Enter tekee silloin oletustoiminnon nykyisellä hakusanalla (enterSelection; TP-PERUSTUKSEN vertaisarvio). Hiiren klikkaus näkyvään riviin avaa sen yhä. Palveluiden ei tarvitse tehdä mitään.

HeaderSearch: kapean näytön hakupaneeliin täyte (p-3 pr-12). Kenttä oli paneelin reunassa, ja sen tyhjennysruksi osui paneelin sulkuruksin päälle (TP-TTK:n selaintesti 390 px:n leveydellä). _(TP-TTK; koskee: kaikki)_

Näytä vanhemmat julkaisut (79)▾

v0.57.0

2026-10-22
Lisätty
  • •HeaderSearch: yläpalkin haku AppFramen headerEndiin TP-PERUSTUKSEN mallista (palvelun omistaja 23.10.2026: "design-systemissä ei taida olla vielä tyylejä näihin"). SearchCombobox yläpalkin ulkoasulla (valkoinen kenttä, ohut reuna, haalea fokusrengas), Ctrl+K / ⌘K kohdistaa, vihje vain hiirellä ja näppäimistöllä, ja kapealla näytöllä pyöreä hakupainike, joka avaa saman haun paneeliin ylhäältä. Kiinteä oletuskoko 242 × 32 px, mitattu TP-PERUSTUKSEN hausta 640–1440 px:n leveyksillä, joten palvelu voi korvata oman hakunsa ilman pikselieroa. Datan antaa palvelu kuten SearchComboboxille. TP-PERUSTUS voi korvata oman HakuKentta- ja MobiiliHaku-parinsa tällä; ulkoasu on sama. _(TP-TTK; koskee: ei ketään)_

v0.56.2

2026-10-23
Korjattu
  • •ServiceSwitcher: pienennetyn sivupalkin monogrammi text-sm → text-xs. Kolmikirjaiminen lyhenne (MED, IAM, TPV …) ylitti 32 px:n ruudun reunat text-sm font-semiboldillä (TP-MEDIAn AppFrame-siirto 23.10.2026, kuvakaappauksessa "MED" yli ruudun). Valikossa sama lyhenne oli jo text-xs. Käyttäjäpalvelun ei tarvitse tehdä mitään; vendoroi omaan tahtiin. _(TP-MEDIA; koskee: kaikki)_

v0.56.1

2026-10-22
Korjattu
  • •Input, Textarea ja Select: tausta bg-background (vaaleassa valkoinen) eikä bg-transparent (palvelun omistaja 22.10.2026). Läpinäkyvä kenttä sulautui harmaaseen pohjaan: TableFramen työkalurivillä (bg-frame) hakukenttä näytti tekstiltä eikä kentältä (TP-TTK:n Keräyspyynnöt-näkymä). Valkoisella pohjalla (kortti, sisältöalue) ulkoasu ei muutu. Harmaalla pohjalla kenttä erottuu nyt valkoisena. Tummassa teemassa kenttä on sisältöalueen sävyinen (--background) ja erottuu kehyksestä tummempana. Palveluiden ei tarvitse tehdä mitään, ja oma bg-*-luokka voittaa yhä. Reunattomaan upotettuun kenttään lisää `bg-transparent`: muoto className="border-0 px-0 shadow-none focus-visible:ring-0" (hakurivi, jonka kääre piirtää reunan ja pohjan) saa muuten oman --background-laatan, joka näkyy tummassa teemassa ja harmaalla kääreellä erisävyisenä suorakulmiona (TP-PERUSTUKSEN vertaisarvio). Paketin sisällä tällaista käyttöä ei ole: AppLauncherin hakukenttä on reunallinen ja erottuu popoverissa tummempana kuten kehyksessäkin, ja SearchCombobox käyttää omaa <input>ia. _(TP-TTK; koskee: kaikki)_

v0.56.0

2026-10-22
Lisätty
  • •Kuoren osat TP-PERUSTUKSESTA (README kohta 8; omistajan pyyntö 22.10.2026):
    • ◦ServiceSwitcher: palvelun vaihtaja sivupalkin ylälaitaan, monogrammi serviceMonogram.
    • ◦AccountMenu: oma tili -painike sivupalkin alalaitaan. Valikossa linkit, teema (next-themes), sävy ja uloskirjautuminen; compact yläpalkkiin kapealle näytölle.
    • ◦VersionNotice ja VersionNoticeCard: "palvelu on päivittynyt", kun välilehti ajaa vanhaa versiota (reitti /api/versio, { sha, builtAt }).
    • ◦AppFrame: environment="staging" | "dev" näyttää ympäristömerkin murupolun perässä ("Koeversio" tai "Kehitysversio"). Tuotannossa merkkiä ei ole.

Uusia osia ei vielä käytetä missään, joten näkyvää muutosta ei ole. Luokat ovat samat kuin TP-PERUSTUKSESSA, jotta siirto niihin onnistuu ilman pikselieroa. _(TP-PERUSTUS; koskee: ei ketään)_

  • •Työkalu scripts/kuvavertailu/: pikselivertailu kahden instanssin välillä palvelukohtaisilla asetuksilla. Jokainen sivu ladataan kerran ja teemat vaihdetaan sivulla, sarjat ajetaan rinnakkain ja selaimen kello jäädytetään. Kantavirheportti päättyy koodiin 3, ero rajan yli koodiin 1. Todennettu esittelysivustoa vasten: sama sivusto 0, mutaatio 1 (vain mutatoidut näkymät) ja kantavirhe 3. Tehty TP-PERUSTUKSEN vaihe B:tä varten (12 min → 105 s). Paketin koodi ennallaan. _(TP-PERUSTUS; koskee: ei ketään)_
  • •README: käyttöönoton kohta 8 "Uusi ulkoasu askel askeleelta". Kohdassa on palveluiden yhteinen järjestys pohjasta kuoreen (palvelun vaihtaja, navigaatio, oma tili -painike, yläpalkki), sivun alkuun, kehyksiin ja taulukoihin, tiloihin ja viimeistelyyn. Jokaisen osan tila on merkitty (paketissa tai tulossa), ja mukana on julkaisutahti. Kohtaan 7 on lisätty TP-TTK:n siirron huomiot: vierityssäiliö on sisältökortti, ja palvelun nimi piilotetaan pienennetyssä tilassa. _(TP-PERUSTUS; koskee: ei ketään)_

v0.55.1

2026-10-22
Lisätty
  • •README: käyttöönoton kohta 7 "Kehykset ja kuori (vaihe D)". Kohdassa käydään läpi tokenien HSL-muoto, kehyksen ja taulukon eri kaskadit (shadow-xs, TableCellin täyte, data-cell-center), kehysmuodot, AppFramen paikat TP-PERUSTUKSEN siirron mukaan sekä todennus pikselivertailulla ja kantavirheportilla. Näkyy esittelysivuston Käyttöönotto-sivulla. _(TP-PERUSTUS; koskee: ei ketään)_

v0.55.0

2026-10-22
Lisätty
  • •Segmented: muunnelma variant="surface", joka käyttää painikkeiden valkoista tyyliä: valkoinen pohja, reuna ja varjo kuten outline-painikkeessa, korkeus 30 px ja valittu kohta harmaana (bg-muted) korostusvärin sijaan. Työkalurivin kytkimiin (Omat | Kaikki, Taulukko | Kortit), joissa korostusvärinen valinta näytti painikerivissä virheeltä. Oletus (default) on ennallaan. TP-PERUSTUKSEN Valitsin korvautuu tällä. _(TP-PERUSTUS; koskee: ei ketään)_

v0.54.1

2026-10-22
Korjattu
  • •TableFrame: kehyksen taulukkosäännöt ([data-table-frame-body]) ovat nyt kerrosten ulkopuolella. @layer components -kerroksessa paketin oma TableCell (p-3 align-middle) ja TableHead (h-10) voittivat kehyksen täytteen 0,625 rem, ylätasauksen ja 32 px:n otsakkeen, ja rivit olivat 4 px korkeampia kuin mallissa (TP-PERUSTUKSEN kuvavertailu 22.10.2026). Kehys itse ([data-frame]) pysyy kerroksessa. Lukko tests/frame-kerros.test.mjs. Yksikään palvelu ei vielä käytä TableFramea, joten näkyvä muutos on vain esittelysivustolla. _(TP-PERUSTUS; koskee: ei ketään)_

v0.54.0

2026-10-22
Lisätty
  • •AppFrame: kuori TP-PERUSTUKSEN mallin mukaan (päätös 0004) — sivupalkin ylä- ja alalaita (sidebarHeader, sidebarFooter), vierivä sisältökortti, yläpalkissa pienennys paneelikuvakkeella, headerStart (murupolku) ja headerEnd, nauha banner. Asettaa --shell-top: 3.375rem. SidebarToggle sai valinnan icon="panel". AppShell on ennallaan, joten mikään olemassa oleva näkymä ei muutu. _(TP-PERUSTUS; koskee: ei ketään)_
  • •Kehysperhe ja sävyteemat TP-PERUSTUKSEN ulkoasusta (päätös 0003): Frame (+ FramePanel, FrameBody), StatFrame, TableFrame ja TableFramePagination; tyylit styles/frame.css kerroksessa components. Uudet tokenit --frame, --frame-border, --sidebar, --nav-active, --nav-active-foreground ja apuluokat bg-frame, border-frame-border, bg-sidebar, bg-nav-active, text-nav-active-foreground. Sävyteemat @import "@karkkainen/ui/styles/savyt.css" ja <html data-vari="neutraali|petroli|kanerva|punainen">. Kaikkia koskee vain yksi asia: vaalean teeman --muted-foreground 45,1 % → 40,6 % (aputeksti hieman tummempi, AA myös hover- ja valintapinnalla). Muu on uutta eikä muuta mitään ennen käyttöönottoa. AppShell asettaa nyt juurielementtiinsä --shell-top (3rem, insetissä 3.625rem), jota tahmeat elementit lukevat; renderöinti muuttuu vain tämän inline-muuttujan verran (TP-MEDIAn vertaisarvio: ilman sitä TableFramen työkalurivi jäi yläpalkin alle). _(TP-PERUSTUS; koskee: kaikki)_
Muutettu
  • •NavList: navigaatiokohdat tekstin värillä 82 %:ssa (oli apuharmaa), jotta kohta ja ryhmän otsikko eivät ole samaa väriä (omistaja 22.10.2026, vaihtoehto C). Ryhmän otsikko on pienempi (0,6875 rem), väljempi (0,06 em) ja sen yläpuolella on lisätilaa; väri pysyy täytenä apuharmaana, koska 80 % olisi vaaleassa 3,4:1 eli alle AA:n. Sivupalkin kohdat tummenevat kaikissa palveluissa. Uusi lukko tests/nav-kontrasti.test.mjs laskee otsikon ja kohdan kontrastin sivupalkkia vasten jokaisessa sävyssä ja molemmissa teemoissa. _(TP-PERUSTUS; koskee: kaikki)_
  • •Pinnat TP-PERUSTUKSEN mallin mukaan (päätös 0005): sivupalkki #111111 (--sidebar 6,7 %), sisältöalue #161616 (--background 8,6 %), reunat #242424 (--border 14,1 %), kortit 10,6 %, muted/accent/secondary 12,2 %, input 16,1 %, kehys 12,9 % / 16,9 %. Tumma tausta vaalenee kaikissa palveluissa (oli 3,9 %, ja sisältö oli tummempi kuin sivupalkki). Neutraalin sävyn sivupalkki 6,4 % → 6,7 %. Vaalea (oletus ja neutraali): sivupalkki #FAFAFA (98 %), reunat #F0F0F0 (94,1 %), sisältöalue #FFFFFF ennallaan; inputin reuna pysyy 89,8 %. Muut sävyt ennallaan; --muted-foreground ennallaan; sävyteemojen tumman apuharmaan 55 % vahvistettiin (päätös 0003). _(TP-PERUSTUS; koskee: kaikki)_
  • •Yhteinen kehitys: paketin kehittävät käyttäjäpalveluiden agentit (päätös 0001). Uusi AGENTS.md, muutostiedostot ja npm run julkaise; vanhat ylläpitäjän muistiinpanot docs/historia/:ssa. Paketin koodi ja ulkoasu ennallaan, joten vendorointia ei tarvita. _(TP-PERUSTUS; koskee: ei ketään)_
Korjattu
  • •AppShell, AppFrame ja ThemeProvider kirjoittavat tallennusavaimen esilatausskriptiin skriptiLiteraalilla (src/lib/skripti.ts) eivätkä pelkällä JSON.stringifyllä, joka ei suojaa merkkijonoa </script> eikä merkkejä U+2028/U+2029 (CodeQL js/bad-code-sanitization, hälytykset #3 ja #6). Avaimet ovat kehittäjän antamia, joten riski oli teoreettinen. Toiminta ei muutu: sama avain, sama arvo. Lisäksi korjattu ReDoS sivuston datan tuottavassa skriptissä (scripts/sivustodata.mjs, hälytys #5). _(TP-PERUSTUS; koskee: kaikki)_

v0.53.1

2026-10-20

⚠️ Korjattu: lähdekoodin kommentti tuotti kelvottoman CSS-säännön

0.53.0 kaataa Tailwind 4 -kuluttajan Turbopack-dev-käännöksen. Löytö on TP-PERUSTUKSEN, mitattu heidän dev-instanssistaan n. 19:50Z.

theme.css sanoo @source "../src", ja Tailwind 4 skannaa polun tekstinä — se ei jäsennä TypeScriptiä eikä erota kommenttia koodista. app-shell.tsx:146 sisälsi JSDocissa esimerkin top-[var(…)], jossa kolme pistettä on yksi merkki (U+2026). Tailwind poimi sen luokkaehdokkaaksi ja tuotti .top-\[var\(…\)\] { top: var(…) }:

Turbopack (dev)   Parsing CSS source code failed
                  Unexpected token Ident("…")        → käännös kaatuu
next build        Found 1 warning while optimizing generated CSS
                  ^-- Unexpected token Ident("…")    → paluuarvo 0

Korjaus on kommentin sanamuoto. Mitattu src/:stä: tämä oli ainoa ei-ASCII-sisältöinen hakasulkuehdokas 23 tiedostossa jotka käyttävät kolmea pistettä — kaikki muut ovat prosaa hakasulkeiden ulkopuolella.

Vaikutus riippuu käännöspolusta, ja se on syytä tietää:

0.53.0:n oire
Turbopack devkaatuu, käännös ei valmistu
next buildvaroitus, sääntö pudotetaan tuotoksesta

Mitattu demon buildista: rikkinäinen sääntö ei ole tuotoksessa ja naapurit (top-full, top-0, sticky, border-border, bg-card) ovat tallella — eli tuotantobuildissa menetys on yksi turha sääntö.

★★ Ja demon build näki tämän ja jatkoi

Demon build on ainoa automaattinen tarkistus kuluttajien asetelmalle, ja se tulosti tämän varoituksena ja palautti nollan. CI oli vihreä koko ajan — sekä PR #4:ssä että mainissa.

> Varoitus jota kukaan ei lue ei ole vahti.

Sama muoto kuin 2>/dev/null ja kuin next buildin import-varoitus 0.14.0:ssa, joka päästi neljä undefined-komponenttia tuotantoon. Ero on että tällä kerralla ääni ei ollut suljettu pois — se oli tulosteessa ja kukaan ei katsonut sitä.

Lukko

tests/tokens.test.mjs skannaa src/**:n ja kaatuu jos hakasulkuehdokkaan sisällä on ASCII:n ulkopuolinen merkki. Todennettu molempiin suuntiin: vian palauttaminen kaataa sen nimeten app-shell.tsx:146, ja ei-ASCII hakasulkeiden ulkopuolella ei laukaise sitä. Raja on ASCII eikä kolme pistettä, koska mitattiin ettei laillisia ei-ASCII-arvoja ole yhtäkään.

★ Se ei kata sitä mitä demon build kattaisi, eli kelvollista mutta väärää CSS:ää. TP-PERUSTUS ehdotti src/**:n skannausta Tailwind 4:llä CI:ssä; se on laajempi ja kalliimpi, ja kirjattu avoimeksi eikä tehdyksi — tämä lukko kattaa tämän vikaluokan, ei kaikkia.

v0.53.0

2026-10-20

⚠️ RIKKOVA: Tailwind 3 → 4

Yhdistetty `main`iin omistajan päätöksellä, ei vendoroitu kenellekään. Siirto koskee kaikkia kuutta kuluttajaa, ja käyttöönotto on kunkin palvelun omistajan päätös (AGENTS.md §3). Minor eikä major, koska paketti on 0.x-sarjassa — rikkovuus luetaan tästä otsikosta, ei numerosta.

v3v4
tailwind-preset.ts + tailwind.config.ts@import "@karkkainen/ui/styles/theme.css"
content: ["…/karkkainen-ui/src/**"]ei tarvita — teema sisältää @source "../src"
tailwindcss-animatetw-animate-css
tailwind-merge ^2^3
safelist: [{ pattern }]@source inline("bg-{…}-{400,500,600}")
borderColor.DEFAULT + addBase(table)@layer base teemassa

`overrides`-lohko poistettu, ja se on mitattu eikä pääteltyä. Se pinnasi source-map-jsin ja postcss-selector-parserin neljän Dependabot-hälytyksen takia (6.10.2026); molemmat tulivat transitiivisesti postcssin kautta. Mitattu lukkotiedostosta: `postcss`, `postcss-nested`, `postcss-selector-parser` ja `source-map-js` ovat kaikki nolla osumaa — v4 ei käytä postcssia lainkaan. Override oli siis kuollutta konfiguraatiota, ja sen oma kommentti vaati tämän mittauksen juuri siinä tapauksessa että tailwindia nostetaan.

Komponenttien luokat: outline-none → outline-hidden (39), shadow-sm → shadow-xs (11), shadow → shadow-sm (3). Säteet eivät muutu, koska teema määrittelee --radius-sm/md/lg kuten preset.

Mitattu demosta: sama 760 luokan joukko kuin v3-buildissa (erot vain escapoinnissa), ja teeman osat käännöksessä: safelist, varjot, reunasääntö, table, ss01, .dark. Lukot siirretty presetistä teemaan, ja jokainen kaatuu rikottaessa.

v4:n oletukset sellaisinaan, ei v3:n jäljittelyä (omistajan linjaus 20.10.2026). Näkyvät erot v3:een, mitattu demon kuvavertailusta:

  • •painikkeiden kursori on default eikä pointer
  • •space-y-* on v4:ssä alamarginaali: NavListin ryhmäotsikon alle tulee 4 px, joka v3:ssa jäi pois Nextin Linkillä (inline-<a>)
  • •CardHeader käyttää gap-1.5ia space-y-1.5:n sijaan (v4:n tapa flex-sarakkeessa)
  • •Tailwindin oletusväripaletti on v4:ssä oklch ja kylläisempi. Koskee kaikkien palvelujen tiilivärejä (AppService.color, esim. bg-fuchsia-500) ja muita Tailwindin omia värejä. Paketin tokenit eivät muutu. TP-MEDIA mittasi selaimessa: waffle 0,46 %, Tilastot 0,96 % pikseleistä, asettelu identtinen.
  • •placeholder-tekstin sävy muuttuu hieman (TP-MEDIA, 0,06 %)

Pilotti TP-TTK (20.10.2026, heidän mittauksensa heidän kuvassaan): kaksi vaihetta, ensin 0.52.0 Tailwind 3:lla (PR #75), sitten tämä haara (PR #76, ei yhdistetty). Next 16.3.8 + Tailwind 4.3.3 + Node 24.18 kääntyy — ensimmäinen Next 16 + TW4 -käännös. tsc puhdas, vitest 101/101. Kuvat: selviä pikselieroja 0,00 %, hienoisia sävyeroja 0,01–12 % (päätelmä: v4 sekoittaa alfan oklabissa color-mixillä).

CSS-koko, mitattu demosta: 55,7 → 73,0 kt raakana (+31 %), gzip 10,5 → 13,1 kt (+25 %). Kasvu on v4:n rakennetta: @supports-varahaarat alfaväreille 5,8 kt, @property 4,7 kt, @layer theme 4,5 kt.

TP-TTK:lla, mitattu heidän kuvistaan: 59,0 → 83,0 kt raakana, gzip 11,1 → 15,3 kt (+4,2 kt, +38 %). Demon luku ei yleisty: pohja on eri, ja kasvu riippuu siitä paljonko alfavärejä ja teemamuuttujia käännökseen päätyy. Mittaa kunkin palvelun luku sen omasta buildista.

TP-TTK tuotannossa (20.10.2026, heidän ilmoituksensa): PR #76 + #77, vendoroitu a0f80a5, sisältö 9048cc27bf67. ⚠️ Kuluttaja ajaa nyt tuotannossa haaraa, jota ei ole mainissa — jokainen v3-korjaus mainiin on vietävä myös tänne, kunnes haara on yhdistetty. Dokumentaation skannauskorjaus pienensi heidän CSS:äänsä 3 419 B raakana / 432 B gzip; TW4:n kokonaiskasvu v3:sta heillä +3,8 kt gzip. Kuvat ennen ja jälkeen korjauksen 8/8 identtisiä.

Pilotti TP-MEDIA (20.10.2026, heidän mittauksensa): PR #43, ei yhdistetty. Next 16.3.8 + Tailwind 4.3.3 kääntyy, tsc OK, testit 63/63, image käynnistyy. Ulkoasu katsottu selaimessa testikontissa v3-imagea vasten (1440 px, molemmat teemat): asettelu identtinen, waffle-tiilet ja tumma teema toimivat, tuotehaun 13/13 kuvaa latautuu, 0 JS-virhettä. CSS 48,9 → 80,0 kt raakana — vertaa 0.43:a ja 0.52+v4:ää, eli siinä on myös 0.44–0.52:n lisäykset.

Pilotin löytämä vika, korjattu: v4 skannaa automaattisesti kaiken mitä .gitignore ei sulje, ja kuluttajat committoivat vendor/:in. Vendoroidun README:n varoitusteksti tuotti käännökseen .shadow-cardin. theme.css sulkee nyt ../README.md:n ja ../CHANGELOG.md:n (@source not). Dokumentaatio tuotti kokeessa 4,7 kt raakaa CSS:ää (gzip 0,6 kt). Demo ei voinut löytää tätä, koska sen vendor/ on gitignoressa.

v0.52.1

2026-10-20
Korjattu

NavListin space-y-1 poistettu — kuollut luokka josta tuli v4:ssä 4 px

TP-PERUSTUS mittasi Tailwind 4 -stagingissaan 4 px:n raon ryhmäotsikon ja ensimmäisen linkin väliin. Löydös piti paikkansa; mekanismi oli paketin oma rakenne eikä Nextin, ja mittaus paljasti isomman asian.

Kun LinkComponent on annettu — kaikki kuusi kuluttajaa antavat sen — linkki renderöityy luokattomana <a>:na, jonka sisällä tyylitelty <span>. Luokaton <a> on display:inline, eikä pystymarginaali vaikuta siihen.

Mitattu selaimesta kolmella säännöllä samalla DOMilla:

v3  space-y-1        välit [0,0,0]   korkeus 121   <a> inline
v4  space-y-1        välit [4,0,0]   korkeus 125   <a> inline
    flex + gap-1     välit [4,4,4]   korkeus 133   <a> block (flex blockifioi)

★★ Eli luokka ei ole koskaan tehnyt mitään kuluttajille. Se on ollut paikallaan 0.8.0:n AppShellista asti ja tarkoitettu antamaan 4 px linkkien väliin; v3 antaa nollan joka väliin. v4 antaa 4 px vain otsikolle, koska se on ainoa lapsi joka on block — eli siihen yhteen paikkaan johon sitä ei tarkoitettu.

Poistettu molemmista paikoista (ryhmäkontti ja footer). Rivirytmi 36 px tulee py-2:sta ja säilyy: v3:ssa muutos on nolla pikseliä, v4:ssä stray-4 px katoaa. Jos väli linkkien väliin joskus halutaan, se on flex flex-col gap-1 — flex blockifioi lapsensa, joten linkkiin ei tarvitse koskea. Se olisi näkyvä muutos ja omistajan päätös.

★ Ja luokka on tuttu: kopio joka ei voi kaatua mitään, ajautuu — files-kenttä, presetin fallback-värit, ja nyt luokka joka lupaa välin eikä anna sitä. Erona on että tämä alkoi tehdä jotain version vaihtuessa, eikä lakannut.

Korjattu samalla

contentClassNamein dokumentoitu oletus oli ajautunut

TP-PERUSTUS siirsi koko palvelunsa AppShell variant="inset"iin 20.10.2026 — ensimmäinen käyttöönotto — ja tarvitsi contentClassName="max-w-none p-0 space-y-0", koska heidän sivunsa tuovat oman täytteensä. Se toimii tailwind-mergellä kuten dokumentoitu.

Mutta `space-y-0` ei ollut ohjeessa. JSDoc sanoi oletukseksi mx-auto max-w-6xl px-5 py-8 md:px-8; koodissa on myös space-y-8. Dokumentoitu kopio luokkajonosta oli erkaantunut yhdellä luokalla, ja he löysivät puuttuvan kokeilemalla.

Korjattu, ja mitattu kolmikko kirjattu JSDociin esimerkkinä. Lukko tekee kopiosta luetun: tests/app-shell.test.mjs poimii jonon koodista ja vertaa JSDocin riviin — ja vaatii lisäksi että inset- ja default-haaran jonot ovat keskenään samat, koska ne kirjoitetaan erikseen. Kaatuminen todennettu kolmeen suuntaan: vanha JSDoc-rivi, haarojen erkaantuminen, ja poiminnan rikkominen (positiivinen kontrolli kaatui omalla viestillään 0 !== 2).

★ Oletusta ei muutettu. Laskuri on 1: yksi palvelu on päätynyt neutralisoimaan kolme asiaa. Jos toinen päätyy samaan, oletus on väärä eikä ohje — ja se on omistajan päätös, ei saman kierroksen sivutuote.

Kirjattu, ei rakennettu: tahmea elementti inset-kortin sisällä

TP-PERUSTUS tarkensi löydöksensä: PageHeader variant="statement" ei ole tahmea eikä tarvitse offsetia. Toistuva tarve on sticky-elementti `inset`-kortin sisällä — heillä Takaisin-rivi, työkalurivit ja luokittelun toimintorivi.

Offset on kahden paketin vakion summa: topbarin h-12 (3rem) + kortin ylärako p-2.5 (0.625rem) = 3.625rem. Molemmat ovat app-shell.tsx:n luokkia, eli kuluttaja joutuu summaamaan ne ja kovakoodaamaan tuloksen. Jos kumpi tahansa muuttuu, jokaisen tahmean rivin offset menee väärin hiljaa.

Kirjattu insetin JSDociin ja lukittu: app-shell.test.mjs poimii molemmat luokat koodista ja vertaa summaa JSDocin lukuun.

★★ Ja lukko oli ensin itsensä vahvistava. Ensimmäinen versio haki h-\d ja p-[\d.] mistä tahansa tiedostosta — ja osui omaan JSDociini, jonne olin juuri kirjoittanut luvut tekstinä. Testi luki dokumentaation kahdesti ja meni läpi: h-12 → h-14 ja p-2.5 → p-3 eivät kaataneet sitä. Vain kolmas mutaatio (JSDocin luku) kaatoi, ja se oli se joka paljasti asian — kun testi menee läpi, tarkista voiko se kaatua. Poiminta on nyt ankkuroitu luokkariveihin, ja kaikki kolme kaatavat omalla viestillään.

Muuttujaa ei lisätty. Laskuri 1. Jos toinen palvelu päätyy samaan, luonteva muoto on kehyksen asettama --tp-shell-top jota kuluttaja lukee top-[var(…)]illa — API-lisäys ja omistajan päätös. Ehdotus on TP-PERUSTUKSEN.

#### Ja contentClassNamein oletusta ei muutettu, koska mittaukseni oli väärä

Olin viemässä omistajalle havaintoa "kaikki kuusi kuluttajaa ohittavat `max-w-6xl`-oletuksen". Osumat tulivat vendoroidun kopion JSDocista: ajoin grep -rh, joka poistaa tiedostonimet, joten perässä oleva grep -v vendor/ ei suodattanut mitään.

Oikea luku nimet mukana:

TP-Vastaanotto  contentClassName="max-w-none"
TP-TTK          "mx-auto max-w-none px-5 py-8 md:px-8"
TP-MEDIA        "mx-auto max-w-none px-5 py-8 md:px-8"
TP-IAM          — ei anna
TP-NOTIFY       — ei anna
TP-PERUSTUS     ei käytä AppShellia

3/5 eikä 6/6, ja kaksi ei-ohittavaa tarkoittaa että 1152 px kelpaa heille. Oletus on siis oikein dokumentoitu rajaus eikä vika — ja "kaikki hylkäävät sen" olisi ollut keksitty mandaatti API-muutokselle. Neljäs instrumenttivirhe samana päivänä, ja sama perhe kuin muut: suodatin ei nähnyt sitä mitä se suodatti.

Lukittu: paketti kovakoodasi saman summan kahdesti itse

Sticky-offsetin kirjaus paljasti kolmannen paikan: kortin min-h-[calc(100vh-3rem-1.25rem)] toistaa topbarin korkeuden ja pystypehmusteen kahdesti (3rem = h-12, 1.25rem = p-2.5 × 2). Eli kolme paikkaa pitää liikkua yhdessä, ja kaksi niistä on samassa tiedostossa.

Lukko kattaa nyt kaikki kolme, ja kaatuminen on todennettu viiteen suuntaan — jokainen omalla viestillään: topbarin korkeus, kortin pehmuste, calcin kaksi lukua erikseen, ja JSDocin summa.

★ Ja yksi asia jäi kirjaamatta eikä korjatuksi: rounded-[13px] on mielivaltainen arvo eikä johdettu --radius:ista (0.5rem). Se on tietoinen arvo prototyypin ulkoasusta, mutta se ei ole tokenissa eikä johdettavissa — joten kuluttaja joka kopioi kortin kopioi myös sen.

Kirjattu: TP-PERUSTUS kopioi insetin ulkoasun — laskuri 1 kolmelle paikalle

Omistaja tilasi 20.10. asettelun joka ei mahdu `AppShell`in paikkoihin: murupolku ja kutistuspainike yläpalkkiin, käyttäjätili ja kirjautuminen sivupalkin alareunaan, palvelun nimi sen yläreunaan. Nykyiset paikat ovat product, search, actions, user — ja `user` on topbarissa, murupolulle ei ole paikkaa lainkaan.

He tekivät kehyksen itse DS:n osista (MobileNav, AppLauncher, SidebarToggle, NavList, Breadcrumb, Avatar, DropdownMenu) ja kopioivat `inset`in ulkoasuluokat.

★★ Se kopio on eri lajia kuin sticky-offset. Offsetin he ratkaisivat DOM-mittauksella ja irtautuivat paketista; ulkoasua ei voi mitata DOMista. Jos muutan kortin pyöristystä, varjoa tai pohjaväriä, heidän kehyksensä erottuu viidestä muusta hiljaa — eikä mikään täällä voi kaatua siitä. Kirjattu ilmoitusrekisteriin ainoana suojana, ei täydentävänä.

Paikkoja ei lisätty. Laskuri 1, ja he sanoivat itse ettei tätä pyydetä tehtäväksi heti. Jos toinen palvelu päätyy samaan, muoto on kolme ReactNode-paikkaa (headerStart, sidebarHeader, sidebarFooter) — ehdotus on heidän ja mitattu heidän toteutuksestaan.

Lukko

tests/render.test.mjs, positiivisella kontrollilla (§11.7): sama haku vaatii että nav-tason space-y-4 on tallella ja ryhmäkontin space-y-1 ei. space-y-4 toimii, koska sen lapset ovat ryhmädivejä — ilman kontrollia väite menisi läpi myös jos space-y katoaisi tiedostosta kokonaan.

Kaatuminen todennettu molempiin suuntiin: space-y-1 takaisin → kaatuu oikealla viestillä; space-y-4 pois → positiivinen kontrolli kaatuu. Testit 419 → 420 (417 läpi, 0 kaatunutta, 3 ohitettua).

Oma mittarini oli ensin rikki, ja se on kirjattava

Ensimmäinen selainkoe antoi v4:lle [0,0,0] eli kumosi heidän löydöksensä. Syy oli omassa CSS:ssäni: kirjoitin .v4 :where(& > …), ja & ilman nesting-kontekstia on virheellinen — selain pudotti koko säännön hiljaa. Mittasin lasten margin-bottomin ja se oli 0px myös otsikolla, eli sääntöä ei ollut sovellettu lainkaan.

★★ Olin kumoamassa oikeaa löydöstä rikkinäisellä mittarilla, ja ainoa syy ettei niin käynyt on että luku oli ristiriidassa heidän kanssaan ja tarkistin instrumentin ennen kuin lähetin. Sama kuin doctype ja selainprofiili: oikea komento, oikea tuloste, väärä kysymys.

v0.52.0

2026-10-20
Lisätty

rakenne — kehys korttina, lause otsikkona, jono, poikkeus, muutos, tilapiste

Omistajan päätös 20.10.2026: TP-Perustuksen ja TP-Vastaanoton prototyypin ulkoasu tuodaan pakettiin. Kuusi päätöstä käännettiin paketin kieleen: Tailwind 3.4, Radix, palvelinrenderöinti.

> ⚠️ Tämän kappaleen alkuperäinen muotoilu oli väite jota en voinut > tukea, ja osaväitteet ovat eri luokkaa. Kirjoitin "prototyyppi on > tehty coss ui:lla, Base UI:lla ja Tailwind 4:llä, lisenssi AGPL, joten > mitään ei kopioitu" mittaamatta yhtäkään niistä. TP-PERUSTUS kysyi > 20.10. missä prototyyppi on, enkä pystynyt vastaamaan. Luokat > eriteltyinä: > > | osaväite | luokka | > |---|---| > | mitään ei kopioitu | mitattu, ja tämä on se joka merkitsee: nolla @base-ui- tai coss-riippuvuutta, komponentit ovat Radix + Tailwind 3.4, peerDependencies ennallaan | > | prototyyppi on olemassa | korroboroitu — omistajan kuvakaappaus TP-PERUSTUKSEN kautta, ja claude-tp-ds on mitattu tailnet-isäntä (100.121.174.41, eri kone kuin tämä) | > | se ajaa osoitteessa claude-tp-ds… | relay, ei mitattu — isäntä ei vastannut täältä HTTP:llä 20.10. | > | pino on coss ui / Base UI / Tailwind 4 | omistajan lausuma, välitetty. Ei mitattu kenenkään toimesta | > | coss ui on AGPL-3.0 | TP-PERUSTUKSEN mittaus julkisesta LICENSE-tiedostosta (apps/origin, apps/ui MIT; muu AGPL) | > > Lisenssiturva ei siis nojaa relayhin. "Mitään ei kopioitu" on > väite omasta työstäni ja se on tarkistettavissa paketista — ja juuri > se on se lause jonka joku lukisi todisteena. Prototyypin pinon kuvaus > on taustaa, ja se on nyt merkitty taustaksi. > > ★ Virhe ei ollut vanhentunut tieto vaan luokan puuttuminen: viisi > eri vahvuista väitettä yhdessä virkkeessä, jossa vahvin kantoi > heikoimpien uskottavuutta. Sama kenttä kuin kohde ja luokka luvun > vieressä, nyt proveniessissa. > > ⚠️ Ja lisenssi mitattiin 20.10. lähteestä, koska omistaja ehdotti > coss ui:n käyttöönottoa. TP-PERUSTUS mittasi ensin, ja tarkistin > saman itse (raw.githubusercontent.com/cosscom/ui, haara main): > > > LICENSING.md oletus AGPLv3; MIT-osio: apps/origin/ ja apps/ui/ > lause KATKEAA: "licensed under their original" > apps/ui/package.json license MIT, private: true > packages/ui/package.json license AGPL-3.0-or-later, name @coss/ui, ei private > > > ★★ Se kenttä jota kumpikaan ei ollut katsonut on ratkaiseva: > paketti jonka nimi on `@coss/ui` ilmoittaa itse > `AGPL-3.0-or-later`. apps/ui on MIT mutta private: true — se on > rekisterisivusto eikä jaeltava paketti, ja sen ui:sync kopioi > lähteet packages/ui/src:stä. Hakemistosijainti ei siis ratkaise > kysymystä, ja MIT-poikkeuksen lause on upstreamissa kesken. > > ⚠️ Kaksi väitettä tästä kappaleesta on mitattu vääräksi samana > päivänä — kopiointisuunta ja «lause on kesken». Ks. korjaus alla. > Kappale on jätetty näkyviin koska se kertoo millä tiedolla päätös > vietiin omistajalle. > > Tämä ei koske 0.52.0:aa taaksepäin — mitattu: nolla > coss-riippuvuutta, mitään ei kopioitu. Se koskee omistajan > tulevaa päätöstä, ja se on päätös jota kumpikaan sessio ei tee: > paketin kopio asuu kuudessa palvelurepossa, joten kysymys ei ole > "saako yksi palvelu käyttää sitä". Oikeudellinen tulkinta siitä > voittaako hakemistopoikkeus pakettimanifestin ei ole kummankaan > sessio asia — me kerroimme mitä tiedostot sanovat. > > ⚠️ Korjaus 20.10.2026: luin yhden kahdesta tiedostosta, ja > *"poikkeus on kesken"* oli väärä perustelu. Omistaja toi upstreamin > issuen #487 (*"onko apps/ui myös MIT"*, avattu 4.11.2025, suljettu > completed 7.11.). Mittasin sen jälkeen korjauksen diffin ja molemmat > tiedostot: > > > cosscom/ui PR #488 mergetty 2025-11-04T09:31:23Z, kaksi tiedostoa > LICENSING.md + rivi apps/ui/ ← lista, lause jäi katkonaiseksi > README.md:128 MIT nimetty, molemmat hakemistot > cosscom/ui README.md:129 "AGPLv3: All other directories" > apps/ui/LICENSE 404 — poikkeus on pelkkää prosaa > > > Lause katkeaa yhä `LICENSING.md`:ssä, mitattu uudelleen tänään — > mutta poikkeus on lausuttu loppuun `README.md`:ssä, jota en ollut > lukenut. Väite oli siis tosi siitä tiedostosta jonka mittasin ja > väärä upstreamista, ja käytin sitä perusteluna. ★ Ja tiedosto jonka > nimi lupasi vastauksen oli se vajaa: luin LICENSING.md:n enkä > yleistä README.md:tä, koska edellinen kuuluu aiheeseen. > > ★★ Johtopäätös ei liiku, ja oikea perustelu on vahvempi kuin > väärä: ylläpitäjiltä kysyttiin nimenomaan tästä poikkeuksesta, he > lisäsivät apps/ui:n — eivätkä `packages/ui`:ta — ja > README.md:129 sanoo kaikkien muiden hakemistojen olevan AGPL. Eli > upstreamin oma prosa asettaa packages/ui:n AGPL:n alle > eksplisiittisesti eikä epähuomiossa. > > ★★ Ja sitten mittasin `ui:sync`in itse, ja suunta on päinvastainen > kuin olen kertonut kahdesti. apps/ui/scripts/sync-ui.mts: > > > :6 sourceRoot = apps/ui/registry/default ← lähde > :15 targetRoot = ../../packages/ui/src ← kohde > :14 // From apps/ui → ../../packages/ui/src/* > ui:sync:imports biome --write packages/ui/src/… jälkikäsittely kohteelle > kontrolli: molemmat puut 55 tiedostoa > > > Kirjoitettu lähde on MIT-merkityssä hakemistossa, ja > `packages/ui/src` on siitä generoitu kopio jonka tuonnit on > kirjoitettu @coss/ui/...-muotoon. Eli perusteluni «MIT-hakemisto > täytetään AGPL-lähteestä» ei ole epätarkka vaan käänteinen, ja > se oli koko painoa kantava osa. > > ★ Vikaluokka on tämän tiedoston omaa: vahvistin että skripti on > olemassa ja raportoin sen suunnan mitattuna. TP-PERUSTUS mittasi > ensin, minä "tarkistin saman itse" — ja tarkistin viereisen asian. > Sama muoto kuin commit-identiteetit → kanavan olemassaolo: kaksi > mittausta eri asioista näyttää riippumattomalta toistolta. > > Mikä jää voimaan: packages/ui/package.json ilmoittaa > AGPL-3.0-or-later, eli sen paketin asentaminen tai linkittäminen > kantaa AGPL:n, ja README.md:129 asettaa kaikki muut hakemistot > AGPL:ään. Mikä kaatui: väite että MIT-hakemisto on AGPL-lähteen > kopio. Mikä on yhä avoin: sama koodi on kahdella merkinnällä > samassa repossa, eikä apps/ui:lla ole omaa LICENSE-tiedostoa > (404) — MIT-myönnös on luettelomerkki README.md:ssä ja lista > LICENSING.md:ssä, jonka lause ei nimeä lisenssiä. Halvin > konkreettinen teko on pyytää upstreamia lisäämään > `apps/ui/LICENSE`, jolloin myönnös ei ole prosan varassa. > > ★ Ja relayn pointteri oli väärä: sulkeva kommentti linkittää > cosscom/coss#488, jota ei ole olemassa (404 sivuna ja API:sta); > korjaus on cosscom/ui#488. Samannimisyys jatkuu manifesteihin: > cosscom/coss:issa on toinen packages/ui, myös @coss/ui, mutta > ilman `license`-kenttää ja private: true, repossa jonka README > sanoo AGPL-3.0-only. Sama pakettinimi tarkoittaa kahta eri asiaa > kahdessa repossa, eikä kumpikaan ole MIT — ja linkkiä seuraava saa > 404:n josta voi päätellä ettei korjausta tehty.

lisäysmitä se ratkaiseelaskuri
AppShell variant="inset"sisältö pyöristettynä korttina haalealla pohjalla; sivupalkki ja topbar ilman reunaviivojaprototyypin 9 näkymää
PageHeader variant="statement", eyebrow, leadotsikko on lause joka kertoo tilanteen, ei nimilappu5 näkymää
WorkQueuepäätökset jonona omistajittain, aria-current, ↑/↓1 näkymä, 8 kohtaa
IssueCardkenen korjattava, selitys ihmisen kielellä, tekninen viesti taitettuna, toimenpide5 poikkeusta
ChangeRow + ChangePairmuutos kahtena rivinä «−»/«+» — muutoshistorian ainoa esitystapa4 näkymää
StatusDottila + tarkenne taulukon solussa ilman pilleriä3 taulukkoa

⚠️ Laskuri on prototyypin, ei kuluttajan. Aiemmat lisäykset mitattiin kuluttajien koodista; tämä on omistajan päätös tuoda ulkoasu ennen kuin yksikään kuluttaja on sitä pyytänyt. Kirjattu päätökseksi eikä mitatuksi tarpeeksi, kuten Breadcrumb 0.47.0:ssa.

Kolme käännöstä jotka eivät olleet kopioita

1. Taitto ilman tilaa. Prototyypin Collapsible on Base UI:n client-komponentti. IssueCardin tekninen viesti on <details>: palvelinrenderöitävä, toimii ilman JavaScriptiä — sama valinta kuin VersionDiffin muuttumattomilla riveillä.

2. Varjo presetiin, ei luokkaan. Prototyyppi kirjoitti kortin varjon CSS-muuttujaan ja käytti sitä [box-shadow:var(--x)]!:llä. Tailwind 3:ssa se on boxShadow.surface presetissä — ja avain ei ole väri, koska shadow-<väri> generoi saman luokan kahdesti (0.42.0:n löydös). Lukittu.

3. `title` on `ReactNode`, ja se on tarkoituksellinen. Lauseotsikon luvut väritetään tilavärillä (text-success), ja se on paketin ainoa paikka jossa tilaväri on tekstinä: 28 px pitää kontrastin siinä missä 14 px ei.

Mitä ei tehty

  • •Palvelukohtaista sävyä ei ole. Prototyypissä jokaisella palvelulla on oma pääväri (petroli, laventeli). Paketin tokenit ovat neutraaleja, ja --primaryin vaihtaminen on tokenipäätös, ei komponentti. Kuluttaja voi ohittaa sen omassa globals.css:ssään; sitä ei ole mitattu eikä suositella ennen kuin on.
  • •`VersionDiff` ei saanut pinottua muotoa. ChangeRow on yhden arvon muutos siellä missä se näytetään; kahden version vertailu avaimittain pysyy sarakkeina.
  • •`WorkQueue` on vain lista. Sisältöalue, kahden sarakkeen asettelu ja «seuraavaan avoimeen» -siirto riippuvat siitä mitä asiat ovat, ja ne ovat kuluttajan. README:ssä ja demossa on resepti.

Demo: /v3 puhtaalta pöydältä

Vanha rakenneluonnos poistettiin lukematta. Seitsemästä sivusta viisi oli saman kehityshaaran omia välivaiheita; 0.46.0:aan nähden poistui viisi tiedostoa: /v3/toimittajat, /v3/toimittaja ja /v3:n vertailunäkymä komponentteineen. Omistaja päätti katselmoinnissa 20.10.2026, ettei niitä palauteta. Tilalla on kaksi näkymää jotka kuluttaja kirjoittaisi sellaisenaan: /v3 (TP-Vastaanoton yleiskatsaus, kokonaan palvelinkomponentti) ja /v3/muutokset (työjono). Orvoiksi jääneet tiedostot etsittiin tuontikaaviosta eikä nimistä. DemoShell ohittaa kehyksen /v3-etuliitteellä, ei luettelolla.

Korjattu katselmoinnissa

  • •`NavItem.exact` (additiivinen). Yleiskatsaus omassa polussaan (/v3) pysyi aktiivisena alasivulla /v3/muutokset, eli navigaatiossa oli kaksi `aria-current`ia. Vika ei ollut uusi: isNavItemActive on aina käsitellyt vain juuren tarkkana, ja se osuu jokaiseen palveluun jonka yleiskatsaus ei ole juuressa — prototyypin /vastaanotto + /vastaanotto/noudot on juuri se muoto. isNavItemActive sai kolmannen, valinnaisen parametrin; kutsupinta ennallaan. Testi todennettu rikkomalla: ilman lippua kaksi aktiivista.
  • •`IssueCard`in toimenpiteet omaan sarakkeeseen vasta `lg`:stä, ei sm:stä. 768 px:ssä oma sarake puristi selityksen ~300 px:n palstaksi (mitattu kuvakaappauksesta). Kapeammalla ne ovat tekstin alla.

Vientipinta ja testit

0.46.0:aan nähden 127 → 149 ajonaikaista vientiä, lukittu. 51 komponenttitiedostoa, "use client" 20 (work-queue viimeisin). Testit 0.46.0:n 325 → 419 (416 läpi, 0 kaatunutta, 3 ohitettua). Saman kehityshaaran sisällä luvut kulkivat 392 → 417 ennen katselmointia; erotin on haaran sisällä vs. jaettuun versioon nähden.

Kahdeksan lukkoa todennettu rikkomalla, jokainen kaatoi vähintään yhden testin: "use client" pois jonosta, kortin varjo väärä, sr-only-sana pois, <details> aina, lauseotsikko sai viivan, pisteen aria-hidden pois, surface pois presetistä, aria-current kaikilla.

v0.51.0

2026-10-19
Lisätty

Checkbox — natiivi, nolla riippuvuutta

Laskuri 2 — /v3/projektin virstanpylväslista ja toimittajan tuoteluettelon rivivalinta. @radix-ui/react-checkbox ei ole peereissä, joten natiivi <input type="checkbox"> samalla kuviolla kuin Switch.

Ero Switchiin on lupaus eikä muoto: valintaruutu tallentuu lomakkeen mukana tai kerää kohteita toimintoa varten, kytkin on heti voimassa.

⚠️ Rajaus: ei välitilaa

«Valitse kaikki» -ruudun välitila on DOM-ominaisuus eikä attribuutti, joten sen asettaminen vaatii refin ja efektin — eli client-rajan. Komponentti pysyy palvelinpuolella; kuluttaja joka tarvitsee välitilan kääräisee sen omaan client-komponenttiinsa. Lukittu testillä.

Kaksi löydöstä matkalta

1. Oma vika ensimmäisessä versiossa: className päätyi sekä kääreeseen että ruutuun, jolloin kuluttajan w-full olisi levittänyt myös ruudun. Korjattu ja lukittu.

2. `renderToStaticMarkup` escapeeraa `&`:n. Tailwindin mielivaltainen valitsin [&_svg] on HTML:ssä [&amp;_svg]. Ensimmäinen testi haki escapetonta muotoa ja kaatui — oikeasta syystä, väärään kohteeseen.

Vientipinta ja testit

143 → 144 vientiä, 46 → 47 tiedostoa, "use client" pysyy 19:ssä. Testit 383 → 392 (389 läpi, 0 kaatunutta).

Kymmenen lukkoa todennettu rikkomalla; kaksi todennusta uusittiin. Luokkalukon mutaatio lisäsi vain välilyönnin eikä palauttanut vikaa, ja välitilalukon mutaatio lisäsi kommentin — jonka lukko nimenomaan poistaa ennen hakua. Kumpikin näytti «lukko tyhjä» -tulokselta, ja kumpikin oli mutaation vika eikä lukon.

v0.50.0

2026-10-19
Lisätty

Switch — natiivi, nolla riippuvuutta

Laskuri 3 — laskutusnäkymän kulunhallinta ja kaksi lisäosariviä. Referenssissä kytkin esiintyy molemmissa tiloissa, ja tila on juuri se osa jonka käsin piirretty versio yleensä unohtaa.

Neljäs kerta sama valinta, mutta eri perustein

Label, Progress ja Select jätettiin Radixista pois koska peer oli joko jo maksettu tai hinta pieni. `@radix-ui/react-switch` ei ole `peerDependencies`issä lainkaan, eli tässä se olisi ollut aito uusi riippuvuus kuudelle palvelulle.

Natiivi <input type="checkbox"> jolle annetaan role="switch" saa selaimelta välilyönnin, checked-tilan, lomakkeen name/value-parin ja :disabled-tilan. Radix toisi tilanhallinnan, jonka React tekee jo.

★ `aria-checked`ia ei kirjoiteta. Natiivi checked kertoo tilan, ja oma ARIA-attribuutti sen rinnalla olisi kahdes lähde samalle tiedolle. Lukittu testillä.

Kytkin vai valintaruutu — lupaus on eri

Kytkin lupaa että muutos on heti voimassa. Valintaruutu lupaa että se tallentuu lomakkeen mukana. Jos rivillä on Tallenna-painike, oikea komponentti on valintaruutu — vaikka molemmat ovat <input type="checkbox">. Kirjattu komponentin JSDociin, koska ero ei näy koodista.

Rakenteellinen yksityiskohta joka on kirjattava

Nuppi on pseudoelementti eikä lapsi: Tailwindin peer-* osuu vain sisarukseen, ei sisaruksen lapseen. Lapsena toteutettu nuppi ei liikkuisi tilan mukana, ja se olisi hiljainen vika — kytkin näyttäisi toimivan värin puolesta mutta nuppi jäisi paikalleen.

Vientipinta ja testit

142 → 143 ajonaikaista vientiä, lukittu samassa commitissa. 45 → 46 komponenttitiedostoa; "use client" pysyy 19:ssä. Testit 375 → 383 (380 läpi, 0 kaatunutta, 3 ohitettua).

Yhdeksän uutta lukkoa, jokainen todennettu rikkomalla, eikä yksikään todennus vaatinut uusimista.

v0.49.0

2026-10-19
Lisätty

Label, Textarea ja Progress — kolme nollahintaista primitiiviä

komponenttilaskuririippuvuus
Label3 — /v3/tehtava, /v3/projekti, asetusnäkymätei mitään
Textarea2 — kommenttikenttä, kuvauskenttäei mitään
Progress3 — laskutusnäkymän käyttömittaritei mitään

Jokainen oli kirjoitettu käsin niihin näkymiin joissa sitä tarvittiin, ja merkitty siellä käsin piirretyksi. Textarean tapauksessa se ei ollut tyylipuute vaan toimimaton käyttöliittymä: kommenttilaatikko oli div johon ei voinut kirjoittaa.

Kolme kertaa sama valinta: natiivi eikä Radix

shadcn rakentaa nämä Radixin päälle. Kaikissa kolmessa Radix olisi tuonut pari attribuuttia ja vienyt komponentin client-puolelle.

★ `@radix-ui/react-label` on jo `peerDependencies`issä eikä yksikään tiedosto käytä sitä — sama tilanne kuin Tooltipilla ja Tabsilla, eli se olisi ollut ilmainen. Se jätettiin silti pois: nimilappu on lomakkeen yleisin yksittäinen elementti, ja client-rajan vetäminen sen takia jokaiseen kuluttajalomakkeeseen maksaa enemmän kuin kaksoisklikkauksen tekstivalinnan esto tuo. Sama perustelu kuin Selectillä.

⚠️ Seuraus joka on kirjattava: @radix-ui/react-label ja @radix-ui/react-separator jäävät lupauksiksi joita mikään ei lunasta. Niiden poistaminen peerDependenciesistä on sopimusmuutos ja kuuluu omistajalle, ei tähän julkaisuun.

Kaksi rajausta jotka on kirjattu eikä oletettu pois

1. `Progress` ei tee rengasta. Laskutusnäkymässä on myös pyöreä 79 %:n mittari; se vaatii SVG:n ja kehän laskennan eikä ole tämän variantti.

2. `Progress` ei tee määrittelemätöntä tilaa. Jatkuvasti liikkuva palkki on latausilmaisin eikä edistyminen.

Vientipinta ja testit

139 → 142 ajonaikaista vientiä, lukittu samassa commitissa. 42 → 45 komponenttitiedostoa; "use client" pysyy 19:ssä, eli kaikki kolme ovat palvelinpuolella. Testit 360 → 375 (372 läpi, 0 kaatunutta, 3 ohitettua).

14 uutta lukkoa, jokainen todennettu rikkomalla — ja kaksi todennusta jouduttiin uusimaan. Textarean venytyslukko ei kaatunut ensi yrittämällä, koska mutaatio osui JSDociin eikä luokkaan: ensimmäinen resize-y tiedostossa on selittävässä kommentissa. Labelin elementtilukko tuotti käännösvirheen, koska mutaatio vaihtoi vain avaustagin. Kumpikin olisi mennyt läpi todennuksena jos olisi luettu vain lopputulos eikä syytä.

v0.48.0

2026-10-19
Lisätty

Avatar — kahdennuksen poisto, ei uutta kyvykkyyttä

Laskuri 15 tiedostossa. rounded-full-tunnusmerkki oli kirjoitettu erikseen seitsemässä paikassa paketissa (user-menu, notification-center ×3, changelog, app-launcher) ja kahdeksassa demossa. Jokainen valitsi oman kokonsa, kirjasinkokonsa ja reunansa.

Sama kuvio kuin NavListilla aikanaan: kopio toimii tänään ja eriytyy ensimmäisessä muutoksessa.

Kaksi rajausta, molemmat omistajan päätöksiä 19.10.2026

1. Ei valokuvaa. Avatar on ikoni. src/components/issä ei ollut yhtäkään <img>-elementtiä ennen tätä eikä ole tämän jälkeenkään — kuva toisi lataustilan, virhetilan, fallback-ketjun ja CSP-kysymyksen kuudelle kuluttajalle. Lukittu testillä, jotta seuraava lukija ei lisää src-propsia täydellisyyden vuoksi.

2. Ei monogrammia. Ikoni, ei nimikirjaimia.

★ Hinta on kirjattu eikä oletettu pois: sama ikoni jokaisella tarkoittaa ettei avatar enää erota henkilöitä. Listassa jossa rivillä on avatar muttei nimeä, se oli ainoa henkilötieto. Siksi tone="identity" värittää kehän nimestä johdetulla sävyllä — erottelu säilyy ilman nimikirjaimia. Oletus on neutral.

avatarSavy(name) on viety erikseen, jotta sama nimi tuottaa saman sävyn joka paikassa ja jotta vakaus on testattavissa eikä väite.

Saavutettavuus

Ilman nameia avatar on koriste (aria-hidden), koska nimi lukee vieressä. namein kanssa se saa role="img" ja saavutettavan nimen — anna se aina kun avatar on ainoa henkilötieto.

Vientipinta ja testit

137 → 139 ajonaikaista vientiä, lukittu samassa commitissa. 41 → 42 komponenttitiedostoa, "use client" pysyy 19:ssä (Avatar on palvelinpuolella). Testit 350 → 360 (357 läpi, 0 kaatunutta, 3 ohitettua).

Kymmenen uutta lukkoa, jokainen todennettu rikkomalla. Yksi todennus uusittiin: ensimmäinen mutaatio kaatoi sarjan ajonaikaiseen virheeseen eikä siihen väitteeseen jota oli tarkoitus lukita.

v0.47.0

2026-10-19
Lisätty

neljä navigaatiokomponenttia — eri laskurit, eri perustelut

Komponentit siirrettiin demo/components/luonnos/ista pakettiin. Laskurit eroavat, eikä niitä niputeta yhdeksi tapaukseksi:

komponenttilaskuriperuste
Tabs3 — 11 tiedostoa (TP-IAM 5, TP-PERUSTUS 4, TP-VASTAANOTTO 2)kynnys ylittyi kaksi palvelua sitten
Breadcrumb1 — 3 tiedostoa, vain TP-PERUSTUSrepon omistajan päätös 19.10.2026
TabNav0omistajan pyyntö; korjaa mitatun ARIA-virheen
Segmented0omistajan päätös 19.10.2026

Kaksi viimeistä eivät ole mitattuja tarpeita vaan päätöksiä, ja ne on kirjattu niin — jotta seuraava lukija näkee eron Tabsiin.

TabNav on olemassa koska role="tab" on väärä lupaus linkille

role="tab" lupaa samassa dokumentissa olevan paneelin jonka valinta vaihtaa. Linkki navigoi pois. Ruudunlukijan käyttäjä kuulee "välilehti, 1/4", painaa, ja koko sivu vaihtuu — lupaus rikkoutuu eikä mikään kerro siitä.

Siksi reittivälilehdet ovat nav + lista + linkit, ja aktiivinen saa aria-current="page". Ulkoasu on identtinen `Tabs`in kanssa tarkoituksella: käyttäjälle ne ovat sama asia, ja ero on vain siinä mitä avustavalle teknologialle luvataan.

⚠️ Tabs vaatii @radix-ui/react-tabs — yksi kuluttaja joutuu asentamaan

@radix-ui/react-tabs oli jo `peerDependencies`issä ilman että yksikään tiedosto käytti sitä, ja viisi kuudesta kuluttajasta oli sen jo asentanut. Tämä komponentti ei siis maksa heille uutta riippuvuutta.

⚠️ TP-NOTIFY on ainoa jolla sitä ei ole asennettuna. Heidän on lisättävä `@radix-ui/react-tabs` ennen kuin he tuovat tämän, muuten build kaatuu.

Avoin vika: TabNav merkitsee alipolulla kaksi välilehteä

isNavItemActive korostaa ylätason kohdan alipolulla — sivupalkissa oikein ja tarkoituksellinen, välilehtirivissä ei. Polulla /toimittajat/303592/kasittelyt sekä Tuotteet että Käsittelyt saavat aria-current="page", eli ruudunlukijalle kerrotaan että käyttäjä on kahdella sivulla yhtä aikaa.

★ Komponentti on olemassa korjatakseen saavutettavuusvirheen, ja tämä on toinen sellainen — pienempi, mutta samaa lajia.

Ei korjattu tässä julkaisussa. Korjaus on valinta eikä ilmeinen, ja isNavItemActive on jaettu NavListin kanssa eikä sitä saa muuttaa tämän takia. Vaihtoehdot: (a) TabNav vertaa tarkasti, (b) TabNav merkitsee vain tarkimman osuman, (c) kuluttaja antaa active-propsin. Kukin muuttaa sopimusta eri tavalla; repon omistajan päätettäväksi. Mitattu käytös on lukittu testiin, joka kaatuu kun se korjataan.

Kolme muuta rajausta jotka on kirjattu eikä oletettu pois

1. Radix ei renderöi suljettua paneelia. Tabsin suljetun TabsContentin sisältöä ei voi todentaa SSR:stä — sama rajaus kuin MobileNavin paneelilla ja Tooltipin sisällöllä. Kirjattu testiksi.

2. `TabNav` ei vieritä aktiivista välilehteä näkyviin kapealla ruudulla, koska se vaatisi client-rajan. Jos välilehtiä on paljon, kuluttaja kääräisee sen omaan client-komponenttiinsa.

3. `Tabs` ja `Segmented` ovat `"use client"`, `TabNav` ja `Breadcrumb` eivät. Jälkimmäiset ottavat currentPathin propsina eivätkä kutsu usePathname():a, joten ne eivät vedä client-rajaa kuluttajan kehykseen.

Korjattu ennen julkaisua

TabNav kaatoi jokaisen palvelinkomponentin

tab-nav.tsx kutsui React.createContextia ilman "use client" -direktiiviä. Koska barrel vie sen, mikä tahansa tuonti `@karkkainen/ui`:sta palvelinkomponenttiin kaatoi sovelluksen — demossa app/layout.tsx, eli jokainen sivu.

★ Se läpäisi kaiken mitä meillä oli:

tarkistustulosmiksi se ei nähnyt
npm testvihreärenderToStaticMarkup ei tunne palvelin/client-rajaa
npx next buildvihreäRSC-sääntöjä valvotaan dev-moodissa, ei tuotantobuildissa
next devkaatuitämä oli ainoa joka näki sen

Vihreä build oli siis vihreä väärästä syystä — sama muoto kuin muut tämän dokumentin kirjaamat tapaukset, ja erityisen nolo koska build oli nimetty tämän muutoksen tärkeimmäksi todisteeksi.

Korjaus säilyttää palvelinrajan eikä poista sitä: Context korvattiin React.Childrenillä, samalla kuviolla jota Breadcrumb jo käyttää. Vaihtoehto olisi ollut lisätä "use client", mutta se olisi kumonnut komponentin perustelun — raja on tarkoituksellinen ja README kirjaa sen estäneen kolme liukumaa.

⚠️ Uusi rajaus: TabNavLinkin on oltava TabNavin suora lapsi periäkseen currentPathin. Kääreen sisällä aktiivisuus annetaan active-propsilla.

Lukko: tests/render.test.mjs kaatuu nyt jos palvelinpuolen komponentti käyttää client-API:a. Kommentit poistetaan ennen hakua, koska JSDocin käyttöesimerkeissä (* const [x, setX] = useState(…)) nimet esiintyvät laillisesti — ilman sitä data-table.tsx antaisi väärän positiivisen. Todennettu rikkomalla.

Vientipinta ja testit

127 → 137 ajonaikaista vientiä, lukittu samassa commitissa (tests/tuki/vientipinta-lukko.mjs). 37 → 41 komponenttitiedostoa, 17 → 19 `"use client"`; SKILL.md:n luvut ja client-lista päivitetty.

Testit 325 → 350 (347 läpi, 0 kaatunutta, 3 ohitettua). Jokainen uusi lukko on todennettu rikkomalla, ja yksi rikkomistodennus jouduttiin uusimaan: ensimmäinen mutaatio tuotti käännösvirheen eikä testin kaatumista, eli olisi ollut punainen väärästä syystä.

v0.46.0

2026-10-19
Lisätty

Tooltip — mitattu aukko, nolla uutta riippuvuutta

<TooltipProvider>              {/* juureen kerran */}
  <Tooltip>
    <TooltipTrigger asChild>
      <Button size="icon" variant="ghost" aria-label="Pysäytä täydennys">
        <Pause className="size-4" />
      </Button>
    </TooltipTrigger>
    <TooltipContent>Pysäytä täydennys <TooltipKbd>P</TooltipKbd></TooltipContent>
  </Tooltip>
</TooltipProvider>

Mitattu 19.10.2026 kuuden kuluttajan lähteestä: 127 natiivia `title=`-attribuuttia 54 tiedostossa neljässä palvelussa (TP-PERUSTUS 95, TP-VASTAANOTTO 26, TP-IAM 5, TP-TTK 1). Natiivi title on huono vihje mutta ilmainen, ja juuri siksi se levisi: sekunnin viive, ei tyyliä, ei näppäimistöllä eikä kosketuslaitteella, ei Escapea.

★ `@radix-ui/react-tooltip` oli jo `peerDependencies`issä ilman että yksikään tiedosto käytti sitä, ja viisi kuudesta kuluttajasta oli sen jo asentanut. Tämä komponentti ei siis maksa heille uutta riippuvuutta — se oli jo luvattu sopimuksessa, vain toimittamatta.

⚠️ TP-NOTIFY on ainoa jolla sitä ei ole asennettuna. Heidän on lisättävä @radix-ui/react-tooltip ennen kuin he tuovat tämän.

Kolme rajausta jotka on kirjattu eikä oletettu pois

1. Vihje ei korvaa `aria-label`ia. Radix liittää sen aria-describedbyllä, eli se on kuvaus eikä nimi. Ikoninappi tarvitsee silti oman nimensä. Lukittu renderöintitestissä.

2. Kosketuslaitteella vihjettä ei ole. Jos tieto on välttämätöntä, se kuuluu näkyväksi tekstiksi — vihje on lisä.

3. SSR ei voi todentaa sisältöä. Radix ei renderöi vihjettä suljettuna, sama rajaus kuin MobileNavin paneelilla. Testit kattavat liipaisimen ja TooltipKbd:n, eivät sisältöä.

Lisätty

kontrastilukot käännetylle pinnalle

Vihje on paketin ainoa paikka jossa etuala ja tausta vaihtavat roolit (bg-foreground + text-background), joten sen kontrasti ei tule mistään muusta lukosta. tests/tokens.test.mjs laskee sen nyt erikseen molemmille teemoille.

teksti pinnalla        19,80:1 vaalea · 18,97:1 tumma
TooltipKbd täytöllään  15,81:1 vaalea · 15,28:1 tumma
TooltipKbd-reuna        2,14:1 / 1,78:1 — alle 3:1 TARKOITUKSELLA

Reuna on koriste: merkki erottuu täytöstään ja merkityksen kantaa teksti, joten reuna ei ole ainoa tiedonvälittäjä. Sama perustelu kuin tilamerkin reunalla 0.37.0:ssa.

Todennettu rikkomalla: --foreground lähelle --backgroundia kaataa sen viestillä "Tooltipin teksti on teemassa vaalea 1.09:1 — alle AA:n".

v0.45.0

2026-10-19
Muutettu

kirjasin Interistä Geistiin

Omistajan päätös 19.10.2026.

Paketti ei lataa fontteja, joten tämä on fallback-muutos eikä latausmuutos. Preset lukee --font-sans ja --font-mono kuten ennenkin; muuttuu vain se mitä käytetään jos kuluttaja ei niitä aseta:

- sans: ["var(--font-sans, Inter)", …]
+ sans: ["var(--font-sans, Geist)", …]
- mono: ["var(--font-mono, JetBrains Mono)", …]
+ mono: ["var(--font-mono, Geist Mono)", …]

Varsinainen vaihto tapahtuu kuluttajan `layout.tsx`:ssä, ei täällä. Yksi rivi per palvelu, eikä se vaadi uutta riippuvuutta: Geist ja Geist Mono ovat molemmat next/font/googlen listalla (mitattu Nextin omasta font-data.json:ista, 1 862 fonttia), ja molemmat tukevat latin- ja latin-ext-osajoukkoja ja ovat muuttuvia fontteja.

- import { Inter, JetBrains_Mono } from "next/font/google";
+ import localFont from "next/font/local";
+ // src: "…/node_modules/geist/dist/fonts/geist-sans/Geist-Variable.woff2"
+ // weight: "100 900", variable: "--font-sans"

⚠️ `next/font/google`n Geist on riisuttu build — ks. alla. Käytä geist-npm-pakettia, ja `localFont` eikä `geist/font/sans`: paketin oma vienti asettaa --font-geist-sans, kun preset lukee --font-sans. Demossa tämä meni ensin väärin, ja oire oli hiljainen: fontit ladattiin mutta sivu renderöityi fallback-pinolla, koska muuttuja jäi määrittelemättömäksi.

Kunnes palvelu vaihtaa, mikään ei muutu sen ajossa — fallback puree vain jos muuttujaa ei ole asetettu, ja kaikki viisi asettavat sen.

Lisätty

lukko fonttiperheelle

Fonttivalinta eli neljässä paikassa eikä mikään kaatunut jos ne erkaantuivat: presetin fallback, skillin pohja.html (Tailwind-konfiguraatio ja <link>), ja esimerkki-tokenit.html. Juuri tämä vaihto oli se hetki jolloin ajautuminen olisi tapahtunut.

tests/skill.test.mjs vertaa nyt presetin fallbackia skillin molempiin tiedostoihin ja Google Fonts -URLiin. Todennettu rikkomalla: presetin vaihto Satoshiksi kaataa sen oikealla testinimellä.

Sama liike kuin files-kentällä ja presetin fallback-väreillä: ainoa tapa pitää kopio elossa on antaa sille lukija joka kaatuu.

Lisätty

kirjasimen renderöintiasetukset presetissä

Omistajan päätös 19.10.2026. Asetukset ovat addBaseissa eivätkä ohjeessa samasta syystä kuin reunan oletusväri 0.28.0:ssa: askel jonka jokainen palvelu joutuisi muistamaan erikseen.

html {
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
  font-feature-settings: "ss01";
}

Kolme rajausta jotka on mitattu eikä oletettu:

1. `antialiased` ei ole tarkempaa vaan ohuempaa. Se ohittaa alipikselisilotuksen. Tyylivalinta, sama jonka Vercel ja Linear tekevät — tummalla pinnalla parantaa, vaalealla keventää.

2. `ss01` on vain Vercelin buildissa. Mitattu molempien OpenType-taulukoista:

Google Fonts / Fontsource   ccmp dnom frac kern liga locl mark mkmk numr pnum tnum
Vercelin geist-npm          + aalt case dlig ordn sinf ss01…ss11 subs sups

Alkuperäisessä ehdotuksessa olivat myös cv01 ja cv02; niitä ei ole yhdessäkään mitatussa Geist-buildissa, joten ne jätettiin pois inertteinä eikä kirjoitettu riville näyttämään tehokkailta.

3. `tabular-nums` ei hajoa. Tailwind toteuttaa sen font-variant-numericilla, joka on eri ominaisuus kuin font-feature-settings. Todennettu käännetystä CSS:stä.

Huomioitavaa jakelussa

tailwind-preset.ts on HASHEDissa, joten sisältötiiviste liikkuu. 0.43.0 on avoimena neljässä kuluttajassa, eikä sama versionumero saa saada kahta sisältöä — nämä kuuluu viedä yhtenä kierroksena.

v0.44.0

2026-10-15
Lisätty

sivukoon valitsin — ja arvot tulevat kutsujalta

<Pagination … pageSizes={[25, 50, 100]} onPageSizeChange={setKoko} />
<DataTable  … pageSizes={[25, 50, 100]} onPageSizeChange={setKoko} />

Omistajan päätös 15.10.2026. Rajaus oli kirjattu 0.19.0:sta asti muodossa *"ei tarjoa valitsinta"*, ja laskuri jäi yhteen — yksikään kuluttaja ei ole pyytänyt sitä.

Paketti ei tarjoa oletuslistaa, ja syy on mitattu kuluttajien rajapinnoista:

TP-PERUSTUS  classification-review   katto 100   oletus 20
             imports                 katto 100   oletus 25
             imports/[id]/products   katto 200   oletus 25
TP-VASTAANOTTO  gs1-synkka           SIVUKOKO = 50, kiinteä

Kolme eri kattoa saman palvelun sisällä. Jos lista tulisi täältä, se tarjoaisi kahdessa näkymässä kolmesta valinnan jonka rajapinta hylkää — katto on sen joka vastaa kyselyyn. Sama raja kuin lajittelussa ja routerissa.

#### Koon vaihto ankkuroi, ei nollaa

pageForNewSize(pagination, uusiKoko) on viety erikseen ja laskee uuden sivun ensimmäisestä näkyvästä rivistä: sivulla 8 á 25 riviä oleva käyttäjä katsoo riviä 176, ja sadan rivin sivuilla sama rivi on sivulla 2. Nollaus sivulle 1 heittäisi hänet alkuun eikä mikään kertoisi miksi.

Logiikka on lib/table.ts:ssä eikä komponentissa, koska klikkausta ei voi todentaa SSR:stä — nyt väite on testattavissa ilman selainta. Mukana ominaisuustesti joka tarkistaa 60 yhdistelmälle että ankkurirutiini osuu sivulle jolla rivi oikeasti on.

#### Jos nykyinen koko ei ole listalla, se lisätään siihen

Muuten <select> näyttäisi listan ensimmäistä arvoa ja valehtelisi siitä mitä ruudulla on. Tämä ei ole reunatapaus: TP-PERUSTUKSEN oletukset ovat 20 ja 25, eli luonteva lista [25, 50, 100] ei kata toista niistä.

Korjattu

sivutusrivi oli kiinni kortin alareunassa

Omistajan havainto kuvakaappauksesta. Paginationilla oli px-3 mutta ei pystysisennystä lainkaan, joten reunasta reunaan menevässä taulukossa (<CardContent className="p-0">) rivi istui suoraan kortin alareunaa vasten.

★ Ja oma demoni kiersi sen neljässä kohdassa — jokainen esimerkki oli <div className="rounded-md border py-3">. Kirjoitin kiertotavan itse enkä nimennyt sitä kiertotavaksi. Se on sama muoto kuin reunavärisääntö ennen 0.28.0:aa: askel jonka jokaisen kutsujan on muistettava kuuluu pakettiin, ei ohjeeseen.

navin sisennys   px-3        → p-3
DataTablessa                   + pt-0, koska juuren space-y-3 antaa ylätilan
demon kääreet    py-3 pois      ← ainoa automaattinen todiste että tuonti riittää

Mitattu selaimesta eikä lähteestä (Playwright, demo 1280×900):

ennenjälkeen
itsenäinen Pagination reunakehyksessä0 / 0 px12 / 12 px
DataTablen sivutus12 / 0 px12 / 12 px

Käännetyn CSS:n emittointijärjestys tarkistettu samalla: .p-3 tavussa 27 722, .pt-0 tavussa 28 988 — jälkimmäinen voittaa, eli yhdistelmä p-3 pt-0 laskee oikein. Testisarja lukee luokkajonon eikä laskettua arvoa, ja se rajaus on kirjattu testiin itseensä.

Lukot

Viisi, ja kaatuminen todennettu rikkomalla kukin erikseen: navin pystysisennys, valitsin vaatii molemmat propsit (positiivinen kontrolli mukana), nykyinen koko lisätään listaan, selite on olemassa, ja DataTablen pt-0.

v0.43.0

2026-10-15
Korjattu

palvelutiilen väri karsiutui buildista

Omistaja huomasi puhelimen kuvakaappauksesta, että waffle-valikossa viisi palvelua kuudesta näkyi ilman taustaväriä — pelkkä valkoinen kuvake mustalla.

AppService.color on Tailwind-luokka ("bg-amber-500") joka tulee palvelurekisteristä ajonaikana JSONina. Tailwind skannaa lähdekoodin tekstinä eikä näe sitä, joten luokka karsiutuu buildista. Mikään ei kaadu, mikään ei varoita, ja tiili renderöityy ilman väriä.

⚠️⚠️ KORJAUS 15.10.2026, ja se kumoaa tämän merkinnän alkuperäisen perustelun. Kirjoitin tähän että viisi kuudesta on rikki, ja mitta­sin sen väärin kahdella tavalla. Kolme kuluttajaa korjasi minut tunnin sisällä.

1. Hain polusta jota kahdella ei ole. Luin .next/static/css/*.css, mutta Next 16 kirjoittaa CSS:n `.next/static/chunks/`-hakemistoon. TP-TTK ja TP-MEDIA ajavat Next 16:ta, joten molemmilla tulos oli nolla — ja nolla olemattomasta polusta näyttää identtiseltä puuttuvalta luokalta. Mitattu uudelleen molemmista hakemistoista:

                 css/   chunks/
TP-Vastaanotto     5       0     CSS asuu css/:ssä
TP-Perustus        5       0
TP-IAM             6       0
TP-TTK             0       6     CSS asuu chunks/:issa
TP-MEDIA           0       6
TP-NOTIFY          6       0

2. Yleistin yhden kuluttajan arkkitehtuurin kuudelle. Oletin että color tulee rekisteristä ajonaikana kaikilla. Se ei pidä: jokaisella on oma paikallinen väritaulu, jossa luokat ovat literaaleina — ja literaalit Tailwind näkee. TP-PERUSTUS kartoittaa värin palvelun id:stä eikä käytä bg-indigo-500:aa lainkaan, joten sen "puuttuminen" ei tarkoittanut mitään.

Oikea laajuus: yksi kuluttaja oli rikki, ja se oli TP-NOTIFY — ainoa joka tosiasiassa luki rekisterin color-kentän. He löysivät sen ja korjasivat sen itse omalla safelistillään ennen kuin raportoivat.

Mitä tästä jää voimaan

Safelist on ennakoiva eikä korjaava, ja TP-TTK muotoili sen oikein: en perusteena rikki vaan ennakoiva.

Peruste pätee silti: kontrakti sanoo että color on Tailwind-luokka, rekisteri palauttaa sen nyt kaikille, ja se hetki jona joku ottaa kentän käyttöön on hetki jona hänen tiilensä muuttuvat värittömiksi ilman varoitusta. Juuri niin kävi TP-NOTIFYlle. Safelist poistaa sen ansan kuudelta kerralla.

⚠️ Ja hinta on nyt perusteltava uudelleen: +11,8 % CSS:ää kuudelle palvelulle estääkseen vian jota yhdelläkään ei tällä hetkellä ole. Se on omistajan päätös eikä minun, ja esitin sen alun perin korjauksena johon ei tarvita päätöstä.

Korjaus on presetissä eikä kuudessa configissa

safelist: [{ pattern: new RegExp(`^bg-(${TIILIVARIT})-(400|500|600)$`) }]

Kuviona eikä luettelona. Luettelo olisi kopio rekisterin sisällöstä, ja uusi palvelu uudella värillä näkyisi hiljaa värittömänä. Muotoilu on TP-NOTIFYn.

Hinta mitattu demon buildista: +5 185 tavua, +11,8 % — sama kuin heidän mittauksensa omasta repostaan.

Safelist on kontraktin osa eikä toteutusyksityiskohta

Sen poistaminen presetistä on rikkova muutos, ei siivousta. AppService.color on dokumentoitu Tailwind-luokaksi, ja safelist on ainoa asia joka pitää sen lupauksen silloin kun luokka tulee ajonaikaisesta datasta.

Siksi se ei nojaa tähän lauseeseen vaan lukkoon: tests/tokens.test.mjs kaatuu jos safelist katoaa, jos se muuttuu luettelosta kuvioksi päin vastoin, tai jos kuvio lakkaa kattamasta rekisterin nykyisiä värejä. Lukko ajetaan CI:ssä joka commitissa.

★ Varaus on TP-NOTIFYn, ja se on oikea: vika on hiljainen, joten kuluttaja joka poistaa oman safelistinsä presetin varassa tarvitsee jotain joka huomaa jos preset lakkaa kantamasta sitä. Lause ei huomaa mitään; testi huomaa.

★ Ja syy siihen miksi tämä kuuluu pakettiin eikä kuluttajille: paketti määrittelee kontraktin että `color` on Tailwind-luokka, ja paketti toimittaa presetin. Kuluttaja ei voi lukea tätä ansaa kontraktista — löytäjäkään ei tiennyt sitä, ja huomasi vasta kun omistaja katsoi valikkoa puhelimella.

⚠️ Avoin rakenteellinen kysymys

color on kehyskohtainen luokkanimi datana, ja se on sama luokka jonka iconName ratkaisi toisin: rekisteri kantaa nimen, paketti ratkaisee sen. Värille vastaava muoto olisi CSS-väriarvo jonka komponentti asettaa tyylinä — silloin Tailwind ei ole datapolulla lainkaan eikä safelistiä tarvittaisi.

Se on rikkova muutos AppServiceiin ja muutos rekisteriin, eli omistajan päätös. Safelist korjaa oireen tänään; kysymys jää auki.

Lukko

tests/tokens.test.mjs — safelistin on oltava kuvio, sen on katettava rekisterin kuusi nykyistä väriä ja värejä joita siellä ei vielä ole, muttei mitä tahansa sävyä. Kuvio rakennetaan presetin omasta merkkijonosta, jottei testi kanna omaa kopiotaan väriperheistä.

Kaatuminen todennettu vaihtamalla kuvio luetteloksi.

v0.42.0

2026-10-15
Korjattu

kortin varjoa ei näkynyt lainkaan — nimi törmäsi väriin

Omistaja katsoi demon ja näki, että varjo ilmestyy vasta kun yhden CSS-rivin ottaa pois päältä. Rivi oli tämä:

.shadow-card{--tw-shadow:0 1px 3px 0 rgb(0 0 0/0.05), …}          /* koko */
.shadow-card{--tw-shadow-color:hsl(var(--card));
             --tw-shadow:var(--tw-shadow-colored)}                /* väri */

★★ `shadow-<nimi>` on Tailwindissa sekä koko että varjon väri, ja colors.card on olemassa — eli oma boxShadow.card tuotti saman luokan kuin väriutility. Jälkimmäinen voitti, ja varjon väriksi tuli kortin oma taustaväri: valkoinen valkoisella.

Avain on nyt elevated, ja luokka shadow-elevated.

Lukko oli vihreä, ja se tarkisti väärää asiaa

tests/render.test.mjs luki arvon presetistä ja näki sen oikein. Arvo oli oikea; se ei vain päätynyt ruudulle. Lukko tarkisti olemassaolon eikä voimassaoloa — ja käännetyn CSS:n tarkistuskin olisi mennyt läpi, koska ensimmäinen sääntö on siellä aivan oikein.

Uusi lukko on yleinen eikä tätä tapausta varten: yksikään `boxShadow`-avain ei saa olla myös väriavain. Törmäävät nimet ovat kaikki colors-lohkon avaimet — card, border, primary, muted, accent, brand ja kymmenen muuta.

Todennettu kahdesti: lukko kaatuu kun avain palautetaan card:iksi, ja käännöksessä on nyt yksi .shadow-elevated-sääntö entisen kahden sijaan.

⚠️ Luokan nimi muuttui

shadow-card → shadow-elevated. Nimi oli olemassa muutaman tunnin eikä se toiminut kertaakaan, joten kutsupaikkoja ei pitäisi olla — mutta jos jokin palvelu ehti käyttää sitä omassa koodissaan, se lakkaa toimimasta hiljaa. Mitattu kuudesta kuluttajasta: nolla osumaa.

v0.41.1

2026-10-14
Korjattu

Fingerprint ei ratkennut lucide 1.x:llä

TP-TTK mittasi omasta kontistaan: icons["Fingerprint"] on `undefined` lucide-react 1.31.0:ssa. Kokoelmassa nimi on FingerprintPattern, mutta vanha nimi elää nimettynä vientinä. Seuraus: TP-IAM-tiili putosi hiljaa monogrammiin kahdella kuluttajalla.

Vahvistin sen täällä asentamalla 1.31.0 erikseen:

Inbox · Boxes · PackageSearch · Images · Bell   icons: ON
Fingerprint                                     icons: puuttuu, vienti: ON
FingerprintPattern                              icons: ON

resolveServiceIcon kokeilee nyt kokoelman ja sen jälkeen nimetyn viennin, jolloin aliakset kattavat uudelleennimeämiset molemmissa pääversioissa. Kokoelma on yhä ensisijainen. Deny-lista estää sen että iconName: "icons" palauttaisi koko kokoelman.

★★ Lukko ei voinut nähdä tätä, ja syy on rakenteellinen

Vendoroitu paketti käyttää kuluttajan lucide-versiota, ei tämän repon lukittua. Mitattu: kuluttajista kahdella on ^1.31.0 ja kahdella ^0.469.0. Testini ajoi 0.469.0:aa, jossa nimi löytyy — eli lukko oli vihreä ja oikeassa omasta ympäristöstään, ja väärässä siitä jossa koodi ajaa.

> Peer-riippuvuuden versio on kuluttajan valinta. Testi joka ajaa > omaa lukkoaan ei testaa sitä mitä kuluttaja ajaa.

Siksi resolveServiceIcon ottaa nyt ikonilähteen valinnaisena toisena parametrina — sauma on testiä varten, jotta aliaspolku voidaan todentaa synteettisellä 1.x-muotoisella lähteellä. Lisäksi fix on todennettu oikeaa 1.31.0:aa vasten: kaikki kuusi ratifioitua nimeä ratkeavat.

★ Ja löydös tuli sieltä mistä se ainoana saattoi tulla: kuluttajalta joka ajaa eri versiota. He eivät kiertäneet sitä omassa koodissaan vaan kirjoittivat tunnetun aukon testiksi joka kääntyy kun korjaus saapuu.

v0.41.0

2026-10-14
Korjattu

0.38.0:n kortin varjo oli väärä

Omistaja antoi CSS:n jota hän tarkoitti:

.shadow-sm { --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color),
                          0 1px 2px -1px var(--tw-shadow-color) }
.shadow-black\/5 { --tw-shadow-color: #0000000d }

Eli vakiogeometria puolitetulla alfalla — sama muoto kuin shadowilla, puolet sen tummuudesta. shadow-card on nyt 0 1px 3px 0 rgb(0 0 0 / 0.05), 0 1px 2px -1px rgb(0 0 0 / 0.05).

            0.38.0      pyydetty
sumennus     12 px        3 px      neljäsosa
alfa         10 %         5 %       puolet
siirtymä      2 px        1 px

★★ Mitattu luku ei kerro mitä mitattiin

0.38.0:n arvo oli johdettu mittaamalla: luin omistajan osoittaman kuvan pikselit, sain sivuille 6 px ja 6 %, alas 8 px ja 11 %, ja johdin niistä sumennuksen ja siirtymän. Jokainen askel oli oikein laskettu.

Ja koko ketju osoitti väärään elementtiin. Kuvassa ollut kortti ei ollut se jonka CSS:ää hän tarkoitti.

Se on sama vikaluokka kuin tämän päivän muut — dist/-haku polkuun jota ei ole, git log origin/main haaraan jota ei ole — mutta kallein muoto, koska tulos näytti tarkalta. Osumaton haku palauttaa tyhjän ja herättää kysymyksen; mittaus väärästä kohteesta palauttaa luvun.

> Kun lähde ja mittaus ovat ristiriidassa, lähde voittaa. CSS on aie; > pikselit ovat havainto jostain.

⚠️ 0.38.0 on kuudessa avoimessa PR:ssä ja yhdessä tuotannossa (TP-MEDIA). Väärä varjo on siis jo ajossa yhdessä palvelussa — se on tämän korjauksen kiireellisin syy, ei changelogin siisteys.

v0.40.0

2026-10-14
Korjattu

rivin hover poltti tumman teeman

Omistajan raportti: tyhjän taulukon rivi "polttaa koko värin taustaväriksi" tummassa teemassa. Mittasin kuvakaappauksen pikselit ja sain tasan vakioarvon — bg-muted/50, #181818 kortin #0a0a0a:n päällä. Vika ei ollut arvossa vaan siinä että sama alfa käyttäytyy teemoissa täysin eri tavalla.

         pohja   hover/50   muutos
vaalea     255        250     −2 %
tumma       10         24   +140 %     ← sama luokka, sama alfa
tumma /25   10         17    +70 %

★★ Syy on tokeneissa, ei komponentissa. ShadCN:n neutraali asteikko on epäsymmetrinen: vaaleassa --muted (#f5f5f5) on lähes sama kuin --background (#ffffff), tummassa --muted (#262626) on 3,8-kertainen pohjaansa (#0a0a0a) nähden. Yksi alfa ei voi palvella molempia, ja siksi tässä on dark:-haara vaikka niitä muuten vältetään — kyse ei ole mausta vaan mittasuhteesta.

Tavoite ei ole sama suhteellinen askel: vaaleassa se olisi näkymätön. Tavoite on näkyvä muttei polttava.

Korjattu

ei-klikattavat rivit korostuivat

Sama kuvakaappaus paljasti toisen vian: tyhjän tilan rivillä ei ole mitään klikattavaa, mutta se korostui. Korostus lupaa vuorovaikutusta jota ei ole — sama havainto jonka TP-IAM teki Badgen hoverista.

Hover poistettu neljältä riviltä: tyhjä, virhe, luuranko ja otsikkorivi. Datarivi korostuu yhä.

⚠️ Otsikkorivi oli vaaleassa näkymätön (−2 %) ja tummassa +140 %, eli sama polttaminen — se ei ollut omistajan raportissa mutta on sama vika.

★ Ja testi löysi korjauksestani kaksi vikaa

1. `hover:bg-transparent` ei kumoa `dark:hover:bg-muted/25`:tä. tailwind-merge poistaa vain saman variantin, joten tummassa rivit olisivat korostuneet edelleen. Vaadittiin molemmat.

2. Otsikkorivi oli jäänyt kokonaan huomaamatta — lukko laski korostuvat rivit eikä nimennyt niitä, ja luku oli 2 eikä 1.

Molemmat olisivat menneet läpi jos lukko olisi tarkistanut vain sen rivin jonka tiesin korjanneeni. Kaatuminen todennettu kahdella mutaatiolla.

v0.39.0

2026-10-14
Lisätty

palvelutiili näyttää ikonin monogrammin sijaan

Omistajan huomio TP-IAM:n kautta: tiilen monogrammi (TPV) istuu palvelun nimen (TP-Vastaanotto) vieressä ja kertoo saman kahdesti — nimen toisto, ei tieto palvelusta.

{ id: "tpv", name: "TP-Vastaanotto", short: "TPV",
  iconName: "Inbox", color: "bg-sky-500" }

iconName on lucide-nimi sellaisenaan, ja se tulee IAM:n palvelurekisteristä samalla tavalla kuin short ja color. Omistaja ratifioi kuusi nimeä 14.10.2026: Inbox · Boxes · Fingerprint · PackageSearch · Images · Bell.

icon ottaa valmiin komponentin jos nimi ei riitä. `short` ja `color` jäävät: haku täsmää monogrammiin, se on varamerkki ikonittomalle palvelulle, ja ikoni tarvitsee silti tiilen taustan.

★★ Omistajan päätös teki ratkaisusta yksinkertaisemman, ei vain kalliimman

Esitin ensin kuratoitua karttaa paketissa, koska nimen geneerinen ratkaisu käyttää lucide-react:n icons-kokoelmaa ja kumoaa tree-shakingin. Väitin hintaa kysymättä sitä; omistaja haastoi luvun, ja mittasin sen:

              ennen     jälkeen
/             159 kB     270 kB
/komponentit  191 kB     302 kB
jaettu        103 kB     103 kB   ← ennallaan

Eli +111 kB gzipattuna, yhtenä chunkkina joka jää välimuistiin. Kuusi ikonia erikseen olisi ollut noin kilotavu — 88-kertainen ero.

Päätös: "Ei ole +100 kt kasvu ongelma. Ikonilla sen sijaan on tunnistettavaa arvoa navigoinnissa."

★ Ja se poisti kaksi ongelmaa jotka kuratoidussa kartassa olivat: paketti ei omista ikonisanastoa, eikä uusi palvelu vaadi pakettijulkaisua ennen kuin sen ikoni näkyy. Kartta olisi ollut kopio jota mikään ei voi lukita.

⚠️ Mitattu luku on hinta, ei peruste. Kirjasin ensin "ei hyväksyttävä hinta" ennen kuin tiesin sen — luku sattui tukemaan johtopäätöstä, mutta väite oli esitetty ilman sitä.

Ratkaisu on puhtaassa funktiossa, ja syy on katve

resolveServiceIcon asuu lib/:issä ja on viety. Kaksi mitattua syytä:

1. Tiiltä ei voi todentaa SSR:stä. Se on PopoverContentin sisällä, jota Radix ei renderöi suljettuna — sama katve kuin MobileNavin paneelilla. ★ Ensimmäinen versio testeistä meni läpi väärästä syystä: <svg löytyi avauspainikkeen omasta ikonista, ei tiilestä.

2. Sama valinta tehdään kahdessa paikassa. TP-IAM renderöi omat palvelukorttinsa, ja ilman vientiä heillä olisi oma kartta — eli juuri se sukulinja jonka takia nimi ylipäätään siirrettiin rekisteriin.

Tuntematon nimi palauttaa undefined ja tiili putoaa monogrammiin. Se ei ole virhe: rekisteri ja paketin lucide-versio päivittyvät eri tahdissa.

Lukko

Kuusi väitettä, mukaan lukien että kaikki kuusi ratifioitua nimeä löytyvät lukitusta lucide-versiosta — jos jokin nimetään uudelleen, tiili putoaisi monogrammiin hiljaa. Kaatuminen todennettu kahdella mutaatiolla.

★ Ja yksi testi oli ensin väärällä mittarilla: typeof lucide-ikonista on "object" eikä "function", koska ne ovat forwardRef-komponentteja.

v0.38.0

2026-10-14
Muutettu

kortin varjo on nyt pintavarjo eikä pudotusvarjo

Omistajan päätös. Hän osoitti shadcn:n sivulta kortin jonka varjo "puhuu eri kieltä" kuin meidän, ja mittasin kuvan pikseleistä mitä se tarkoittaa:

             sivuilla        alhaalla
mitattu      6 px, 6 %       8 px, 11 %

★★ Yksikään Tailwindin vakioaskel ei tuota tätä muotoa. shadow-md ulottuu sivuille 2 px ja shadow-lg alas 14,5 px. Vakioasteikko on pudotusvarjoja — siirtymä hallitsee — ja mitattu on hajavaloa, jossa sumennus hallitsee. Siksi ero näkyy silmään vaikka molempia kutsutaan varjoksi.

shadow-card presetissä: 0 2px 12px 0 rgb(0 0 0 / 0.10). Sumennus 12 px antaa sivuille 6 px, siirtymä 2 px vie alareunan kahdeksaan — eli arvo on johdettu luvuista eikä valittu asteikosta.

Mitä mittaus kumosi matkalla

1. `Card`issa ei ollut mitään vikaa suhteessa shadcn:ään. Meidän shadow ja heidän shadow-sm ovat tavulleen sama arvo; Tailwind 4 vain nimesi asteikon uudelleen. Luin molemmat lähteestä.

2. ui.shadcn.com ei määrittele `--shadow-*`-muuttujia lainkaan. Hain heidän teematiedostonsa: he ajavat Tailwindin vakioasteikkoa, samaa kuin me. Kuvan kortti ei siis ollut Card oletuksillaan vaan esimerkki joka oli valinnut isomman varjon itse. "Parempaa asetusta" ei ollut olemassa — oli esimerkki jonka muodon pystyi mittaamaan.

Oma askel eikä shadowin uudelleenmäärittely

shadow on Buttonin ja Badgein täytetyillä varianteilla. Jos se pehmennettäisiin, pienet painikkeet saisivat 12 pikselin kehän.

Ja korkeusjärjestys säilyy. Mitattu molemmilta: kortti < popover (shadow-md) < dialog (shadow-lg), ja tikapuut ovat identtiset shadcn:n kanssa. shadow-md kortille — ensimmäinen ehdotukseni — olisi litistänyt kaksi alinta rungaa samaksi.

⚠️ Näkyvä muutos jokaisessa kortissa kuudessa palvelussa.

⚠️ Arvo on johdettu kuvasta, ei todennettu selaimessa. Käännetystä CSS:stä on todennettu että sääntö syntyy oikealla arvolla; miltä se näyttää, on omistajan katsottava. Demo on ajossa.

Lukko

tests/render.test.mjs — Card käyttää shadow-cardia eikä vakioaskelta, preset määrittelee arvon, ja korkeusjärjestys on tarkistettu lähteestä. Molemmat kaatumiset todennettu erikseen: kortti takaisin shadowiin, ja arvo takaisin shadow-md:n arvoksi.

v0.37.1

2026-10-14
Korjattu

README ei kertonut Dockerfilen järjestystä

Kuudes kuluttaja kytki paketin ensimmäistä kertaa, ja konttibuild kaatui `npm ci`:hin vaikka paikallinen asennus oli mennyt läpi hetkeä aiemmin. Syy on file:-riippuvuuden ehto: hakemiston on oltava olemassa asennushetkellä, eli COPY vendor ./vendor kuuluu ennen npm ci:tä.

Virhe nimeää tämän paketin, eli näyttää paketin vialta vaikka on Dockerfilen järjestys. Mitattu README:stä ennen korjausta: nolla osumaa sanoihin Dockerfile, COPY vendor ja npm ci koko tiedostossa.

★★ Ja kerroin saman asian heille tuntia aiemmin — mutta perusteluna sille minne kopio sijoitetaan, en käyttöönoton askeleena. He kävelivät siihen silti. Sama vikaluokka kuin sortRowsin varoitus väärässä tiedostossa: oikea teksti väärässä kategoriassa ei tavoita lukijaa ennen kuin päätös on jo tehty. Havainto ja ehdotus ovat TP-NOTIFYn.

Korjattu

content-polku näytti sitä mitä kukaan ei käytä

tailwind.config-esimerkissä luki ./node_modules/@karkkainen/ui/src/** ja vendoroitu polku oli varoituslohkon viimeisellä rivillä. Kuusi kuluttajaa kuudesta vendoroi, eikä kukaan asenna rekisteristä — eli esimerkki näytti polun joka ei ole kenenkään ensisijainen.

Esimerkissä on nyt ./vendor/karkkainen-ui/src/**. Symlinkkipolku toimii yhä ja viisi ajaa sitä, mutta suora polku ei riipu siitä seuraako Tailwindin globbaus symlinkkiä. Perustelu on TP-NOTIFYn, joka valitsi sen itse ja ilmoitti poikkeavansa dokumentista — se oli dokumentissa, väärässä paikassa.

Lisätty

navigointi ei ole klikkaus

onRowClick antaa rivin käyttöön role="button"in, tabIndexin ja Enter-tuen — ja ne ovat väärä ratkaisu jos klikkaus vie toiselle sivulle. Linkki antaa ilmaiseksi keskiklikkauksen, uuden välilehden, osoitteen kopioinnin, käyntihistorian ja esilatauksen; klikattava rivi ei anna niistä yhtäkään.

Mitattu 14.10.2026: yksikään kuuden kuluttajan koodissa ei käytä `onRowClick`ia, ja ainoa DataTable-kuluttaja tekee navigoinnin linkeillä soluissa — 11 tiedostoa, 18 linkkiä. Ekosysteemi oli jo valinnut oikein, mutta valinta ei ollut dokumentissa — eli se oli oikea vahingossa.

★ Havainto on TP-NOTIFYn, ja se osui kohtaan jonka olin tuntia aiemmin lukinnut testillä: korjauksenne on oikea, mutta se parantaa huonompaa vaihtoehtoa. Lukko on edelleen oikein — se vain koskee sitä tapausta jossa rivin klikkaus tekee jotain eikä vie jonnekin.

rowHref-propsia ei lisätty. Laskuri on 1.

Lisätty

positiivinen kontrolli tabindex-kiellolle

tests/render.test.mjs väitti vetokahvasta "ei fokusoitava", eikä mikään testi osoittanut että renderöity tabindex ylipäätään havaittaisiin. Kielto oli samassa asemassa kuin poistettu `cookieDomain`-testi.

Kontrolli lukitsee samalla käytöksen jota ei ollut testattu kertaakaan: DataTable renderöi tabIndexin klikattavalle riville. Todennettu poistamalla se lähteestä. Periaate on TP-NOTIFYn: kieltävä väite tarvitsee positiivisen kontrollin, eikä mutaatio löydä sen puutetta — mutaatio kohteeseen jota ei ole näyttää vihreää.

Ei vendoroitu

0.37.0 on yhden kuluttajan mainissa ja viidessä avoimessa PR:ssä. Samaan versionumeroon ei committoida sen jälkeen kun se on jaettu, joten tämä on oma versionsa ja rider seuraavan oikean julkaisun kyydissä. Muutos on pelkkää dokumentaatiota; ajossa ei muutu mitään.

v0.37.0

2026-10-14
Korjattu

pehmeät tilamerkit olivat alle AA:n 78 käyttöpaikassa 95:stä

Omistajan päätös 14.10.2026. Avoin kohta oli mitattu edellisenä päivänä: Badgen pehmeät variantit olivat muotoa bg-<tila>/15 text-<tila>, ja se ei täytä AA:ta vaaleassa teemassa.

                     ennen            jälkeen (text-foreground)
success              4,15                   17,29
warning              4,08                   17,35
info      lepo 4,75 · hover 4,08            17,12
lukumerkki (NotificationCenter)  4,13       16,94

Jakauma ekosysteemistä, jäsennettynä JSX:stä eikä grepillä — mitattu 14.10.2026 kunkin kuluttajan `main`ista, eli siitä pohjasta jolle tämän julkaisun PR:t avattiin:

                success  warning  info  destructive   pehmeät / yht
TP-Vastaanotto       18       11     1            7        30 / 37
TP-Perustus          17       19     2            5        38 / 43
TP-IAM                7        7     0            0        14 / 14
TP-TTK                1        4     0            1         5 /  6
TP-MEDIA              0        0     0            0         0 /  0
                     43       41     3           13        87 / 100

87 käyttöpaikkaa sadasta oli pehmeää ja alle AA:n jo levossa.

⚠️ Luku oli 13.10. mitattuna 78/95, ja kirjoitin sen ensin tähän muistista. Ero ei ole mittarissa vaan maailmassa: palvelut kehittyvät, ja Badge-kutsupaikkoja syntyi vuorokaudessa viisi lisää. Käyttöpaikkojen määrä on havainto eikä vakio, ja ilman mittaushetkeä sen vieressä se vanhenee huomaamatta — mikä on tämän repon oma toistuvin vika, nyt omassa julkaisumerkinnässä.

★★ Väri siirtyi pisteeseen, koska merkissä ei ole ikonia

0.34.1 mittasi että reuna ei kanna signaalia (alfa-reuna 1,57–2,90, alle 1.4.11:n 3:1-rajan) ja jätti kantajaksi ikonin. Badgeilla ei ole ikonia — joten se sai pisteen: täysi tilaväri ilman alfaa, mitattuna 4,18–9,80:1 merkin omaa taustaa vasten, myös rivin hoverin päällä (huonoin 3,98).

Ilman kantajaa neutraali teksti olisi vienyt tilavärin pois kokonaan: jäljelle olisi jäänyt /10-tausta, joka on 1,76:1 pohjaa vasten.

★★ Ja tämä kumoaa lukon oman väitteen. tests/tokens.test.mjs:n poikkeuslistassa luki "kumpaakaan ei voi korjata `text-foreground`illa menettämättä merkin koko signaalia". Se piti paikkansa vain ehdolla jota kukaan ei kyseenalaistanut: että värille ei ole muuta kantajaa kuin teksti. Ehto oli kirjoitettu mahdottomuudeksi — sama vikaluokka kuin "ei saatavilla" ohitetuissa testeissä: väite mahdottomuudesta sulkee kysymyksen, väite sijainnista jättää sen auki.

★ Sama ratkaisu syntyi kolmessa palvelussa itsenäisesti 13.10. (TP-IAM, TP-TTK, TP-PERUSTUS) ennen kuin se tuli tänne.

Hover poistettiin kaikista varianteista

Badge on <div> eikä mitään klikattavaa, joten hover-tyyli laukesi pelkästä kursorin osumisesta ilman että mitään voi tehdä — ja infoilla se vei kontrastin 4,75:stä 4,08:aan, eli ainoa variantti joka läpäisi levossa kaatui kosketuksesta. Havainto on TP-IAM:n.

Mitä tämä ei muuta

destructive on täysi variantti (4,83 / 6,48) eikä pehmeä, joten sitä ei kosketa. Seuraus on tietoinen: StatusBadgein error on äänekkäämpi kuin kolme muuta tilaa, ja virheen pitääkin erottua.

Kutsupaikat eivät muutu: Badge piirtää pisteen itse, eikä yhdenkään 95:n call sitein tarvitse liikkua. dot={false} ottaa sen pois.

⚠️ Näkyvä muutos 78 merkissä neljässä palvelussa.

Lukko

tests/render.test.mjs — kuusi uutta väitettä: teksti neutraali, piste täydellä tilavärillä, ei bg-current, ei hoveria yhdessäkään variantissa, täydet variantit ilman pistettä, ja StatusBadge sykkii yhdellä pisteellä eikä kahdella. Kaatuminen todennettu palauttamalla success vanhaan muotoonsa: neljä väitettä kaatui.

tests/tokens.test.mjs:n AVOIN-lista on nyt tyhjä, ja lista on jätetty rakenteena paikalleen: se on se mekanismi jolla seuraava tietoinen poikkeus kirjataan näkyviin.

v0.36.1

2026-10-14

Kirjattu: pienennyksen muistaminen nojaa inline-skriptiin

TP-MEDIA ajoi 0.36.0:n tuotantoon ja mittasi CSP-riskin jonka nimesin heille: heillä ei ole CSP-otsaketta lainkaan, joten esilatausskripti ajaa estotta. Mittasin loput neljä lähteestä — nolla viidestä rajoittaa skriptejä, ja TP-IAM:n ainoa CSP-rivi on frame-ancestors 'none', jossa ei ole script-srcia eikä default-srcia.

Riski on silti kirjattava, koska heikennys on hiljainen: script-src ilman noncea estäisi skriptin, mikään ei kaatuisi, ja pienennys toimisi — mutta muistettu tila ei palaisi ennen maalausta. Kirjattu app-shell.tsx:ään ja README:hen; nonce-propsia ei rakennettu, koska laskuri on 0.

Korjattu

README lupasi jotain mitä koodi ei tehnyt

Kirjoitin README:hen että sidebarStorageKey={null} jättää skriptin pois. Se ei pitänyt paikkaansa — skripti renderöityi silti ja teki tyhjää. Korjattu koodista eikä lupauksesta, ja lukittu testillä (kaatuminen todennettu poistamalla ehto).

★ Lupaus ja toteutus erkanivat saman commitin sisällä, enkä huomannut sitä kirjoittaessani vaan vasta kun luin oman lauseeni uudelleen. Sama muoto kuin pageItemsin JSDoc 0.18.0:ssa: väite dokumentaatiossa ennen kuin koodi vastasi sitä.

Ei vendoroitu

0.36.0 on jo kolmella mainissa ja yhdellä tuotannossa. Samaan versionumeroon ei committoida sen jälkeen kun se on jaettu (7.10. opetus), joten tämä on oma versionsa — ja se riittää seuraavan oikean julkaisun kyytiin, koska ajossa muuttuu vain tyhjä skriptielementti tapauksessa jota kukaan ei käytä.

v0.36.0

2026-10-14
Lisätty

sivupalkin pienennys

Omistajan pyyntö: huomaamaton caret-painike jolla sivupalkin voisi kiinnittää vasempaan sivuun minimoituna, koska mikropalveluissa on raskaita taulukoita jotka vaatisivat kokoleveää työskentelyä.

AppShellin sivupalkin alalaidassa on nyt painike joka kutistaa palkin 56 px:n ikonipalkiksi — ei piiloon. Navigaatio säilyy, sisältöalue saa 184 px lisää leveyttä.

sidebarCollapsible        painike alalaitaan            oletus true
defaultSidebarCollapsed   alkutila ensimmäisellä kerralla  oletus false
sidebarStorageKey         localStorage-avain            oletus "tp-sidebar"

⚠️ Näkyvä muutos: painike ilmestyy jokaiseen AppShell-kehykseen. sidebarCollapsible={false} ottaa sen pois.

⚠️ Pienennys ei leventää mitään jos sisältö on rajattu. Oletus max-w-6xl (1152 px) syö koko hyödyn yli 1400 px:n näytöillä. Mitattu neljästä kuluttajasta: kaikki neljä ajavat jo täyttä leveyttä (contentClassName="max-w-none" kolmella, scoped-CSS yhdellä), joten hyöty toteutuu kaikilla.

★★ Ehto joka ei ole tyylivalinta: data-sidebar ei ole Reactin

Tila asuu juuren data-sidebar-attribuutissa, jonka painike ja esilatausskripti kirjoittavat — ei JSX. Syy on konkreettinen vika jonka vastakkainen ratkaisu olisi tuottanut:

> Kuluttajat antavat currentPathin usePathname()-kutsusta, joten kehys > renderöityy uudelleen jokaisella navigoinnilla. Jos juuri > renderöisi attribuutin propsista, React palauttaisi sen joka kerta — ja > pienennetty sivupalkki ponnahtaisi auki sivua vaihtaessa.

React ei koske attribuuttiin jota se ei itse asettanut. data-shell sen sijaan on JSX:ssä: se on kiinnityskohta joka ei muutu, eikä closest() voi nojata attribuuttiin jota ei vielä ole.

Sama syy pitää AppShellin palvelinpuolella: tila olisi vaatinut "use client":in jaettuun kehykseen. Nyt direktiivi on vain SidebarToggleissa — sama jako kuin MobileNavilla ja ColumnResizeHandleilla.

Painike on toiminto eikä mukavuus

Fokusoitava, aria-expanded, aria-controls ja vaihtuva aria-label. Se on tarkoituksella päinvastoin kuin `ColumnResizeHandle`, joka on aria-hidden ja fokusoimaton: leveyden säätö on mukavuus, mutta navigaation piilottaminen on toiminto, ja näppäimistökäyttäjä menettäisi sen kokonaan eikä vain mukavuutta.

Kutistettu rivi on pelkkä ikoni, mutta nimi säilyy: sr-only eikä hidden, ja title antaa työkaluvihjeen.

Lukko ja todennus

tests/render.test.mjs + viety SidebarToggle vientipintalukkoon. Kaatuminen todennettu lisäämällä data-sidebar takaisin juuren JSX:ään: kaksi väitettä kaatui ja virheilmoitukset nimesivät oikean kohdan.

Luokat on todennettu käännetystä CSS:stä: viisi group-data-sääntöä syntyy ja kaikki viisi ovat @media (min-width:1024px):n sisällä.

★ Ja se tarkistus piti tehdä kahdesti: ensimmäinen mittaukseni väitti ettei mediakyselyä ole, koska haku osui tavalliseen `.sr-only`- utilityyn ennen varianttia. Oikea komento, oikea tuloste, väärä kysymys — neljäs kerta viikon sisällä, ensimmäinen jonka sain kiinni ennen kuin kerroin sen kenellekään.

⚠️ Luokat kirjoitetaan kokonaisina merkkijonoina. Tailwind skannaa lähdekoodin tekstinä, eikä koostettu ${ETULIITE}px-0 tuota yhtäkään osumaa. Tämä paketti shipataan lähdekoodina, joten skannaus tapahtuu kuluttajan buildissa — vika näkyisi vasta siellä, eikä mikään kaatuisi.

⚠️ Tämä ei tavoita TP-PERUSTUSta

Pyyntö tuli heidän näkymästään (kuvakaappaus), mutta TP-PERUSTUS ei käytä `AppShell`ia — mitattu: nolla osumaa <AppShelliin, oma components/sidebar.tsx (90 riviä) ja oma layout. Julkaisu tavoittaa neljä viidestä.

Siksi SidebarToggle on viety erikseen: oma kehys saa saman toiminnon kolmella asialla (data-shell-kiinnityskohta, leveysluokka, painike sivupalkin sisälle). Se on heidän päätöksensä, ei minun.

Demo

/komponentit sai ensimmäisen AppShell-osionsa — kehystä ei ole ennen demottu lainkaan. Kutistettu tila näytetään oikealla `NavList`illa oikeassa kehyksessä, ei käsin piirretyllä kopiolla, joten luokkien ajautuminen näkyy siellä.

v0.35.0

2026-10-14
Korjattu

sarakkeen vetokahva oli levossa näkymätön

Omistajan raportti käyttäjiltä: sarakkeen leveyden raahaaminen ei näy — sen löytää vasta kun siihen osuu sokkona. Ensimmäinen käyttäjälle näkyvä vika 0.21.0:n (mobiilinavigaatio) jälkeen, ja sama muoto: ei rikki vaan puuttuva, eikä mikään vihreä näytä puuttuvaa.

ColumnResizeHandle oli 0.26.0:sta asti levossa opacity-0, ja hover paljasti viivan värillä --border. Mitattu otsikkosolun pinnalla (suluissa rivin hover --muted/50):

tila    väri                  vaalea          tumma
lepo    (ei mitään)              —              —      ← näkymätön
hover   --border           1,26 (1,21)    1,31 (1,17)  ← käytännössä näkymätön
veto    --primary         17,93 (17,18)  18,97 (17,01)

Eli löytäminen edellytti että löysi: ainoa lepotilan signaali oli cursor: col-resize, joka vaatii osoittimen jo valmiiksi neljän pikselin päähän rajasta. Raportin mukana tulleessa kuvakaappauksessa näkyvä viiva oli vetotila — ainoa tila jossa kahva oli oikeasti näkyvissä.

Kolme tilaa, jokainen mitattuna:

lepo    --border             1,26 (1,21)    1,31 (1,17)   hiusviiva, sama kuin rivien rajat
hover   --muted-foreground   4,74 (4,54)    7,85 (7,04)   ylittää 1.4.11:n 3:1
veto    --primary           17,93 (17,18)  18,97 (17,01)  ennallaan

Lepotila ei yritä yltää 3:1:een, ja se on tietoinen raja. Se on taulukon oma viivakieli — sarakkeiden välinen raja näkyy samoin kuin rivien välinen — eikä merkitystä kantava grafiikka. Hover on se joka kantaa merkityksen (*tästä voi tarttua*), ja se ylittää rajan.

⚠️ Näkyvä muutos, ei pelkkä korjaus. Vedettävissä taulukoissa sarakerajoille ilmestyy hiusviiva jota siellä ei ennen ollut. Koskee kahta kuluttajaa viidestä, mitattuna mainista: TP-PERUSTUS (products-table.tsx, DataTablen columnWidths) ja TP-VASTAANOTTO (components/sarakeleveydet.tsx, kahva erikseen Table-primitiivien kanssa). Kolmella muulla nolla osumaa — ei vedettäviä taulukoita.

Tilojen värit ovat ehdollisia eivätkä päällekkäisiä: hover:-luokka on tarkempi kuin luokka ilman pseudoa, joten raahauksen väri olisi hävinnyt hoverille juuri silloin kun osoitin on kahvan päällä.

Lisätty

demo vedettävistä sarakkeista

Ominaisuus julkaistiin 0.26.0:ssa ilman yhtäkään demoa, ja /komponentit on se paikka jossa visuaalinen tarkistus tapahtuu (§11). Eli vika oli kahdeksan päivää näkyvissä siellä missä kukaan ei katsonut sitä. Nyt DataTable-osiossa on oma lohkonsa, jossa lepotila, hover ja veto näkyvät.

Lukko

tests/render.test.mjs — lepotilassa on viiva, sitä ei piiloteta, ja hover eroaa levosta. Kaatuminen todennettu palauttamalla vanha `opacity-0`-lepotila: virheilmoitus nimesi oikean väitteen (*lepotilaa ei piiloteta*).

v0.34.1

2026-10-13
Korjattu

0.34.0:n oma ohje oli harhaanjohtava

Pelkkää dokumentaatiota — ajossa ei muutu mitään. 0.34.0 sanoi pehmeästä tilalaatikosta "teksti neutraalilla, tilaväri reunaan ja ikoniin", ja reuna ei kanna signaalia. Mitattu laatikon taustaa vasten:

                         vaalea   tumma     (1.4.11 raja 3:1)
ikoni  text-destructive    4,13     6,44     ✅ kantaa
reuna  border-…/30         1,57     1,68     ❌  ← tämä on FormDialogissa
reuna  border-…/50         2,13     2,54     ❌
reuna  border-…/70         2,90     3,79     ❌ vaaleassa
reuna  border-…  (täysi)    4,13     6,44     ✅

Yksikään alfa-reuna ei yllä 3:1:een vaaleassa. Eli ohje jonka mukaan väri siirtyy reunaan antaa luvan poistaa ikonin, ja silloin AA-rike vaihtuu 1.4.1-rikkeeseen: väri on ainoa tiedonvälittäjä eikä riitä siihen.

Oikea muoto: ikoni kantaa värin, reuna on koriste. Ja ohjelmallinen signaali on eri kanava — ikoni on aria-hidden, joten ruudunlukijalle väitteen kantaa role="alert" / role="status".

Miten tämä löytyi

Kaksi kuluttajaa löysi saman asian eri suunnista tunnin sisällä, ja kumpikaan ei ollut lukenut toisen havaintoa:

  • •TP-IAM korjasi omat 13 kohtaansa ja mittasi ikonin perustelun erikseen: "teksti neutraalille yksinään on ohje jota voi noudattaa väärin" — ilman ikonia jäljelle jää vain reuna.
  • •TP-TTK törmäsi siihen toteutuksessa: heidän reunansa oli border-destructive/50 eli 2,13:1, ja pelkkä tekstin vaihto ei riittänyt. Heidän muotoilunsa: jos ohje sanoo "tilaväri reunaan", alpha-reuna kumoaa sen.

★ FormDialogissa korjaus ei vaadi koodimuutosta, koska ikoni on jo paikallaan ja kantaa värin. Vain ohje oli väärä — ja se on se osa joka leviää kuluttajien omaan koodiin.

★★ Ja toinen korjaus 0.34.0:aan: sen 🟦-luokittelu oli väärä

0.34.0 merkittiin 🟦 *"ei muuta ruudulla tänään mitään"* perusteella "mitattu, ettei yksikään viidestä anna `FormDialog`ille `error`- tai `success`-propsia".

Väite oli tosi mutta tyhjä: noita propseja ei ole olemassa. error ja success ovat komponentin sisäistä tilaa (form-dialog.tsx:115–116), jotka asetetaan onSubmitin heitosta (rivi 142) ja paluuarvosta (138). Kukaan ei voi antaa niitä, eikä se kerro renderöityykö laatikko.

Oikea kysymys on renderöityykö, ja vastaus on kyllä kahdessa palvelussa. Mitattu lähteestä:

                 <FormDialog>   joista heittävässä tiedostossa
TP-IAM                      2   2   roolit-client.tsx (roolin luonti ja muokkaus)
TP-Vastaanotto             16   1   add-supplier-modal.tsx
TP-Perustus · TP-TTK · TP-MEDIA  0  0

Korjattu luokittelu: 🟩 TP-IAM ja TP-Vastaanotto, 🟦 kolme muuta. TP-IAM:n roolidialogien virheilmoitus oli vaaleassa 4,14:1 ja on nyt 17,0:1; TP-Vastaanoton yksi aito modaali samoin.

★ Ja mittaus toistaa itsenäisesti sen mikä tähän pakettiin kirjattiin elokuussa: "TP-V:ssä on 18 modaalia, mutta niistä vain yksi kutsuu oikeasti API:a." Kaksi kuukautta myöhemmin luku on sama — ja ne 15 stubia perivät nyt AA:ssa olevan mallin sinä päivänä kun ne kytketään.

Löydös on TP-IAM:n, ja heidän muotoilunsa virheluokalle on tarkin: erotin oli *propsi* kun oikea erotin oli *renderöityykö*. Se meni läpi juuri siksi että mittaus oli aito — grepattavissa, toistettavissa, viidestä repoista. Oikea lukema, väärä kysymys.

Ei vendoroitu

0.34.0 on mergetty kaikilla viidellä (leimat mitattu origin/main:ista 13.10.2026, sama tiiviste fb74a800cc74), ja viisi PR:ää dokumentaatiokorjauksesta olisi kalliimpaa kuin yksi oikea signaali. Tämä ajetaan seuraavan aidon julkaisun kyydissä — sama päätös kuin 0.26.1:ssä. SKILL.md ei ole PAYLOADissa, joten sen korjaus on jo voimassa täällä.

⚠️ Mutta luokittelun korjaus ei odota vendorointia, koska se on se rivi jonka perusteella kuluttaja päättää kiirehtiikö. Kerrottu kaikille viidelle suoraan.

v0.34.0

2026-10-13
Muutettu

pehmeän tilalaatikon teksti on text-foreground

FormDialogin virhelaatikko ja onnistumislaatikko: viestin teksti oli tilaväri (text-destructive / text-success) pehmeällä saman värin taustalla. Nyt teksti on text-foreground ja tilaväri on reunassa ja ikonissa.

                                         vaalea    tummassa
text-destructive  bg-destructive/10:llä   4,13:1    6,44:1
text-success      bg-success/10:llä       4,44:1    9,80:1
text-foreground   samalla taustalla      16,9:1    17,1:1

Omistajan päätös. Kaksi kuluttajaa mittasi rajauksen omasta koodistaan (TP-IAM virheilmoitukset, TP-TTK neljä laatikkoa), kumpikaan ei tehnyt paikallista ohitusta, ja TP-TTK kysyi suoraan onko 4,14:1 hyväksyttävä normaalikokoiselle tekstille. Ei ole.

Väri säilyy signaalina: reuna ja ikoni ovat ei-tekstiä, joille WCAG 1.4.11 vaatii 3:1 — ne läpäisevät samoilla luvuilla joilla teksti ei läpäissyt.

★ Onnistumislaatikko korjattiin samalla, vaikka päätös koski virhelaatikkoa. Ne ovat sama kuvio samassa komponentissa ja mitattuna sama vika (4,44:1), ja eri tavalla korjattuina ne näyttäisivät virheeltä vierekkäin.

★★ Ja tämä kumoaa osan tokenien omasta perustelusta

tokens.css ja SKILL.md sanoivat tilaväreistä: "pehmeä tausta tehdään läpinäkyvyydellä (`bg-success/15 text-success`), silloin sama token kelpaa myös ikoniin eikä tummalle teemalle tarvita omaa haaraa." Tokenit ovat oikein ja tumma teema ei tarvitse haaraa — mutta suositeltu käyttötapa ei täytä AA:ta vaaleassa kolmella tilavärillä neljästä:

             /10 vaalea   /15 vaalea
destructive     4,13         3,81
success         4,44         4,15
warning         4,36         4,08
info            5,14         4,75   ← ainoa joka läpäisee

Perustelu oli kirjoitettu tumman teeman ongelmaan (dark:-haaran poistamiseen) ja luettiin ohjeeksi molempiin teemoihin. Korjattu molemmissa paikoissa.

Lukko

tests/tokens.test.mjs kaatuu jos tilaväri palaa pehmeän taustan tekstiksi. Se skannaa kaikki merkkijonot eikä vain className-attribuutteja — ensimmäinen versio luki vain attribuutteja, eikä olisi nähnyt badge.tsx:n cva-variantteja lainkaan. Todennettu kolmeen suuntaan: korjauksen palautus, poikkeuslistan vanheneminen, ja uusi rike cva-varianttiin.

⚠️ Kaksi kohtaa jäi, ja ne ovat nimetyssä poikkeuslistassa

kohtaalfavaaleamiksi ei samaa korjausta
Badge success / warning / info/154,15 · 4,08 · 4,75merkin koko signaali on sen väri; text-foreground poistaisi sen
NotificationCenterin lukumerkki/104,13sama, ja merkki on luku eikä viesti

Nämä ovat omistajan avoin päätös, eivät hyväksytty tila. Ne ovat lukon AVOIN-listassa mitattuine lukuineen, ja lista on itsekin luettu: jos kohta korjataan eikä listaa päivitetä, sarja kaatuu. Korjaus niille on eri kuin tässä — todennäköisesti kylläisempi tausta ja etualatoken, mikä on oma päätöksensä.

v0.33.0

2026-10-13
Muutettu

--brand on KHK:n #de0021

--brand   0 72% 50% (#db2424)  →  351 100% 43.5% (#de0021)

Repon omistajan päätös. Arvo 0 72% 50% tuli extractista 10.8.2026 ja on ollut sama kaikki 32 julkaisua; KHK:ssa (kilpailijahintojen kartoitus) sama K-logon punainen on #de0021. Kaksi arvoa, molemmat dokumentoitu logon punaiseksi, ja sävy on eri: 0 vastaan 351. Kysymys ei ollut mitattavissa täältä — se ratkesi omistajan päätöksellä KHK:n hyväksi.

Tämä on näkyvä muutos yhdessä paikassa: UserMenun avatar on ainoa bg-brand-käyttöpaikka paketissa. Kuluttajan oma bg-brand-käyttö vaihtuu samalla.

Miksi tämä kannattaa tietää 0.31.0:n jälkeen

Kun --destructive sai 0.31.0:ssa KHK:n #dc2626:n, se oli vanhasta brändipunaisesta ΔE 0,72 päässä eli käytännössä sama väri: tuhoava painike ja brändiväri eivät eronneet toisistaan vaaleassa teemassa. Nyt ΔE on 5,94, eli sama erotus kuin KHK:lla alun perin. Kontrasti parani myös: valkoinen brändivärin päällä on 5,09:1 (oli 4,90:1).

⚠️ text-brand tummalla pohjalla on 3,89:1. Brändiväri on taustaväri (bg-brand text-brand-foreground), ei tekstiväri; jos sitä käytetään tekstinä tummassa, se tarvitsee oman arvon .dark:issa.

Korjattu samalla

presetin fallback-arvot olivat ajautuneet

tailwind-preset.ts kantaa kolme fallbackia muodossa hsl(var(--x, <arvo>)) siltä varalta että preset otetaan käyttöön tuomatta tokens.css:ää. `--border`in fallback oli kahdessa paikassa `214.3 31.8% 91.4%` — ShadCN:n sinertävä slate-arvo, jonka tokens.css jätti 0.30.0:ssa (oikea arvo 0 0% 89.8%).

Vaikutus tänään on nolla, koska kaikki viisi kuluttajaa tuovat tokens.css:n. Mutta se on sama luokka kuin files-kenttä, joka erkaantui PAYLOADista yhdellä rivillä koska mikään ei lukenut sitä: kuollut konfiguraatio ei pysy oikeana, se vain lakkaa näyttämästä väärältä.

Korjaus ei ollut arvojen päivittäminen vaan niiden tekeminen luetuiksi: tests/tokens.test.mjs vertaa nyt jokaista presetin fallbackia tokens.css:n :root-arvoon. --font-sans ja --font-mono ovat nimetty poikkeuslistaan, koska paketti ei lataa fontteja itse eikä niitä ole tokeneissa. Todennettu rikkomalla.

v0.32.0

2026-10-13
Lisätty

SearchCombobox ja nextSelection — hakukentän kuori ilman datakerrosta

Koskee: TP-IAM (odottaa tätä aloittaakseen haun) ja TP-Vastaanotto (oma combobox, ks. alla). Ei koske: TP-Perustus (lähde, vaihtaa omaan tahtiinsa), TP-TTK, TP-MEDIA. Additiivinen: uusi komponentti, uusi lib-funktio, ei muutoksia olemassa oleviin vienteihin.

Lähde on TP-Perustuksen yläpalkin haku (13.10.2026), siirretty pakettiin kun TP-IAM tarvitsi saman ja DS:n ylläpitäjä mittasi viidestä kuluttajasta: TP-Vastaanotolla on jo 215 rivin role="combobox"-kenttä, josta puuttuu aria-controls, aria-autocomplete, aria-activedescendant, role="listbox", role="option", aria-selected ja nuolinäppäimet kokonaan — kenttä ilmoittaa avustavalle teknologialle olevansa combobox ja jättää sopimuksen täyttämättä. Lisäksi siinä on viive ilman järjestysnumeroa, eli vanhentuneen vastauksen bugi on piilevänä.

  • •`SearchCombobox<T>` ("use client", 15.): search(query, signal) propsina (§5 funktio ennen koukkua), kuori omistaa viiveen (200 ms), minCharsin (2), järjestysnumeron ja edellisen pyynnön keskeytyksen. Litteä osumalista ryhmäotsikoin, renderHit kuluttajalta, Enter → onOpen tai onDefault, footer saa closen. Valinta rivillä onMouseDown + preventDefault. Kenttä on kontrolloimaton: kohdesivun oma suodatin näyttää hakusanan osoitteesta (mitattu TP-Perustuksen tuotelistasta), joten yläpalkin kenttää ei esitäytetä.
  • •`nextSelection(current, count, direction)` ./lib/searchissa: -1 = ei valintaa, alas kiertää, ylös palaa -1:een. Palvelinturvallinen.
  • •Katselmoinnissa löytyi kaksi puutetta tyhjässä haarassa, molemmat korjattu samassa julkaisussa: aria-controls osoitti <ul>:ään jota ei renderöidä ilman osumia (uuttaminen testattavuuden vuoksi siirsi juuri sen attribuutin jota testi ei näe), ja tyhjältä viestiltä puuttui paketin sääntö role="status" (0.14.0). Paneeli on nyt oma sisäinen komponenttinsa SearchComboboxPanel, jolloin tyhjä haara on SSR-testattava.
  • •Vientipintalukko: +2 (SearchCombobox, nextSelection). Testit: tests/search-combobox.test.mjs. Demo: osio /komponentit-sivulla, search ratkaisee paikallisesta taulukosta.

Nimistö englanniksi paketin konvention mukaan (117 viennissä nolla suomenkielistä tunnistetta); dokumentaatio suomeksi.

v0.31.0

2026-10-13
Muutettu
  • •`--destructive` molemmissa teemoissa. Arvot KHK:n (kilpailijahintojen kartoitus) paletista repon omistajan päätöksellä. Vaalea 0 84.2% 60.2% → `0 72.2% 50.6%` (#ef4444 → #dc2626) ja tumma 0 62.8% 30.6% → `0 90.6% 70.8%` (#7f1d1d → #f87171).
  • •`--destructive-foreground` tummassa `0 0% 98%` → `0 0% 9%`. Tämä ei ole erillinen päätös vaan arvon seuraus: 0 0% 98% kirkkaan punaisen päällä on 2,65:1, ja juuri sitä paria käyttävät Buttonin ja Badgen destructive-variantit sekä StatusBadgen error. Tumma etuala on sama ratkaisu kuin --successilla, --warningilla ja --infolla.

Tämä on näkyvä muutos, ei pelkkä korjaus. Tuhoava painike vaihtuu vaaleassa kirkkaanpunaisesta tummanpunaiseksi, ja tummassa päinvastoin: tummanpunaisesta kirkkaaseen mustalla tekstillä.

Miksi

text-destructive oli 3,76:1 vaaleassa ja 1,98:1 tummassa — molemmat alle AA:n (4,5:1). Se oli kirjattu tiedossa olevaksi rajaukseksi tokenin viereen, SKILL.md:hen ja kolmeen PR-kuvaukseen, ja kirjaus ei estänyt mitään: kolme kuluttajaa mittasi käyttöpaikat omasta koodistaan tuntien sisällä kun asia nostettiin — TP-IAM 13 (kaikki virheilmoituksia, mukaan lukien kirjautumislomake), TP-TTK 6, TP-PERUSTUS virhetekstit. TP-MEDIA ei käytä tokenia lainkaan.

Arvo tuli lopulta toista reittiä kuin mittauksestani: KHK ajaa tummana #f87171, ja oma mitattu ehdokkaani oli 0 90% 70% = #f76e6e — ero 0,8 prosenttiyksikköä valoisuutta. Kaksi pöytää päätyi samaan arvoon toisistaan tietämättä.

Lukko

tests/tokens.test.mjs laskee nyt kontrastin tokenien arvoista eikä vertaa merkkijonoja, ja vaatii AA:n neljältä tilaväriltä molemmissa teemoissa — sekä tekstinä pohjalla että täytenä varianttina etualansa kanssa. Todennettu rikkomalla kolme arvoa erikseen; jokainen kaatoi testin oikealla luvulla (2,65 · 1,98 · 3,76).

Tiedossa oleva rajaus

bg-destructive/10 text-destructive (FormDialogin virhelaatikko) on vaaleassa 4,13:1 — parani 3,29:stä muttei yllä AA:han; tummassa sama on 6,44:1. Kirjattu rajauksena eikä oletettu pois. Korjaus olisi viestin tekstin vaihto text-foregroundiin, mikä on oma päätöksensä eikä tämän julkaisun osa.

Ei otettu KHK:sta

  • •Brändipunainen. KHK:n --accent-primary on #de0021 eli sävy 351; DS:n --brand on #db2424 eli sävy 0. Molemmat on dokumentoitu K-logon punaiseksi, eikä kysymys ole mitattavissa täältä. ⚠️ Seuraus joka on syytä tietää: --destructive ja --brand ovat nyt vaaleassa ΔE 0,72 päässä toisistaan eli käytännössä sama punainen. KHK:lla ne erottuvat (ΔE 5,94), koska heidän korostusvärinsä on eri sävy. Avoin omistajalle.
  • •Neutraaliportaat. KHK:n harmaat ovat samaa perhettä (sävy 0, kylläisyys 0 %) mutta eri portailla; --text-muted olisi mennyt väärään suuntaan (4,41:1 vs. nykyinen 7,85:1).

v0.30.1

2026-10-13
Korjattu

ThemeProviderin JSDoc väitti kahta asiaa jotka mitattiin vääriksi

Pelkkää dokumentaatiota — ajossa ei muutu mitään. Molemmat väitteet olivat tässä tiedostossa, joka kopioituu viiteen repoon, ja molemmat löytyivät kuluttajan raportista.

1. *"Ilman `cookieDomain`ia käyttäytyminen on entinen"* ei pidä. Jaettu eväste lähetetään koko domainille, joten heti kun yksi palvelu kirjoittaa sen, samalla hostilla on kaksi tp-theme-evästettä samalla polulla. document.cookie ei kerro attribuutteja, joten esilataus lukee niistä ensimmäisen. Mitattu Chromiumista 13.10.2026, tuore profiili per tapaus:

luontijärjestysdocument.cookieesilataus lukee
host ensintp-theme=HOST; tp-theme=DOMAINHOST
domain ensintp-theme=DOMAIN; tp-theme=HOSTDOMAIN

Nuoremman päivitys ei muuta järjestystä, eli ensin luotu varjostaa toista pysyvästi (RFC 6265 §5.3 kohta 11). Seuraus joka on käyttäjälle näkyvä: palvelussa jolla ei ole cookieDomainia, ja jonka käyttäjä on käynyt ensin jaetun evästeen kirjoittavassa palvelussa, esilataus ylikirjoittaa localStoragen jaetulla arvolla joka latauksella — oma toggle ei pysy. Mitattu mainista 13.10.2026: kolme viidestä ajaa ilman cookieDomainia (TP-Vastaanotto, TP-TTK, TP-MEDIA) samalla kun kaksi kirjoittaa jaetun. Kahden palvelun käyttöönotto muutti kolmen muun käyttäytymistä.

Oire ja korjaus ovat TP-PERUSTUKSEN raportti; järjestysmekanismi on mitattu täällä. Korjausta itse mekanismiin ei ole tässä julkaisussa — se on omistajan päätös, ks. JSDoc.

2. `defaultTheme`-luku oli 1/5, oikea on 3/5. JSDoc sanoi "tumma TP-IAM:ssa ja vaalea neljässä muussa" ja suositteli että yksi palaisi lightiin. Mitattu mainista rakenteesta 13.10.2026: TP-IAM, TP-PERUSTUS ja TP-TTK ovat system (TP-TTK:lla ei attribuuttia lainkaan), TP-Vastaanotto ja TP-MEDIA light. Enemmistö on jo liikkunut, joten suositus osoitti väärään suuntaan. Oikea luku oli mitattu jo 10.10.2026 ja kirjattu AGENTS.md:hen — muttei tähän tiedostoon.

Tiedoksi kuluttajille

contentHash muuttuu, koska JSDoc on src:ssä. Ajossa ei muutu mitään, ja tätä ei ole vendoroitu: vendor:check sanoo viittä vanhentuneeksi siihen asti, ja se on tosi eikä hälytys.

v0.30.0

2026-10-13
Muutettu

vaalea teema on neutraali — sama liike kuin 0.29.0 teki tummalle

Omistajan päätös, tehty sen jälkeen kun vaihtoehto oli nähtävissä rinnakkain nykyisen kanssa. Nyt molemmat teemat ovat ShadCN:n `neutral`-pohjaväriä: sävy 0, kylläisyys 0 %.

tokenennen (slate)nyt (neutral)ΔE
--foreground · --card-foreground · --popover-foreground222.2 84% 4.9%#0208170 0% 3.9%#0a0a0a8,2
--primary · --secondary-foreground · --accent-foreground222.2 47.4% 11.2%#0f172a0 0% 9%#17171714,5
--secondary · --muted · --accent210 40% 96.1%#f1f5f90 0% 96.1%#f5f5f52,4
--muted-foreground215.4 16.3% 46.9%#64748b0 0% 45.1%#73737314,5
--border · --input214.3 31.8% 91.4%#e2e8f00 0% 89.8%#e5e5e54,7
--ring222.2 84% 4.9%#0208170 0% 3.9%#0a0a0a8,2
--primary-foreground · --destructive-foreground210 40% 98%#f8fafc0 0% 98%#fafafa1,3

--destructive, tilavärit ja --brand ovat ennallaan — niiden sävy on merkitys eikä neutraali.

#### Muutos ei ole pinnoissa vaan tekstissä

ΔE on värierotäisyys CIE76:n mukaan: alle 2,3 ei erotu ilman vierekkäistä vertailua, noin 10 on selvästi näkyvä.

Vaimennetut pinnat eivät käytännössä liiku (ΔE 2,4), koska ne ovat jo lähes valkoisia — sinisyys näkyy vasta kun pintaa on paljon. Leipäteksti, toissijainen teksti ja ensisijainen painike liikkuvat (ΔE 8,2–14,5). Käytännössä tämä on siis tekstin ja painikkeiden neutralointi, ei taustojen — toisin kuin tummassa, jossa liikkui kaikki.

#### Kontrasti pysyi

vaalea, valkoista taustaa vasten      ennen      jälkeen
  --foreground                        19,99:1    19,80:1
  --primary                           17,84:1    17,94:1
  --muted-foreground                   4,75:1     4,74:1   ← juuri ja juuri AA
  --border                             1,23:1     1,26:1   (reuna, ei tekstiä)

⚠️ `--muted-foreground` on 4,7:1 sekä ennen että jälkeen, eli juuri ja juuri WCAG AA:n 4,5:1 yläpuolella molemmissa. Neutraali arvo on hitusen tummempi (45,1 % vs. 46,9 %), joten muutos ei huononna sitä — mutta jos tätä joskus halutaan parantaa, se on eri päätös kuin sävy.

#### 🟩 Koskee kaikkia viittä, ja nyt myös vaaleassa teemassa

0.29.0 kosketti vain tumman teeman käyttäjiä. Tämä näkyy jokaiselle, koska vaalea on oletus. Kutsupinta ei muutu eikä yksikään propsi.

Mitattu ennen julkaisua viidestä globals.css:stä: yksikään ei ylikirjoita näitä tokeneita. Yksi tunnettu kohta joka ei seuraa mukana on AIMO:n toimittaja-ikoni, joka laskee sävynsä itse (hsl(var(--tk-savy) 60% 90%)). Se ei rikkoudu; istuuko se uuteen neutraaliperheeseen on heidän arvionsa.

#### Miksi vaalea tehtiin erikseen eikä samalla kertaa

0.29.0:n rajaus oli "pyyntö koski tummaa teemaa", ja se kirjattiin rajaukseksi eikä unohdukseksi. Vaalea ratkaistiin kun vaihtoehto oli nähtävissä rinnakkain — kolme pakotettua paneelia samasta näkymästä, mitatut ΔE- ja kontrastiluvut vieressä. Päätös tehtiin kuvasta ja luvuista, ei kuvailusta.

v0.29.0

2026-10-12
Muutettu

tumma teema on neutraali — musta ja harmaa, ei sinistä

Omistajan päätös. ShadCN:n oletuspohjaväri on slate, jonka jokainen neutraali arvo kantaa sinisen sävyn. Tumma tausta oli 222.2 84% 4.9% eli #020817 — tumma laivastonsininen, ei musta. Sama sävy oli kortissa, popoverissa, reunassa, vaimennetussa tekstissä ja fokusrenkaassa.

Arvot ovat nyt ShadCN:n oma `neutral`-pohjaväri, eivät omia sekoituksia: sävy 0, kylläisyys 0 %, eli tummassa teemassa vaihtuu vain valoisuus.

tokenennennyt
--background · --card · --popover222.2 84% 4.9%#0208170 0% 3.9%#0a0a0a
--foreground · --primary210 40% 98%#f8fafc0 0% 98%#fafafa
--secondary · --muted · --accent · --border · --input217.2 32.6% 17.5%#1e293b0 0% 14.9%#262626
--muted-foreground215 20.2% 65.1%#94a3b80 0% 63.9%#a3a3a3
--ring212.7 26.8% 83.9%#cbd5e10 0% 83.1%#d4d4d4
--primary-foreground ja tilavärien -foreground222.2 47.4% 11.2%#0f172a0 0% 9%#171717

#### 🟩 Tämä koskee kaikkia viittä, ja se on muutos eikä korjaus

Jokainen tumman teeman pinta vaihtaa väriä. Mikään kutsupinta ei muutu eikä yksikään propsi — mutta jos palvelussa on tummalle teemalle käsin viritettyjä värejä paketin tokenien vierellä, ne eivät enää istu samaan sävyperheeseen.

Mitattu ennen julkaisua viidestä globals.css:stä: yksikään ei ylikirjoita näitä tokeneita (nolla override-riviä; TP-PERUSTUKSEN .dark-lohko asettaa vain color-schemein ja oman toimittaja-ikoni-sävynsä, joka on heidän omansa eikä seuraa tätä). Eli muutos menee perille sellaisenaan, eikä kenenkään tarvitse tehdä mitään.

#### Mitä kontrastille tapahtui: ei mitään mitattavaa

Mitattu WCAG-suhteina tummaa taustaa vasten, ennen ja jälkeen:

--foreground         19,09:1  ->  18,96:1
--muted-foreground    7,80:1  ->   7,85:1
--ring               13,46:1  ->  13,35:1
--border              1,37:1  ->   1,31:1   (reuna, ei tekstiä)

Neutralointi ei ole tummentaminen. Tausta jopa valoisui (4,9 % → 3,9 % on HSL-valoisuus, mutta sininen sävy nosti vanhan luminanssia), ja ero jokaisessa tekstisuhteessa on alle 0,15.

#### ⚠️ Mitä tämä EI korjaa — --destructive on yhä auki

--destructive on tummassa teemassa tummempi (0 62.8% 30.6%, #7f1d1d) eikä kirkkaampi kuin vaaleassa, toisin kuin --success, --warning ja --info. Mitattu 12.10.2026:

tumma teema, teksti tummalla taustalla
  --success      11,35:1
  --warning      10,73:1
  --info          9,01:1
  --destructive   1,98:1   ← ei riitä

tokens.css:n oma kommentti lupaa että bg-success/15 text-success toimii molemmissa teemoissa ilman dark:-haaraa. Lupaus ei kata `--destructive`ia, ja rajaus on nyt kirjattu sen viereen tiedostoon. Arvon muuttaminen on erillinen päätös omistajalle — se on näkyvä muutos viidelle palvelulle, ei tämän julkaisun sivutuote.

#### Vaalea teema on edelleen slate, ja se on rajaus eikä unohdus

Pyyntö koski tummaa teemaa. Vaaleassa --foreground on yhä #020817 ja --muted yhä #f1f5f9 eli viileä harmaa, joten teemat ovat nyt eri neutraaliperheestä. Se näkyy eniten laajoilla vaimennetuilla pinnoilla (AIMO käyttää bg-muted/40 sivun taustana). Jos sekin neutraloidaan, se on oma päätöksensä ja oma julkaisunsa.

#### Lisäksi: tokenit-artefakti.css:llä on nyt generaattori

scripts/skill-tokenit.mjs tuottaa Clauden skillin artefaktitokenit styles/tokens.css:stä (--check kertoo onko kopio ajan tasalla). Tiedoston otsikko on sanonut "GENEROITU … älä muokkaa käsin" siitä asti kun se luotiin — mutta generaattoria ei ollut, vain käsityö ja lukkotesti. Lupaus oli isompi kuin kattavuus; nyt se pitää. Todennettu rikkomalla: --check palauttaa 1 ja lukkotesti kaatuu.

v0.28.1

2026-10-08
Korjattu

defaultTheme="light" poistettu käyttöönottoesimerkeistä

Rivi oli README:n §3:ssa ja ThemeProviderin JSDocissa. next-themesin oma oletus on enableSystemin kanssa "system" (defaultTheme: u = n ? "system" : "light", mitattu 0.4.6:n dististä) — eli käyttöjärjestelmän teema, jos riviä ei kirjoita lainkaan. Viisi kuluttajaa ohittivat siis järkevän oletuksen jokainen erikseen.

Puutteen löysi TP-IAM 8.10.2026.

#### ⚠️ Korjaus tähän merkintään: syysuunta oli päinvastainen

Tämän merkinnän ensimmäinen versio sanoi että rivi "levisi esimerkistämme viiteen palveluun". Se ei pidä, ja minä julkaisin sen tarkistamatta yhtäkään repoa. TP-IAM yleisti kahdesta havainnosta, TP-PERUSTUS mittasi vastaesimerkin, ja vasta sitten mittasin itse:

repolayout.tsxsuhde README:ni (10.8.2026)
TP-Vastaanotto18.6.2026ennen — ei voinut kopioida meiltä
AIMO8.7.2026ennen
TP-IAM8.7.2026ennen
TP-TTK11.8.2026jälkeen — olisi voinut
TP-MEDIA14.8.2026jälkeen — olisi voinut

★ Tämän repon ensimmäinen commit on 10.8.2026 ja sen otsikko on *"Alkuperäinen extract TP-Vastaanoton packages/ui:sta"*. Rivi tuli siis TP-Vastaanoton alkuperäisestä prototyypistä, ja README peri sen extractissa — me kopioimme heiltä, ei toisin päin. Kolme viidestä on vanhempi kuin koko tämä repo.

Ja JSDoc-esimerkkiin rivi tuli vasta 8.10.2026 (0.27.0), eli se ei voinut olla lähde kenellekään.

Korjaus on silti oikea teko: rivi oli esimerkissä, esimerkki on rajapintaa, ja kaksi kuluttajaa olisi voinut saada sen sieltä. Mutta "levisi meiltä viiteen" oli syyväite viiden repon historiasta jota en ollut katsonut.

★ Arvoa ei vaihdettu `"system"`:iin vaan rivi poistettiin. Rivin puuttuminen on itsedokumentoiva — defaultTheme="system" näyttäisi valinnalta jonka joku voi myöhemmin "korjata" takaisin.

#### Tämä ei muuta mitään nykyisille kuluttajille

Kaikki viisi antavat propsin eksplisiittisesti, joten korjaus on esimerkissä eikä koodissa. ThemeProvider ei ole koskaan asettanut defaultThemeia itse eikä aseta nyt.

Jos poistat rivin omasta palvelustasi, mittaa ensin tummien tokenien kattavuus. Palvelu jolla .dark on puutteellinen näyttäisi rikkinäisen käyttöliittymän käyttäjille jotka eivät ole sitä pyytäneet, ja se on pahempi kuin epäyhtenäinen oletus. Pelkällä tokens.css:llä kattavuus on täysi — mitattu: 28 tokenia :rootissa, 25 .darkissa, ja ne kolme eroa (--brand, --brand-foreground, --radius) kuuluu pitää samoina. Mittaa oma repo, koska omat lisätokenit näkyvät vain siellä.

★★ Ja osittainen käyttöönotto on valitsemattomille huonompi kuin ei lainkaan. Valitsematon käyttäjä on tasan se ryhmä jota 0.27.0:n jaettu eväste ei voi auttaa, koska mitään ei ole tallennettu ennen ensimmäistä valintaa. Jos neljä palvelua eivät liiku, rehellinen vaihtoehto on että viides palaa takaisin — ei että neljä jäävät puolitiehen.

Lisätty

lukko joka estää rivin palaamisen

tests/vientipinta.test.mjs tarkistaa neljä paikkaa (README, JSDoc, demon layout, demon koodinäyte). Selittävä proosa saa mainita nimen; vain propsina annettu arvo kaataa testin. Kaatuminen todennettu.

Esimerkki on rajapintaa. Se ei ole koodia jonka tyypintarkistin näkee eikä tekstiä jonka kuluttaja lukee kertaalleen — se on se muoto jossa ominaisuus otetaan käyttöön. Lukko on siksi oikea riippumatta siitä kuka kopioi keneltä: rivin palaaminen esimerkkiin tekee siitä lähteen jokaiselle joka aloittaa tästä päivästä eteenpäin.

Muutettu

demo ei anna defaultThemeia

Demo noudattaa README:n ohjetta sellaisenaan, joten sen oletus on nyt käyttöjärjestelmän teema. Sama syy kuin 0.28.0:ssa reunasäännön kanssa: jos rivi palaa demoon, demo lakkaa todentamasta sitä mitä ohjeessa lukee.

v0.28.0

2026-10-08
Korjattu

reunaviivan oletusväri oli kiinteä vaalea harmaa

Tailwindin perusreset asettaa reunan värin arvoon #e5e7eb (gray-200) — ei `currentColor`iin. Paketissa on 41 kohtaa joiden reunan väri jää sen varaan.

Löytö on TP-PERUSTUKSEN AIMO:sta, kuvatodistein.

#### ⚠️ Mikä oli ajossa rikki ja mikä ei — mitattu viidestä kuluttajasta

Vain yksi viidestä oli altis, ja se oli AIMO. Mittasin 8.10.2026 kaikkien globals.css:t ja niiden historian:

* { @apply border-border }altis
TP-Vastaanottokyllä, 11.8.2026 astiei
TP-IAMkyllä, 8.7.2026 astiei
TP-TTKkyllä, 11.8.2026 astiei
TP-MEDIAkyllä, 14.8.2026 astiei
AIMO (TP-Perustus)ei ollut — lisäsi 8.10.2026 sillaksikyllä

★ Vika ei ollut design systemin virhe vaan yhden asennusaskeleen puuttuminen yhdessä palvelussa. Neljä viidestä teki README:n §3:n askeleen heti alussa eikä nähnyt oiretta kertaakaan.

Se on silti oikea syy siirtää sääntö pakettiin. Askel jonka voi jättää tekemättä jätetään joskus tekemättä, ja tässä tapauksessa se näkyi vasta kolmen kuukauden päästä ja vain toisessa teemassa. Juuri sitä paketti estää: ei virhettä, vaan mahdollisuuden virheeseen.

#### <table>-sääntö on vakuutus, ei korjaus havaittuun oireeseen

Mittasin myös tämän: yksikään viidestä ei piirrä reunaa `<table>`-elementille. Table laittaa reunat <tr>:lle, eikä kukaan anna <Table>:lle className="border" — nolla osumaa viidestä reposta.

★★ Eli TP-PERUSTUKSEN ★★-havainto on kaskadina oikea mutta tänään vaikutukseton, ja AIMO:n oireen aiheutti universaali tapaus eikä <table>. addBase-sääntö on siellä siksi että <Table className="border"> on helppo kirjoittaa ja hiljainen rikkoutuessaan — ei siksi että se korjaisi mitään nyt ajossa olevaa.

Preset korjaa tämän kahdessa kohdassa, ja molemmat tarvitaan:

mitämitä se kattaa
1theme.borderColor.DEFAULTpreflightin oma *,::before,::after-sääntö
2addBase({ table: … })<table>, jolla on preflightissa oma sääntö

★★ Pelkkä universaali sääntö ei riitä `<table>`:lle. Preflight sisältää erillisen table { border-color: inherit } -säännön, ja elementtivalitsin (0,0,1) on tarkempi kuin universaali (0,0,0) — joten universaali häviää sille myös myöhempänä. Havainto on TP-PERUSTUKSEN, ja he löysivät sen tarkistamalla käännetyn CSS:n eivätkä lähdettä.

★ Heidän johtopäätöksensä omasta oireestaan ei silti pitänyt, ja minä toistin sen ennen kuin mittasin. He päättelivät kaskadista että pelkkä * ei olisi korjannut heidän taulukoitaan. Mittaus sanoo toisin: heidän reunansa ovat <tr>:llä, joten * olisi riittänyt. Kaskadianalyysi oli oikea; se vastasi kysymykseen jota heidän oireensa ei esittänyt.

Muutettu

* { @apply border-border } ei ole enää asennusohjeessa

Sääntö on ollut README:n §3:ssa alusta asti, eli viiden palvelun on täytynyt muistaa se erikseen. Neljä muisti, yksi ei — ja se yksi näki oireen kolmen kuukauden päästä, vain tummassa teemassa, ja vain koska joku katsoi kuvaa. Askel jonka unohtaminen näkyy noin kuuluu pakettiin, ei ohjeeseen.

Vanha sääntö saa jäädä kuluttajan globals.css:ään: arvo on sama, eikä myöhempi universaali sääntö kumoa presetin table-sääntöä. Mitään ei ole pakko tehdä — päivitys riittää.

⚠️ Mikä teillä näkyvästi muuttuu: mitattuna ei mikään

Varoitus näkyvyydestä on TP-PERUSTUKSEN, ja se oli oikein lähettää. Mittaus kumosi sen tälle kuluttajajoukolle:

muuttuukomiksi
<tr>, <td>, <div>…eikaikilla viidellä on nyt * { @apply border-border }, arvo on sama
<table>-elementin oma reunaeikukaan ei piirrä sellaista — nolla osumaa viidestä
::before / ::after -reunatjos niitä on*-valitsin ei kata pseudoelementtejä, preflight kattaa
border-red-500 jne.eiutilities-kerros voittaa base-kerroksen

Tarkista omastasi ennen kyytiin ottamista:

grep -rn '<Table[^>]*className="[^"]*border' .   # odotettu: ei osumia

AIMO:lle tämä koskee: paikallinen silta apps/ai-porch-ui/src/app/globals.css:ssä voi poistua — vendoroi ensin, poista sitten, katso tumma teema.

Neljälle muulle tämä ei koske ajossa mitään. Arvo on se, että sääntö ei ole enää oman globals.css:n varassa: jos se joskus poistuu sieltä, reunat eivät enää mene rikki.

Miksi korjaus on presetissä eikä tokens.css:ssä

Mitattu, ei valittu. @layer base ei voi asua tokens.css:ssä:

Syntax error: tokens.css `@layer base` is used but no matching
`@tailwind base` directive is present.

Next ajaa jokaisen CSS-tiedoston omana PostCSS-moduulinaan, joten tuonti ei inlinetä sitä globals.css:ään ennen Tailwindia. Kerroksettomana sääntö taas päätyisi preflightin eteen ja häviäisi sille hiljaa. Kumpikaan muoto ei kelpaa — ainoa toimiva paikka on preset.

tests/tokens.test.mjs lukitsee tämän kolmella testillä, ja niiden kaatuminen on todennettu rikkomalla kukin erikseen.

v0.27.0

2026-10-08
Lisätty

teemavalinta jaetaan TP-palvelujen välillä

ThemeProvider saa cookieDomain-propsin. Sen kanssa dark/light-valinta kulkee palvelusta toiseen; ilman sitä käyttäytyminen on entinen.

<ThemeProvider
  attribute="class"
  defaultTheme="light"
  enableSystem
  cookieDomain={process.env.SESSION_COOKIE_DOMAIN}
>

Kuluttajalle muutos on yksi rivi. useTheme() ja toggle-rajapinta ovat koskemattomia.

Juurisyy ei ollut bugi

next-themes tallentaa valinnan localStorageen, joka on origin-kohtainen. Jokainen TP-palvelu on oma subdomain, joten waffle-menusta siirtyvä käyttäjä sai eri teeman — valintaa ei ollut koskaan jaettu.

Ratkaisu: eväste tp-theme jaetulle domainille, ja esilatausskripti joka kopioi sen localStorageen ennen next-themesin omaa skriptiä. Palvelinpuolinen luku ei riittäisi: next-themesin skripti asettaa luokan varaston perusteella ennen ensimaalausta ja ylikirjoittaisi sen.

Mekaniikka on TP-IAM:n, ja ristiintarkistus korjasi siitä kaksi asiaa

Molemmat olivat kohtia joissa päättely oli esitetty varmempana kuin se oli, eikä kumpikaan olisi paljastunut kummankaan omalla tarkistuksella.

1. `storageKey`n pinnaus olisi pyyhkinyt kuluttajan valinnan. Alkuperäinen muoto pinnasi avaimen {...props}:n yli, jotta esilataus kirjoittaisi varmasti samaan paikkaan. Se olisi hiljaa ohittanut kuluttajan oman storageKeyn. Korjattu muoto johtaa esilatauksen samasta propista — avain destrukturoidaan ulos, joten {...props} ei voi enää sisältää sitä eikä kirjoittaja ja lukija voi erkaantua rakenteellisesti. Yksi lähde kolmelle lukijalle: esilataus, next-themes, evästevartija.

2. Eväste olisi kylvetty oletuksesta eikä valinnasta. Mitattu next-themes 0.4.6:n dististä: theme on localStorage.getItem(key) || defaultTheme, eli tyhjällä varastolla "light" eikä undefined. Ilman varastotarkistusta eväste kirjoittuisi ensimmäisellä latauksella — ja `defaultTheme`n myöhempi muuttaminen ei enää purisi keneenkään joka on kertaakaan käynyt jossain palvelussa. Vartija kirjoittaa nyt vain tallennetusta arvosta.

Kolme yksityiskohtaa jotka eivät ole kosmeettisia

`Secure` johdetaan `location.protocol`ista eikä `NODE_ENV`:stä. TP-ympäristössä ajetaan dev-serveriä tuotannossa, joten env-vertailu antaisi väärän tuloksen.

`cookieDomain` on propsi eikä `NEXT_PUBLIC_*`-envi. Client-komponentti ei näe ajonaikaista enviä, ja NEXT_PUBLIC_* leivotaan build-aikaan — jos image rakennetaan ilman .enviä, arvo olisi tyhjä ja jakaminen hiljaa pois päältä. Se on pahin vikamuoto tässä: toggle toimii, valinta ei kulje.

`system` ei resolvoidu. Esilataus hyväksyy vain dark, light ja system; jos se resolvoisi system:in, käyttöjärjestelmää seuraava käyttäjä jumittuisi valintahetken teemaan.

Mitä tämä ei korjaa

`defaultTheme`n eroja. Käyttäjä joka ei ole koskaan valinnut mitään näkee kunkin palvelun oman oletuksen. Mitattu 8.10.2026: kaikilla viidellä kuluttajalla `defaultTheme="light"` — ei siis tällä hetkellä ongelma, mutta palaa jos yksi muuttuu.

Toista välilehteä toisessa palvelussa. Valinta näkyy siellä vasta seuraavalla latauksella.

⚠️ Uloskirjautuminen ei saa tyhjentää `tp-theme`-evästettä. Se on esitystilaa eikä istuntotilaa, ja se on samalla jaetulla domainilla kuin istuntoeväste — niputtaminen nollaisi valinnan joka kerta.

⚠️ Mitä testit eivät kata

Evästeen kirjoitusta. renderToStaticMarkup ei aja effecteja, joten kattamatta jäävät: kirjoittaako vartija vain tallennetusta arvosta, Secure-lipun johtaminen, ja se ettei oletus kylvä evästettä. Raja on lukittu omalla testillään.

Testit kattavat rakenteen: skriptien järjestys (esilataus ensin, todennettu renderöidystä HTML:stä eikä pääteltu), kolme hyväksyttyä arvoa, ja avaimen johtaminen propista molemmille lukijoille. Kaksi asiaa todennettiin rikkomalla: esilataus next-themesin jälkeen ja esilataus kiinteällä avaimella — kukin kaatoi oikean testin.

Ja yksi testi poistettiin koska se ei voinut kaatua: "`cookieDomain` ei vuoda DOMiin" läpäisi myös silloin kun propsi annettiin tarkoituksella NextThemesProviderille — se ei levitä propseja DOM-elementille, joten vuotoa ei ole olemassa. Vihreä väite jota mikään ei voi kumota ei ole vahti.

v0.26.1

2026-10-06
Korjattu

viisi riippuvuushaavaa, kaikki transitiivisia

source-map-js            1.2.1  -> 1.2.2    juuri ja demo
postcss-selector-parser  6.1.4  -> 7.1.6    juuri ja demo
sharp                    0.35.4 -> 0.35.5   demo

Ei muutosta pakettikoodiin. Diffi on overrides-kenttiä ja lukkotiedostoja; src/ on koskematon. Kuluttajalle tämä ei tee mitään — vendoroitu kopio ei sisällä lukkotiedostoa.

`braces` jää korjaamatta: haavoittuva alue on <= 3.0.3 ja uusin julkaistu on 3.0.3, joten pakottaminen ei ole vaihtoehto vaan mahdottomuus. Altistus on käännösaikainen glob-laajennus omasta konfiguraatiosta; se odottaa ylävirtaa.

⚠️ Major-loikka joka vaati mittauksen

postcss-selector-parser 6 → 7 ylittää sen mitä `tailwindcss` 3.4.19 ilmoittaa vaativansa (^6.1.2). Läpäisevä build ei todista että tuotos on sama, joten rakensin saman lähteen kahdesti:

6.1.4   43 036 tavua   sha256 b6eac9611a43f4c0
7.1.6   43 036 tavua   sha256 b6eac9611a43f4c0

Identtinen. Rajaus kuuluu mittauksen viereen: se koskee meidän valitsimiamme eikä kaikkia mahdollisia. Jos tailwindcssia nostetaan, mittaa uudelleen — ehto on kirjattu package.json:iin eikä vain tänne.

Ja miksi tämä oli käsityötä

dependabot_security_updates on päällä, ja se tuotti nolla PR:ää viidelle korjattavalle hälytykselle. Syy ei ole asetuksessa: tämän paketin dependencies on tyhjä, joten jokainen hälytys on transitiivinen, ja automaattinen korjaus osaa nostaa suoran riippuvuuden muttei kirjoittaa overrides-kenttää.

Se oli kirjaamaton seuraus siitä mitä on aiemmin kutsuttu rakenteelliseksi nollaksi: tyhjä dependencies suojaa ajonaikaisesti ja estää samalla automaattisen korjauksen.

v0.26.0

2026-10-02
Lisätty

vedettävä sarakeleveys DataTableen

Käyttäjä voi vetää sarakkeen leveyttä otsikon oikeasta reunasta. Kaksoisnapsautus kahvaan palauttaa oletuksen.

<DataTable
  columnWidths={leveydet}
  onColumnWidthsChange={setLeveydet}
  columns={[
    { key: "nimi", header: "Nimike", width: 240, cell: … },
    { key: "valinta", header: "", resizable: false, cell: … },
  ]}
/>

Uudet kentät: columnWidths, onColumnWidthsChange, minColumnWidth (oletus 48) sekä sarakkeella width ja resizable.

Ei koske ketään joka ei ota sitä käyttöön. Ilman columnWidthsia ja ilman sarakkeen widthiä taulukko renderöityy tavulleen kuten ennen — ei colgroupia, ei table-fixediä, ei kahvoja, ei katkaisua. Se on lukittu testillä, koska se on ainoa asia tässä julkaisussa joka voisi rikkoa neljän muun kuluttajan taulukot.

Pyyntö tuli kahdelta, ja muoto muuttui matkalla

TP-Vastaanotto ja AIMO pyysivät tätä samana päivänä; AIMO:n tuotelistassa Nimike-sarake katkeaa. Alkuperäisessä määrittelyssä leveydet olisivat tallentuneet `localStorage`en paketin sisällä. Sitä ei tehty:

  • •`localStorage` + vetokahva olisi pakottanut `"use client"`:in `DataTable`en, ja silloin jokainen kuluttaja menettäisi palvelinrenderöinnin — myös ne jotka eivät vedä saraketta.
  • •Avain, elinikä ja se mitä tehdään kun sarakejoukko muuttuu, ovat palvelun päätöksiä. Viimeinen on se joka menee pieleen jos kirjasto arvaa sen.

Tila kulkee siis propeina, samoin kuin lajittelu ja sivutus jo kulkevat, ja veto on omassa "use client"-komponentissaan (ColumnResizeHandle, viety myös erikseen). DataTable pysyy palvelinrenderöitävänä.

Kaksi päätöstä jotka näkyvät käytössä

Viimeinen näkyvä sarake ei saa leveyttä eikä kahvaa. Jos kaikilla olisi kiinteä leveys eivätkä ne summautuisi taulukon leveyteen, selain skaalaa ne kaikki suhteessa — yhden vetäminen liikuttaisi muitakin ja raahaus tuntuisi rikkinäiseltä. Yksi joustava sarake pitää summan kohdallaan.

Veto ei renderöi rivejä uudelleen. Raahauksen aikana kirjoitetaan suoraan <col>-elementin tyyliin; onColumnWidthsChange kutsutaan kerran, kun osoitin nostetaan. Jos jokainen pointermove kutsuisi takaisinkutsua, tuhannen rivin taulukko renderöityisi uudelleen jokaisella pikselillä.

⚠️ Mitä testit eivät kata

Itse vetoa ei voi todentaa renderöintitesteillä. renderToStaticMarkup ei suorita käsittelijöitä eikä tunne osoitinta, joten raahaus, minimileveys ja kaksoisnapsautuksen palautus ovat kattamatta. Testit kattavat rakenteen: colgroup, leveydet, kahvojen sijainnin, aria-hidden ilman fokusoitavuutta, ja sen ettei mikään näistä ilmesty ilman leveyksiä.

Kolme asiaa todennettiin rikkomalla ennen julkaisua — table-fixed aina päälle, leveys viimeiselle sarakkeelle, tabIndex kahvaan — ja kukin kaatoi oikean testin.

Erilliskäyttö vaatii kolme asiaa, ja puuttuva ehto varoittaa

ColumnResizeHandle on viety erikseen omia Table-pohjaisia taulukoita varten. Silloin kutsujan on annettava table-layout: fixed, <col data-rs-key="…"> samalla avaimella, ja position: relative otsikkosolussa.

Ilman niitä kahva renderöityy eikä veto tee mitään — hover toimii, osoitin vaihtuu, leveys ei muutu. Kehitysrakennuksessa puuttuva <col> varoittaa konsolissa ensimmäisellä vedolla; tuotannossa ei, koska varoitus on kutsujalle eikä käyttäjälle.

Ehdot lisättiin kun TP-Vastaanotto kertoi käyttävänsä kahvaa kolmessa Table-pohjaisessa näkymässä. Niitä ei ollut dokumentoitu — alkuperäinen JSDoc sanoi "erillisenä sitä tarvitaan jos rakennat oman taulukon" kertomatta mitä se vaatii.

Näppäimistöpolkua ei ole. Leveys on mukavuus eikä toiminto: sisältö on luettavissa ilman vetoa. Jos leveydestä joskus tulee toiminto — esimerkiksi sarakkeen piilottaminen vetämällä nollaan — tämä ratkaisu ei enää kelpaa.

v0.25.0

2026-09-11
Lisätty

./lib/*-alipolku

parseChangelogin tai sortStateFromParamsin tuominen vaati tähän asti barrelin (@karkkainen/ui), joka vetää mukanaan koko komponenttipuun. Nyt apufunktiot saa suoraan:

import { parseChangelog } from "@karkkainen/ui/lib/changelog";
import { sortStateFromParams } from "@karkkainen/ui/lib/sort";

Vanha reitti toimii ennallaan — tämä on lisäys, ei korvaaja.

Miksi tällä on väliä. Paketti shipataan lähdekoodina, joten barrelin tuominen tarkoittaa että kuluttajan työkalu joutuu jäsentämään jokaisen .tsx-tiedoston vaikka tarvittu funktio olisi pelkkää .ts:ää. Yleensä se vain maksaa aikaa; joskus se kaataa työkalun kokonaan.

Mitattuna samasta testistä, sama funktio molempia reittejä:

transformimport
@karkkainen/ui (barrel)151 ms290 ms
@karkkainen/ui/lib/changelog24 ms35 ms

Luku on yhden tiedoston mikromittaus ilman jsdomia, ja se laimenee oikeassa sarjassa. TP-Vastaanotolla barreliin koskee 2 testitiedostoa 26:sta, joten kokonaisajassa ero on suhteessa 2/26 — eikä transform (2,7 s) ole heidän sarjansa hallitseva vaihe vaan collect (5,5 s). 8× on tosi per tuonti, ei per sarja. Älä siteeraa sitä kokonaisajan lupauksena.

Alipolun arvo onkin ensisijaisesti rakenteellinen: testi joka tarvitsee vain apufunktion irtoaa komponenttipuusta kokonaan. Nopeus on sivutuote.

Mitattu jälkikäteen oikeasta sarjasta (TP-Vastaanotto, 26 tiedostoa, 360 testiä):

ennen    972 / 996 / 967 ms    transform ~2,70 s
jälkeen  780 / 727 / 738 ms    transform ~2,65 s

−24 % kokonaiskestossa, `transform` ennallaan. Hyöty ei siis tullut sieltä mistä kerroin sen mittasin — se syntyi latausvaiheessa (collect). Tarkin muotoilu on siis: 8× per tuonti on tosi, mutta sarjassa se näkyy `collect`issa eikä `transform`issa. Molemmat aiemmat väitteeni olivat epätarkkoja: ensin liioittelin kertoimen merkitystä, sitten varoitin liikaa sen laimenemisesta.

TP-Vastaanotto törmäsi tähän siirtyessään vitest 4:ään: kaksi heidän 26 testitiedostostaan kaatui jäsennysvirheeseen, eivätkä kumpikaan käytä komponentteja — toinen tuo parseChangelogin, toinen tyypin SortParamNames. Barrel oli ainoa reitti kumpaankin.

En pystynyt toistamaan heidän jäsennysvirhettään (vitest 4.1.11 + vite 8.3.0, jossa Rolldown mukana, myös server.deps.inline pakotettuna), joten en väitä tämän korjaavan sitä. Se ratkaisee kuitenkin heidän tapauksensa toista kautta: kummankaan testin ei tarvitse enää koskea yhteenkään .tsx-tiedostoon.

Rajaus: ./lib/* kattaa src/lib/-hakemiston, jonka kaikki kahdeksan tiedostoa ovat puhdasta .ts:ää ilman komponenttituonteja. Kaksi uutta testiä lukitsee sen molemmista suunnista — jos lib-tiedosto joskus tuo komponentin tai alkaa sisältää JSX:ää, alipolun lupaus rikkoutuisi hiljaa, ja se näkyisi vasta kuluttajan työkalun virheilmoituksena.

Ei koske ajossa ketään ilman toimenpidettä. Mikään olemassa oleva tuonti ei muutu; hyöty syntyy vasta kun kuluttaja vaihtaa tuontipolun.

v0.24.2

2026-09-02

Dokumentoitu: geneerisyys vaatii sarakkeiden annotaation

AIMO otti 0.24.1:n käyttöön ja poisti kavennuskääreet. Yksi asia yllätti: DataTableColumn<Supplier>[] päättelee K = string, jolloin kapea SortState<Lajitteluavain> ei enää kelpaa. Korjaus on toinen tyyppiargumentti — DataTableColumn<Supplier, Lajitteluavain>[].

Virheilmoitus osoittaa oikeaan paikkaan, mutta ohje säästää ihmettelyn. Esimerkki DataTablen JSDocissa sisältää nyt sen rivin.

Ei vendoroida erikseen. Muutos on kommentti, ja kolme viime julkaisua ei ole koskettanut neljää viidestä kuluttajasta — TP-V ja TP-TTK huomauttivat siitä samana päivänä. Tämä lähtee seuraavan oikean muutoksen kyydissä, kuten 0.22.3 aikanaan.

v0.24.1

2026-09-01
Korjattu

DataTablen lajitteluavain on nyt geneerinen

0.24.0 tyypitti sortin ja onSortChangein muotoon SortState<string>. Palvelu joka pitää lajittelutilaansa kapeana unionina — "nimi" | "luottamus" — joutui kaventamaan jokaisessa kutsussa:

onSortChange={(next) => setSort(next as SortState<Avain>)}

Nyt DataTable on geneerinen myös avaimen yli ja päättelee sen sarakkeista:

const [sort, setSort] = useState<SortState<Avain>>(…);
<DataTable columns={…} sort={sort} onSortChange={setSort} />

Ei rikkova. K on string oletuksena, joten leveä tyyppi toimii ennallaan ja kavennuskäärintä vain lakkaa olemasta tarpeen.

AIMO raportoi tämän ensimmäisenä käyttöpäivänä, migroituaan molemmat käyttöpaikkansa. He merkitsivät sen ei-kiireelliseksi ja kirjattavaksi "seuraavaa kertaa kun API:a avataan" varten.

Tein sen silti heti, ja syy on kirjaamisen arvoinen: laskurisääntö — älä lisää ennen kuin toinen kuluttaja pyytää samaa — olettaa että pyytäjiä voi olla useita. DataTablella on tasan yksi käyttäjä viidestä. Laskuri ei olisi koskaan noussut kahteen, ja sääntö olisi muuttunut "älä korjaa koskaan" -säännöksi.

Todennettu kääntämällä: kapea unioni ilman kavennusta, tsc --noEmit, nolla virhettä. 0.24.0:ssa sama koodi antoi TS2558.

v0.24.0

2026-09-01
Korjattu

DataTable + SortableTableHead tuotti <th> <th>:n sisään

Tämän JSDocin ohje oli väärä, ja se oli väärä tavalla joka ei näy silmällä. DataTablein dokumentaatio neuvoi panemaan SortableTableHeadin sarakkeen headeriin — mutta DataTable kääri jokaisen headerin jo omaan <th>:hensä, ja SortableTableHead renderöi oman <th>:nsä:

<th class="…"><th class="…"><button>Toimittaja</button></th></th>

Kelvotonta HTML:ää. Selaimen jäsennin korjaa puun, jolloin SSR:n tuloste ja hydraatio eroavat — ja taulukko näyttää silti oikealta.

AIMO löysi sen toteuttaessaan lajittelua uuteen näkymään. Todensin sen renderöimällä. Heidän products-table.tsx:nsä käytti juuri tätä yhdistelmää, eli ansa oli tuotannossa; toisessa näkymässä he olivat kiertäneet sen renderöimällä pelkän painikkeen, jolloin aria-sort jäi pois — koska <th> ei ollut heidän käsissään.

Lisätty

ensiluokkainen lajittelu DataTableen

<DataTable
  columns={[{ key: "nimi", header: "Toimittaja", sortKey: "nimi", cell: … }]}
  sort={sort}
  onSortChange={setSort}
/>
  • •DataTableColumn.sortKey + defaultSortDirection
  • •DataTableProps.sort + onSortChange
  • •DataTable renderöi painikkeen omaan `<th>`:hensä ja asettaa aria-sortin siihen — se on ainoa tapa saada se oikein, koska solu ei ole kutsujan käsissä

Sarake muuttuu klikattavaksi vasta kun sekä sort että onSortChange on annettu. Sama raja kuin sivutuksella: ilman käsittelijää painike olisi kuollut.

Lisätty

SortButton

Painike ilman soluelementtiä, jaettu `SortableTableHead`in ja `DataTable`n kesken. Sama syy kuin NavListilla: kahdennettuna ne olisivat eriytyneet ensimmäisellä muutoksella, ja raa'an Tablen lajittelu olisi alkanut näyttää erilaiselta kuin DataTablen.

Testi vertaa molempien tuottamaa painiketta merkki merkiltä.

SortableTableHead jää ennalleen raa'alle Tablelle. Ei rikkovia muutoksia — vanha kutsupinta toimii, mutta DataTablen headeriin sitä ei pidä enää panna.

Muutettu
  • •6 uutta renderöintitestiä (yhteensä 229), joista yksi lukitsee nimenomaan sen ettei <th>:tä ole <th>:n sisällä

v0.23.1

2026-09-01

Dokumentoitu: suorituskykyperustelu ei suojaa pientä listaa

TP-Vastaanotto tarkisti 0.22.3:n lajitteluvaroituksen mittaamalla eikä lukemalla, eikä ansa osunut heihin: heidän ainoa sivutettu näkymänsä lajittelee ja sivuttaa SQL:ssä.

Mutta perustelu oli kirjattu heidän koodiinsa suorituskykysyynä, ei oikeellisuussyynä — 130 593 tuotetta ei mahdu muistiin lajiteltavaksi. Se ratkaisi asian ennen kuin kukaan mietti sivutuksen ja lajittelun yhteisvaikutusta.

Oikea lopputulos väärästä syystä on hauras. Perustelu katoaa heti kun joku sivuttaa pienemmän listan: neljää toimittajaa tai 83 lähetystä ei ole mitään syytä lajitella palvelimella — paitsi juuri tämä.

Paginationin JSDoc sanoo nyt: jos siirrät lajittelun palvelimelle, kirjoita syyksi oikeellisuus äläkä rivimäärää. Rivimäärä muuttuu, oikeellisuus ei.

Havainto on kokonaan heidän; minä olin varoittanut väärästä asiasta oikeaan suuntaan.

v0.23.0

2026-09-01
Lisätty

renderöintitestit

Paketissa oli 191 testiä ja nolla renderöintitestiä. Kaikki olivat logiikkaa tai lähdekoodin merkkijonoja — app-shell.test.mjs luki tiedoston readFileSyncillä ja tarkisti luokkia tekstinä.

Se oli 19.8. tehdyn tuotantovalmiusarvion suurin yksittäinen puute, ja todiste ei ollut teoreettinen: kesän jokaisen UI-vian löysi ihminen joka katsoi ruutua. Mobiilinavigaatio oli neljässä palvelussa rikki kuukausia testien ollessa vihreitä koko ajan.

tests/render.test.mjs — 31 testiä, renderToStaticMarkup ilman jsdomia. Yhteensä 223.

#### Miksi näitä ei ollut ennen

--experimental-strip-types ei osaa JSX:ää, joten yhtäkään komponenttia ei voinut ladata testissä. Este oli tekninen eikä laiminlyönti — mutta se oli myös ohitettavissa, eikä kukaan ollut yrittänyt.

Ratkaisu on tests/tuki/jsx-loader.mjs: Noden lataajakoukku joka kääntää .tsx:n esbuildilla ja resolvoi päätteettömät tuonnit kuten Next.js. Käännös on testien yksityisasia — PAYLOAD ei sisällä tests/:iä, joten paketti on kuluttajille yhä pelkkää lähdekoodia.

#### Testit todennettiin palauttamalla oikeat viat

Vihreä sarja ei todista että testit löytäisivät mitään. Palautin kolme tämän kesän vikaa koodiin ja tarkistin että ne kaatuvat:

palautettu vikakaatui
AppShell: lg:flex → md:flex2 testiä
Pagination: disabloidun napin reuna → opacity-502 testiä
MiniBarChart: nollan korkeus = pienen korkeus2 testiä

#### Mitä ne eivät kata

Klikkauksia, tilasiirtymiä, ulkonäköä. Yksi umpikuja on kirjattu testiksi asti: MobileNavin paneelin sisältöä ei voi todentaa SSR:stä, koska Radixin Sheet ei renderöi sitä ennen avaamista. Jaetun NavListin todellinen hyöty — sama lista kahdessa paikassa — jää siis tämän sarjan ulkopuolelle, ja jos joku kahdentaa listan, testit pysyvät vihreinä.

Älä lue vihreää sarjaa todisteena siitä että käyttöliittymä toimii.

Muutettu
  • •devDependencies (esbuild, react, react-dom) karsitaan vendoroidusta kopiosta, ettei kuluttajan tietoturva-auditti näe testityökaluja kirjaston riippuvuuksina. peerDependencies jää — se on sopimus.
  • •Testikomento ajaa nyt --import ./tests/tuki/rekisteroi.mjs. `npm install` on tarpeen ennen testejä, mikä on uutta: aiemmin paketilla ei ollut yhtään riippuvuutta.

v0.22.3

2026-08-13

Dokumentoitu: sivutus pakottaa lajittelun palvelimelle

AIMO otti Paginationin kolmanteen taulukkoonsa ja raportoi että kaikissa kolmessa sivutus pakotti lajittelun palvelimelle — ja että se ei ollut ilmeinen ansa ennen kuin siihen oli astunut.

Sivutettu lista jonka selain lajittelee järjestää vain näkyvät 25 riviä ja näyttää järjestäneen kaikki 6 342. Käyttäjä klikkaa Hinta, saa uskottavan järjestyksen, eikä mikään kerro että kallein tuote on sivulla 47. Väärä vastaus joka näyttää oikealta — sama luokka kuin vanhentunut hinta 0.22.0:ssa.

Varoitus oli jo olemassa. Se on ollut sortRowsin JSDocissa alusta asti, sanamuodossa joka on täsmälleen oikea. Vika oli sen paikassa: sortRowsin dokumentaation lukee vasta se joka on jo päättänyt lajitella selaimessa. Ansaan astutaan toisesta suunnasta — kun toimivaan taulukkoon lisätään sivutus, eikä kukaan kysy mitä lajittelulle tapahtuu.

Varoitus on nyt myös Paginationin JSDocissa ja README:n Pagination-osiossa, eli siellä missä päätös tehdään.

Mukana myös ansan toinen puoli, koska se tulee vastaan heti kun ensimmäinen on korjattu: lajittelun vaihto palauttaa sivulle 1. Sivu 7 uudessa järjestyksessä on mielivaltainen, mutta näyttää yhtä uskottavalta. applySortToParams ei nollaa sivua itse — se on dokumentoitu raja ja pysyy — mutta nyt ne kaksi luetaan parina siellä missä sivutus lisätään.

Ensimmäinen tuottaa väärän järjestyksen, toinen oikean järjestyksen väärältä sivulta. Kumpikaan ei epäonnistu näkyvästi kertaakaan.

Tämä on saman päivän kolmas tapaus samasta muodosta: tieto oli olemassa, mutta ei siellä missä lukija oli.

v0.22.2

2026-08-13
Korjattu

oma mittaukseni oli väärä

0.22.1 väitti että ekosysteemissä on 16 korttia kiinni yläreunassa. Luku oli väärä. Oikea on 5.

Skriptini katsoi CardContentin luokat muttei sitä mitä sen sisällä on. Kahdeksassa tapauksessa lapsi pehmustaa itsensä — EmptyState tuo oman py-8/py-12:nsa, jolloin pt-0 ei jätä mitään kiinni. Se on sivutuote siitä että `EmptyState` suunniteltiin omavaraiseksi, ja se tekee CardContentin oletuksesta merkityksettömän juuri siinä yhdistelmässä.

TP-Vastaanotto huomasi sen. Annoin heille listan viidestä paikasta, he avasivat jokaisen ja vastasivat että kolme niistä on EmptyStateejä eikä vikoja. Automaattinen mittaus näki kuvion oikein, mutta lopputuloksen näki vasta se joka avasi tiedostot.

Jäljelle jäävät ovat enimmäkseen taulukoita joissa px-0 on tarkoituksellinen — taulukon on ulotuttava kortin reunaan. Niissä pystysuunnan pt-0 on arvioitava silmällä eikä skriptillä.

45 kiertoa pitää edelleen paikkansa, ja se on se luku joka todistaa oletuksen olevan väärä. Väärä oli se osa jonka esitin todisteena siitä ettei kukaan ollut huomannut.

Muutettu
  • •card.tsx: JSDocin luvut korjattu ja virhe kirjattu näkyviin
  • •demo: kaksi korttia esimerkkisivulla käyttää nyt CardHeaderia — ne olivat kiinni yläreunassa, eli kaksi viidestä oli minun

v0.22.1

2026-08-13
Korjattu

viimeistely jonka jätin tekemättä

Omistaja katsoi 0.22.0:n esimerkkinäkymän ja löysi kolme asiaa. Kaikki kolme olivat sellaisia jotka näkyvät vain valmiissa näkymässä, ei komponentissa yksinään — enkä ollut katsonut sivua kokonaisuutena.

1. `MiniBarChart` sai hoverin. 0.22.0 luotti natiiviin title-attribuuttiin. Se odottaa noin sekunnin, ei anna mitään palautetta siitä mitä osoitetaan, eikä sitä voi tyylitellä. 30 pylvään rivissä ongelma ei ole arvon näyttäminen vaan sen tietäminen mitä osoittaa. Nyt pylväs kirkastuu ja arvo näkyy sen yllä — group-hover, pelkkää CSS:ää, joten komponentti pysyy palvelinpuolella.

2. Akselilla on neljä selitettä kahden sijaan. Kaksi kertoo vain jakson pituuden; neljällä yksittäisen pylvään voi paikantaa. ticks-propsi säätää määrää.

3. Päivämäärän muoto on datan päätös, ja esimerkkidata korjattiin. 07-14 on epäselvä suomalaiselle lukijalle: kuukausi tulee ennen päivää ja väliviiva näyttää väliltä. Esimerkki käyttää nyt muotoa 14.7.. MiniBarChart ei muotoile päivämääriä — se näyttää sen mitä saa — mutta JSDoc sanoo tämän nyt ääneen.

Dokumentoitu: CardContentin pt-0 on ansa

Esimerkkisivun otsikot olivat kiinni kortin yläreunassa. Syy ei ollut sivussa vaan oletuksessa: CardContent on p-6 pt-0, koska CardHeaderilla on jo oma pehmusteensa.

Mittasin ekosysteemin. CardContent esiintyy 61 kertaa ilman `CardHeader`ia, ja 45 niistä lisää pehmusteen käsin. TP-IAM ei käytä CardHeaderia kertaakaan ja kiertää oletuksen kaikissa 19:ssä. Loput 16 ovat kiinni yläreunassa — kukaan ei ollut huomannut.

45 kiertoa riittää todisteeksi siitä että oletus on väärä, mutta sitä ei voi vaihtaa turvallisesti: p-6 lisäisi 24 px:n välin 102:een otsikolliseen korttiin, ja first:pt-6 voittaisi pseudoluokan spesifisyydellä kutsujan oman p-3:n ja p-0:n — AIMO:lla on p-0-kortti jossa taulukko on reunasta reunaan.

Oletus jää, ansa on nyt dokumentoitu card.tsx:n JSDocissa mittauksineen. 16 kiinni olevaa paikkaa on raportoitu kuluttajille.

v0.22.0

2026-08-13
Lisätty

StatCard, MiniBarChart ja lukumuotoilijat

Mittarinäkymän palikat. Syy on että LLM-kustannusnäkymiä ollaan rakentamassa useaan palveluun, ja ilman näitä jokainen kirjoittaisi omat `compactTokens`- ja `formatCost`-funktionsa — lyhyitä funktioita joissa on päätöksiä (kynnys, desimaalit, kieli). Kolme eri päätöstä samasta luvusta näyttää kolmelta eri luvulta.

  • •StatCard + StatCardRow — ikoni, selite, iso luku, lukuohje
  • •MiniBarChart — aikasarjan pylväät, ei kaaviokirjastoa, ei client-rajaa
  • •lib/format.ts — compactNumber, formatCost, formatCount, formatShare

Kumpikaan komponentti ei tiedä LLM:istä mitään. Sama pari kelpaa jonon pituudelle ja toimittajamäärille.

#### Mitä paketti ei anna: hintoja

StatCard ottaa cost-arvon valmiina eikä laske sitä. Mitattu esikuva kovakoodasi $3/M ja $15/M käyttöliittymään ja johti niistä osakustannukset — samalla kun kokonaissumma tuli tietokannasta, jonne se oli laskettu erikseen. Sama luku kahdesta lähteestä. Ne täsmäävät niin kauan kuin tariffit sattuvat olemaan samat.

Paketissa se olisi pahempi: hinnasto vendoroituisi neljään repoon, ja contentHash kertoo vain että kopio vastaa lähdettä — ei että luku on oikein. Vanhentunut hinta ei tuota virhettä, tyhjää tilaa eikä punaista. Se tuottaa uskottavan summan.

#### Kolme mitattua vikaa jotka nämä korjaavat

1. Väri koristeena. Esikuvan neljä mittarikorttia olivat sininen, vihreä, violetti ja oranssi, eikä mikään väri tarkoittanut mitään. StatCardin oletus on neutraali; sävyn saa vain luku joka on arvio. 2. Nolla näytti samalta kuin melkein nolla. Pylväskaaviossa nollapäivä oli 0 px ja pieni päivä 2 px. Ne ovat eri väite. MiniBarChart piirtää tausta-uran, jolloin tyhjä erottuu. 3. Pyöristys tuotti väärän suuruusluokan. compactNumber(999 999) olisi naiivisti 1000.0k, joka luetaan miljoonaksi. Porras nousee.

#### Esimerkkinäkymä demossa

/esimerkit/llm-kaytto on kopioitava pohja, ei paketin komponentti: koko LLM-käyttönäkymä tietosopimuksineen. Paketti ei julkaise LlmCostDialogia — mittarit ovat palvelukohtaisia, ja valmis näkymä pakottaisi kaikille yhden palvelun käsitteet.

Muutettu
  • •README: komponenttitaulukko ja uusi osio StatCard/MiniBarChart
  • •16 uutta testiä (tests/format.test.mjs)

v0.21.0

2026-08-12
Korjattu

navigaatio oli saavuttamattomissa kapeilla näytöillä

`AppShell` piilotti sivupalkin `md`:n alapuolella eikä tarjonnut tilalle mitään. Puhelimella ja tabletilla käyttäjä ei päässyt yhteenkään näkymään: TP-Vastaanotolla 22 ja TP-IAM:llä 14 kohdetta.

<aside className="… hidden … md:flex">   // eikä mitään muuta

Omistaja raportoi sen käyttäjiltä. Mittasin: AppShellissa oli nolla mobiilinavigaatiota — ainoat osumat Sheet|Menu-haussa olivat UserMenu JSDocissa.

#### Vika ei syntynyt AppShellissa, mutta se on sen korjattava

Tarkistin historiasta: yhdelläkään kuluttajalla ei ollut mobiilinavigaatiota ennen `AppShell`iäkään. En siis poistanut toimivaa; peritin puuttuvan.

Se ei ole puolustus. Kehys teki puutteesta yhdenmukaisen neljässä palvelussa, ja minä katselmoin sen neljä kertaa PR-kuvauksissa mainitsematta asiaa kertaakaan. Yhdenmukainen puute on myös yhdessä paikassa korjattava — se on ainoa hyvä puoli.

#### Raja on lg eikä md

Tabletti pystyasennossa on 768 px, eli täsmälleen md. Sillä rajalla se olisi saanut sivupalkin joka vie kolmanneksen leveydestä.

Liian aikaisin piilotettu sivupalkki on haitaton; liian myöhään piilotettu on tämä vika.

#### AppShell pysyy palvelinkomponenttina

Paneelin avaustila vaatii "use client":in, joten se on omassa MobileNav-komponentissaan. Jos se olisi AppShellissa, koko kehyksestä tulisi client-komponentti — ja se on yksi niistä yhdestätoista joiden README lupaa toimivan palvelinpuolella.

Sama jako kuin SortableTableHeadilla: Table on palvelinpuolella, klikattava otsikko ei.

Lista itse on uudessa NavListissä ilman direktiiviä, joten sivupalkki ja mobiilivalikko renderöivät saman koodin. Kahdennettuna ne olisivat eriytyneet ensimmäisellä muutoksella.

#### Yksi asia joka olisi jäänyt huomaamatta

onNavigate sulkee paneelin. Ilman sitä paneeli jäisi auki uudelle sivulle, koska Next.js:n client-navigaatio ei unmounttaa kehystä. Palvelinnavigaatiossa se sulkeutuisi itsestään — siksi puute näkyisi vasta oikeassa sovelluksessa eikä demossa.

Seitsemän uutta testiä vartioivat client-rajaa, katkaisukohtaa ja sitä ettei lista kahdennu. Kaikki kolme rikkoutuisivat hiljaa: "use client" AppShellissa toimisi täydellisesti ja siirtäisi vain rajan ylös kaikilla kuluttajilla.

v0.20.0

2026-08-12
Muutettu

EmptyStatein rajaus kirjattu

Ei uutta koodia — `EmptyState`in JSDoc kertoo nyt mikä on tyhjä tila ja mikä ei. Sama muoto kuin URL-apureiden rajauksessa: mitä paketti tuntee, mitä palvelu määrittelee.

Mittasin ekosysteemin: 30 keskitettyä vaimennettua lohkoa jotka näyttävät samalta ja tarkoittavat viittä eri asiaa.

kplEmptyState
tyhjä tulos13kyllä
virhe12ei — role="alert", DataTable.error
lataus3ei — luuranko
ei pääsyä1ei — oma näkymänsä
ei valintaa1ei — kehote, ei tulos

Virheitä on melkein yhtä paljon kuin tyhjiä, ja ne ovat näköisiään. Se on rajauksen paras perustelu: ilman sitä lähes puolet käyttöpaikoista olisi väärää komponenttia — eikä se näkyisi katselmoinnissa vaan vasta ruudunlukijalla, koska role="status" väittäisi virheestä "tässä on tulos, ja tulos on tyhjä."

Sääntö: jos käyttäjän seuraava teko on eri, se on eri komponentti. Tyhjä tulos → odota tai muuta hakua. Virhe → yritä uudelleen. Ei pääsyä → ota yhteyttä.

Kaksi rajatapausta joita ei kannata pakottaa tähän:

  • •AIMO:n `/ei-paasya` on oma sivunsa otsikkoineen ja jatko-ohjeineen — pääsyn puute vaatii toimenpiteen jota käyttäjä ei voi tehdä itse.
  • •TP-TTK:n *"haku onnistui osittain"* näkyy yhtä aikaa rivien kanssa, eikä siis ole tyhjä tila lainkaan.

Komponentin käytös on ennallaan; muutos on src:ssä, joten sisältötiiviste vaihtuu ja kopiot on päivitettävä.

v0.19.0

2026-08-12
Lisätty

sivun säilyttäminen URLissa

Omistajan huomio 0.18.0:n jälkeen. Paketissa oli jo täsmälleen tämä kuvio lajittelulle (0.7.0), joten sivutus sai saman muodon eikä uutta suunnittelua tarvittu.

// sivu vaihtuu
router.replace(`?${applyPageToParams(searchParams, sivu)}`);

// palvelinkomponentissa
const sivu = pageFromParams(searchParams, { totalPages });

PAGE_PARAM_NAMES on { page: "sivu", pageSize: "koko" } — jaettu samasta syystä kuin SORT_PARAM_NAMES: sivutuslinkki on kirjanmerkittävä, ja jos jokainen palvelu valitsisi omansa, sama osoite tarkoittaisi eri asiaa eri palvelussa.

#### Kolme päätöstä joilla on käyttäjänäkyvä seuraus

Sivu 1 jätetään pois osoitteesta. ?sivu=1 ei kerro mitään mitä oletus ei kertoisi, ja se tekee jaetuista linkeistä pidempiä kuin niiden tarvitsee olla.

Nollaus on sama kutsu sivulla 1. Tämä on se osa jonka unohtaminen näkyy: sivulla 12 tehty haku jättäisi käyttäjän sivulle 12 tuloksessa jossa on kaksi sivua, ja ruudulla olisi tyhjä taulukko vaikka osumia on.

const p = applyPageToParams(searchParams, 1);
p.set("haku", teksti);
router.replace(`?${p}`);

Roska ei kaada sivua. ?sivu=abc, ?sivu=0 ja ?sivu=-4 putoavat ykköseen; totalPages annettuna ?sivu=9999 rajautuu viimeiseen sivuun. Ilman rajausta se päätyisi kyselyyn ja käyttäjä näkisi tyhjän taulukon eikä viimeistä sivua.

pageSizeFromParams ottaa allowed-listan, ja se on turvallisuusasia eikä kosmetiikkaa: ?koko=100000 päätyisi muuten suoraan kyselyyn.

#### Yksi kaksinnus jonka jätin tietoisesti

toSearchParams on nyt sekä lib/sort.ts:ssä että lib/table.ts:ssä. Yritin jakaa sen omaan lib/params.ts:ään ja se ei toiminut: Nodein --experimental-strip-types vaatii .ts-päätteen relatiivisiin tuonteihin, ja paketin tsconfig kieltää sen (allowImportingTsExtensions: false). Päätteen salliminen muuttaisi käännössopimusta neljälle kuluttajalle.

Viisitoista riviä kahdessa paikassa on halvempi kuin uusi rajoite neljässä repossa. Jos kolmas käyttäjä ilmaantuu, ratkaisu on siirtää molemmat samaan tiedostoon — ei jakaa kolmatta kopiota.

13 uutta testiä, mukaan lukien kierros URLin läpi jokaisella sivulla.

v0.18.0

2026-08-12
Lisätty

Pagination ja pageItems

Omistaja huomautti että AIMO:n sivutus on huonosti toteutettu. Se oli minun. Heidän koodinsa on kaksi riviä (pagination={…} onPageChange={…}); kaiken muun renderöi DataTablePaginationRow — 0.13.0:ssa julkaisemani yksityinen apuri.

<Pagination pagination={data.pagination} onPageChange={setPage} />
1–25 / 776                    ‹ Edellinen  1 2 3 4 5 … 32  Seuraava ›

DataTable renderöi tämän itse; erikseen sitä tarvitaan listoille jotka eivät ole taulukoita.

#### Kolme vikaa jotka olivat siinä

Kaikki näkyivät AIMO:n tuotelistan kuvakaappauksessa (776 riviä, 32 sivua):

  • •`Sivu 1 / 32` oli staattista tekstiä. Sivulle 20 pääsi vain klikkaamalla Seuraava 19 kertaa.
  • •Disabloitu *Edellinen* lukeutui tekstiksi. disabled:opacity-50 haalistaa myös reunan, joten sivulla 1 vasen kontrolli näytti selitteeltä oikean napin vieressä. Se on sama virhe jonka olin päivää aiemmin diagnosoinut toisen palvelun käyttöliittymästä — harmaa painike joka olikin disabled. Helppo tehdä uudelleen, koska opacity on lyhin tapa merkitä disabloitu.
  • •`1–25 / 776` ja `Sivu 1 / 32` kertoivat saman asian kahdesti.

Disabloitu nappi säilyttää nyt reunan ja saa vaimennetun täytön: se näyttää napilta jota ei voi painaa, ei tekstiltä.

#### pageItems pitää rivin pituuden vakiona

pageItems(1, 206)    // 1 2 3 4 5 … 206
pageItems(100, 206)  // 1 … 99 100 101 … 206
pageItems(206, 206)  // 1 … 202 203 204 205 206

Kun toinen katkaisu jää pois laidan lähellä, ikkuna kasvaa yhdellä sen tilalle. Ilman sitä rivi kapenisi laidoilla ja numerot liikkuisivat hiiren alta sivua vaihtaessa. Se on funktion tärkein tehtävä ja ainoa syy miksi laskenta on lib/:issä eikä komponentissa.

Ensimmäinen toteutukseni ei tehnyt sitä, ja oma testini paljasti sen: kirjoitin väitteen vakiopituudesta testiksi (pituudet.size === 1), ja se kaatui heti — 6 vs. 7. Väite oli JSDocissa ennen kuin toteutus vastasi sitä.

Toinen sääntö: katkaisumerkkiä ei koskaan käytetä yhden sivun tilalla. Ellipsi vie saman tilan kuin numero muttei kerro mitään eikä ole klikattava.

14 testiä, mukaan lukien invariantit kaikille sivuille: nykyinen sivu on aina mukana, laidat ovat aina mukana, kaksi ellipsiä ei koskaan vierekkäin, ja pituus on vakio.

#### Mitä tämä ei tee

Ei sivukoon valitsinta. Se vaatisi päätöksen sallituista arvoista, ja se on palvelun rajapinnan asia — AIMO:n katto on 100. Jos useampi tarvitsee sitä, se lisätään silloin.

v0.17.0

2026-08-11
Lisätty

NotificationCenter

Ei toivelistalta vaan kysymyksestä "pitäisikö meidän tuoda PWA-ominaisuuksia kuten ilmoitukset". Mittasin, ja vastaus jakautui kahtia:

  • •PWA-infraa ei ole yhdelläkään palvelulla — ei manifestia, ei service workeria, ei Notification.requestPermissionia. Se ei myöskään kuulu tähän kerrokseen: service workerin scope on sovellustason, ja push vaatii VAPID-avaimet, tilausten tallennuksen ja backendin. Ei tehty.
  • •Ilmoituskeskus oli jo kahdesti, ja siitä tuli tämä.
<NotificationCenter
  notifications={ilmoitukset}
  kinds={{ failed: { tone: "error", icon: ServerCrash } }}
  LinkComponent={NextLink}
  onMarkRead={(id) => merkitseLuetuksi(id)}
  footer={<Link href="/halytykset">Katso kaikki →</Link>}
/>

Paketti omistaa kellon, lukumerkin ja listan rakenteen; palvelu omistaa ilmoitukset, sanaston, linkit ja lukutilan säilytyksen.

#### Kolme toteutusta, kolme eri tilaa

TP-Vastaanotto169 riviä, käytössä — koko toteutus
AIMO34 riviä, käytössä — pelkkä kello ja tyhjä tila, "sisältö tulee myöhemmin"
TP-IAM161 riviä, orpo — ei importattu mistään; he poistivat sen

⚠️ Ensimmäinen mittaukseni löysi vain kaksi näistä. Vertasin TP-V:tä ja TP-IAM:iä koska niillä oli sama tiedostonimi ja identtiset tunnisteet, enkä huomannut AIMO:n 34-rivistä versiota ennen kuin skannasin kaikki neljä repoa vendoroinnin yhteydessä. Sama virhe kuin `Select`issä: etsin sitä muotoa jonka olin jo nähnyt.

TP-IAM:n kopio oli merkki merkiltä TP-V:n tiedosto — kaikki seitsemän tunnistetta täsmäsivät ja markOneRead oli identtinen — mutta sitä ei importattu mistään. He tarkistivat sen kun kysyin, ja poistivat sen samana päivänä.

Orpo on silti paras perustelu komponentille, kahdesta syystä:

  • •Sen footer linkittää reitteihin joita heillä ei ole (/halytykset, /asetukset/halytykset; ainoat maininnat koko repossa ovat nuo kaksi riviä). Kuolleet linkit tulivat kokonaiskopioinnissa, ja footer on tässä slot — jaettu toteutus ei olisi voinut tuoda niitä.
  • •Kopio ajautui ilman että kukaan katsoi. Siihen lisättiin EmptyState 0.14.0:ssa huomaamatta ettei sitä käytetä, ja sen sävyt jäivät kiinteiksi (text-amber-600, bg-red-500) siinä missä TP-V:llä ne ovat tokeneita.

Kopio elää omaa elämäänsä myös silloin kun kukaan ei käytä sitä — ja silloin sen virheet eivät korjaannu käytössä vaan säilyvät seuraavaan kopiointiin.

Ja AIMO:n tapaus on ehkä tärkeämpi kuin kumpikaan: heidän 34-rivinen versionsa on kello joka odottaa dataa. Jaetusta komponentista he saavat listan, lajit, lukumerkin ja tyhjän tilan valmiina siltä päivältä kun sisältö saapuu — sen sijaan että kirjoittaisivat 169 riviä uudelleen.

#### Lukumerkintä puuttuu jos sitä ei voi säilyttää

⚠️ `onMarkRead` ja `onMarkAllRead` ovat valinnaisia, mutta ilman niitä lukumerkintää ei näytetä lainkaan.

Molemmissa alkuperäisissä markOneRead asetti vain useState-arvon — ei fetchiä, ei localStorageia. "Merkitse luetuiksi" katosi sivun latauksessa. Nappi joka unohtaa on huonompi kuin ei nappia, koska käyttäjä luulee tehneensä jotain.

TP-V tarkisti oman mallinsa ja vastaus oli vielä selvempi: heillä ilmoitukset johdetaan kyselystä eikä niitä ole tietueina, joten lukumerkinnälle ei ole mitään mihin kirjoittaa. Molemmat kuluttajat vahvistivat että piilotus on heille oikea ratkaisu.

#### unread tarkoittaa tämä käyttäjä ei ole nähnyt — ei tämä on tärkeä

Tämä on julkaisun tärkein nimeäminen. TP-V:llä unread oli kovakoodattu lajin mukaan (failed → true, stale → false), jolloin se ei ollut käyttäjäkohtainen tila lainkaan: kaksi operaattoria näki saman merkin, eikä kumpikaan voinut poistaa sitä.

Kellon merkki on lupaus siitä että jokin on uutta sinulle. Jos palvelu ei pysty seuraamaan sitä, kenttä jätetään pois — vakavuus kuuluu kindiin ja sitä kautta sävyyn.

Lisätty

unreadBadge, unreadCount, sortNotifications

Laskenta lib/notifications.ts:ssä, 12 testiä. Reunatapaukset:

  • •unreadBadge(0) → null eikä "0" — tyhjä kello on eri asia kuin kello jossa lukee nolla
  • •puuttuva `unread` tarkoittaa luettua — väärä merkki kellossa on kohinaa jota käyttäjä ei voi poistaa
  • •aikaleimattomat menevät loppuun, eivät alkuun; sama sääntö kuin compareValuesissa
  • •järjestys on vakaa, jottei lista hyppää uudelleenrenderöinnissä

v0.16.0

2026-08-11
Lisätty

diffEntries ja VersionDiff

Toivelistan viimeinen kohta. Ja se on julkaistu tietäen ettei sillä ole vielä mitään vertailtavaa.

const rivit = diffEntries(
  vanha.attributes.map((a) => ({ key: a.name, value: a.value, meta: a.source })),
  uusi.attributes.map((a) => ({ key: a.name, value: a.value, meta: a.source })),
);
<VersionDiff entries={rivit} beforeLabel="v3" afterLabel="v4" />

#### Mittaus: yksi kuluttaja, nolla versioparia

AIMO on ainoa jolla on versioituja tietueita — TP-V:llä ei ole versiointia lainkaan eikä TP-IAM:n auditlokissa ole ennen/jälkeen-paria.

He mittasivat oman kantansa pyynnöstäni:

tuotteita377
useampi kuin 1 versio0
attribuutteja / tuote0–39, ka. 22

Diffillä ei ole tänään mitään vertailtavaa. Versioita alkaa syntyä kun rikastusehdotusten katselmointi alkaa — jokainen hyväksytty ehdotus luo uuden version — mutta niitä ei ole vielä.

Heidän suosituksensa: "kirjoittakaa vertailulogiikka, älkää esitystä". Logiikka on testattavissa ilman dataa; esitys ei.

Julkaisin molemmat, mutta esitys on tarkoituksellisesti hillitty ja merkitty tarkistettavaksi kun oikeita versiopareja on. lib/diff.ts on se osa johon luotan.

#### compareMeta on oletuksena true, ja se on kuluttajan korjaus

Kirjoitin sen ensin falseksi perustelulla "käyttäjä lukee arvoja eikä alkuperiä". AIMO kumosi sen konkreettisella tapauksella:

> Rikastus ehdottaa painoksi 1,25 ja ihminen hyväksyy sen — arvo tulee > merkinnällä enrichment. Jos joku myöhemmin kirjoittaa saman arvon käsin, > source muuttuu ja se tarkoittaa ihminen vahvisti tämän itse. Se on > eri tieto vaikka luku on sama.

Heidän muotoilunsa yleistyy ja on nyt koodissa: ehdotus on väite lähteestä, tallennettu arvo on päätös.

Se muutti myös komponenttia: alkuperä näytetään rivillä, ja pelkkä alkuperän muutos korostuu erikseen — muuten rivillä lukisi sama arvo kahdesti ilman selitystä.

#### Vertailun kolme sääntöä

Kaikki kolme ovat tarpeen tietueissa jotka tulevat ulkopuolelta, ja kaikki kolme ovat testeissä:

  • •`null`, `undefined` ja `""` ovat sama asia. Puuttuva kenttä voi olla mikä tahansa niistä. Ilman tätä diffi täyttyisi riveistä "(tyhjä) → (tyhjä)".
  • •`5` ja `"5"` ovat sama arvo — sama kenttä voi tulla eri ajolla eri tyyppisenä. Mutta `"5 kg"` ei ole `5`: muunnos tehdään vain jos molemmat ovat äärellisiä lukuja.
  • •`0` ja `false` eivät ole tyhjiä. Klassinen falsy-ansa, oma testinsä.

#### Muuttumattomat rivit ovat <details>-elementissä

Natiivi disclosure eikä React-tila, koska tila pakottaisi "use client":in. Sivuvaikutuksena se toimii ilman JavaScriptiä.

Sama rajanveto kuin Tablein densityssä: kun valittavana on React-tila ja natiivi elementti joka tekee saman, natiivi voittaa. use client -sarake on nyt estänyt kaksi liukumaa kahdessa julkaisussa.

Identtisille versioille komponentti näyttää EmptyStatein eikä tyhjää taulukkoa.

v0.15.0

2026-08-11
Lisätty

density

Toivelistan kohta 7, AIMO:n toive. Ehto oli osa toivetta ja se on pidetty: density="compact"-propsi, ei omaa token-sarjaa.

<Table density="compact">…</Table>
<DataTable density="compact" … />

compact pudottaa solujen pehmusteen p-3 → px-2 py-1.5 ja otsikkorivin h-10 → h-8.

#### Mittaus ei tukenut tätä, ja kerron sen suoraan

Kävin läpi kaikki 30 taulukkoa: yksikään ei ylikirjoita pehmustetta käsin. Ei p-2:ta, ei py-1:tä, ei h-8:aa. Aiemmilla kolmella kerralla (§8.1.5–§8.1.7) mittaus näytti että toive kuvasi eri asiaa kuin toisto — tällä kertaa se näytti ettei toistoa ole lainkaan.

Tarkistin myös toisen akselin: text-xs esiintyy 19 solussa, mutta hajallaan yksi solu per taulukko (aikaleimat, tunnukset, monospace) eikä koko taulukon tiiviytenä. Se ei siis ole tiheyttä väärällä akselilla, kuten ensin arvelin.

Julkaisin sen silti, koska ehto oli mietitty valmiiksi ja variantti on additiivinen. Mutta se on tämän listan ensimmäinen komponentti jota kukaan ei ole vielä tarvinnut — jos se jää käyttämättä, se on kirjattu tähän eikä pääteltävissä jälkikäteen.

#### Toteutus on jälkeläisvalitsin, ei konteksti

density === "compact" && "[&_td]:px-2 [&_td]:py-1.5 [&_th]:h-8 [&_th]:px-2"

Solut eivät tiedä tiheydestä mitään. Luonteva vaihtoehto olisi ollut React-konteksti, mutta se olisi tehnyt Tablesta client-komponentin — ja Table on yksi niistä yhdestätoista jotka README:n taulukko lupaa toimivan palvelinpuolella.

tests/table.test.mjs vartioi molempia: ettei Tableen ilmesty "use client":iä eikä createContextia, ja ettei tiheydelle synny tokeneita.

Todensin generoidun CSS:n eikä lähdekoodin:

.[&_td]:py-1\.5 td{padding-top:.375rem;padding-bottom:.375rem}

v0.14.0

2026-08-11
Lisätty

EmptyState

Toivelistan kohta 6, siltä osin kuin se oli vielä tekemättä — vahvistusdialogi tuli FormDialogin mukana 0.10.0:ssa ja taulukoiden tyhjä tila DataTablein empty-propsina 0.13.0:ssa. Jäljellä oli taulukon ulkopuolinen tyhjä tila.

<EmptyState
  icon={Inbox}
  description="Kun toimittajilta saapuu tuoteaineistoa, se näkyy täällä
               kanavasta riippumatta (URL, SFTP, sähköposti, manuaali)."
/>

#### Selitys, ei otsikko

description on ainoa pakollinen propsi. Mittasin 17 tyhjää tilaa kolmesta palvelusta: yhdessäkään ei ole erillistä otsikkoa, eikä yhdessäkään toimintopainiketta. Ne ovat ikoni ja yksi kappale.

TP-Vastaanoton /noudot on paras esimerkki siitä miksi:

> "Yhdelläkään toimittajalla ei ole cron-aikataulutettua noutoa. Muut > kanavat (SFTP, sähköposti, API) ovat reaktiivisia — niitä ei voi > ennakoida."

Otsikko "Ei noutoja" olisi lisännyt rivin muttei tietoa. Hyvä selitys kertoo miksi näkymä on tyhjä, ei mitä käyttäjä jo näkee.

title on silti olemassa pitkille selityksille, mutta dokumentoituna niin että sitä ei lisätä pelkästään koska se on olemassa.

#### action on valinnainen, ja useimmiten sitä ei pidä käyttää

⚠️ Jätä pois jos sama painike on jo sivulla. TP-Vastaanoton toimittajalista osoittaa PageHeaderin painikkeeseen sen sijaan että kahdentaisi sen:

> "Lisää ensimmäinen »Lisää toimittaja« -painikkeesta, niin sen kanavat voi > aktivoida heti perään."

Se on tietoinen valinta eikä puute — tarkistin että painike on samalla sivulla PageHeaderin actions-slotissa. Kaksi samaa painiketta samalla ruudulla on epäselvempi kuin yksi ja viittaus siihen.

#### Ei kääri itseään korttiin

Kuluttajat käyttävät tätä kahdessa eri kohdassa: joko oma Card taulukon tilalla (/saapuvat) tai valmiin CardContentin sisällä (toimittajat-client). Jos komponentti käärisi itsensä, jälkimmäinen saisi kortin kortin sisään — ja molemmat muodot ovat samassa repossa.

#### size="compact"

Pudottaa pystypehmusteen ja jättää ikonin pois. Ahtaisiin paikkoihin: popover, kortin sisäinen lista, ja DataTablein empty jossa solussa on jo oma pehmuste.

<DataTable … empty={<EmptyState size="compact" description="Ei rivejä." />} />

role="status" eikä alert: tyhjä tulos on tulos, ei virhe. Sama erottelu kuin DataTablessa, jossa error syrjäyttää tyhjän tilan.

v0.13.0

2026-08-11
Lisätty

DataTable

Toivelistan kohta 4, AIMO:n kipupiste. Mittasin ekosysteemin ensin, ja löysin eri ongelman kuin toivelistalla luki.

Toive puhui 5 146 rivistä ja RENDER_CAP-katkaisusta, eli sivutuksesta. Mutta 27 dataohjatusta taulukosta yksikään ei renderöi sivunvaihtoa — AIMO:n rajapinta palauttaa paginationin, mutta käyttöliittymä lukee siitä vain itemsin. Sivutus ei ollut puuttuva komponentti vaan puuttuva näkymä.

Se mikä oikeasti toistui:

kpl
Dataohjattuja taulukoita27
Tyhjä tila käsin kirjoitettuna20
colSpan käsin laskettuna6
…joista ehdollinen3
const sarakkeet: DataTableColumn<Toimittaja>[] = [
  { key: "nimi",   header: "Toimittaja", cell: (t) => t.nimi },
  { key: "maara",  header: "Lähetyksiä", cell: (t) => t.maara, align: "right" },
  { key: "toimet", header: "", cell: (t) => <Valikko …/>, hidden: !canEdit },
];

<DataTable columns={sarakkeet} rows={rivit} rowKey={(t) => t.id}
           empty="Ei toimittajia. Lisää ensimmäinen yllä olevasta painikkeesta." />

`colSpan` johdetaan sarakkeista. Se on komponentin tärkein tehtävä. Ekosysteemissä oli colSpan={isAdmin ? 7 : 6} ja colSpan={serviceRoles.length + 1} — käsin ylläpidettyjä kopioita sarakemäärästä, jotka hajoavat hiljaa kun sarake lisätään. Mikään ei kaadu; tyhjä rivi vain kutistuu tai levittäytyy väärin.

hidden poistaa sarakkeen otsikosta, riveiltä ja colSpanista yhdellä lipulla. Oikeus ilmaistaan siis kerran eikä kahdessa toisistaan tietämättömässä paikassa.

#### Neljä tilaa, joista kaksi ei ole sama asia

loading → luurankorivit · error → punainen role="alert" · tyhjä → empty · muuten rivit.

Tyhjä tulos ja epäonnistunut haku eivät ole sama asia, ja jälkimmäisen näyttäminen edellisenä on väärä väite lopputuloksesta. Siksi error syrjäyttää empty:n eikä sulaudu siihen.

empty on valinnainen mutta kirjoita se aina: ilman sitä tyhjä taulukko on pelkkä otsikkorivi, mikä näyttää yhteysvirheeltä.

#### Mitä se ei tee

Ei lajittele eikä sivuta dataa. rows renderöidään sellaisenaan. Se on AIMO:n asettama ehto: heidän datansa ei ole muistissa, joten client-side -taulukko olisi ollut heille käyttökelvoton alusta asti.

Lajittelu tulee headerin kautta — se on mikä tahansa node, joten SortableTableHead menee sinne sellaisenaan ja URL-parametrit pysyvät palvelun hallussa. Sama raja kuin 0.7.0:ssa.

#### Sivutus ja katkaisu ovat eri asioita

pagination renderöi rivivälin, onPageChange lisää napit, ja molemmat piirtyvät vain jos sivuja on enemmän kuin yksi — aina disabloidut napit ovat kohinaa.

truncated on eri asia: se on AIMO:n "näytetään 100 ensimmäistä, tarkenna hakua". Katkaisu on suorituskykyraja, sivutus on navigointi.

#### Puuttuva käsittelijä ei ole hiljainen

Riviväli näytetään heti kun `pagination` on annettu, myös ilman `onPageChange`ia — ja kehitysajossa puuttuva käsittelijä kirjoittaa varoituksen konsoliin.

Tämä on suoraan AIMO:n perustelusta. Heidän rajapintansa on palauttanut paginationin koko ajan, mutta käyttöliittymä luki siitä vain itemsin. Kukaan ei huomannut, koska PORCH_INPUT_ROW_LIMIT: "100" pitää kehitysdatan niin pienenä ettei rajaan törmää — suurin tarkistuslista on 18 riviä. Todelliset tiedostot ovat 2 890 ja 6 342 riviä, ja 17,6 %:n tarkistusasteella lista olisi noin 500 tuotetta eli 20 sivua.

He pyysivät sivutusta oletukseksi eikä valinnaiseksi, koska "jos se on valinnainen, me unohdamme kytkeä sen päälle ja huomaamme puutteen samalla hetkellä kun katto nousee". Paketti ei voi reitittää heidän puolestaan, mutta se voi tehdä puutteesta näkyvän — ruudulla lukee `1–25 / 5146` silloinkin kun nappeja ei ole.

⚠️ Älä esilataa seuraavaa sivua. Sivunvaihto ei ole kaikilla halpa palvelinkutsu: AIMO viipaloi tuotelistan palvelimen muistissa, joten page-vaihto lukee koko joukon uudelleen. Kirjattu ettei sitä lisätä myöhemmin vahingossa.

Lisätty

pageRange, needsPagination

pageRange({ page: 206, pageSize: 25, totalItems: 5146, totalPages: 206 })
// → { start: 5126, end: 5146 }

Rivivälin laskenta sivutustilasta. Omana funktionaan lib/:issä, koska .tsx-tiedostoa ei voi testata --experimental-strip-typesilla — sama jako kuin compareValuesilla.

Laskenta tulee sivutustilasta eikä rivimäärästä. rows.length on vain nykyisen sivun koko, eikä totalItems välttämättä tule samasta kyselystä: AIMO laskee totalsin nyt samasta muistissa olevasta joukosta, mutta kantaan siirryttäessä se olisi erillinen COUNT. Muoto pysyy, laskentahetki ei — ja komponentti ei saa olettaa toisin.

Kolme reunatapausta joissa naiivi (page-1)*pageSize + 1 menee väärin, ja kaikki kolme ovat testeissä:

  • •tyhjä tulos → 0–0, ei 1–0 joka lukee ylösalaisin
  • •vajaa viimeinen sivu → loppu on totalItems
  • •sivu tuloksen ulkopuolella (suodatus kaventui kesken selaamisen) → tyhjä väli
Muutettu

AppLauncherin href-varoitus kutistettiin: TP-IAM korjasi palvelurekisterin palauttamaan absoluuttiset URLit, joten rekisterikohtainen esimerkki poistettiin ja semantiikka linkitetään heidän rajapinta.md:hensä. Ydin jää — waffle on cross-origin, joten href on absoluutti kaikille paitsi omalle palvelulle. Se on komponentin ominaisuus eikä rekisterin bugi.

v0.12.0

2026-08-11
Lisätty

Select

Toivelistan kohta 3. Toive kuului "TP-IAM hand-rollaa natiivin <select>:in neljässä paikassa". Mittasin ne, ja niitä oli 20 — TP-V 16, TP-IAM 4.

<Select value={maa} onChange={(e) => setMaa(e.target.value)}>
  <option value="FI">Suomi</option>
  <option value="SE">Ruotsi</option>
</Select>

Tämä on natiivi `<select>`, ei Radix Select, vaikka @radix-ui/react-select on peer-riippuvuutena ja asennettuna kaikilla kolmella. Kaikki 20 ovat lyhyitä <option>-listoja dialogin sisällä — ei hakua, ei ryhmiä, ei omaa alkiorenderöintiä. Radix ratkaisisi ongelmia joita niillä ei ole ja maksaisi kolme jotka niillä on: jokaisen listan uudelleenkirjoitus, portaali dialogin sisällä ja "use client".

Näin siirtymä on <select> → <Select> ja luokkarivi pois. Nimet SelectTrigger / SelectContent / SelectItem jäävät varaamatta, jotta rikkaampi valitsin voi tulla myöhemmin ilman rikkovaa muutosta.

#### Mitä se korjaa

Kaikki 20 kopioivat Inputin luokkarivin mutta katkaisivat sen `shadow-sm`:ään:

PuuttuiSeuraus
focus-visible:ring-*selaimen oma outline — sama lomake näytti kahta eri fokusmerkkiä
disabled:*disabloitu lista ei erottunut käytettävissä olevasta
transition-colorsreunan väri hyppäsi

TP-IAM:n neljä olivat lisäksi py-2 ilman h-9:ää, eli 2 px korkeampia kuin vieressä oleva `Input`.

Tämä on siis ennen kaikkea yhdenmukaisuuskorjaus, ei uusi kyky. Ja se on vika jota ei näe kirjoitushetkellä vaan vasta näppäimistöllä — juuri siksi se oli levinnyt 20 paikkaan.

tests/select.test.mjs vertaa luokkarivejä Inputiin, jottei sama katkaisu toistu paketissa.

#### options ja placeholder

<Select
  placeholder="Valitse palvelu…"
  options={palvelut.map((p) => ({ value: p.id, label: p.name }))}
/>

options poistaa .map-toiston; children toimii yhä, jotta siirtymä on nollamuutos. placeholder renderöityy disabloituna rivinä tyhjällä arvolla, eli se näkyy suljetussa listassa muttei ole valittavissa takaisin.

Muutettu

Tablein dokumentaatio

⚠️ Jos taulukko on grid- tai flex-lapsi, se lapsi tarvitsee `min-w-0`.

Tablein kääre hoitaa vaakavierityksen, mutta vasta kun esivanhempi voi kutistua — gridin ja flexin oletus min-width: auto estää sen. Ilman min-w-0:aa sisältö vuotaa oikealta ja koko sivu saa vaakavierityksen, eli oire näkyy muualla kuin missä vika on.

Havainto on TP-TTK:lta, joka mittasi asian viidellä leveydellä 768–1920 px. AppShellin <main> on jo min-w-0 flex-1, joten ansa on nimenomaan kuluttajan omissa välikerroksissa.

Muutos on JSDocia eikä muuta käytöstä — mutta se on src:ssä, joten se muuttaa sisältötiivisteen. Siksi se tuli tämän julkaisun mukana eikä yksin.

v0.11.0

2026-08-11
Lisätty

StatusBadge

Toivelistan kohta 5. Kolmella palvelulla oli oma versionsa, ja ne erosivat juuri sanastossa: TP-V received/forwarded_to_aimo, AIMO completed/retry, TP-TTK searching/extracting. Yhteistä oli viisi sävyä ja se että käynnissä oleva työ erottuu.

<StatusBadge tone="success">Valmis</StatusBadge>
<StatusBadge tone="info" pulse>Käsittelyssä</StatusBadge>

Paketti omistaa sävyt ja sykkivän pisteen; palvelu omistaa sanaston. Sama raja kuin lajittelussa: DS tarjoaa kontrollit ja tilan muodon, ei sisältöä.

Sävyt ovat success · warning · error · info · neutral. Nimet ovat tilasanastoa eivätkä Badge:n visuaalisia variantteja (destructive, secondary), koska ne ovat eri asia — mutta ne mappautuvat Badge:hen, joten paletti ei ole kahdennettu ja tilaväritokenit (0.6.1) hoitavat tumman teeman.

Sykkivä piste on AIMO:n idea ja heidän perustelunsa on lainattu koodiin: piste saa tilan lukeutumaan eläväksi eikä jumittuneeksi. Piste perii tekstin värin (bg-current), joten se seuraa sävyä automaattisesti, ja pysähtyy motion-reducella.

Lisätty

createStatusBadge

// lib/status.tsx — kerran palvelua kohti
export const StatusBadge = createStatusBadge({
  received:  { tone: "info",    label: "Vastaanotettu" },
  sending:   { tone: "warning", label: "Lähetetään", pulse: true },
  failed:    { tone: "error",   label: "Epäonnistui" },
});

<StatusBadge status={rivi.status} />

Tekee tyypitetyn merkin kiinteälle sanastolle. TypeScript tarkistaa että status on kartan avain, joten tuntematon tila on käännösaikainen virhe eikä tyhjä merkki.

Kartoittamaton arvo — esimerkiksi ulkopuolisesta putkesta tuleva uusi tila — renderöityy neutraalina ja säilyttää alkuperäisen arvon `title`-attribuutissa, jolloin se on selvitettävissä ilman lokien kaivamista. Se on AIMO:n toteutuksesta.

Tämä korvaa TP-V:n 22-rivisen ja AIMO:n 58-rivisen tiedoston. AIMO:n versio käyttää yhä kiinteitä Tailwind-sävyjä dark:-haaroineen — se on 0.6.1:tä edeltävää koodia, ja siirto poistaa sen samalla.

v0.10.0

2026-08-11
Lisätty

FormDialog

Toivelistan kohta 1 ja sen parhaiten valmisteltu — perustuu AIMO:n analyysiin viidestä dialogista (AGENTS.md §8.1).

<FormDialog
  open={open} onOpenChange={setOpen}
  title="Lisää toimittaja"
  onSubmit={async () => {
    const res = await fetch("/api/toimittajat", { method: "POST", body });
    const json = await res.json().catch(() => null);
    if (!res.ok || !json?.success) throw new Error(json?.error ?? "Tallennus epäonnistui");
    onDone?.();
  }}
>
  <label>…</label>
</FormDialog>

`onSubmit` saa heittää. Komponentti nappaa poikkeuksen ja näyttää viestin — kutsujan ei tarvitse omaa try/catchia. Se on komponentin tärkein tehtävä: TP-Vastaanoton 18 modaalista 16 hallitsee lähetystilaa mutta vain yksi näyttää virheen. Kun asetussivujen stubeille tulee backend, ne saisivat nykyisellä mallilla äänettömän virhekäsittelyn.

Komponentti omistaa lähetystilan, virheen, virheen nollauksen sulkiessa (toistui kaikissa viidessä alkuperäisessä), nappien disabled-tilan ja lataustekstin sekä <form>-elementin jotta Enter lähettää.

closeOnSuccess={false} jättää dialogin auki ja näyttää onnistumisviestin — onSubmitin palauttama merkkijono. Ilman sitä kaksi AIMO:n viidestä dialogista jäisi komponentin ulkopuolelle, koska niillä tulos itsessään on se mitä käyttäjä lukee.

variant="destructive" vahvistusdialogeille joissa ei ole kenttiä.

Virheruutu on role="alert" eikä status: toisin kuin Toastin vahvistus, tämä on virhe jonka käyttäjän on nähtävä ennen kuin hän jatkaa.

Lisätty

AppShell sai kaksi propsia käyttöönoton palautteesta

Molemmat TP-Vastaanotolta, joka otti kehyksen käyttöön ja poisti 358 riviä:

  • •`contentClassName` — sisältöalueen oletusleveys max-w-6xl ei ollut ohitettavissa. Heidän /saapuvat-taulukkonsa kapeni 1920 px:n näytöllä 1680 px:stä 1152 px:ään. Anna max-w-none datapainotteisille näkymille.
  • •`NavItem.external` — navigaatiokohta renderöityy tavallisena <a>:na LinkComponentin sijaan. Tarpeen proxytetyille reiteille joita kehys ei reititä sisäisesti (heidän /docs/). Ilman tätä kohta oli jätettävä navigaatiosta pois kokonaan.

Siirtymähuomio: sticky top-0 sivun sisällä menee topbarin alle, koska kehys tekee sivusta scrollaavan. Käytä top-12 (topbar on h-12). TP-V:llä vanha kehys ei scrollannut lainkaan, joten muutos paljasti vian jota ei ollut olemassa ennen kehyksen vaihtoa.

v0.9.0

2026-08-11
Lisätty

Toast — itse-sulkeutuva ilmoitus

Toivelistan kohta 2. Alkuperäinen toteutus on TP-IAM:n, tuotannossa PR #39:stä asti; he tarjosivat sen pakettiin ja lähettivät lähdekoodin.

{viesti && <Toast key={viesti} message={viesti} tone="success" />}

Deklaratiivinen, ei jonoa. Yksi ilmoitus kerrallaan: renderöi komponentti kun on jotain kerrottavaa. Provider + imperatiivinen toast() on isompi API-pinta jota kukaan ei ole pyytänyt, ja se on lisättävissä myöhemmin taaksepäin yhteensopivasti — toisin päin ei. TP-IAM:n kolme käyttöpaikkaa näyttävät kaikki yhden ilmoituksen kerrallaan.

Neljä yksityiskohtaa tuli heidän toteutuksestaan sellaisenaan, eikä niitä olisi keksinyt spesifikaatiosta kirjoittamalla:

  • •Kaksivaiheinen `shown`/`gone`-tila — täysi unmount vasta poistumisanimaation jälkeen, ei kesken.
  • •`aria-live="polite"` eikä `assertive` — vahvistus ei saa keskeyttää ruudunlukijaa kesken lauseen.
  • •`motion-reduce`-haara.
  • •`key`-remount on kutsujan vastuulla. Sisäinen nonce piilottaisi sen, onko kyse uudesta ilmoituksesta vai saman päivityksestä.

#### Kaksi lisäystä siirron yhteydessä

  • •Hover- ja fokuspysäytys. Ajastin pysähtyy kun hiiri tai fokus on ilmoituksen päällä. TP-IAM oli harkinnut ja jättänyt sen pois, koska heidän viestinsä ovat lyhyitä vahvistuksia — mutta suositteli sitä jaettuun versioon, ja on oikeassa: pidempi viesti sulkeutuisi kesken lukemisen. Fokus on mukana hiiren rinnalla, jotta näppäimistökäyttäjä ja ruudunlukija ehtivät.
  • •`warning` ja `error` sävyiksi success/info:n rinnalle, ja kaikki neljä tilaväritokeneista (0.6.1). Alkuperäinen käytti kiinteitä text-emerald-500/text-sky-500-sävyjä; nyt text-success/text-info, eli tumma teema toimii ilman dark:-haaraa.

duration={0} jättää ilmoituksen auki kunnes se suljetaan käsin. onDismiss kertoo kun se on poistunut näkyvistä.

v0.8.0

2026-08-11
Lisätty

AppShell — sovelluskehys

Toivelistan kärkikohta (§8 kohta 1) tehty. Kehyksiä oli neljä erillistä toteutusta: TP-Vastaanotto (358 riviä topbaria ja sivupalkkia), TP-IAM (223), AIMO (232) ja tämän repon demo (153). TP-IAM:in ja AIMO:n <header>-rivi oli merkki merkiltä identtinen.

// app/(app)/layout.tsx
"use client";
import NextLink from "next/link";
import { usePathname } from "next/navigation";
import { AppShell, UserMenu } from "@karkkainen/ui";

<AppShell
  product="TP-TTK"
  services={services}          // waffle, suodata kykyjen mukaan ennen antamista
  nav={nav}                    // sivupalkki, ryhmiteltynä
  currentPath={usePathname()}
  LinkComponent={NextLink}
  search={<Hakukentta />}      // valinnainen
  actions={<Pikatoiminnot />}  // valinnainen
  user={<UserMenu … />}
>
  {children}
</AppShell>

Ei hookkeja eikä kehysriippuvuuksia. Nykyinen polku ja linkkikomponentti annetaan propseina, joten sama komponentti toimii Nextissä, Vitessä ja palvelinkomponentissa. Sama linja kuin AppLauncherilla ja sortStateToParamsilla.

Navigaatio on dataa, ei JSX:ää (NavGroup[]), joten kykysuodatus on tavallista taulukon suodatusta eikä ehdollista renderöintiä. Palvelu suodattaa listan ennen kuin antaa sen.

isNavItemActive on viety erikseen ja asuu lib/nav.ts:ssä. Kaksi sääntöä, molemmat opittu olemassa olevista toteutuksista: juuri (`/`) vaatii tarkan osuman (muuten se olisi aktiivinen joka sivulla) ja alipolku korostaa ylätason kohdan, mutta kauttaviivan kanssa jottei /toimittajat aktivoidu polulla /toimittajat-arkisto.

Se asuu lib/:issä myös käytännön syystä: `.tsx`-tiedostoa ei voi testata Nodein --experimental-strip-typesilla, koska JSX vaatii muunnoksen eikä pelkkää tyyppien riisuntaa. Sama jako kuin compareValuesilla.

Demo käyttää nyt AppShellia: 153 riviä kehyskoodia muuttui dataksi ja kahdeksi propsiksi.

5 uutta testiä (yhteensä 91).

Dokumentoitu: vendoroinnin kaksi ansaa

Molemmat TP-Vastaanotolta, joka ajoi vendoroinnin ensimmäistä kertaa itse:

  • •Kontissa ajettaessa tarvitaan `git config --global --add safe.directory`, koska git kieltäytyy lukemasta repoa jonka omistaja on eri kuin prosessin käyttäjä. Ilman sitä sourceState() epäonnistuu juuri siinä kohdassa jossa se lukee commit-shan — ja virhe näyttää likaisuustarkistukselta vaikka työpuussa ei ole vikaa.
  • •Kohdepolku on vendor-hakemisto itse, ei kuluttajan repojuuri. Ohjeessa luki vain <kohdepolku>.

v0.7.2

2026-08-11
Lisätty

applySortToParams

router.replace(`?${applySortToParams(searchParams, next)}`);

Päivittää lajittelun olemassa olevaan osoitteeseen ja säilyttää muut parametrit. Tämä on se osa jota sortStateToParams ei yksin ratkaise: se palauttaa tyhjän objektin kun lajittelua ei ole eikä siis kerro mitkä vanhat parametrit pitäisi poistaa. Ilman tätä jokainen kutsuja kirjoittaa saman poista–poista–aseta -silmukan.

Ei mutatoi syötettä. Hyväksyy URLSearchParamsin, Next:in searchParams-objektin tai merkkijonon; toistuvat arvot säilyvät toistuvina.

Sivutusta ei nollata — lajittelun vaihtaminen sivulla 5 jättää käyttäjän sivulle 5. Se on harvoin haluttua, mutta parametrin nimi ja nollauksen politiikka ovat palvelun asioita eivätkä paketin.

Pyyntö tuli TP-Vastaanotolta, joka oli kirjoittanut saman silmukan käsin.

Dokumentoitu: SORT_PARAM_NAMES:n toinen käyttötapa

TP-Vastaanotto ei korvannut omaa vakiotaan viedyllä, vaikka juuri he pyysivät sen vientiä. He kiinnittävät nimet itse ja vertaavat niitä testissä paketin oletuksiin.

Syy on hyvä: lajittelulinkki on kirjanmerkittävä osoite, ja kiinnitys suojaa sen. Jos paketin oletukset muuttuisivat, tallennetut linkit lakkaisivat toimimasta hiljaa — kiinnitettynä muutos näkyy testin kaatumisena.

Vienti ei siis poista päällekkäisyyttä vaan tekee erosta havaittavan. Se on eri käyttötapa kuin miksi sitä pyydettiin, ja nyt dokumentoitu.

9 uutta testiä (yhteensä 86).

v0.7.1

2026-08-11
Lisätty

SORT_PARAM_NAMES

Oletusnimet (jarjesta, suunta) viety luettavassa muodossa. sortStateToParams palauttaa tyhjän objektin kun lajittelua ei ole, joten se ei kerro kutsujalle mitkä nimet pitäisi poistaa olemassa olevasta osoitteesta. Ilman vientiä jokainen kuluttaja joutuisi pitämään omaa vakiotaan — juuri sitä päällekkäisyyttä jonka apurit poistavat.

Havainto tuli TP-Vastaanotolta käyttöönoton jälkeen: he joutuivat pitämään nimet omana vakionaan. Heidän toinen huomionsa on dokumentoitu: jos pinnaat nimet, tee siitä testi — lajittelulinkki on kirjanmerkittävä osoite, ja oletusten muutos rikkoisi tallennetut linkit hiljaa.

Lisätty

timeValue — aikaleima lajiteltavaksi

sortRows(rows, sort, (row, key) =>
  key === "saapunut" ? timeValue(row.saapunut) : row[key],
);

Toteutus on TP-Vastaanotolta ja perustelu heidän. `null` säilyy `null`:ina eikä muutu nollaksi: nolla tarkoittaisi vuotta 1970, jolloin tyhjä aikaleima nousisi laskevassa järjestyksessä kärkeen — täsmälleen se vika jonka compareValues muuten välttää pitämällä tyhjät lopussa. Sama ansa toisessa naamiossa.

Miksi erikseen eikä `compareValues`:n sisällä? ISO-merkkijonot menisivät useimmiten oikein myös merkkijonovertailussa, mutta compareValues käyttää numeric: true, joka pilkkoo numerojaksot. Sen käyttäytyminen aikaleimoilla ei ole ilmeistä eikä siihen kannata luottaa; epoch-millisekunti on yksiselitteinen.

Hyväksyy string | number | Date | null | undefined. Luku palautetaan sellaisenaan, koska se on jo epoch-millisekunti — Date.parse("0") tulkitsisi sen vuodeksi 2000. Testi nappasi juuri sen bugin ensimmäisestä toteutuksesta.

7 uutta testiä (yhteensä 74).

v0.7.0

2026-08-11
Lisätty

sortStateToParams ja sortStateFromParams

Lajittelutila URL-parametreina, jotta järjestys voidaan soveltaa kyselyssä eikä selaimessa.

// Palvelinkomponentti
const sort = sortStateFromParams(await searchParams, ["nimi", "saapunut"]);
const rows = await haeLahetykset({ orderBy: sort.key, dir: sort.direction });

// Otsikkopainike (client)
onSort={(next) => router.replace(`?${new URLSearchParams(sortStateToParams(next))}`)}

Kuvio on TP-Vastaanotolta. Heidän perustelunsa on tarkempi kuin aiempi dokumentaatiomme: /saapuvat hakee 200 riviä tietokannasta, joten selainlajittelu järjestää vain haetun joukon — oikein niin kauan kuin lista ei kasva, ja väärin heti kun se kasvaa, ilman että mikään hajoaa näkyvästi. Lisäksi sivu saa pysyä palvelinkomponenttina.

`allowedKeys` on pakollinen parametri, eikä syyttä. ?jarjesta= tulee käyttäjältä ja päätyy usein suoraan ORDER BY:hyn. Tuntematon avain palauttaa { key: null } eli palvelimen oman järjestyksen — ei virhettä, koska katkennut linkki ei saa kaataa sivua. Se on funktion tärkein tehtävä ja katettu testeillä.

sortStateFromParams hyväksyy sekä URLSearchParamsin että Next:in searchParams-objektin (Record<string, string | string[] | undefined>), joten samaa funktiota voi kutsua palvelimelta ja selaimesta. Toistuva parametri (?jarjesta=a&jarjesta=b) käsitellään ottamalla ensimmäinen.

sortStateToParams palauttaa tyhjän objektin kun lajittelua ei ole, jotta kutsuja voi jättää parametrit pois osoitteesta sen sijaan että URL:ssa roikkuisi ?jarjesta=.

#### Paketti ei ota reititysriippuvuutta

TP-V tarjosi valmista käärettä joka kutsuu router.replacen, mutta se riippuisi next/navigation:sta. Sitä ei otettu: paketissa on linja jossa kehysside injektoidaan, ei tuoda riippuvuutena — sama kuin AppLauncherin LinkComponentilla. Next:in reititys-API on jo kerran muuttunut (middleware.ts → proxy.ts), ja riippuvuus estäisi paketin käytön muualla kuin Nextissä.

Jaettu on siis se mikä oikeasti toistuu: parametrien nimet, jäsennys, oletukset ja avaimen validointi. router.replace on kolme riviä ja kuuluu palvelulle.

11 uutta testiä (yhteensä 67).

v0.6.1

2026-08-11
Lisätty

tilavärit tokeneiksi (--success, --warning, --info)

Löytö on TP-TTK:n. He huomasivat että paletista puuttuivat onnistumis-, varoitus- ja infoväri — ja löysivät sen badge.tsx:n omasta kommentistamme, jossa kierto oli selitetty muttei korjattu.

Heidän diagnoosinsa oli tarkka: "tuo kierto valuu kuluttajille — meillä se näkyi `bg-emerald-600`-rikkeenä, koska emme tienneet `success`-variantin olevan olemassa." Kuluttaja joka ei löydä tokenia keksii oman sävyn, ja se on huonompi kuin kierto jota se korvaa.

<span className="bg-success text-success-foreground">Täysi</span>
<span className="bg-success/15 text-success">Pehmeä — Badgen tapa</span>
<CheckCircle className="text-success" />
<span className="size-2 rounded-full bg-warning" />

Erillistä pehmeää tokenia ei ole tarkoituksella. Arvo on täysi kylläisyys kuten --destructiveilla, ja pehmeä tausta tehdään läpinäkyvyydellä. Silloin sama token kelpaa ikoniin, reunaan ja tilapisteeseen — ja tumman teeman haara katoaa, koska token itse kirkastuu ja tausta seuraa mukana.

Muutettu

Badge:n tilavariantit yksinkertaistuivat

- success: "border-transparent bg-emerald-100 text-emerald-900 hover:bg-emerald-200
-           dark:bg-emerald-950 dark:text-emerald-200 dark:hover:bg-emerald-900"
+ success: "border-success/20 bg-success/15 text-success hover:bg-success/25"

Sama warningille ja infolle. Sävy muuttuu hieman — aiemmat olivat Tailwindin -100/-900-pari, nyt tokenin alfa. Ero on pieni ja tarkoituksellinen; molemmat teemat käyttäytyvät nyt johdonmukaisesti.

Changelog-komponentin julkaisematon-merkintä käyttää nyt text-warningia kiinteän text-amber-500:n sijaan. Se on samalla todiste siitä että tokenit ovat käyttökelpoisia badgejen ulkopuolella.

> Versionumerosta: tämä lisää uutta tokenipintaa, mikä on AGENTS.md §6:n > mukaan MINOR. Julkaistiin silti PATCHina omistajan pyynnöstä. Vendoroinnissa > versio ei ohjaa asennusta vaan katselmointi, joten signaali on tässä > merkinnässä eikä numerossa.

v0.6.0

2026-08-11
Lisätty

SortableTableHead — taulukon lajittelu

Kolmessa palvelussa on 30 taulukkoa, joista yksi osaa järjestyä. Se yksi on AIMO:n tuotetaulukko, ja he olivat kirjoittaneet sitä varten sortable-table-head.tsx:n joka rakentui jo tämän paketin TableHead:in päälle. Siirretty pakettiin heidän luvallaan; logiikka ja perustelut ovat heidän.

const [sort, setSort] = useState<SortState<"nimi">>({ key: null, direction: "asc" });
const sorted = sortRows(rows, sort, (row, key) => row[key]);

<SortableTableHead sortKey="nimi" sort={sort} onSort={setSort}>
  Toimittaja
</SortableTableHead>

compareValues osaa kolme asiaa jotka menisivät helposti väärin: tyhjät arvot pysyvät lopussa molemmissa suunnissa, numeeriset merkkijonot vertaillaan numeroina ("1056" ennen "7350":ää) ja aakkostus on suomalainen. 0 ja false eivät ole tyhjiä vaikka ovatkin falsy.

Testit (tests/sort.test.mjs, 17 kpl) kattavat nimenomaan nämä — ne rikkoutuisivat hiljaa, koska väärä järjestys näyttää uskottavalta.

Kaksi korjausta siirron yhteydessä, molemmat AIMO:n omasta katselmoinnista. Ne olivat kunnossa yhdessä palvelussa muttei jaettuna pintana:

  • •`label`-propsi. Aiempi aria-label teki String(children), joka tuottaa "[object Object]" heti kun otsikossa on elementtejä. Ei näy silmällä eikä kaadu — juuri sellainen vika jota ei huomata.
  • •`locale` on parametri (oletus "fi") eikä kovakoodattu arvo.

Lisäksi lisättiin aria-sort sarakkeeseen: se on oikea ARIA-attribuutti lajiteltavalle sarakkeelle ja kertoo tilan taulukkorakenteen tasolla, ei vain painikkeen nimessä.

`compareValues` ja `sortRows` asuvat `lib/`:issä, eivät komponentin vieressä, koska komponentti on "use client". Palvelinkomponentti ei voi kutsua client-moduulista vietyä funktiota — ja palvelinpuolen lajittelu on juuri se tapaus jota varten ne on erikseen viety.

#### Raja jonka paketti vetää

sortRows on vain muistissa olevalle datalle. Sivutetussa taulukossa se olisi väärin, koska se järjestäisi vain näkyvän sivun; silloin komponentista tarvitaan vain SortState ja järjestys sovelletaan kyselyssä.

Yleisemmin: design system omistaa kontrollit ja tilan muodon, ei datan hankintaa. Sama koskee hakua — hakukenttä kuuluu tänne, se mistä rivit tulevat ei. Muuten komponentti pakottaisi datan muistiin ja rikkoisi palvelinsivutetut näkymät.

Korjattu

demon Sheet-esimerkki sulkeutui väärään reunaan

side="left" avattu paneeli liukui ulos oikeasta reunasta. Vika oli demossa, ei komponentissa: esimerkki luki suunnan samasta tilasta joka ohjasi avausta (open={side !== null} + side={side ?? "right"}), jolloin variantti vaihtui kesken poistumisanimaation.

Ansa on yleinen, joten se on nyt dokumentoitu sekä komponentin JSDociin että demoon: pidä auki/kiinni ja variantti erillisinä tiloina.

v0.5.0

2026-08-11

Paneelien ulkoasu- ja rakennekorjaus. Kaikki kolme muutosta tulivat samasta havainnosta: paneeliin syötetään usein tietoa jota luetaan taustan sisältöalueelta, ja aiempi toteutus teki siitä mahdotonta.

Sheet on 0.4.0:sta eikä sillä ole vielä yhtään kuluttajaa (tarkistettu kaikista kolmesta repostä), joten oletuksia muutettiin vapaasti. Kenenkään koodi ei rikkoudu.

Muutettu

Sheet ei enää peitä taustaa

  • •Sumennus poistettu. backdrop-blur teki taustan sisällöstä lukukelvotonta juuri silloin kun sitä tarvittiin.
  • •Tummennus pois oletuksesta. Uusi overlay-propsi (oletus false) palauttaa sen kun taustan piilottaminen on nimenomainen tarkoitus.
  • •Varjo shadow-lg → shadow-2xl, jotta paneelin reuna erottuu ilman tummennusta.

Dialog säilyttää sumennuksen tarkoituksella: se on lyhyt keskeytys jossa taustan piilottaminen on koko pointti.

Lisätty

SidePanel — sisältöaluetta kaventava sivupalkki

Sheet liukuu sisällön päälle ja Radix lukitsee taustan, joten sitä ei voi käyttää samanaikaisesti. Kun se on nimenomaan tarkoitus, tarvitaan layout-elementti eikä overlayta.

<div className="flex min-h-0 flex-1">
  <main className="min-w-0 flex-1 overflow-y-auto">…taulukko…</main>

  <SidePanel open={open} onClose={() => setOpen(false)}>
    <SidePanelHeader>
      <SidePanelTitle>Toimittajan tiedot</SidePanelTitle>
    </SidePanelHeader>
    <SidePanelBody>…</SidePanelBody>
  </SidePanel>
</div>

Paneeli on flex-sisar sisältöalueen kanssa ja animoi leveyttään. Sisältöalue tarvitsee `min-w-0` — ilman sitä flex ei anna sen kaventua ja paneeli työntää sen ulos näkymästä. Se on kuvion yleisin virhe ja siksi dokumentoitu sekä komponentissa että demossa.

Kapealla näytöllä (< md) paneeli menee sisällön päälle, koska inline-paneeli puristaisi sisältöalueen käyttökelvottoman kapeaksi puhelimessa.

Ei hookkeja eikä globaaleja kuuntelijoita: sulkeminen on kutsujan vastuulla, joten paneelin tila voi tulla myös URL:sta ilman asiakaspuolen tilaa.

Muutettu

paneelien kehys jaettuun moduuliin

SheetHeader, SheetBody ja SheetFooter ovat nyt panel.tsx:n PanelHeader/PanelBody/PanelFooter, joita myös SidePanel käyttää. Vientinimet eivät muuttuneet. Sheet ja SidePanel ovat eri asioita — overlay ja layout-elementti — mutta niiden sisärakenne on sama, eikä sitä kannata ylläpitää kahtena kopiona.

Demoon lisätty viides käyttötapaus, joka näyttää sisältöalueen kaventumisen, sekä kolmen vaihtoehdon päätössääntö (Dialog · Sheet · SidePanel).

v0.4.0

2026-08-11
Lisätty

Sheet — reunasta liukuva sivupaneeli

<Sheet open={open} onOpenChange={setOpen}>
  <SheetContent size="lg">
    <SheetHeader>
      <SheetTitle>Toimittajan tiedot</SheetTitle>
    </SheetHeader>
    <SheetBody>{/* pitkä sisältö vierii tässä */}</SheetBody>
    <SheetFooter>
      <Button variant="ghost" onClick={() => setOpen(false)}>Peruuta</Button>
      <Button onClick={onSave}>Tallenna</Button>
    </SheetFooter>
  </SheetContent>
</Sheet>

Rakentuu samalle Radix-dialogille kuin Dialog, joten fokusansa, Esc, scroll-lukitus ja aria-attribuutit tulevat samasta lähteestä. Ei uutta riippuvuutta — @radix-ui/react-dialog on jo peer-riippuvuutena.

side: right (oletus) · left · top · bottom. size: sm · default · lg · full — leveys vaakasuunnassa, korkeus pystysuunnassa. Kapealla näytöllä paneeli on aina täysleveä.

`SheetBody` on se osa joka tekee tästä toimivan. Otsikko ja footer ovat kiinteitä, keskiosa vierii. Dialog vierittää koko sisältönsä, jolloin "Tallenna" katoaa näkyvistä pitkässä lomakkeessa — juuri silloin kun käyttäjä etsii sitä.

Tämä on uutta pintaa, ei kolmen toteutuksen yhdistämistä. Toisin kuin Changelog (0.3.0), yhdelläkään kuluttajalla ei ole omaa sivupaneelia: tarkistin kaikki kolme repoa ennen toteutusta. Komponentti ei siis korvaa mitään eikä vaadi migraatiota — se on saatavilla kun tarve tulee.

README:ssä on Dialog vs. Sheet -päätössääntö ja neljä käyttötapausta (detaljinäkymä, suodattimet, pitkä lomake, tapahtumaloki). Kaikki ovat klikattavina demo-sivustolla.

v0.3.0

2026-08-11
Lisätty

Changelog — versiohistorianäkymä jaettuna komponenttina

Jokaisella TP-palvelulla on /versiohistoria-sivu joka parsii oman CHANGELOG.md:nsä. Niitä oli neljä erillistä toteutusta samasta asiasta (TP-V, TP-IAM, AIMO ja tämän repon demo).

import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { Changelog, PageHeader } from "@karkkainen/ui";

export default async function Page() {
  const md = await readFile(join(process.cwd(), "CHANGELOG.md"), "utf8");
  return (
    <>
      <PageHeader title="Versiohistoria" subtitle="…" />
      <Changelog markdown={md} />
    </>
  );
}

Komponentti ei lue tiedostoa itse: polku on palvelukohtainen, ja node:fs rikkoisi sen asiakaspuolella. Kuluttaja antaa markdownin merkkijonona.

Jaettu versio osaa enemmän kuin mikään korvattavista. Kolme olemassa olevaa toteutusta pudottavat taulukot ja sisäkkäiset listat hiljaa — TP-V:n changelogissa on 72 taulukkoriviä ja 181 sisäkkäistä listamerkintää, jotka eivät nyt näy heidän omalla sivullaan. Lisäksi niiden versio-regex vaatii version ja päivämäärän, joten ## Julkaisematon katoaisi kokonaan.

Tuettu: versio-otsikot sekä ajatus- että tavuviivalla, versiottomat osiot, vapaamuotoiset ###-otsikot, koodilohkot, taulukot, sisäkkäiset listat ja inline-muotoilut. initialCount-propsi (oletus 10) siirtää vanhemmat julkaisut <details>-elementin taakse, jottei TP-V:n 86 julkaisua tuota valtavaa DOMia.

Viety myös parseChangelog ja sen tyypit, jos haluat renderöidä itse.

Migraatio on vapaaehtoinen. Muutos on puhtaasti additiivinen; olemassa olevat sivut toimivat ennallaan kunnes ne korvataan.

Lisätty

testit CHANGELOG-parserille

Parseri palvelee neljää palvelua, joten sen rikkoutuminen näkyisi neljässä paikassa. tests/changelog.test.mjs ajaa sen oikeita changelogeja vasten (kaikki neljä) eikä pelkkää synteettistä syötettä — juuri ne erot, jotka tässä oikeasti kaatoivat asioita, olisivat jääneet muuten huomaamatta.

Testit paljastivat heti yhden bugin: sisennetty luoti päätyi ylätasolle, koska alikohta-päätös laskettiin ennen edellisen merkinnän sulkemista.

npm test ajaa nyt --experimental-strip-types-lipulla, jotta TypeScript- lähdettä voi testata ilman käännösvaihetta.

Repolla on oma koti (2026-08-11)

Design system asuu nyt omassa GHE-repossaan: [`Karkkainen-AI/TP-DESIGN-SYSTEM`](https://karkkainen.ghe.com/Karkkainen-AI/TP-DESIGN-SYSTEM).

Tämä ei muuta pakettia eikä jakelua — contentHash on ennallaan, eikä vendoroitujen kopioiden tarvitse päivittyä. package.json:in repository-kenttä osoittaa nyt olemassa olevaan osoitteeseen (se ei kuulu sisältötiivisteeseen, joten kentän korjaus ei merkitse kopioita vanhentuneiksi).

Aiemmin kirjattu perustelu "uutta GHE-repoa ei voitu luoda" oli väärä — sitä ei ollut kokeiltu. Jakelupäätös ei silti muutu: AIMO:n allow-git=none estää git-riippuvuuden heillä joka tapauksessa, joten vendorointi on yhä ainoa tapa joka toimii kaikille kolmelle kuluttajalle. Ks. AGENTS.md §9.

Demo-sivuston riippuvuudet päivitetty

GHE:n Dependabot löysi kolme korkeaa haavoittuvuutta (postcss, sharp) Next.js:n omista riippuvuuksista. Korjattu overrides-kentällä pysyen Next 15.5:ssä — next@16 olisi tehnyt demosta huonon savutestin, koska kaikki kolme kuluttajaa ajavat 15.5:tä. npm audit: 0.

Ei vaikuta pakettiin. demo/ ei kuulu vendoroitavaan sisältöön.

v0.2.0

2026-08-11

Demo-sivuston rakentaminen paljasti yhdeksän epäjohdonmukaisuutta paketissa (ks. AGENTS.md §11.1). Kaikki on korjattu tässä versiossa.

Kahdeksan yhdeksästä ei vaadi kuluttajalta mitään. Yksi vaatii — lue ensimmäisenä.

⚠️ Vaatii toimenpiteen: TP-Vastaanotto

React Flow -teemoitus on siirretty pois styles/tokens.css:stä omaan valinnaiseen tiedostoonsa. Se ei kuulunut jaettuun tokenitiedostoon: se teemoittaa kolmannen osapuolen kirjastoa, joka ei ole edes paketin peer-riippuvuus, ja jokainen palvelu sai sen käyttipä sitä tai ei.

Jos palvelusi käyttää React Flow'ta, lisää globals.css:ään:

@import "@karkkainen/ui/styles/tokens.css";
@import "@karkkainen/ui/styles/react-flow.css";   /* ← uusi */

Tunnettu kuluttaja: TP-Vastaanoton `/topologia`. Ilman riviä kartan tumma teema regressoituu. Vika on hiljainen — mikään ei kaadu, kartta vain näyttää väärältä tummassa teemassa.

TP-IAM:illa on react-flow riippuvuutena mutta ei käyttöä lähdekoodissa; AIMO ei käytä sitä. Heitä muutos ei koske.

Poistettu
  • •`--header`, `--header-foreground`, `--header-muted` ja presetin header-väri. Ne oli dokumentoitu &rdquo;M365-tyyliseksi tummaksi navy headeriksi&rdquo;, mutta niillä oli 0 osumaa kaikissa kolmessa kuluttajassa — TP-V:n topbar on vaalea (bg-card). Kuollutta pintaa joka lupasi ilmettä jota ei ole olemassa. Jos tarvitset navy-headerin, määrittele se palvelussasi.
Lisätty
  • •`UploadDropzone`: `title`, `description`, `accept`, `multiple`. Tekstit olivat kovakoodattuja TP-Vastaanoton käyttötapaukseen (&rdquo;Tuettu: XLSX, CSV, XML, JSON, ZIP&rdquo;), mikä teki komponentista kelvottoman muille. Oletukset ovat entiset. multiple={false} korvaa edellisen valinnan sen sijaan että kasvattaisi listaa.
  • •`UserMenu`: `profileHref`. Oletuksena https://tp-iam.karkkai.ioa.fi/oma-tili — TP-IAM:in konventio ei enää ole jokaisen palvelun erikseen muistettava. onProfile voittaa yhä oletuksen, ja profileHref={null} piilottaa rivin. TP-V ja TP-IAM antavat jo onProfilen, joten heidän käytöksensä ei muutu.
  • •`timeAgo(iso, now?)`. Funktio luki Date.now():n sisäisesti, jolloin palvelinrenderöinti ja hydraatio tuottivat eri tuloksen ja React valitti. Ilman argumenttia käytös on entinen.
  • •`--brand` ja `--brand-foreground` CSS-muuttujina. Brändiväri oli ainoa kovakoodattu preseteissä, joten se ei reagoinut teemaan eikä ollut palvelun ohitettavissa. Preset lukee ne fallbackilla hsl(var(--brand, 0 72% 50%)), joten bg-brand toimii silloinkin kun tokens.css:ää ei tuoda.
  • •`UploadDropzoneProps` viety `index.ts`:stä.
Korjattu
  • •`Badge`-variantit `success`/`warning`/`info` saivat `dark:`-haaran. Ne käyttävät kiinteitä Tailwind-sävyjä (tokenipaletissa ei ole onnistumis- tai varoitusväriä) ja jäivät siksi vaaleiksi laikuiksi tummassa teemassa.
  • •`UserMenu`:n avatar käyttää `bg-brand`:iä kovakoodatun bg-red-600:n sijaan. Visuaalisesti sama väri. Uloskirjautuminen on tarkoituksella yhä oma punaisensa: se on tuhoavan toiminnon väri, ei brändiväri.
  • •`tailwind-preset.ts`:n docstring neuvoi monorepo-polkua "../../packages/ui/src/**", joka ei ole toiminut extractin jälkeen.
Lisätty

kuluttajan eheystarkistus (verify.mjs)

Kopion mukana matkustaa nyt verify.mjs, joka toimii ilman DS-repoa:

node vendor/karkkainen-ui/verify.mjs    # 0 ok · 2 ei leimaa · 3 muokattu

Se laskee kopion oman sisällön tiivisteen ja vertaa sitä kopion omaan leimaan, eli havaitsee paikallisen muokkauksen. Ei riippuvuuksia, ei gitiä, ei verkkoa — todennettu kontissa jossa kumpaakaan ei ole.

Tämä sulkee aukon jonka TP-IAM löysi migraatiossaan: vendor:check on cross-repo-työkalu eikä toimi kuluttajan ympäristössä. Työnjako on nyt selvä — staleness = ylläpitäjän `vendor:check`, muokkaus = kuluttajan `verify.mjs`.

Ehdotus oli TP-IAM:in; he tarjoutuivat toteuttamaan sen omalle puolelleen, mutta se olisi johtanut kolmeen rinnakkaiseen toteutukseen — juuri siihen ongelmaan jonka takia design system irrotettiin. Nyt se tulee kaikille kopion mukana.

Tiivistelogiikka siirtyi verify.mjs:ään ja scripts/vendor.mjs importtaa sen sieltä. Yksi toteutus: jos leimaus ja tarkistus laskisivat tiivisteen eri tavalla, jokainen tuore kopio näyttäisi muokatulta.

9 testiä (tests/verify.test.mjs), joista tärkein varmistaa että tarkistus toimii eristettynä DS-reposta — muuten se testaisi vain sitä että lähde sattuu olemaan saatavilla.

Lisätty

testit vendorointityökalulle

tests/vendor.test.mjs — 15 testiä (+ 9 verify.test.mjs:ssä), npm test. Nodein sisäänrakennettu test runner, ei riippuvuuksia (sama linja kuin työkalulla itsellään).

Työkalu oli tähän asti ilman testejä, vaikka kolmen tuotantopalvelun riippuvuudenhallinta lepää sen varassa ja molemmat sen viat löytyivät vasta käytössä.

Testit ajavat työkalua kertakäyttöistä fixture-repoa vasten (VENDOR_SOURCE_ROOT, vain testejä varten) eivätkä tätä repoa — siksi myös likaisen työpuun tapaus on testattavissa sotkematta oikeaa lähdettä.

Kattaa: leiman kentät, scripts-kentän poiston, jakelun ulkopuolisten tiedostojen poisrajauksen, likaisen työpuun eston ja --allow-dirty-ohituksen, tarkistuksen kaikki neljä paluuarvoa, poistettujen tiedostojen siivouksen ja leiman determinismin.

Todennettu mutaatiotestillä, koska läpimenevä testi ei todista mitään ellei se osaa myös epäonnistua: kun scripts-poisto palautetaan, juuri se testi kaatuu; kun tarkistus vaihdetaan takaisin commit-vertailuun, kaatuu juuri dokumentaatiocommitti-testi.

Korjattu

dokumentaatiotiedostot eivät enää merkitse kopioita vanhentuneiksi

README.md ja CHANGELOG.md kuuluvat vendoroitavaan sisältöön, joten ne laskettiin myös sisältötiivisteeseen — ja jokainen changelog-merkintä merkitsi kaikki kolme tuotantokopiota vanhentuneiksi. Se on täsmälleen se turha hälytys jonka poistamiseksi tiiviste alun perin tehtiin; vika paljastui heti kun tarkistus ajettiin tämän merkinnän kirjoittamisen jälkeen.

Tiivisteeseen lasketaan nyt vain se mikä vaikuttaa kuluttajan buildiin (src/, styles/, tailwind-preset.ts, tsconfig.json). Dokumentaatio kopioidaan edelleen mukaan, jotta kuluttaja näkee mikä muuttui.

Tarkistus vastaa siis kysymykseen "vaikuttaako lähteen muutos minuun", ei kysymykseen "onko jokin tiedosto eri".

Korjattu dokumentaatiossa

mistä vendor:check:n voi ajaa

README ja AGENTS.md väittivät että tarkistus sopii "kuluttajan käynnistysskriptiin". Se ei ole mahdollista: tarkistus lukee DS-lähderepon git-tilan ja laskee sen sisällöstä tiivisteen verratakseen, eikä DS-repoa ole kuluttajan kontissa. Kyseessä on cross-repo-työkalu, joka toimii vain siellä missä sekä tämä repo että kuluttajan kopio ovat läsnä.

TP-IAM lupasi väitteen perusteella kytkeä tarkistuksen konttinsa käynnistykseen ja huomasi migraatiossa ettei se voi toimia. Virhe oli dokumentaatiossa, ei heidän ymmärryksessään.

Kirjattu samalla heidän ehdotuksensa paikallisesta eheystarkistuksesta (kuluttaja laskee oman kopionsa tiivisteen ja vertaa omaan leimaansa — havaitsee muokkauksen muttei staleneutta) päätettäväksi: AGENTS.md §7.

Korjattu

laskentatavan vaihdos ei enää syytä kopiota muokatuksi

Edellinen korjaus (dokumentaatio pois tiivisteestä) muutti tiivisteen laskentatapaa, jolloin vanhalla tavalla lasketut leimat eivät enää täsmänneet. Tarkistus tulkitsi eron peukaloinniksi ja antoi kahdelle koskemattomalle tuotantokopiolle ⛔-tuomion paikallisesta muokkauksesta.

Leimaan kirjataan nyt hashVersion. Jos kopion versio eroaa nykyisestä, tarkistus kertoo suoraan että vertailu ei ole mahdollinen ja pyytää ajamaan vendoroinnin uudelleen — eikä epäile kopion sisältöä. Paluuarvo 2.

Kasvata `HASH_VERSION`:ia aina kun `HASHED` tai `payloadHash` muuttuu.

Vika löytyi ajamalla tarkistus oikeita tuotantokopioita vasten heti edellisen korjauksen jälkeen. Testit eivät olisi löytäneet sitä: ne kaikki kirjoittivat leiman tuoreella algoritmilla, joten yksikään ei koskaan kohdannut vanhaa muotoa. Testi tälle lisätty.

Korjattu

vendorointityökalun kaksi puutetta

Molemmat paljastuivat TP-Vastaanoton migraatiossa (ensimmäinen kuluttaja joka siirtyi vendorointiin).

1. Turhat `scripts` vendoroidussa kopiossa. Leimattu package.json peri scripts.vendor-komennot, jotka osoittivat scripts/vendor.mjs:ään — tiedostoon jota ei vendoroida. Kopiossa ne olivat rikkinäisiä viittauksia. Kenttä poistetaan nyt leimauksen yhteydessä.

2. Tarkistus vertasi committia, ei sisältöä. Jokainen dokumentaatio- tai demo-commit merkitsi kaikki kopiot vanhentuneiksi vaikka paketin sisältö oli ennallaan. Osui heti TP-V:n migraatioon: leima piti päivittää commitista, jonka ainoa muutos oli demo/-hakemistossa.

Leimaan kirjataan nyt myös vendoroitavan sisällön sha256-tiiviste (contentHash; package.json on laskennasta pois, koska siihen kirjoitetaan leima). Tarkistus nojaa tiivisteeseen, ja commit säilyy alkuperätietona — sen eroaminen raportoidaan huomautuksena eikä varoituksena.

Sivutuote: tiiviste paljastaa myös jos kopiota on muokattu paikallisesti. Sääntö "`vendor/` on generoitua, älä muokkaa" oli tähän asti pelkkä toive; nyt se on valvottavissa. Uusi paluuarvo 3.

Vanhat leimat ilman tiivistettä toimivat edelleen — tarkistus putoaa takaisin commit-vertailuun ja kertoo siitä.

Lisätty

demo-sivusto

demo/ — Next.js-sovellus joka renderöi kaikki komponentit varianteilleen, design-periaatteet oikein/väärin -pareina ja tokenit molemmissa teemoissa. Julkaistu osoitteessa tp-ds-ui-kit.karkkai.ioa.fi IP-allowlistin takana.

Paketin koodiin ei koskettu. Tämä ei siis vaikuta yhteenkään kuluttajaan eikä vaadi uutta versiota: demo/ ei kuulu vendor.mjs:n PAYLOAD-listaan, joten se ei päädy vendoroituihin kopioihin.

Sivusto kuluttaa pakettia vendoroituna kopiona samalla reseptillä kuin tuotantopalvelut, joten se toimii savutestinä README:n käyttöönotto-ohjeelle. Ohje oli tähän asti testaamaton; nyt todennettu toimivaksi (transpilePackages, Tailwindin content-polku ja @import tokeneihin).

Sivuston rakentaminen paljasti yhdeksän epäjohdonmukaisuutta paketissa (mm. käyttämättömät header-tokenit, UserMenu:n kovakoodattu brändiväri ja Badge-varianttien puuttuva tumma teema). Yhtäkään ei ole korjattu — ne on kirjattu AGENTS.md §11.1:een päätettäväksi yhdessä kuluttajien kanssa.

v0.1.0

2026-08-10

Irrotettu omaksi repokseen

Design system asui aiemmin TP-Vastaanoton monorepossa hakemistossa packages/ui, mistä TP-IAM ja AIMO kopioivat sen itselleen. Kolme kuluttajaa, kolme eri jakelutapaa, ei synkronointiprosessia.

Extract tehtiin hetkellä jolloin kaikki kolme kopiota olivat bittitarkasti identtisiä, joten sovitustyötä ei tarvittu.

Koodiin ei koskettu. Tämä versio vastaa sisällöltään täsmälleen TP-Vastaanoton packages/ui:ta commitissa c9e0cc1:

  • •13 komponenttia: Button, Card, Badge, Input, Table, Popover, DropdownMenu, Dialog, PageHeader, AppLauncher, UserMenu, UploadDropzone, ThemeProvider
  • •Design-tokenit (styles/tokens.css) ja Tailwind-preset
  • •Utilit: cn, formatBytes, formatDateTime, timeAgo

Muutettu vain dokumentaatio: README.md:n asennusohjeet vastaamaan omaa repoa, uusi AGENTS.md (nykytila, kuluttajat, migraation tilanne) ja tämä tiedosto.

Migraatio on tekemättä — yksikään palvelu ei vielä kuluta tätä repoa. Ks. AGENTS.md §2.

Jakelu: vendorointi + versioleima

Jakelutavaksi päätettiin vendorointi, koska uutta GHE-repoa ei voitu luoda eikä git-riippuvuus siksi ole mahdollinen (ks. AGENTS.md §7).

scripts/vendor.mjs (ei riippuvuuksia):

  • •npm run vendor -- <polku> kirjoittaa kopion ja leimaa sen package.json:iin vendored-kentällä: versio, commit-sha ja commitin aikaleima. Kieltäytyy jos työpuu on likainen (--allow-dirty ohittaa).
  • •npm run vendor:check -- <polku> kertoo onko kopio ajan tasalla ja kuinka monta committia jäljessä. Paluuarvot: 0 ajan tasalla, 1 vanhentunut, 2 ei leimaa.

Leima ratkaisee vendoroinnin pahimman heikkouden: aiemmin yksikään kolmesta kuluttajasta ei pystynyt sanomaan mikä versio heillä on ajossa ilman git-arkeologiaa.

CHANGELOG.md lisätty files-kenttään, jotta kuluttaja näkee vendoroidusta kopiosta mikä muuttui.