@pokela/components 0.0.0-stage → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Accordion.d.ts ADDED
@@ -0,0 +1,34 @@
1
+ import { type ReactNode } from 'react';
2
+ /**
3
+ * Avautuva rivi.
4
+ *
5
+ * Vain yksi kerrallaan auki — siitä huolehtii <AccordionGroup>.
6
+ *
7
+ * Rivi itse EI ole listarivin sukulainen vaan sama rivi: se käyttää
8
+ * `.list-row`-runkoa modifioijalla `--expandable`. Ero on siinä että
9
+ * tämä on <button> eikä linkki, ja siksi se on oma komponenttinsa —
10
+ * avaustila, paneeli ja ryhmälogiikka eivät kuulu linkkiin.
11
+ *
12
+ * Toteutus on <button> + region, ei <details>, koska "vain yksi auki"
13
+ * vaatii jaetun tilan ja koska avautuva sisältö sisältää otsikoita
14
+ * joiden pitää pysyä dokumentin rakenteessa.
15
+ */
16
+ export type AccordionProps = {
17
+ number: string;
18
+ title: string;
19
+ description: string;
20
+ meta: string;
21
+ children: ReactNode;
22
+ open: boolean;
23
+ onToggle: () => void;
24
+ labels: {
25
+ open: string;
26
+ close: string;
27
+ };
28
+ };
29
+ export declare function Accordion({ number, title, description, meta, children, open, onToggle, labels, }: AccordionProps): import("react/jsx-runtime").JSX.Element;
30
+ /** Pitää huolen että vain yksi rivi on auki kerrallaan. */
31
+ export declare function useAccordionGroup(): {
32
+ isOpen: (slug: string) => boolean;
33
+ toggle: (slug: string) => void;
34
+ };
package/Accordion.js ADDED
@@ -0,0 +1,16 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useId, useState } from 'react';
4
+ import Icon from './Icon';
5
+ export function Accordion({ number, title, description, meta, children, open, onToggle, labels, }) {
6
+ const id = useId();
7
+ return (_jsxs("div", { className: "accordion", children: [_jsxs("button", { type: "button", className: "list-row list-row--numbered list-row--expandable bleed", "aria-expanded": open, "aria-controls": id, onClick: onToggle, children: [_jsx("span", { className: "meta list-row__number", children: number }), _jsxs("span", { className: "list-row__main", children: [_jsx("span", { className: "list-row__title", children: title }), _jsx("span", { className: "list-row__description", children: description })] }), _jsx("span", { className: "meta list-row__meta", children: meta }), _jsx(Icon, { name: open ? 'minus' : 'plus', size: "l", className: "list-row__icon" }), _jsx("span", { className: "visually-hidden", children: open ? labels.close : labels.open })] }), open ? (_jsx("div", { id: id, className: "accordion__panel page", role: "region", "aria-label": title, children: children })) : null] }));
8
+ }
9
+ /** Pitää huolen että vain yksi rivi on auki kerrallaan. */
10
+ export function useAccordionGroup() {
11
+ const [openSlug, setOpenSlug] = useState(null);
12
+ return {
13
+ isOpen: (slug) => openSlug === slug,
14
+ toggle: (slug) => setOpenSlug((current) => (current === slug ? null : slug)),
15
+ };
16
+ }
package/Grid.d.ts ADDED
@@ -0,0 +1,35 @@
1
+ import type { CSSProperties, ElementType, ReactNode } from 'react';
2
+ /**
3
+ * 12-sarakkeinen grid (4 → 8 → 12).
4
+ *
5
+ * Sarakemäärä tulee tokenista `--columns`, joten komponentti ei tiedä
6
+ * breakpointeista mitään. Sijoittelu annetaan sarakkeina, ei pikseleinä:
7
+ *
8
+ * <Col base={4} md={7}> → koko leveys mobiilissa, 7/12 työpöydällä
9
+ * <Col base={4} md={4} startMd={9}> → sarakkeet 9–12
10
+ */
11
+ type GridProps = {
12
+ children: ReactNode;
13
+ as?: ElementType;
14
+ className?: string;
15
+ style?: CSSProperties;
16
+ };
17
+ export declare function Grid({ children, as: Tag, className, style }: GridProps): import("react/jsx-runtime").JSX.Element;
18
+ type ColProps = {
19
+ children: ReactNode;
20
+ /** Sarakkeita 0–599px (max 4). Oletus: koko leveys. */
21
+ base?: number;
22
+ /** Sarakkeita 600–899px (max 8). Oletus: base skaalattuna. */
23
+ sm?: number;
24
+ /** Sarakkeita ≥900px (max 12). Oletus: sm. */
25
+ md?: number;
26
+ /** Aloitussarake ≥600px. */
27
+ startSm?: number;
28
+ /** Aloitussarake ≥900px. */
29
+ startMd?: number;
30
+ as?: ElementType;
31
+ className?: string;
32
+ style?: CSSProperties;
33
+ };
34
+ export declare function Col({ children, base, sm, md, startSm, startMd, as: Tag, className, style, }: ColProps): import("react/jsx-runtime").JSX.Element;
35
+ export {};
package/Grid.js ADDED
@@ -0,0 +1,17 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ export function Grid({ children, as: Tag = 'div', className, style }) {
3
+ return (_jsx(Tag, { className: ['grid', className].filter(Boolean).join(' '), style: style, children: children }));
4
+ }
5
+ export function Col({ children, base = 4, sm, md, startSm, startMd, as: Tag = 'div', className, style, }) {
6
+ const spanSm = sm ?? Math.min(8, base * 2);
7
+ const spanMd = md ?? Math.min(12, spanSm);
8
+ const vars = {
9
+ '--span-base': base,
10
+ '--span-sm': spanSm,
11
+ '--span-md': spanMd,
12
+ ...(startSm ? { '--start-sm': startSm } : {}),
13
+ ...(startMd ? { '--start-md': startMd } : {}),
14
+ ...style,
15
+ };
16
+ return (_jsx(Tag, { className: ['col', className].filter(Boolean).join(' '), style: vars, children: children }));
17
+ }
package/Icon.d.ts ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Ikoni.
3
+ *
4
+ * Systeemi ei käytä ikonikirjastoa — se piirtää omat merkkinsä. Syy on
5
+ * sama kuin kaikessa muussakin täällä: merkin pitää olla samaa kieltä
6
+ * kuin viiva, väli ja typografia, eikä kolmannen osapuolen piirtäjän
7
+ * tulkinta siitä. Käytännön syy tuli kaupan päälle: `↗` ja `✕` olivat
8
+ * ennen tekstimerkkejä, ja ne riippuivat siitä mitä Google Fontsin
9
+ * osajoukossa sattui olemaan. Figmassa nuolta ei löytynyt Archivosta
10
+ * lainkaan.
11
+ *
12
+ * Piirtosäännöt:
13
+ * ruudukko 16 × 16, kaikki geometria puolikkaalla pikselillä
14
+ * viiva --hairline, sama kuin jokainen reuna systeemissä
15
+ * päätteet tylpät, kulmat terävät — --radius on 0
16
+ * väri currentColor, ei koskaan omaa väriä
17
+ *
18
+ * Koot ovat tokeneita: --icon-s (12) tekstin seassa metan kanssa,
19
+ * --icon-m (16) napin sisällä, --icon-l (24) omana painikkeenaan.
20
+ *
21
+ * Nuoli on kahta lajia eikä yhtä: `arrow-right` vie sivustolla eteen-
22
+ * päin, `arrow-up-right` ulos. Ero on lukijalle sama kuin
23
+ * target="_blank" ruudunlukijalle.
24
+ */
25
+ export type IconName = 'arrow-right' | 'arrow-up-right' | 'plus' | 'minus' | 'close' | 'menu';
26
+ export type IconSize = 's' | 'm' | 'l';
27
+ export type IconProps = {
28
+ name: IconName;
29
+ size?: IconSize;
30
+ /** Nimi ruudunlukijalle. Ilman tätä ikoni on koriste (aria-hidden). */
31
+ label?: string;
32
+ className?: string;
33
+ };
34
+ export default function Icon({ name, size, label, className }: IconProps): import("react/jsx-runtime").JSX.Element;
35
+ /** Ikonien nimet järjestyksessä — dokumentaatiosivut lukevat tästä. */
36
+ export declare const ICON_NAMES: IconName[];
package/Icon.js ADDED
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /* Polut 16×16-ruudukolla. Yksi merkki = yksi taulukollinen `d`-polkuja,
3
+ jotta jokainen viiva saa saman hiusviivan eikä täyttöä tarvita. */
4
+ const PATHS = {
5
+ 'arrow-right': ['M2.5 8.5 H13.5', 'M9.5 4.5 L13.5 8.5 L9.5 12.5'],
6
+ 'arrow-up-right': ['M3.5 12.5 L12.5 3.5', 'M5.5 3.5 H12.5 V10.5'],
7
+ plus: ['M8.5 2.5 V14.5', 'M2.5 8.5 H14.5'],
8
+ minus: ['M2.5 8.5 H14.5'],
9
+ close: ['M3.5 3.5 L12.5 12.5', 'M12.5 3.5 L3.5 12.5'],
10
+ menu: ['M1 5.5 H15', 'M1 10.5 H15'],
11
+ };
12
+ export default function Icon({ name, size = 'm', label, className }) {
13
+ return (_jsx("svg", { className: ['icon', `icon--${size}`, className].filter(Boolean).join(' '), viewBox: "0 0 16 16", role: label ? 'img' : undefined, "aria-label": label, "aria-hidden": label ? undefined : true, focusable: "false", children: PATHS[name].map((d) => (_jsx("path", { d: d }, d))) }));
14
+ }
15
+ /** Ikonien nimet järjestyksessä — dokumentaatiosivut lukevat tästä. */
16
+ export const ICON_NAMES = Object.keys(PATHS);
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Veli-Matti Pokela
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/ListRow.d.ts ADDED
@@ -0,0 +1,47 @@
1
+ import type { ElementType } from 'react';
2
+ export type ListRowProps = {
3
+ /** Työn nimi. */
4
+ title: string;
5
+ /** Järjestysnumero vasempaan laitaan. Valinnainen. */
6
+ number?: string;
7
+ /** Yhden lauseen kuvaus. Valinnainen. */
8
+ description?: string;
9
+ /** Rooli tai vuosi, oikeaan laitaan metana. Valinnainen. */
10
+ meta?: string;
11
+ /** `m` on isompi otsikko ja väljempi rytmi — työlistan kärkirivit. */
12
+ size?: 's' | 'm';
13
+ href: string;
14
+ /** Lisäluokka, esim. `invert` kun tila näytetään staattisena. */
15
+ className?: string;
16
+ /**
17
+ * Linkkikomponentti. Oletus on tavallinen `<a>`, jotta komponentti ei
18
+ * ole sidottu mihinkään reititykseen — se on ehto sille että tämä voi
19
+ * elää npm-paketissa. Next.js-sovellus antaa tähän `next/link`:n ja
20
+ * saa reitityksen takaisin.
21
+ */
22
+ as?: ElementType;
23
+ };
24
+ /**
25
+ * Listarivi — sivuston tunnuskomponentti.
26
+ *
27
+ * Yksi rivi, kolme käyttöä. Sivustolla oli pitkään kolme lähes
28
+ * samanlaista riviä: etusivun listarivi, työlistan kärkirivi ja
29
+ * aiemman työn avautuva rivi. Niillä oli sama viiva, sama käännös ja
30
+ * sama sisältö mutta kolme eri toteutusta, jotka ehtivät jo ajautua
31
+ * erilleen. Nyt runko on yksi ja erot ovat modifioijia.
32
+ *
33
+ * Avautuva rivi on <Accordion>: se on <button> eikä linkki, joten se
34
+ * ei mahdu tähän propsiksi ilman että komponentista tulee kaksi
35
+ * komponenttia yhdessä. Se käyttää samaa `.list-row`-runkoa.
36
+ *
37
+ * Hover kääntää pinnan mustaksi. Logiikka on kokonaan CSS:ssä
38
+ * (styles/components/list-row.css), joten komponentti ei tiedä
39
+ * tiloista mitään.
40
+ *
41
+ * `.bleed` hoitaa vaakapaddingin, jotta rivi ulottuu reunaan mutta
42
+ * teksti pysyy .page:n linjassa.
43
+ *
44
+ * Linkki tulee `as`-propsina eikä tuontina. Siksi komponentti ei tiedä
45
+ * Next.js:stä mitään ja kelpaa sellaisenaan mihin tahansa projektiin.
46
+ */
47
+ export default function ListRow({ title, number, description, meta, size, href, className, as: Link, }: ListRowProps): import("react/jsx-runtime").JSX.Element;
package/ListRow.js ADDED
@@ -0,0 +1,34 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Listarivi — sivuston tunnuskomponentti.
4
+ *
5
+ * Yksi rivi, kolme käyttöä. Sivustolla oli pitkään kolme lähes
6
+ * samanlaista riviä: etusivun listarivi, työlistan kärkirivi ja
7
+ * aiemman työn avautuva rivi. Niillä oli sama viiva, sama käännös ja
8
+ * sama sisältö mutta kolme eri toteutusta, jotka ehtivät jo ajautua
9
+ * erilleen. Nyt runko on yksi ja erot ovat modifioijia.
10
+ *
11
+ * Avautuva rivi on <Accordion>: se on <button> eikä linkki, joten se
12
+ * ei mahdu tähän propsiksi ilman että komponentista tulee kaksi
13
+ * komponenttia yhdessä. Se käyttää samaa `.list-row`-runkoa.
14
+ *
15
+ * Hover kääntää pinnan mustaksi. Logiikka on kokonaan CSS:ssä
16
+ * (styles/components/list-row.css), joten komponentti ei tiedä
17
+ * tiloista mitään.
18
+ *
19
+ * `.bleed` hoitaa vaakapaddingin, jotta rivi ulottuu reunaan mutta
20
+ * teksti pysyy .page:n linjassa.
21
+ *
22
+ * Linkki tulee `as`-propsina eikä tuontina. Siksi komponentti ei tiedä
23
+ * Next.js:stä mitään ja kelpaa sellaisenaan mihin tahansa projektiin.
24
+ */
25
+ export default function ListRow({ title, number, description, meta, size = 's', href, className, as: Link = 'a', }) {
26
+ return (_jsxs(Link, { href: href, className: [
27
+ 'list-row bleed',
28
+ number ? 'list-row--numbered' : null,
29
+ size === 'm' ? 'list-row--m' : null,
30
+ className,
31
+ ]
32
+ .filter(Boolean)
33
+ .join(' '), children: [number ? _jsx("span", { className: "meta list-row__number", children: number }) : null, _jsxs("span", { className: "list-row__main", children: [_jsx("span", { className: "list-row__title", children: title }), description ? _jsx("span", { className: "list-row__description", children: description }) : null] }), meta ? _jsx("span", { className: "meta list-row__meta", children: meta }) : null] }));
34
+ }
package/README.md CHANGED
@@ -1,3 +1,60 @@
1
- # Temporary Holding Version
1
+ # @pokela/components
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ React-komponentit: **Pokela Design System**, versio 1.2.0.
4
+
5
+ > Generoitu hakemistosta `components/` — samoista tiedostoista joita sivusto
6
+ > ajaa. Älä muokkaa käsin: muutokset tehdään lähteeseen ja paketti
7
+ > rakennetaan uudelleen komennolla `npm run paketti`.
8
+
9
+ ## Käyttö
10
+
11
+ ```js
12
+ import { ListRow, Icon, Accordion, Grid, Col, Reveal } from '@pokela/components';
13
+ import '@pokela/components/styles.css';
14
+ ```
15
+
16
+ `styles.css` tuo myös `@pokela/tokens`:n muuttujat, joten erillistä tuontia ei
17
+ tarvita.
18
+
19
+ ## Komponentit
20
+
21
+ - `Icon`
22
+ - `ListRow`
23
+ - `Accordion`
24
+ - `Grid`
25
+ - `Reveal`
26
+ - `ThemeScript`
27
+ - `ThemeToggle`
28
+
29
+ ### Linkit
30
+
31
+ `ListRow` ei ole sidottu mihinkään reititykseen. Oletuksena se renderöi
32
+ tavallisen `<a>`:n; Next.js-sovellus antaa oman linkkinsä propsina:
33
+
34
+ ```jsx
35
+ import Link from 'next/link';
36
+
37
+ <ListRow as={Link} title="Työn nimi" href="/tyot/esimerkki" />
38
+ ```
39
+
40
+ ## Mitä paketissa EI ole
41
+
42
+ - **Media, LogoRow, CaseText, CaseBlock, CaseBlocks, CaseBlockDerived, ListRowShowcase** — lukevat sivuston omaa sisältöä (`@/content`, `@/lib`)
43
+ - **Nav, Footer** — tarvitsevat sivuston sanakirjan ja reitityksen
44
+ - **BeforeAfter** — rakentuu Median varaan, ja Media lukee kuvamanifestia
45
+ - **ComponentView, PrintCv** — ovat riippumattomia mutta sivuston ominaisuuksia — lähdekoodinäkymä ja CV:n tulostusasu — eivät design systemin osia
46
+
47
+ ## Tyylit
48
+
49
+ - `styles/base.css`
50
+ - `styles/icon.css`
51
+ - `styles/list-row.css`
52
+ - `styles/accordion.css`
53
+
54
+ Luokat ovat globaaleja, eivät CSS-moduuleja. Se on tarkoituksellista: sama
55
+ luokka on myös Figmassa komponentin nimenä, ja hajautettu nimi katkaisisi
56
+ yhteyden.
57
+
58
+ ## Lisenssi
59
+
60
+ MIT. Koskee tätä pakettia, ei sitä repoa josta se on generoitu.
package/Reveal.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ import { type ReactNode } from 'react';
2
+ /**
3
+ * Reveal: 12px nousu + häivytys kerran, kun lohko tulee näkyviin.
4
+ *
5
+ * prefers-reduced-motion hoidetaan CSS:ssä (.reveal-säännöt ovat
6
+ * no-preference-median sisällä), joten tämä ei tarvitse omaa
7
+ * haaraa — luokka vain ei tee mitään jos liikettä ei haluta.
8
+ */
9
+ export default function Reveal({ children }: {
10
+ children: ReactNode;
11
+ }): import("react/jsx-runtime").JSX.Element;
package/Reveal.js ADDED
@@ -0,0 +1,32 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useEffect, useRef, useState } from 'react';
4
+ /**
5
+ * Reveal: 12px nousu + häivytys kerran, kun lohko tulee näkyviin.
6
+ *
7
+ * prefers-reduced-motion hoidetaan CSS:ssä (.reveal-säännöt ovat
8
+ * no-preference-median sisällä), joten tämä ei tarvitse omaa
9
+ * haaraa — luokka vain ei tee mitään jos liikettä ei haluta.
10
+ */
11
+ export default function Reveal({ children }) {
12
+ const ref = useRef(null);
13
+ const [shown, setShown] = useState(false);
14
+ useEffect(() => {
15
+ const node = ref.current;
16
+ if (!node || shown)
17
+ return;
18
+ if (!('IntersectionObserver' in window)) {
19
+ setShown(true);
20
+ return;
21
+ }
22
+ const io = new IntersectionObserver(([entry]) => {
23
+ if (entry.isIntersecting) {
24
+ setShown(true);
25
+ io.disconnect();
26
+ }
27
+ }, { rootMargin: '0px 0px -10% 0px' });
28
+ io.observe(node);
29
+ return () => io.disconnect();
30
+ }, [shown]);
31
+ return (_jsx("div", { ref: ref, className: shown ? 'reveal is-in' : 'reveal', children: children }));
32
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Teeman palautus ennen ensimmäistä maalausta.
3
+ *
4
+ * Ilman tätä sivu välähtäisi vaaleana ennen kuin tallennettu tumma
5
+ * teema ehtii päälle. Skripti on tarkoituksella pieni ja synkroninen,
6
+ * ja se ajetaan <head>issä ennen tyylejä.
7
+ *
8
+ * Ilman tallennettua valintaa data-themeä ei aseteta lainkaan, jolloin
9
+ * tokens.css:n `color-scheme: light dark` seuraa käyttöjärjestelmää.
10
+ */
11
+ export declare const THEME_STORAGE_KEY = "pokela-theme";
12
+ export default function ThemeScript(): import("react/jsx-runtime").JSX.Element;
package/ThemeScript.js ADDED
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * Teeman palautus ennen ensimmäistä maalausta.
4
+ *
5
+ * Ilman tätä sivu välähtäisi vaaleana ennen kuin tallennettu tumma
6
+ * teema ehtii päälle. Skripti on tarkoituksella pieni ja synkroninen,
7
+ * ja se ajetaan <head>issä ennen tyylejä.
8
+ *
9
+ * Ilman tallennettua valintaa data-themeä ei aseteta lainkaan, jolloin
10
+ * tokens.css:n `color-scheme: light dark` seuraa käyttöjärjestelmää.
11
+ */
12
+ export const THEME_STORAGE_KEY = 'pokela-theme';
13
+ const script = `(function(){try{var t=localStorage.getItem(${JSON.stringify(THEME_STORAGE_KEY)});if(t==='light'||t==='dark'){document.documentElement.setAttribute('data-theme',t)}}catch(e){}})()`;
14
+ export default function ThemeScript() {
15
+ return _jsx("script", { dangerouslySetInnerHTML: { __html: script } });
16
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Teemanvaihto tekstinappina — ei ikonia, ei väriä.
3
+ * Kierto: järjestelmä → vaalea → tumma → järjestelmä.
4
+ *
5
+ * `data-theme` puuttuu kun valinta on "järjestelmä", jolloin
6
+ * tokens.css:n light-dark() seuraa prefers-color-schemeä.
7
+ */
8
+ export default function ThemeToggle({ labels, }: {
9
+ labels: {
10
+ theme: string;
11
+ light: string;
12
+ dark: string;
13
+ system: string;
14
+ };
15
+ }): import("react/jsx-runtime").JSX.Element;
package/ThemeToggle.js ADDED
@@ -0,0 +1,49 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useEffect, useState } from 'react';
4
+ import { THEME_STORAGE_KEY } from './ThemeScript';
5
+ /**
6
+ * Teemanvaihto tekstinappina — ei ikonia, ei väriä.
7
+ * Kierto: järjestelmä → vaalea → tumma → järjestelmä.
8
+ *
9
+ * `data-theme` puuttuu kun valinta on "järjestelmä", jolloin
10
+ * tokens.css:n light-dark() seuraa prefers-color-schemeä.
11
+ */
12
+ export default function ThemeToggle({ labels, }) {
13
+ const [theme, setTheme] = useState('system');
14
+ const [mounted, setMounted] = useState(false);
15
+ useEffect(() => {
16
+ setMounted(true);
17
+ try {
18
+ const stored = localStorage.getItem(THEME_STORAGE_KEY);
19
+ if (stored === 'light' || stored === 'dark')
20
+ setTheme(stored);
21
+ }
22
+ catch {
23
+ /* private mode — pidetään järjestelmän teema */
24
+ }
25
+ }, []);
26
+ function apply(next) {
27
+ setTheme(next);
28
+ const root = document.documentElement;
29
+ if (next === 'system')
30
+ root.removeAttribute('data-theme');
31
+ else
32
+ root.setAttribute('data-theme', next);
33
+ try {
34
+ if (next === 'system')
35
+ localStorage.removeItem(THEME_STORAGE_KEY);
36
+ else
37
+ localStorage.setItem(THEME_STORAGE_KEY, next);
38
+ }
39
+ catch {
40
+ /* valinta jää tälle sivulataukselle */
41
+ }
42
+ }
43
+ const order = ['system', 'light', 'dark'];
44
+ const label = { system: labels.system, light: labels.light, dark: labels.dark }[theme];
45
+ return (_jsx("button", { type: "button", className: "meta meta--s toggle", onClick: () => apply(order[(order.indexOf(theme) + 1) % order.length]), "aria-label": `${labels.theme}: ${label}`,
46
+ /* Ennen hydraatiota näytetään neutraali merkintä, jottei
47
+ palvelimen ja selaimen tila eroa toisistaan. */
48
+ suppressHydrationWarning: true, children: mounted ? label : labels.theme }));
49
+ }
package/index.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ export { default as Icon, ICON_NAMES } from './Icon.js';
2
+ export type { IconName, IconSize, IconProps } from './Icon.js';
3
+ export { default as ListRow } from './ListRow.js';
4
+ export type { ListRowProps } from './ListRow.js';
5
+ export { Accordion, useAccordionGroup } from './Accordion.js';
6
+ export type { AccordionProps } from './Accordion.js';
7
+ export { Grid, Col } from './Grid.js';
8
+ export { default as Reveal } from './Reveal.js';
9
+ export { default as ThemeScript, THEME_STORAGE_KEY } from './ThemeScript.js';
10
+ export { default as ThemeToggle } from './ThemeToggle.js';
package/index.js ADDED
@@ -0,0 +1,8 @@
1
+ /* @pokela/components — kokooma. Generoitu, ks. scripts/paketti-komponentit.mjs. */
2
+ export { default as Icon, ICON_NAMES } from './Icon.js';
3
+ export { default as ListRow } from './ListRow.js';
4
+ export { Accordion, useAccordionGroup } from './Accordion.js';
5
+ export { Grid, Col } from './Grid.js';
6
+ export { default as Reveal } from './Reveal.js';
7
+ export { default as ThemeScript, THEME_STORAGE_KEY } from './ThemeScript.js';
8
+ export { default as ThemeToggle } from './ThemeToggle.js';
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "@pokela/components",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.2.0",
4
+ "description": "React-komponentit: Pokela Design System. Generoitu tiedostoista components/, ei käsin ylläpidetty.",
5
+ "license": "MIT",
6
+ "author": "Veli-Matti Pokela",
7
+ "type": "module",
8
+ "sideEffects": [
9
+ "*.css"
10
+ ],
11
+ "exports": {
12
+ ".": {
13
+ "types": "./index.d.ts",
14
+ "import": "./index.js"
15
+ },
16
+ "./styles.css": "./styles.css",
17
+ "./styles/*": "./styles/*"
18
+ },
19
+ "files": [
20
+ "*.js",
21
+ "*.d.ts",
22
+ "styles.css",
23
+ "styles/",
24
+ "README.md",
25
+ "LICENSE"
26
+ ],
27
+ "dependencies": {
28
+ "@pokela/tokens": "^1.2.0"
29
+ },
30
+ "peerDependencies": {
31
+ "react": ">=18"
32
+ },
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/VeliMattiPokela/pokela.fi.git",
36
+ "directory": "packages/components"
37
+ }
38
+ }
@@ -0,0 +1,41 @@
1
+ /* Avautuva rivi (haitari). Runko on listarivi, tässä on paneeli. */
2
+
3
+ /* Viivat tulevat rivistä (.list-row), ei käärestä — muuten
4
+ yläviivoja olisi kaksi päällekkäin. Sulkeva viiva on
5
+ list-row.css:ssä sääntönä `.list-rows > *:last-child > .list-row`. */
6
+
7
+ /* Rivin ulkoasu tulee list-row.css:stä (.list-row--expandable).
8
+ Täällä on vain se mikä on tälle komponentille omaa: paneeli ja
9
+ sen sisältö. */
10
+
11
+ .accordion__panel { padding-block: var(--space-40) var(--space-56); }
12
+
13
+ /* ---- avautuva sisältö ------------------------------------------- */
14
+
15
+ .detail { display: flex; flex-direction: column; gap: var(--space-56); }
16
+
17
+ .detail__head { display: flex; flex-direction: column; gap: var(--space-20); }
18
+ .detail__intro { color: var(--ink-muted); }
19
+
20
+ .detail__role { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--space-32); }
21
+ @media (min-width: 900px) { .detail__role { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-40); } }
22
+ .detail__role-col { display: flex; flex-direction: column; gap: var(--space-12); }
23
+ .detail__role-title { margin: 0; font-size: var(--text-body-l); }
24
+ .detail__role-note { color: var(--ink-faint); margin: 0; }
25
+
26
+ .detail__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-8); }
27
+ .detail__list li { font-size: var(--text-body-s); line-height: var(--lh-body-s); padding-left: var(--space-16); position: relative; }
28
+ .detail__list li::before { content: '—'; position: absolute; left: 0; color: var(--ink-faint); }
29
+ .detail__list--muted li { color: var(--ink-muted); }
30
+
31
+ .detail__sections { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--space-24); }
32
+ @media (min-width: 900px) { .detail__sections { grid-template-columns: 200px minmax(0, 1fr); gap: var(--space-40); } }
33
+ .detail__sections-body { display: flex; flex-direction: column; }
34
+ .detail__section { padding-block: var(--space-24); border-top: var(--hairline) solid var(--line); }
35
+ .detail__section:first-child { border-top: 0; padding-top: 0; }
36
+ .detail__section-title { margin: 0 0 var(--space-8); font-size: var(--text-body-l); font-weight: var(--weight-500); }
37
+ .detail__section-body { margin: 0; color: var(--ink-muted); }
38
+
39
+ .detail__media { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--space-24); }
40
+ @media (min-width: 900px) { .detail__media { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
41
+ .detail__media > .compare { grid-column: 1 / -1; }
@@ -0,0 +1,308 @@
1
+ /* Pokela — layout-primitiivit
2
+ ------------------------------------------------------------------
3
+ Ei uusia värejä eikä kokoja: kaikki arvot tulevat tokens.css:stä.
4
+ Jokaisella tämän tiedoston luokalla on vastine Storybookissa.
5
+ */
6
+
7
+ *, *::before, *::after { box-sizing: border-box; }
8
+
9
+ html { -webkit-text-size-adjust: 100%; }
10
+
11
+ body {
12
+ margin: 0;
13
+ background: var(--paper);
14
+ color: var(--ink);
15
+ font-family: var(--font-ui);
16
+ font-size: var(--text-body);
17
+ line-height: var(--lh-body);
18
+ -webkit-font-smoothing: antialiased;
19
+ text-rendering: optimizeLegibility;
20
+
21
+ /* Bodoni Modasta on ladattu vain leikkaukset 400 ja 500. Jos jokin
22
+ pyytää lihavaa — esimerkiksi selaimen oletustyyli <h1>–<h6>:lle —
23
+ selain venyttäisi 400:n paksummaksi. Didonessa se tuhoaa juuri
24
+ sen mikä tekee siitä didonen: hiusviivojen ja tolppien
25
+ kontrastin. Kursiivin synteesi jätetään päälle, koska
26
+ koodilohkojen kommentit nojaavat siihen. */
27
+ font-synthesis-weight: none;
28
+ }
29
+
30
+ img, svg, video { display: block; max-width: 100%; }
31
+
32
+ /* Focus: 2px musteviiva 2px offsetilla. Yksi sääntö, koko sivustolle. */
33
+ :focus-visible {
34
+ outline: var(--focus-width) solid var(--ink);
35
+ outline-offset: var(--focus-offset);
36
+ }
37
+ /* Käännetyllä pinnalla focus kääntyy mukana. */
38
+ .invert :focus-visible { outline-color: var(--invert-ink); }
39
+
40
+ /* ---- typografia ---------------------------------------------------- */
41
+
42
+ .display-xl, .display-l, .display-m, .display-s {
43
+ font-family: var(--font-display);
44
+ font-weight: var(--weight-400);
45
+ margin: 0;
46
+ text-wrap: balance;
47
+ }
48
+ .display-xl { font-size: var(--text-display-xl); line-height: var(--lh-display-xl); letter-spacing: var(--tr-display-xl); }
49
+ .display-l { font-size: var(--text-display-l); line-height: var(--lh-display-l); letter-spacing: var(--tr-display-l); }
50
+ .display-m { font-size: var(--text-display-m); line-height: var(--lh-display-m); letter-spacing: var(--tr-display-m); }
51
+ .display-s { font-size: var(--text-display-s); line-height: var(--lh-display-s); letter-spacing: var(--tr-display-s); }
52
+
53
+ .body-l { font-size: var(--text-body-l); line-height: var(--lh-body-l); margin: 0; }
54
+ .body-s { font-size: var(--text-body-s); line-height: var(--lh-body-s); margin: 0; }
55
+
56
+ /* Leipätekstin mitta. Ei koskaan yli 62 merkkiä. */
57
+ .measure { max-width: var(--measure); }
58
+
59
+ .meta {
60
+ /* Nollaus, koska .meta osuu myös <p>- ja <h2>-elementteihin. Ilman
61
+ tätä selaimen oletusmarginaali (1em = 11px) livahti mukaan
62
+ paikkoihin joissa väli tulee vanhemman gapista. */
63
+ margin: 0;
64
+ font-family: var(--font-ui);
65
+ font-size: var(--text-meta);
66
+ line-height: var(--lh-meta);
67
+ font-weight: var(--weight-600);
68
+ letter-spacing: var(--tr-meta);
69
+ text-transform: uppercase;
70
+ color: var(--ink-faint);
71
+ }
72
+ .meta--s { font-size: var(--text-meta-s); line-height: var(--lh-meta-s); letter-spacing: var(--tr-meta-s); }
73
+ .meta--ink { color: var(--ink); }
74
+
75
+ a { color: inherit; text-decoration: none; }
76
+ a:hover { text-decoration: underline; text-underline-offset: var(--space-4); }
77
+
78
+ /* ---- sivu ja grid -------------------------------------------------- */
79
+
80
+ .page {
81
+ max-width: var(--max-width);
82
+ margin-inline: auto;
83
+ padding-inline: var(--page-padding);
84
+ }
85
+
86
+ /* Täysleveä kaista joka hoitaa oman sivupaddinginsa.
87
+ Käytetään listariveille ja käännetyille palkeille — ne ulottuvat
88
+ reunaan, mutta sisältö pysyy samassa linjassa .page:n kanssa. */
89
+ .bleed {
90
+ width: 100%;
91
+ padding-inline: max(var(--page-padding), calc((100% - var(--max-width)) / 2 + var(--page-padding)));
92
+ }
93
+
94
+ .section { margin-block: var(--section-gap); }
95
+
96
+ /* 12-sarakkeinen grid: 4 → 8 → 12. Sarakemäärä tulee tokenista,
97
+ joten yksikään komponentti ei tiedä breakpointeista mitään. */
98
+ .grid {
99
+ display: grid;
100
+ grid-template-columns: repeat(var(--columns), minmax(0, 1fr));
101
+ column-gap: var(--gutter);
102
+ row-gap: var(--space-32);
103
+ }
104
+
105
+ /* Col: leveys ja aloitussarake per breakpoint.
106
+ Arvot annetaan custom-propertyinä (--span-base / --span-sm / --span-md),
107
+ jolloin JSX kertoo sijoittelun ja CSS vain toteuttaa sen. */
108
+ .col { grid-column: span var(--span-base) / span var(--span-base); }
109
+ @media (min-width: 600px) {
110
+ .col { grid-column: var(--start-sm, auto) / span var(--span-sm); }
111
+ }
112
+ @media (min-width: 900px) {
113
+ .col { grid-column: var(--start-md, auto) / span var(--span-md); }
114
+ }
115
+
116
+ .rule { border: 0; border-top: var(--hairline) solid var(--line); margin: 0; }
117
+ .rule--strong { border-top-color: var(--line-strong); }
118
+
119
+ /* Käännetty pinta. Ainoa tehoväline — ei värejä, ei varjoja. */
120
+ .invert {
121
+ background: var(--invert-surface);
122
+ color: var(--invert-ink);
123
+ }
124
+ .invert .meta { color: var(--invert-faint); }
125
+
126
+ /* ---- napit --------------------------------------------------------- */
127
+
128
+ .btn {
129
+ display: inline-flex; align-items: center; justify-content: center;
130
+ gap: var(--space-8);
131
+ font-family: var(--font-ui);
132
+ font-size: var(--text-body-s);
133
+ font-weight: var(--weight-500);
134
+ line-height: 1;
135
+ min-height: var(--tap-min);
136
+ padding: var(--space-12) var(--space-24);
137
+ border-radius: var(--radius);
138
+ border: var(--hairline) solid transparent;
139
+ background: none;
140
+ color: inherit;
141
+ cursor: pointer;
142
+ text-decoration: none;
143
+ transition:
144
+ background var(--dur-fast) var(--ease-standard),
145
+ border-color var(--dur-fast) var(--ease-standard),
146
+ color var(--dur-fast) var(--ease-standard);
147
+ }
148
+ .btn:hover { text-decoration: none; }
149
+
150
+ /* Mobiilissa päätoiminto on täysleveä, 600px:stä sisällön mukainen. */
151
+ .btn--block { width: 100%; }
152
+ @media (min-width: 600px) { .btn--block { width: auto; } }
153
+
154
+ /* Hover = käännös. Reunaviiva tulee border-colorista, ei varjosta. */
155
+ .btn--primary { background: var(--ink); color: var(--paper); border-color: var(--ink); }
156
+ .btn--primary:hover { background: var(--paper); color: var(--ink); }
157
+
158
+ .btn--ghost { border-color: var(--ink); color: var(--ink); }
159
+ .btn--ghost:hover { background: var(--ink); color: var(--paper); }
160
+
161
+ .btn--text {
162
+ min-height: 0;
163
+ padding: 0 0 var(--space-8);
164
+ border: 0;
165
+ border-bottom: var(--hairline) solid currentColor;
166
+ }
167
+
168
+ /* Hiljainen kytkin: teemanvaihto ja vastaavat. Ei .btn-perheen jäsen —
169
+ typografia tulee .meta:sta ja nappimaisuus riisutaan kokonaan, joten
170
+ se ei kilpaile sivun oikeiden toimintojen kanssa. Kosketusalue on
171
+ silti täysi: padding kasvattaa sen ja yhtä suuri negatiivinen margin
172
+ kumoaa vaikutuksen taittoon.
173
+
174
+ Ei ollut navigaatiokohtainen, vaikka asui aiemmin nav.css:ssä nimellä
175
+ .nav__toggle. Nimi esti ThemeTogglen viennin komponenttipakettiin,
176
+ koska komponentti olisi tarvinnut Navin tyylit. */
177
+ .toggle {
178
+ background: none;
179
+ border: 0;
180
+ padding: var(--space-12);
181
+ margin: calc(var(--space-12) * -1);
182
+ min-width: var(--tap-min);
183
+ min-height: var(--tap-min);
184
+ cursor: pointer;
185
+ transition: color var(--dur-fast) var(--ease-standard);
186
+ }
187
+ .toggle:hover { color: var(--ink); }
188
+
189
+ /* Tekstinapilla ei ole pintaa jota kääntää, joten hover on sama liike
190
+ kuin .case-linkillä: viiva paksunee. Padding kevenee saman verran
191
+ kuin viiva kasvaa, jotta napin korkeus ei muutu eikä ympärillä oleva
192
+ teksti nytkähdä osoittimen alla. */
193
+ .btn--text:hover {
194
+ border-bottom-width: var(--hairline-strong);
195
+ padding-bottom: calc(var(--space-8) - (var(--hairline-strong) - var(--hairline)));
196
+ }
197
+
198
+ .btn[disabled], .btn[aria-disabled='true'] {
199
+ color: var(--ink-faint);
200
+ border-color: var(--line);
201
+ background: none;
202
+ cursor: not-allowed;
203
+ }
204
+
205
+ /* Kosketuslaitteella hoveria ei ole — käännös tulee :active-tilassa.
206
+ Tämä lohko on viimeisenä, joten se voittaa hover-säännöt. */
207
+ @media (hover: none) {
208
+ .btn--primary:hover { background: var(--ink); color: var(--paper); }
209
+ .btn--ghost:hover { background: none; color: var(--ink); }
210
+ .btn--text:hover {
211
+ border-bottom-width: var(--hairline);
212
+ padding-bottom: var(--space-8);
213
+ }
214
+
215
+ .btn--primary:active { background: var(--paper); color: var(--ink); }
216
+ .btn--ghost:active { background: var(--ink); color: var(--paper); }
217
+ .btn--text:active {
218
+ border-bottom-width: var(--hairline-strong);
219
+ padding-bottom: calc(var(--space-8) - (var(--hairline-strong) - var(--hairline)));
220
+ }
221
+ }
222
+
223
+ /* ---- media (kuvapaikka) ----------------------------------------------------- */
224
+
225
+ .media {
226
+ position: relative;
227
+ background: repeating-linear-gradient(
228
+ 135deg,
229
+ var(--placeholder-base) 0 12px,
230
+ var(--placeholder-stripe) 12px 24px
231
+ );
232
+ display: flex;
233
+ align-items: flex-end;
234
+ padding: var(--space-16);
235
+ }
236
+ @media (min-width: 600px) { .media { padding: var(--space-24); } }
237
+
238
+ /* Kun paikassa on kuva, raidoitus ja sisennys eivät ole tarpeen:
239
+ kuva täyttää laatikon reunasta reunaan. Sama .media-luokka säilyy,
240
+ jotta kuvasuhde tulee samasta paikasta kummassakin tilassa eikä
241
+ layout liiku kun kuva ilmestyy. */
242
+ .media--kuva {
243
+ display: block;
244
+ padding: 0;
245
+ background: var(--placeholder-base);
246
+ overflow: hidden;
247
+ }
248
+
249
+ /* Kuvasuhteet: mobiilissa pystympi, työpöydällä editoriaalinen.
250
+ 21:9 hero on mobiilissa 4:5 — muuten kuvasta ei näe mitään. */
251
+ .media-hero { aspect-ratio: 4 / 5; }
252
+ .media-4-3 { aspect-ratio: 4 / 3; }
253
+ .media-4-5 { aspect-ratio: 4 / 5; }
254
+ .media-3-4 { aspect-ratio: 3 / 4; }
255
+ .media-1-1 { aspect-ratio: 1 / 1; }
256
+ @media (min-width: 600px) { .media-hero { aspect-ratio: 16 / 9; } }
257
+ @media (min-width: 900px) { .media-hero { aspect-ratio: 21 / 9; } }
258
+
259
+ .media-fill { width: 100%; height: 100%; object-fit: cover; }
260
+
261
+ /* Paikanvaraajan merkintä: järjestysnumero ja tunniste. Vain
262
+ kehityksessä (Media.tsx päättää), ja tarkoitus on että pudotettavan
263
+ tiedoston nimen voi lukea suoraan sivulta ilman terminaalia.
264
+
265
+ Tyyli on sivuston oma: numerot ovat täällä .meta-kokoisia vaimeita
266
+ merkintöjä (ks. ListRow, CV, artefaktilista), eivät
267
+ display-elementtejä. Iso lihava numero nurkassa luki sisältönä eikä
268
+ rakennustelineenä.
269
+
270
+ Numero täydellä musteella, tunniste vaimeana: numero on se jota
271
+ vilkaistaan, tunniste se joka kopioidaan. Tunniste ilman
272
+ versaalimuunnosta, koska se on kopioitava tiedostonimi. */
273
+ .media__teksti { display: flex; flex-direction: column; gap: var(--space-8); }
274
+ .media__merkinta { display: flex; align-items: baseline; gap: var(--space-12); flex-wrap: wrap; }
275
+ .media__numero { color: var(--ink); font-variant-numeric: tabular-nums; }
276
+ .media__tunnus { color: var(--ink-faint); text-transform: none; }
277
+ .media__muoto { color: var(--ink-faint); margin-inline-start: auto; text-transform: none; }
278
+
279
+
280
+ /* ---- reveal --------------------------------------------------------- */
281
+
282
+ @media (prefers-reduced-motion: no-preference) {
283
+ .reveal { opacity: 0; transform: translateY(var(--reveal-shift)); }
284
+ .reveal.is-in {
285
+ opacity: 1;
286
+ transform: none;
287
+ transition:
288
+ opacity var(--dur-reveal) var(--ease-out),
289
+ transform var(--dur-reveal) var(--ease-out);
290
+ }
291
+ }
292
+
293
+ /* ---- saavutettavuus -------------------------------------------------- */
294
+
295
+ .visually-hidden {
296
+ position: absolute; width: 1px; height: 1px;
297
+ padding: 0; margin: -1px; overflow: hidden;
298
+ clip-path: inset(50%); white-space: nowrap; border: 0;
299
+ }
300
+
301
+ .skip-link {
302
+ position: absolute; left: var(--space-16); top: var(--space-16);
303
+ z-index: 100;
304
+ transform: translateY(-200%);
305
+ background: var(--ink); color: var(--paper);
306
+ padding: var(--space-12) var(--space-16);
307
+ }
308
+ .skip-link:focus { transform: none; }
@@ -0,0 +1,40 @@
1
+ /* Ikoni — systeemin oma piirretty merkki.
2
+ ==================================================================
3
+ Ei kirjastoa. Geometria on components/Icon.tsx:ssä, tässä on se
4
+ mikä tekee merkistä osan systeemiä: hiusviiva, currentColor ja
5
+ kolme kokoa tokeneista.
6
+
7
+ `vector-effect: non-scaling-stroke` on tässä olennainen. Ilman sitä
8
+ viivanpaksuus skaalautuisi ruudukon mukana: 16-ruudukon 1 yksikköä
9
+ olisi 24 pikselin ikonissa 1,5 pikseliä ja 12 pikselin ikonissa
10
+ 0,75. Kanssa sen `stroke-width` luetaan ruudun koordinaatistossa,
11
+ jolloin --hairline on hiusviiva joka koossa — juuri se mitä token
12
+ lupaa. */
13
+
14
+ .icon {
15
+ display: inline-block;
16
+ flex: none;
17
+ width: var(--icon-m);
18
+ height: var(--icon-m);
19
+ /* Ikoni istuu tekstin seassa, joten se lasketaan perusviivalle.
20
+ `vertical-align: middle` keskittäisi sen x-korkeuden puoliväliin
21
+ ja nostaisi merkin liian ylös metatekstin rinnalla. */
22
+ vertical-align: -0.125em;
23
+ color: inherit;
24
+ }
25
+
26
+ .icon path {
27
+ fill: none;
28
+ stroke: currentColor;
29
+ stroke-width: var(--hairline);
30
+ /* Tylpät päätteet ja terävät kulmat, koska --radius on 0.
31
+ Nämä ovat SVG:n oletukset — ne on kirjoitettu näkyviin, koska ne
32
+ ovat systeemin päätös eivätkä sattuma. */
33
+ stroke-linecap: butt;
34
+ stroke-linejoin: miter;
35
+ vector-effect: non-scaling-stroke;
36
+ }
37
+
38
+ .icon--s { width: var(--icon-s); height: var(--icon-s); }
39
+ .icon--m { width: var(--icon-m); height: var(--icon-m); }
40
+ .icon--l { width: var(--icon-l); height: var(--icon-l); }
@@ -0,0 +1,206 @@
1
+ /* Listarivi — sivuston tunnuskomponentti.
2
+ ==================================================================
3
+ Yksi rivi, kolme käyttöä. Sivustolla oli pitkään kolme lähes
4
+ samanlaista riviä — listarivi, työlistan kärkirivi ja avautuva
5
+ rivi — joilla oli sama viiva, sama käännös ja sama sisältö, mutta
6
+ kolme eri toteutusta. Nyt runko on yksi ja erot ovat modifioijia:
7
+
8
+ .list-row nimi + kuvaus + rooli, linkkinä
9
+ .list-row--numbered numerosarake vasemmalle
10
+ .list-row--m isompi otsikko ja väljempi rytmi
11
+ .list-row--expandable + / − -osoitin oikeaan laitaan
12
+
13
+ Vaakapadding tulee .bleed-utilitystä, ei tästä: muuten rivi saa
14
+ sivupaddingin kahdesti .page-käärön sisällä.
15
+ */
16
+
17
+ /* ---- runko --------------------------------------------------------- */
18
+
19
+ .list-row {
20
+ display: grid;
21
+ grid-template-columns: minmax(0, 1fr);
22
+ grid-template-areas:
23
+ 'main'
24
+ 'meta';
25
+ align-items: baseline;
26
+ gap: var(--space-8) var(--space-16);
27
+ width: 100%;
28
+ padding-block: var(--space-20);
29
+ min-height: var(--tap-min);
30
+ border-top: var(--hairline) solid var(--line);
31
+ color: var(--ink);
32
+ text-align: left;
33
+ transition:
34
+ background var(--dur-base) var(--ease-standard),
35
+ color var(--dur-base) var(--ease-standard);
36
+ }
37
+
38
+ @media (min-width: 600px) {
39
+ .list-row {
40
+ grid-template-columns: minmax(0, 1fr) auto;
41
+ grid-template-areas: 'main meta';
42
+ gap: var(--space-24);
43
+ padding-block: var(--space-28);
44
+ }
45
+ }
46
+
47
+ .list-rows > .list-row:last-child,
48
+ .list-rows > *:last-child > .list-row {
49
+ border-bottom: var(--hairline) solid var(--line);
50
+ }
51
+
52
+ /* ---- osat ---------------------------------------------------------- */
53
+
54
+ .list-row__number { grid-area: num; }
55
+ .list-row__meta { grid-area: meta; }
56
+
57
+ /* Nimi + kuvaus omana pinonaan, jotta rooli voi asettua oikeaan
58
+ laitaan nimen perusviivalle. */
59
+ .list-row__main {
60
+ grid-area: main;
61
+ display: flex;
62
+ flex-direction: column;
63
+ gap: var(--space-4);
64
+ min-width: 0;
65
+ }
66
+
67
+ .list-row__title {
68
+ font-family: var(--font-display);
69
+ font-size: var(--text-display-s);
70
+ line-height: var(--lh-display-s);
71
+ letter-spacing: var(--tr-display-s);
72
+ }
73
+
74
+ .list-row__description {
75
+ font-size: var(--text-body-s);
76
+ line-height: var(--lh-body-s);
77
+ color: var(--ink-muted);
78
+ }
79
+
80
+ /* ---- numerollinen -------------------------------------------------- */
81
+
82
+ .list-row--numbered {
83
+ grid-template-columns: var(--space-36) minmax(0, 1fr);
84
+ /* Roolilla on oma alue myös mobiilissa. Ilman sitä `grid-area`
85
+ ei osu mihinkään, selain sijoittaa sen automaattisesti ja luo
86
+ implisiittisen sarakkeen — pääsisältö kutistui 86 pikseliin. */
87
+ grid-template-areas:
88
+ 'num main'
89
+ '. meta';
90
+ }
91
+ @media (min-width: 600px) {
92
+ .list-row--numbered {
93
+ grid-template-columns: var(--space-56) minmax(0, 1fr) auto;
94
+ grid-template-areas: 'num main meta';
95
+ }
96
+ }
97
+
98
+ /* ---- iso koko ------------------------------------------------------ */
99
+
100
+ .list-row--m { padding-block: var(--space-24); }
101
+ @media (min-width: 600px) {
102
+ .list-row--m { padding-block: var(--space-32); }
103
+ .list-row--m.list-row--numbered { grid-template-columns: var(--space-56) minmax(0, 1fr) 260px; }
104
+ }
105
+ .list-row--m .list-row__main { gap: var(--space-12); }
106
+ .list-row--m .list-row__title {
107
+ font-size: var(--text-display-m);
108
+ line-height: var(--lh-display-m);
109
+ letter-spacing: var(--tr-display-m);
110
+ }
111
+ .list-row--m .list-row__description { max-width: var(--measure); }
112
+
113
+ /* ---- avautuva ------------------------------------------------------
114
+ Merkki on systeemin oma ikoni (plus / minus), ei tekstimerkki eikä
115
+ kirjaston ikoni — ks. components/Icon.tsx. Nimi oli välillä `sign`
116
+ ja `indicator`; molemmat olivat kiertoilmauksia sille ettei näitä
117
+ merkkejä haluttu kutsua ikoneiksi. Nyt ne ovat ikoneita, ja slotin
118
+ nimi on `icon`. */
119
+
120
+ .list-row--expandable {
121
+ grid-template-columns: var(--space-36) minmax(0, 1fr) auto;
122
+ grid-template-areas: 'num main icon';
123
+ background: none;
124
+ border-left: 0;
125
+ border-right: 0;
126
+ border-bottom: 0;
127
+ font: inherit;
128
+ cursor: pointer;
129
+ }
130
+ /* Mobiilissa merkki vie tilan, joten rooli jää pois. */
131
+ .list-row--expandable .list-row__meta { display: none; }
132
+
133
+ @media (min-width: 600px) {
134
+ .list-row--expandable {
135
+ grid-template-columns: var(--space-56) minmax(0, 1fr) auto var(--space-24);
136
+ grid-template-areas: 'num main meta icon';
137
+ }
138
+ .list-row--expandable .list-row__meta { display: block; }
139
+ }
140
+
141
+ .list-row__icon {
142
+ grid-area: icon;
143
+ /* Ei align-selfiä: ikoni kohdistuu rivin perusviivalle kuten numero
144
+ ja meta. SVG:llä ei ole perusviivaa, joten selain käyttää sen
145
+ alareunaa — merkki istuu otsikon riville, ei kahden tekstirivin
146
+ pystykeskelle. Koko tulee .icon--l:stä. */
147
+ }
148
+
149
+ /* ---- käännös ------------------------------------------------------- */
150
+
151
+ /* `.invert` on mukana, jotta tila voidaan näyttää myös staattisena
152
+ (esim. dokumentaatiossa) ilman omaa sääntöä — muuten .list-row:n
153
+ oma `color` voittaisi utilityn samalla spesifisyydellä ja teksti
154
+ jäisi näkymättömiin. `aria-expanded` on avatun rivin sama tila. */
155
+ .list-row:hover,
156
+ .list-row:focus-visible,
157
+ .list-row.invert,
158
+ .list-row[aria-expanded='true'] {
159
+ background: var(--invert-surface);
160
+ color: var(--invert-ink);
161
+ text-decoration: none;
162
+ }
163
+ .list-row:hover .list-row__description,
164
+ .list-row:focus-visible .list-row__description,
165
+ .list-row.invert .list-row__description,
166
+ .list-row[aria-expanded='true'] .list-row__description,
167
+ .list-row:hover .meta,
168
+ .list-row:focus-visible .meta,
169
+ .list-row.invert .meta,
170
+ .list-row[aria-expanded='true'] .meta { color: var(--invert-faint); }
171
+
172
+ /* Focus-renkaan pitää näkyä käännetyllä pinnalla. */
173
+ .list-row:focus-visible {
174
+ outline-color: var(--invert-ink);
175
+ outline-offset: calc(var(--focus-offset) * -2);
176
+ }
177
+
178
+ /* Sisarrivit vaimennetaan hoverin aikana. */
179
+ .list-rows:hover .list-row:not(:hover) { color: var(--ink-faint); }
180
+ .list-rows:hover .list-row:not(:hover) .list-row__description { color: var(--ink-faint); }
181
+
182
+ /* Kosketus: ei hoveria, käännös :active-tilassa. Viimeisenä = voittaa. */
183
+ @media (hover: none) {
184
+ .list-row:hover {
185
+ background: transparent;
186
+ color: var(--ink);
187
+ }
188
+ .list-row:hover .list-row__description { color: var(--ink-muted); }
189
+ .list-row:hover .meta { color: var(--ink-faint); }
190
+ .list-rows:hover .list-row:not(:hover) { color: var(--ink); }
191
+ .list-rows:hover .list-row:not(:hover) .list-row__description { color: var(--ink-muted); }
192
+
193
+ .list-row:active,
194
+ .list-row[aria-expanded='true'] {
195
+ background: var(--invert-surface);
196
+ color: var(--invert-ink);
197
+ }
198
+ .list-row:active .list-row__description,
199
+ .list-row[aria-expanded='true'] .list-row__description,
200
+ .list-row:active .meta,
201
+ .list-row[aria-expanded='true'] .meta { color: var(--invert-faint); }
202
+ }
203
+
204
+ @media (prefers-reduced-motion: reduce) {
205
+ .list-row { transition: none; }
206
+ }
package/styles.css ADDED
@@ -0,0 +1,6 @@
1
+ /* @pokela/components — kaikki tyylit. Järjestys on merkitsevä. */
2
+ @import '@pokela/tokens/tokens.css';
3
+ @import './styles/base.css';
4
+ @import './styles/icon.css';
5
+ @import './styles/list-row.css';
6
+ @import './styles/accordion.css';