AppFrame
Kuori
Sivupalkki (palvelun vaihtaja, navigaatio, oma tili), vierivä sisältökortti ja yläpalkki pienennyksineen, murupolkuineen ja ympäristömerkkeineen (päätös 0004).
Käyttöpaikat
Suoraan: palvelu tuo osan itse. Välillisesti: osa tulee mukaan toisen paketin osan kautta, ja sen muutos näkyy palvelussa silti. Laskettu julkaisun yhteydessä 23.10.2026.
Käyttö
Paketin README:n luku sellaisenaan.
ServiceSwitcher — palvelun vaihtaja
Sivupalkin ylälaitaan (AppFrame sidebarHeader). Palvelun nimi painikkeena, valikossa TP-palvelut monogrammeineen. Pienennettynä pelkkä monogrammi.
<ServiceSwitcher services={palvelut} currentId="tpp" LinkComponent={Link} />Monogrammi on yksi kirjain tai kaksi, jos toisella palvelulla on sama alkukirjain (serviceMonogram). Ulkoiset osoitteet (https://…) ovat tavallisia linkkejä ja sisäiset LinkComponentilla.
AccountMenu — oma tili -painike
Sivupalkin alalaitaan (sidebarFooter) ja compact-muodossa yläpalkkiin kapealle näytölle (headerEnd), koska sivupalkki on silloin piilossa.
<AccountMenu
name={me.name}
email={me.email}
links={[{ label: "Asetukset", href: "/asetukset", icon: Settings }]}
LinkComponent={Link}
shades
onLogout={kirjauduUlos}
/>Teemavalinta käyttää next-themesiä (showTheme={false} piilottaa sen).
Sävy on jaettu kuten teema (0.58.0, palvelun omistaja 23.10.2026). Kolme riviä palvelussa:
@import "@karkkainen/ui/styles/savyt.css"; /* theme.css:n jälkeen */
<ThemeProvider attribute="class" enableSystem cookieDomain={process.env.SESSION_COOKIE_DOMAIN} shades><AccountMenu … shades />
Valinta tallentuu tp-savy-evästeeseen jaetulle domainille, ja esilataus asettaa <html data-vari> ennen ensimaalausta: Punaisen valinnut näkee Punaisen jokaisessa palvelussa ilman välähdystä. Neutraali = ei määrettä, joten palvelu näyttää oletuspintansa kuten ennen sävyjä; pikselivertailu neutraalilla on siis nollaero. Ilman ThemeProvider shades valikko ei näytä sävyjä, koska valinta ei tallentuisi.
- `cookieDomain` on se, joka jakaa. Ilman sitä eväste on host-kohtainen: sävy toimii ja pysyy tässä palvelussa mutta ei kulje muihin, eikä mikään kerro siitä (TP-TTK:n vertaisarvio 23.10.2026; teemalla kävi samoin).
.karkkai.ioa.fikattaa myös dev- ja staging-osoitteet, ja se on tarkoitus. - Jo auki oleva välilehti toisessa palvelussa saa uuden sävyn vasta uudelleenlatauksessa, kuten teemakin. Se ei ole vika.
- Sävypallojen luokat ovat
src/lib/savy.ts:ssä, jonka paketin@source "../src"skannaa.useShade()antaa valinnan omaan käyttöön ({ shade, setShade }tainull).
Palvelun oma toteutus (shades={{ options, value, onChange }}) toimii yhä, mutta silloin valinta ei kulje muihin palveluihin. Avatar on oletuksena nimikirjain, ja avatar-propsi korvaa sen.
NavList — sivupalkin navigaatio
Sivupalkin listaus. AppFrame ja AppShell piirtävät sen itse nav-propsista, joten palvelu antaa vain datan (NavGroup[], lib/nav.ts). Mitat ovat TP-PERUSTUKSEN mallin mukaiset (palvelun omistaja 23.10.2026), eikä palvelu säädä niitä omalla CSS:llään.
Rytmi (mitattu TP-PERUSTUKSESTA ja esittelysivustolta 23.10.2026, 1400 px):
| Osa | Mitta | Luokat |
|---|---|---|
| Navigaation täyte | 12 px | p-3 |
| Kohde (rivi) | 32 px: teksti 14 px / 20 px + täyte 6 px ylä ja ala. Tausta (hover, nykyinen) 2 px sisempänä molemmin puolin, sisältö 16 px taustan vasemmasta reunasta, oikealla 10 px | mx-0.5 ps-4 pe-2.5 py-1.5 text-sm |
| Kuvake | 17 px, väli tekstiin 10 px | size-[1.0625rem], gap-2.5 |
| Ryhmän otsikko | 30 px: 12 px / 18 px keskipaksu, pienaakkosin, täyte 8 px ylä ja 4 px ala, vasemmalla 18 px | ps-[1.125rem] pt-2 pb-1 text-[0.75rem] leading-[1.125rem] font-medium |
| Vaakatasaus | Kuvake ja otsikko alkavat 30 px sivupalkin reunasta, palvelun vaihtajan kirjaimet 32 px:stä; rivin tausta 14 px reunoista (256 px:n sivupalkissa 14–242 px) | |
| Ryhmien väli | 16 px edellisen ryhmän viimeisestä kohdasta otsikkoon | space-y-4 |
| Kohteiden väli | 0 px: kohteet ovat kiinni toisissaan, rytmi tulee rivin korkeudesta | — |
| Kulmat | rounded-md eli --radius − 2 px (TP-PERUSTUKSESSA 8 px) |
Ryhmäotsikosta seuraavan otsikon kohdalle on siis 30 + n × 32 + 16 px. TP-PERUSTUKSESSA Yleisnäkymä on 12 px:n kohdalla, Tavarantoimittajat-otsikko 60 px:n ja Toimittajat 90 px:n.
Tilat
- Aktiivinen (
aria-current="page"): vaaleassa--nav-hover, tummassabg-accent; keskipaksu,text-accent-foreground. Kohta on aktiivinen myös alipolulla;exact: truerajaa tarkkaan osumaan. - Lepo: teksti
text-foreground/82eli ei apuharmaa, jotta kohta ja ryhmäotsikko erottuvat (omistaja 22.10.2026). Otsikko on apuharmaa (text-muted-foreground). - Hover ja painallus (
:hover,:active): vaaleassa--nav-hover, tummassabg-accent/60; täysi tekstin väri.--nav-hoveron sivupalkin oma sävy 0,0254 tummempana OKLCH:ssa (omistaja 23.10.2026): Neutraali#FAFAFA→#F1F1F1, Petroli#EDF7FA→#E5EFF1, Kanerva#FAF2F9→#F1EAF1, Punainen#F5F5F0→#EDEDE8. - Pienennetty sivupalkki (72 px): rivi on pelkkä keskitetty kuvake, ryhmäotsikot ja merkit piilossa, nimi jää ruudunlukijalle (
sr-only) ja vihjeeksi (title).
Data
import { LayoutDashboard, Truck, Tags } from "lucide-react";
import type { NavGroup } from "@karkkainen/ui";
const nav: NavGroup[] = [
{ label: null, items: [{ href: "/", label: "Yleisnäkymä", icon: LayoutDashboard }] },
{ label: "Tavarantoimittajat", items: [{ href: "/toimittajat", label: "Toimittajat", icon: Truck }] },
{
label: "Kärkkäinen",
items: [{
href: "/luokittelu",
label: "Luokittelu",
icon: Tags,
// Lukumäärä rivin oikeaan reunaan (TP-PERUSTUKSEN malli).
badge: <span className="ms-auto rounded-full bg-muted px-1.5 text-xs tabular-nums text-muted-foreground">99</span>,
}],
},
];
<AppFrame nav={nav} currentPath={pathname} LinkComponent={Link} /* … */ />- Ensimmäinen ryhmä ilman otsikkoa (
label: null) on tavallinen aloitusnäkymälle. - Ryhmän nimi on organisaation sanastoa ("Tavarantoimittajat", "Kärkkäinen", "Hallinta"), ei teknistä luokittelua.
- Kuvakkeet lucide-reactista. Älä anna kuvakkeelle omaa kokoa: rivi piirtää sen 17 px:nä.
- Lukumäärä
badgeina:ms-autovie sen oikeaan reunaan. Näytä luku vain, kun se on käyttäjän oma työ (TP-PERUSTUKSESSA omat keskeneräiset luokittelut), ja jätä merkki pois nollalla tai kun lukua ei saatu (älä näytä nollaa mittauksen epäonnistuessa). - Älä säädä rytmiä palvelun CSS:llä (esim.
#tp-app-frame-sidebar nav a { padding … }). Jos mitta on väärä, korjaa se tähän pakettiin, jotta kaikki palvelut pysyvät samassa rytmissä.
AppLauncher (palvelu-launcher)
import NextLink from "next/link";
import { AppLauncher, type AppService } from "@karkkainen/ui";
const services: AppService[] = [
{ id: "tpv", name: "TP-Vastaanotto", short: "TPV", description: "…",
href: "/", color: "bg-sky-500", current: true },
{ id: "tpp", name: "TP-Perustus", short: "TPP", description: "…",
href: "https://tp-perustus.karkkai.ioa.fi", external: true, color: "bg-amber-500" },
// …muut palvelut
];
export function Header() {
return <AppLauncher services={services} LinkComponent={NextLink} />;
}> ⚠️ `href` on absoluutti kaikille paitsi omalle palvelulle. Waffle-valikko > on määritelmällisesti cross-origin: suhteellinen href osoittaa aina siihen > palveluun jossa valikko renderöityy, ei siihen johon tiili viittaa. > > Ansa on hiljainen: väärästä näkökulmasta kirjoitettu arvo ei ole > rikkinäinen linkki vaan toimivalta näyttävä väärä linkki, joka vie > käyttäjän takaisin sinne mistä hän tuli. > > Tarkista siis mitä lähteesi lupaa: palauttaako se URLin jota voi käyttää > mistä tahansa, vai kunkin palvelun omasta näkökulmasta kirjoitetun. > TP-IAM:n palvelurekisteri lupaa edellisen ja dokumentoi sen — ks. > rajapinta.md, GET /api/iam/services. > > Havainto on TP-TTK:lta, joka törmäsi siihen waffle-valikkoa kytkiessään.
UserMenu
import { UserMenu } from "@karkkainen/ui";
<UserMenu name="Operaattori" email="ops@karkkainen.fi" initial="K" />AppShell — sovelluskehys
Topbar (waffle + palvelun nimi + haku + pikatoiminnot + käyttäjävalikko), sivupalkki ja sisältöalue. Design-ohje vaatii saman kehyksen kaikilta TP-palveluilta, ja tämä on se kehys.
// app/(app)/layout.tsx
"use client";
import NextLink from "next/link";
import { usePathname } from "next/navigation";
import { AppShell, UserMenu, type NavGroup } from "@karkkainen/ui";
const nav: NavGroup[] = [
{ label: null, items: [{ href: "/", label: "Yleisnäkymä", icon: Home }] },
{
label: "Operatiivinen",
items: [
{ href: "/saapuvat", label: "Saapuvat", icon: Inbox },
{ href: "/lataa", label: "Lataa", icon: UploadCloud },
],
},
];
export default function Layout({ children }) {
return (
<AppShell
product="TP-Vastaanotto"
services={services}
nav={nav}
currentPath={usePathname()}
LinkComponent={NextLink}
user={<UserMenu name={session.name} email={session.email} />}
>
{children}
</AppShell>
);
}| Propsi | Merkitys |
|---|---|
product / productHref | Palvelun nimi topbarissa ja sen linkki |
services | Waffle-valikon sisältö. Suodata kykyjen mukaan ennen antamista |
nav / navFooter | Sivupalkki ryhmiteltynä; footer kiinnittyy alalaitaan |
currentPath | Aktiivisen kohdan tunnistus. Nextissä usePathname() |
LinkComponent | Esim. next/link. Oletuksena <a> |
search · actions · user | Slotit topbariin |
hideSidebar | Piilottaa sivupalkin (esim. kirjautumisnäkymä) |
contentClassName | Sisältöalueen luokat. `max-w-none` datapainotteisille näkymille |
sidebarCollapsible | Pienennyspainike sivupalkin alalaitaan. Oletus `true` |
defaultSidebarCollapsed | Alkutila kun mitään ei ole muistissa. Oletus false |
sidebarStorageKey | localStorage-avain. null = ei muisteta. Oletus "tp-sidebar" |
> Navigaatio kapeilla näytöillä. AppShell piilottaa sivupalkin lg:n > (1024 px) alapuolella ja näyttää tilalle hampurilaispainikkeen, joka avaa > saman listan Sheetissä. Sitä ei tarvitse kytkeä — kehys renderöi sen > itse kun nav on annettu. > > Raja on lg eikä md, koska tabletti pystyasennossa on 768 px ja > sivupalkki veisi siitä kolmanneksen.
> Sivupalkin pienennys (0.36.0). Alalaidassa on painike joka kutistaa > sivupalkin 56 px:n ikonipalkiksi — ei piiloon, vaan *kiinnitettynä > vasempaan sivuun minimoituna*. Valinta muistetaan localStorageiin ja > palautetaan ennen ensimmäistä maalausta, joten se ei välähdä auki. > > ⚠️ Pienennys ei leventää mitään jos sisältö on rajattu. Oletus > max-w-6xl (1152 px) syö koko 184 px:n hyödyn yli 1400 px:n näytöillä. > Pienennys ja contentClassName="max-w-none" kuuluvat yhteen. > > Tila asuu juuren data-sidebar-attribuutissa eikä Reactissa, ja se on > ehto eikä tyylivalinta: currentPath tulee usePathname():stä, joten > kehys renderöityy uudelleen jokaisella navigoinnilla — propsista > renderöity attribuutti palautuisi ja sivupalkki ponnahtaisi auki sivua > vaihtaessa. > > ⚠️ Muistaminen nojaa inline-skriptiin, ja CSP voi estää sen > hiljaa. AppShell kirjoittaa kehyksen juureen pienen skriptin joka > palauttaa tilan ennen maalausta — se on paketin ainoa inline-skripti. > script-src ilman noncea estää sen, eikä mikään kaadu: pienennys > toimii, mutta palkki piirtyy ensin auki. Mitattu 14.10.2026: nolla > viidestä kuluttajasta rajoittaa skriptejä. sidebarStorageKey={null} > jättää skriptin pois kokonaan. > > Omaan kehykseen: SidebarToggle on viety erikseen. Kolme asiaa > kutsujalta — data-shell-kiinnityskohta group/shell-luokalla, leveys > lg:group-data-[sidebar=collapsed]/shell:w-14, ja painike sivupalkin > sisälle.
> ⚠️ Kaksi asiaa jotka kannattaa tarkistaa kun otat kehyksen käyttöön. > Molemmat tulivat TP-Vastaanoton käyttöönotosta. > > Sisältöalueen leveys. Oletus on max-w-6xl. Heidän /saapuvat-taulukkonsa > kapeni 1920 px:n näytöllä 1680 px:stä 1152 px:ään. Anna > contentClassName="max-w-none" jos näkymä on datapainotteinen. > > `sticky top-0` sivun sisällä menee topbarin alle, koska kehys tekee > sivusta scrollaavan. Käytä top-12. Jos vanha kehyksesi ei scrollannut, > arvo on ollut merkityksetön — muutos paljastaa vian jota ei ollut > olemassa ennen vaihtoa.
> 💡 `PageHeader` nimeää näkymän, ei palvelua. Kehyksen topbar kantaa jo > tuotenimen, joten title="TP-TTK" lukisi sen ruudulla kahdesti. Oikea > otsikko on se mitä sivu on: "Keräyspyynnöt", "Saapuvat", "Käyttäjät". > > Tähän törmää väistämättä jokainen joka käytti PageHeaderiä jo ennen > kehystä — havainto on TP-TTK:lta. PageHeader ei muutu, sen rooli muuttuu.
> ⚠️ Taulukko grid- tai flex-lapsessa tarvitsee `min-w-0`. Kehyksen > <main> on min-w-0 flex-1, eli se voi kutistua — mutta jos laitat > taulukon omaan grid-sarakkeeseesi, se sarake ei kutistu ilman > min-w-0:aa, ja Tablen oma overflow-auto ei pääse toimimaan. Oire on > että sisältö vuotaa oikealta ja koko sivu saa vaakavierityksen. > > Vika ei ole kehyksessä eikä Tablessa: kumpikin tekee osuutensa oikein. > Flexin ja gridin oletus min-width: auto estää kutistumisen sisällön alle, > ja se ehto on näkymätön kunnes joku laittaa leveää dataa sisään. Havainto > on TP-TTK:lta, joka mittasi lopputuloksen viidellä leveydellä 768–1920 px.
Proxytetyt reitit (route handlerit joita kehys ei reititä sisäisesti) merkitään navigaatiossa external: true, jolloin ne renderöityvät tavallisena <a>:na:
{ href: "/docs/", label: "Dokumentaatio", icon: BookOpen, external: true }Navigaatio on dataa, ei JSX:ää. Kykysuodatus on siksi tavallista taulukon suodatusta:
const nav = kaikkiRyhmat
.map((g) => ({ ...g, items: g.items.filter((i) => sallittu(i)) }))
.filter((g) => g.items.length > 0);Ei hookkeja eikä kehysriippuvuuksia — polku ja linkkikomponentti tulevat propseina, joten sama komponentti toimii Nextissä, Vitessä ja palvelinkomponentissa.
isNavItemActive on viety erikseen jos rakennat oman navigaation. Juuri (/) vaatii tarkan osuman; muut hyväksyvät alipolun mutta kauttaviivan kanssa, jottei /toimittajat aktivoidu polulla /toimittajat-arkisto.
Jos yleiskatsaus on oman polkunsa alla eikä juuressa (/vastaanotto ja sen alla /vastaanotto/noudot), anna sille exact: true. Muuten se pysyy aktiivisena jokaisella alasivulla, ja navigaatiossa on kaksi aria-currentia yhtä aikaa.
variant="inset" — sisältö korttina
Oletuskehys on valkoinen topbar ja sivupalkki reunaviivoin, sisältö haalealla pohjalla. inset kääntää sen: pohja on haalea ja sisältö on sen päällä pyöristettynä korttina, 10 px pohjaa ympärillä ja kerrostettu varjo (shadow-surface, presetissä). Sivupalkki ja topbar istuvat pohjalla ilman reunaviivoja.
<AppShell variant="inset" product="TP-Vastaanotto" nav={nav} currentPath={usePathname()} LinkComponent={NextLink}>
{children}
</AppShell>Muoto tuli TP-Perustuksen prototyypistä: kun navigaatio ja sisältö ovat samalla valkoisella, raskas taulukko ja sivupalkki sulautuvat yhdeksi pinnaksi. Kortti tekee sisällöstä sen, mitä katsotaan. Tummassa teemassa varjo ei erotu, joten kortin reunan tekee ohut vaalea rengas. contentClassName koskee molemmissa muodoissa samaa sisältökäärettä.
Tuonti ja lähde
import { AppFrame, AppShell, SidebarToggle, NavList, MobileNav, AppLauncher, ServiceSwitcher, ACCOUNT_PROFILE_LINK, AccountMenu, HEADER_SEARCH_INPUT, HeaderSearch, UserMenu, ThemeProvider } from "@karkkainen/ui";src/components/app-frame.tsxsrc/components/app-shell.tsxsrc/components/sidebar-toggle.tsxsrc/components/nav-list.tsxsrc/components/mobile-nav.tsxsrc/components/app-launcher.tsxsrc/components/service-switcher.tsxsrc/components/account-menu.tsxsrc/components/header-search.tsxsrc/components/user-menu.tsxsrc/components/theme-provider.tsx