Webbplatsen på alla tio språken

Sajten var hårdkodad engelska medan diagnostikytan redan talade tio
språk. Nu hämtar hela den publika webben — startsidan, ansökan,
inloggningen, språksidan, navigationen och sidfoten — sin text ur
samma katalog som resten av produkten, under nytt prefix webb.
(153 nycklar × 10 språk).

Språkval på webben: besökarens eget val (väljare i sidfoten, sparat i
localStorage med minnesfallback för privat läge) → webbläsarens språk
→ engelska. Rangordningen bor kvar i valjSprak; ny modul
alva/webbsprak.ts bär bara lagring och React-kroken. Portalens länkar
står medvetet kvar på engelska — portalen är produkten och styrs av
konto och organisation, inte av ett besöksval i en sidfot.

Gränsen håller: ordmärket, utläsningen och fasnamnen är ALVA:s
struktur och står likadant i varje språk; lang-attributet följer
valet; språksidans förhandsvisning behåller egen väljare och utgår
från besökarens språk.

Test: startsidans hjälterad och faskort låses mot systemdefinitionen
(två källor för samma mening driver isär tyst), webbnycklarna krävs i
varje språk, och designtesterna jämför nu katalognycklar i stället för
strängar — starkare, eftersom samma nyckel är samma ord på alla språk.
Ny språkrunda mot den bakade artefakten: väljare, sidbyte,
webbläsarspråk och engelsk fallback, 11/11.

775 enhetstester, genomgång 4/4, portalspärr, integrationstest,
artefaktmätning, typkontroll och lint gröna.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
Claude
2026-08-06 22:38:18 +00:00
parent 83944f4962
commit 0068ef9844
18 changed files with 2168 additions and 223 deletions
+56
View File
@@ -0,0 +1,56 @@
// ALVA-SPEC-060 · Webbplatsens språk.
//
// Plattformen väljer språk ur konto → organisation → webbläsare. Den
// publika webbplatsen har inget konto och ingen organisation — där är
// kedjan besökarens eget val (sparat lokalt) → webbläsaren → engelska.
// Rangordningen ligger kvar i `valjSprak`, så att "hur språk väljs" bara
// finns implementerat på ett ställe.
//
// Valet lagras i localStorage men hålls också i minnet: en webbläsare i
// privat läge kan neka lagring, och då ska väljaren ändå fungera för
// sidvisningen i stället för att tyst göra ingenting.
import { useSyncExternalStore } from "react";
import { STANDARD, valjSprak } from "../../../services/gemensam/sprak/index.mjs";
const NYCKEL = "alva.webbsprak";
const lyssnare = new Set<() => void>();
let minne: string | undefined;
function lagratVal(): string | undefined {
try {
return localStorage.getItem(NYCKEL) ?? minne;
} catch {
return minne;
}
}
/** Webbplatsens språk just nu: eget val → webbläsare → engelska. */
export function webbSprak(): string {
const webblasare =
typeof navigator === "undefined" ? [] : (navigator.languages ?? [navigator.language]);
return valjSprak({ anvandare: lagratVal(), webblasare }) as string;
}
/** Sätter besökarens val och meddelar alla vyer som lyssnar. */
export function sattWebbSprak(kod: string): void {
minne = kod;
try {
localStorage.setItem(NYCKEL, kod);
} catch {
// Se filhuvudet: minnesvärdet bär valet när lagringen nekar.
}
for (const meddela of lyssnare) meddela();
}
/** React-krok: webbplatsens språk, med omritning vid byte. */
export function useWebbSprak(): string {
return useSyncExternalStore(
(meddela) => {
lyssnare.add(meddela);
return () => lyssnare.delete(meddela);
},
webbSprak,
() => STANDARD as string,
);
}
@@ -339,22 +339,30 @@ describe("ytor utan verklig funktion är märkta som demonstration", () => {
// En märkning som inte går att missförstå säger två saker: att ingenting
// sker, och vad läsaren ska göra i stället.
describe("demonstrationsmärkningen är entydig", () => {
// Texterna bor sedan flerspråkigheten i katalogen. Garantin är därför
// tvådelad: källtexten säger det som ska sägas, OCH sidan hämtar just
// den nyckeln — annars kan en riktig text stå oanvänd i katalogen.
const sida = (namn: string) => filer.find(([n]) => n.endsWith(`pages/alva/${namn}`))![1];
it("inloggningen säger vad besökaren ska göra", () => {
const kod = sida("LoggaIn.tsx");
expect(kod).toContain("Type anything");
expect(kod).toContain("no account is needed");
it("inloggningen säger vad besökaren ska göra", async () => {
const { EN } = await import("../../../../services/gemensam/sprak/en.mjs");
expect(EN["webb.login.demo"]).toContain("Type anything");
expect(EN["webb.login.demo"]).toContain("no account is needed");
expect(sida("LoggaIn.tsx")).toContain('t("webb.login.demo")');
});
it("ansökan säger att det inte finns någon mottagning", () => {
expect(sida("Ansokan.tsx")).toContain("there is no intake behind it yet");
it("ansökan säger att det inte finns någon mottagning", async () => {
const { EN } = await import("../../../../services/gemensam/sprak/en.mjs");
expect(EN["webb.ansokan.demo"]).toContain("there is no intake behind it yet");
expect(sida("Ansokan.tsx")).toContain('t("webb.ansokan.demo")');
});
it("beskrivningen av avsedd drift är märkt som avsedd, inte som gällande", () => {
it("beskrivningen av avsedd drift är märkt som avsedd, inte som gällande", async () => {
// Två meningar bredvid varandra, där den ena säger att inget händer
// och den andra beskriver en handläggningsprocess, upphäver varandra.
expect(sida("Ansokan.tsx")).toContain("Intended operation:");
const { EN } = await import("../../../../services/gemensam/sprak/en.mjs");
expect(EN["webb.ansokan.avsikt"]).toContain("Intended operation:");
expect(sida("Ansokan.tsx")).toContain('t("webb.ansokan.avsikt")');
});
it("ingen märkning hänvisar till något som läsaren inte kan nå", () => {
@@ -432,7 +440,7 @@ describe("portalen är stängd, eller uttryckligen en demonstration", () => {
expect(loggaIn).toContain("loggaInPlattform");
// Och skylten står kvar exakt när den ska: bara i det läge där
// inloggningen inte prövar något.
expect(loggaIn).toMatch(/!skarpt && \(\s*<Demonstration>/);
expect(loggaIn).toMatch(/!skarpt && (\(\s*)?<Demonstration>/);
});
it("fakturavyn visar organisationens egna fakturor mot en plattform", () => {
@@ -465,18 +473,22 @@ describe("inloggningen heter samma sak överallt", () => {
const start = readFileSync("src/pages/alva/Start.tsx", "utf8");
it("navigationens namn på inloggningen är det som används", () => {
const namn = ram.match(/\{ till: "\/alva\/logga-in", text: "([^"]+)" \}/)?.[1];
expect(namn).toBeTruthy();
// Varje länk till inloggningen på startsidan bär samma text.
// Sedan flerspråkigheten jämförs KATALOGNYCKLAR, inte strängar — det
// är starkare: samma nyckel är samma ord på vartenda språk, medan två
// nycklar med samma engelska text hade kunnat glida isär i en enda
// översättning.
const nyckel = ram.match(/\{ till: "\/alva\/logga-in", nyckel: "([^"]+)" \}/)?.[1];
expect(nyckel).toBeTruthy();
// Varje länk till inloggningen på startsidan bär samma nyckel.
//
// Mönstret tål attribut på länken. Första versionen gjorde inte det,
// och när <Link> fick en className matchade den noll knappar — testet
// slutade mäta i stället för att falla. Det enda som avslöjade det var
// kravet nedan på att den ska hitta NÅGOT, vilket är därför det står
// där och inte som en självklarhet.
const knappar = [...start.matchAll(/to="\/alva\/logga-in"[^>]*>\s*<Knapp[^>]*>([^<]+)<\/Knapp>/g)];
const knappar = [...start.matchAll(/to="\/alva\/logga-in"[^>]*>\s*<Knapp[^>]*>\{t\("([^"]+)"\)\}<\/Knapp>/g)];
expect(knappar.length).toBeGreaterThan(0);
for (const [, text] of knappar) expect(text.trim()).toBe(namn);
for (const [, n] of knappar) expect(n).toBe(nyckel);
});
it("ingen ingång kallar verkstaden för kund", () => {
@@ -194,6 +194,31 @@ describe("metodikvarningen", () => {
});
});
describe("webbplatsens källtext", () => {
// Hjälteraden och metodkorten på startsidan hämtas ur katalogen, men
// systemdefinitionen i @/alva/system är normen. Två källor för samma
// mening driver isär tyst — testet gör skillnaden till ett byggfel.
it("hjälteraden och faskorten säger samma sak som systemdefinitionen", async () => {
const { ALVA, FASER } = await import("../../alva/system");
expect(EN["webb.hero.position"]).toBe(ALVA.position);
expect(EN["webb.hero.definition"]).toBe(ALVA.definition);
for (const f of FASER) {
expect(EN[`webb.fas.${f.id}.syfte`], f.id).toBe(f.syfte);
expect(EN[`webb.fas.${f.id}.avgransning`], f.id).toBe(f.avgransning);
}
});
it("webbnycklarna finns i varje språk — sajten är inte engelska med undantag", () => {
const webb = NYCKLAR.filter((n) => n.startsWith("webb."));
expect(webb.length).toBeGreaterThan(140);
for (const kod of OVRIGA) {
for (const n of webb) {
expect(KATALOGER[kod][n], `${kod}: ${n}`).toBeTruthy();
}
}
});
});
function egetNamn(kod: string): string {
return SPRAK.find((s) => s.kod === kod)!.egetNamn;
}
+29 -36
View File
@@ -7,29 +7,32 @@
import { type FormEvent, useState } from "react";
import { Demonstration, Block, Etikett, FARG, Knapp, Rubrik, Statusmärke } from "@/alva/komponenter";
import { useWebbSprak } from "@/alva/webbsprak";
import { oversattare } from "../../../../services/gemensam/sprak/index.mjs";
import { Ram } from "./Ram";
interface Falt {
namn: string;
etikett: string;
nyckel: string;
typ?: string;
kravs?: boolean;
hjalp?: string;
}
const FALT: Falt[] = [
{ namn: "foretag", etikett: "Company", kravs: true },
{ namn: "orgnummer", etikett: "Organization number", kravs: true },
{ namn: "kontakt", etikett: "Contact person", kravs: true },
{ namn: "epost", etikett: "Email", typ: "email", kravs: true },
{ namn: "telefon", etikett: "Phone", typ: "tel", kravs: true },
{ namn: "tekniker", etikett: "Number of technicians", typ: "number", kravs: true },
{ namn: "bransch", etikett: "Industry", kravs: true },
{ namn: "land", etikett: "Country", kravs: true },
{ namn: "anvandare", etikett: "Expected number of users", typ: "number" },
{ namn: "foretag", nyckel: "webb.falt.foretag", kravs: true },
{ namn: "orgnummer", nyckel: "webb.falt.orgnummer", kravs: true },
{ namn: "kontakt", nyckel: "webb.falt.kontakt", kravs: true },
{ namn: "epost", nyckel: "webb.falt.epost", typ: "email", kravs: true },
{ namn: "telefon", nyckel: "webb.falt.telefon", typ: "tel", kravs: true },
{ namn: "tekniker", nyckel: "webb.falt.tekniker", typ: "number", kravs: true },
{ namn: "bransch", nyckel: "webb.falt.bransch", kravs: true },
{ namn: "land", nyckel: "webb.falt.land", kravs: true },
{ namn: "anvandare", nyckel: "webb.falt.anvandare", typ: "number" },
];
export default function Ansokan() {
const sprak = useWebbSprak();
const t = oversattare(sprak) as (nyckel: string) => string;
const [skickad, setSkickad] = useState(false);
const [referens, setReferens] = useState("");
@@ -47,31 +50,26 @@ export default function Ansokan() {
return (
<Ram>
<div className="mx-auto max-w-[680px] px-6 py-16">
<Etikett>Application</Etikett>
<Etikett>{t("webb.ansokan.mottagen.etikett")}</Etikett>
<div className="mt-2 mb-8">
<Rubrik niva={1}>Submitted</Rubrik>
<Rubrik niva={1}>{t("webb.ansokan.mottagen")}</Rubrik>
</div>
<Demonstration>
Nothing was submitted and no one was notified. The reference below was computed in your
browser from what you typed, and exists nowhere else it will be gone when you close
this page.
</Demonstration>
<Block rubrik="Registration" beteckning={referens}>
<Demonstration>{t("webb.ansokan.mottagen.demo")}</Demonstration>
<Block rubrik={t("webb.ansokan.etikett")} beteckning={referens}>
<dl className="text-[14px] leading-[24px]">
<div className="flex justify-between border-b py-2" style={{ borderColor: FARG.lightSteel }}>
<dt style={{ color: FARG.steel }}>Status</dt>
<dt style={{ color: FARG.steel }}>{t("webb.ansokan.status")}</dt>
<dd>
<Statusmärke status="pending" />
</dd>
</div>
<div className="flex justify-between py-2">
<dt style={{ color: FARG.steel }}>Reference</dt>
<dt style={{ color: FARG.steel }}>{t("webb.ansokan.referens")}</dt>
<dd className="font-mono">{referens}</dd>
</div>
</dl>
<p className="mt-4 border-t pt-4 text-[13px] leading-[20px]" style={{ borderColor: FARG.lightSteel, color: FARG.steel }}>
Applications are reviewed manually. An invoice is issued on approval. The organization is activated
once payment is registered.
{t("webb.ansokan.granskning")}
</p>
</Block>
</div>
@@ -82,18 +80,13 @@ export default function Ansokan() {
return (
<Ram>
<div className="mx-auto max-w-[680px] px-6 py-16">
<Etikett>Registration</Etikett>
<Etikett>{t("webb.ansokan.etikett")}</Etikett>
<div className="mt-2 mb-2">
<Rubrik niva={1}>Request account</Rubrik>
<Rubrik niva={1}>{t("webb.ansok")}</Rubrik>
</div>
<Demonstration>
Nothing is sent. Submitting this form stores nothing, notifies no one, and creates no
application there is no intake behind it yet. The description below states how
registration is intended to work, not what happens today.
</Demonstration>
<Demonstration>{t("webb.ansokan.demo")}</Demonstration>
<p className="mb-8 text-[14px] leading-[22px]" style={{ color: FARG.steel }}>
Intended operation: applications are reviewed manually and no payment is taken at this
stage.
{t("webb.ansokan.avsikt")}
</p>
<form onSubmit={skicka} noValidate={false}>
@@ -101,9 +94,9 @@ export default function Ansokan() {
{FALT.map((f) => (
<div key={f.namn} className="border-t py-2 first:border-t-0" style={{ borderColor: FARG.lightSteel }}>
<label htmlFor={f.namn} className="block text-[11px] font-semibold uppercase tracking-[0.08em]" style={{ color: FARG.steel }}>
{f.etikett}
{t(f.nyckel)}
{f.kravs && <span aria-hidden="true"> ·</span>}
{f.kravs && <span className="sr-only"> (required)</span>}
{f.kravs && <span className="sr-only"> {t("webb.falt.kravs")}</span>}
</label>
<input
id={f.namn}
@@ -117,7 +110,7 @@ export default function Ansokan() {
))}
<div className="border-t py-2" style={{ borderColor: FARG.lightSteel }}>
<label htmlFor="noteringar" className="block text-[11px] font-semibold uppercase tracking-[0.08em]" style={{ color: FARG.steel }}>
Notes
{t("webb.falt.noteringar")}
</label>
<textarea
id="noteringar"
@@ -129,7 +122,7 @@ export default function Ansokan() {
</div>
</Block>
<Knapp type="submit">Submit application</Knapp>
<Knapp type="submit">{t("webb.ansokan.skicka")}</Knapp>
</form>
</div>
</Ram>
+14 -17
View File
@@ -20,15 +20,19 @@ import { type FormEvent, useState } from "react";
import { useNavigate } from "react-router-dom";
import { loggaInPlattform, plattformAktiv } from "@/felsokning/plattform";
import { Demonstration, Block, Etikett, FARG, Knapp, Rubrik } from "@/alva/komponenter";
import { useWebbSprak } from "@/alva/webbsprak";
import { oversattare } from "../../../../services/gemensam/sprak/index.mjs";
import { Ram } from "./Ram";
const FALT = [
{ namn: "epost", etikett: "Email", typ: "email", komplettering: "username" },
{ namn: "losenord", etikett: "Password", typ: "password", komplettering: "current-password" },
{ namn: "epost", nyckel: "webb.falt.epost", typ: "email", komplettering: "username" },
{ namn: "losenord", nyckel: "webb.login.losenord", typ: "password", komplettering: "current-password" },
];
export default function LoggaIn() {
const navigera = useNavigate();
const sprak = useWebbSprak();
const t = oversattare(sprak) as (nyckel: string) => string;
const [fel, setFel] = useState("");
const [arbetar, setArbetar] = useState(false);
const skarpt = plattformAktiv();
@@ -47,7 +51,7 @@ export default function LoggaIn() {
if (!epost || !losenord) {
// Statusspråket gäller även fel: ett konstaterande, inte en ursäkt.
setFel("Authentication incomplete. Email and password required.");
setFel(t("webb.login.ofullstandigt"));
return;
}
@@ -60,7 +64,7 @@ export default function LoggaIn() {
// Serverns egen text visas oförändrad. Den vet vad som hände —
// avstängt konto, spärrat efter för många försök, fel uppgifter —
// och en omskrivning här skulle bara göra beskedet vagare.
setFel(orsak instanceof Error ? orsak.message : "Authentication failed.");
setFel(orsak instanceof Error ? orsak.message : t("webb.login.misslyckades"));
} finally {
setArbetar(false);
}
@@ -69,18 +73,11 @@ export default function LoggaIn() {
return (
<Ram>
<div className="mx-auto max-w-[440px] px-6 py-24">
<Etikett>Access</Etikett>
<Etikett>{t("webb.login.etikett")}</Etikett>
<div className="mt-2 mb-8">
<Rubrik niva={1}>Login</Rubrik>
<Rubrik niva={1}>{t("webb.loggain")}</Rubrik>
</div>
{!skarpt && (
<Demonstration>
This login authenticates nothing. Type anything into Email and Password and press Sign in
no account is needed, and the portal behind it presents fixed example data. Connected to
a platform instance this page authenticates against it, and the portal is closed without a
valid session.
</Demonstration>
)}
{!skarpt && <Demonstration>{t("webb.login.demo")}</Demonstration>}
<form onSubmit={skicka}>
<Block>
@@ -91,7 +88,7 @@ export default function LoggaIn() {
className="block text-[11px] font-semibold uppercase tracking-[0.08em]"
style={{ color: FARG.steel }}
>
{f.etikett}
{t(f.nyckel)}
</label>
<input
id={f.namn}
@@ -113,10 +110,10 @@ export default function LoggaIn() {
<div className="flex items-center justify-between">
<Knapp type="submit" disabled={arbetar}>
{arbetar ? "Signing in" : "Sign in"}
{arbetar ? t("webb.login.loggar_in") : t("webb.login.logga_in")}
</Knapp>
<a href="/alva/ansokan" className="text-[12px] uppercase tracking-[0.08em]" style={{ color: FARG.steel }}>
Forgot password
{t("webb.login.glomt")}
</a>
</div>
</form>
+64 -21
View File
@@ -3,17 +3,29 @@
//
// Navigationen är en rad text, inte en meny med ikoner. Den som söker
// något i ett industrisystem vet vad det heter.
//
// ---- Språket -----------------------------------------------------------
//
// Den publika navigationen och sidfoten talar besökarens språk (val i
// sidfoten → webbläsare → engelska). Portalens länkar står MEDVETET kvar
// på engelska: portalen är produkten, och dess språk styrs av konto och
// organisation (ALVA-SPEC-060) — inte av ett besöksval i en sidfot.
import type { ReactNode } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { loggaUtPlattform, plattformKonto } from "@/felsokning/plattform";
import { ALVA, PLATTFORMSVERSION } from "@/alva/system";
import { FARG } from "@/alva/komponenter";
import { useWebbSprak, sattWebbSprak } from "@/alva/webbsprak";
import { SPRAK, oversattare } from "../../../../services/gemensam/sprak/index.mjs";
type Sprakpost = { kod: string; namn: string; egetNamn: string; granskat: boolean };
const SPRAKEN = SPRAK as Sprakpost[];
const PUBLIKT = [
{ till: "/alva", text: "Overview" },
{ till: "/alva/ansokan", text: "Request account" },
{ till: "/alva/logga-in", text: "Login" },
{ till: "/alva", nyckel: "webb.nav.oversikt" },
{ till: "/alva/ansokan", nyckel: "webb.ansok" },
{ till: "/alva/logga-in", nyckel: "webb.loggain" },
];
const PORTAL = [
@@ -29,10 +41,20 @@ const PORTAL = [
{ till: "/felsokning", text: "Diagnostics" },
];
const FOT = [
["/alva/impressum", "webb.fot.impressum"],
["/alva/dataskydd", "webb.fot.dataskydd"],
["/alva/villkor", "webb.fot.villkor"],
["/alva/tillganglighet", "webb.fot.tillganglighet"],
["/alva/sprak", "webb.fot.sprak"],
["/alva/utgavor", "webb.fot.utgavor"],
] as const;
export function Ram({ children, portal = false }: { children: ReactNode; portal?: boolean }) {
const plats = useLocation();
const navigera = useNavigate();
const lankar = portal ? PORTAL : PUBLIKT;
const sprak = useWebbSprak();
const t = oversattare(sprak) as (nyckel: string) => string;
// Finns en riktig session ska den gå att avsluta. Utan konfigurerad
// plattform finns ingen, och då visas ingen utloggning heller — en
// knapp som inte loggar ut något är samma sorts sken som m-9 gällde.
@@ -41,7 +63,7 @@ export function Ram({ children, portal = false }: { children: ReactNode; portal?
return (
// `alva-yta` bär typografin ur ALVA-SPEC-001 och håller den skild
// från värdapplikationens antikva. Se src/index.css.
<div className="alva-yta min-h-screen" style={{ background: FARG.background, color: FARG.graphite }}>
<div lang={sprak} className="alva-yta min-h-screen" style={{ background: FARG.background, color: FARG.graphite }}>
<header className="border-b" style={{ borderColor: FARG.lightSteel, background: FARG.white }}>
<div className="mx-auto flex max-w-[1040px] flex-wrap items-center justify-between gap-4 px-6 py-4">
<Link to={portal ? "/alva/portal" : "/alva"} className="flex items-baseline gap-4">
@@ -63,8 +85,9 @@ export function Ram({ children, portal = false }: { children: ReactNode; portal?
av marginalerna, inte av att posterna klumpas ihop. */}
<nav aria-label={portal ? "Portal" : "Site"} className="w-full sm:w-auto">
<ul className="flex flex-wrap justify-between gap-x-4 gap-y-2 sm:justify-end sm:gap-6">
{lankar.map((l) => {
{(portal ? PORTAL : PUBLIKT).map((l) => {
const aktiv = plats.pathname === l.till;
const text = "nyckel" in l ? t(l.nyckel) : l.text;
return (
<li key={l.till}>
<Link
@@ -73,7 +96,7 @@ export function Ram({ children, portal = false }: { children: ReactNode; portal?
className="text-[12px] font-semibold uppercase tracking-[0.08em]"
style={{ color: aktiv ? FARG.blue : FARG.steel }}
>
{l.text}
{text}
</Link>
</li>
);
@@ -122,30 +145,50 @@ export function Ram({ children, portal = false }: { children: ReactNode; portal?
<div className="mx-auto max-w-[1040px] px-6 py-6">
<nav aria-label="Legal">
<ul className="flex flex-wrap gap-x-6 gap-y-2 text-[12px]">
{[
["/alva/impressum", "Impressum"],
["/alva/dataskydd", "Privacy"],
["/alva/villkor", "Terms"],
["/alva/tillganglighet", "Accessibility"],
["/alva/sprak", "Languages"],
["/alva/utgavor", "Release notes"],
].map(([till, text]) => (
{FOT.map(([till, nyckel]) => (
<li key={till}>
<Link to={till} style={{ color: FARG.steel }}>
{text}
{t(nyckel)}
</Link>
</li>
))}
</ul>
</nav>
<div
className="mt-6 flex flex-wrap justify-between gap-4 border-t pt-6 font-mono text-[11px]"
className="mt-6 flex flex-wrap items-center justify-between gap-4 border-t pt-6"
style={{ borderColor: FARG.lightSteel, color: FARG.steel }}
>
<Link to="/alva/utgavor" style={{ color: FARG.steel }}>
{PLATTFORMSVERSION}
</Link>
<span>{ALVA.position}</span>
{/* Väljaren sätter besökarens val, som väger tyngre än
webbläsarens språk men aldrig rör plattformens eget
språkval — se filhuvudet. */}
<div className="flex items-center gap-4">
<label
htmlFor="webbsprak"
className="text-[11px] font-semibold uppercase tracking-[0.08em]"
style={{ color: FARG.steel }}
>
{t("sprak.valj")}
</label>
<select
id="webbsprak"
value={sprak}
onChange={(h) => sattWebbSprak(h.target.value)}
className="border px-4 py-2 text-[12px]"
style={{ borderColor: FARG.lightSteel, background: FARG.white, color: FARG.graphite }}
>
{SPRAKEN.map((s) => (
<option key={s.kod} value={s.kod}>
{s.egetNamn}
</option>
))}
</select>
</div>
<div className="flex flex-wrap gap-x-6 gap-y-2 font-mono text-[11px]">
<Link to="/alva/utgavor" style={{ color: FARG.steel }}>
{PLATTFORMSVERSION}
</Link>
<span>{t("webb.hero.position")}</span>
</div>
</div>
</div>
</footer>
+45 -50
View File
@@ -19,10 +19,15 @@
// inte är fackgranskad på rumänska kan planera för det. Samma chef som
// får veta det av en tekniker som missförstått en säkerhetsinstruktion
// kan inte.
//
// Sidans egen text står på besökarens språk (webb.sprak.-nycklarna).
// Förhandsvisningen har en EGEN väljare: den visar hur grindens hinder
// ser ut på ett språk man granskar, inte det språk man läser sidan på.
import { useState } from "react";
import { SPRAK, metodikvarning, oversattare, tackning } from "../../../../services/gemensam/sprak/index.mjs";
import { Block, Etikett, FARG, Rubrik, Tabell } from "@/alva/komponenter";
import { useWebbSprak } from "@/alva/webbsprak";
import { Ram } from "./Ram";
type Sprakpost = { kod: string; namn: string; egetNamn: string; granskat: boolean };
@@ -54,8 +59,14 @@ function Sektion({ etikett, rubrik, children }: { etikett: string; rubrik: strin
}
export default function Sprak() {
const [valt, setValt] = useState("de");
const t = oversattare(valt) as (nyckel: string) => string;
const sprak = useWebbSprak();
const t = oversattare(sprak) as (nyckel: string, variabler?: Record<string, string>) => string;
// Förhandsvisningens språk är inte sidans. Den som läser på engelska
// ska kunna granska den polska översättningen — utgångsvalet är därför
// besökarens eget språk när det inte är källspråket, annars tyska.
const [valt, setValt] = useState(sprak !== "en" ? sprak : "de");
const prov = oversattare(valt) as (nyckel: string) => string;
const post = SPRAKEN.find((s) => s.kod === valt)!;
const varning = metodikvarning(valt) as string | null;
@@ -63,56 +74,49 @@ export default function Sprak() {
<Ram>
<section className="border-b" style={{ borderColor: FARG.lightSteel, background: FARG.white }}>
<div className="mx-auto max-w-[1040px] px-6 py-16">
<Etikett>Localization</Etikett>
<Etikett>{t("webb.sprak.etikett")}</Etikett>
<div className="mt-2">
<Rubrik niva={1}>Languages</Rubrik>
<Rubrik niva={1}>{t("webb.sprak.rubrik")}</Rubrik>
</div>
<p className="mt-6 max-w-[680px] text-[16px] leading-[26px]" style={{ color: FARG.graphite }}>
English is the default and the source language. Nine translations follow. What the platform does
not do is claim that a translated interface means a translated method.
{t("webb.sprak.ingress")}
</p>
</div>
</section>
{/* ---- Den avgränsning som styr allt annat ---- */}
<Sektion etikett="Principle" rubrik="Two kinds of text">
<Sektion etikett={t("webb.sprak.princip.etikett")} rubrik={t("webb.sprak.princip.rubrik")}>
<div className="grid gap-8 md:grid-cols-2">
<Block rubrik="Interface text" beteckning="Falls back silently">
<Block rubrik={t("webb.sprak.granssnitt.rubrik")} beteckning={t("webb.sprak.granssnitt.beteckning")}>
<p className="text-[14px] leading-[22px]" style={{ color: FARG.graphite }}>
Labels, buttons, statuses. Finite, rarely changed. An English string reaching a German user is
an irritation, not a hazard so a missing translation falls back to English without comment.
{t("webb.sprak.granssnitt.text")}
</p>
</Block>
<Block rubrik="Procedure text" beteckning="Never falls back silently">
<Block rubrik={t("webb.sprak.metodik.rubrik")} beteckning={t("webb.sprak.metodik.beteckning")}>
<p className="text-[14px] leading-[22px]" style={{ color: FARG.graphite }}>
Instructions for work on a vehicle. Here an unreviewed translation is worse than a foreign
language one because English <em>looks</em> foreign, while a bad translation looks like an
instruction. It is shown in English and marked, with the language named.
{t("webb.sprak.metodik.text")}
</p>
</Block>
</div>
<p className="mt-8 max-w-[760px] border-l-2 pl-6 text-[15px] leading-[24px]"
style={{ borderColor: FARG.lightSteel, color: FARG.steel }}>
The method itself is never translated. Phase names and status words are ALVA&rsquo;s structure and
read identically in every country, so an auditor can read a Romanian and a German case record
without knowing which language the workshop works in.
{t("webb.sprak.invariant")}
</p>
</Sektion>
{/* ---- Redovisningen ---- */}
<Sektion etikett="Coverage" rubrik="What is translated, and what is reviewed">
<Sektion etikett={t("webb.sprak.tackning.etikett")} rubrik={t("webb.sprak.tackning.rubrik")}>
<Tabell
kolumner={["Language", "Interface", "Method reviewed by a specialist"]}
kolumner={[t("webb.sprak.kolumn.sprak"), t("webb.sprak.kolumn.granssnitt"), t("webb.sprak.kolumn.granskat")]}
rader={SPRAKEN.map((s) => [
`${s.namn}${s.egetNamn}`,
`${Math.round((tackning(s.kod) as number) * 100)} %`,
s.granskat ? "Yes" : "No — procedure text shown in English",
s.granskat ? t("webb.sprak.granskat.ja") : t("webb.sprak.granskat.nej"),
])}
/>
<p className="mt-6 max-w-[680px] text-[13px] leading-[20px]" style={{ color: FARG.steel }}>
Interface coverage is measured, not estimated: a test fails the build if any key is missing from
any language. Review status is a statement about people, not about files, and is set by hand.
{t("webb.sprak.matt")}
</p>
{/* Att engelska är källspråket betyder inte att den engelska
metodiktexten är fackgranskad. Den är översatt från svenska,
@@ -120,22 +124,19 @@ export default function Sprak() {
varnar för på de andra nio språken. */}
<p className="mt-4 max-w-[680px] border-l-2 pl-6 text-[13px] leading-[20px]"
style={{ borderColor: FARG.varning, color: FARG.graphite }}>
The English procedure text is a translation of the Swedish source and has not yet been read by a
specialist working in the trade. It is held to the same standard as the other nine languages, and
the same statement is made about it here rather than quietly excepted because it is the source.
{t("webb.sprak.kallsprak")}
</p>
</Sektion>
{/* ---- Beviset ---- */}
<Sektion etikett="Verification" rubrik="The strings that stop a case">
<Sektion etikett={t("webb.sprak.bevis.etikett")} rubrik={t("webb.sprak.bevis.rubrik")}>
<p className="mb-8 max-w-[680px] text-[15px] leading-[24px]" style={{ color: FARG.steel }}>
These are the sentences that refuse to let a technician close a case. A refusal nobody understands
is a refusal with no way through so they are the right strings to judge a translation by.
{t("webb.sprak.bevis.ingress")}
</p>
{/* Rutnät, inte flexradbrytning. Tio språk bryts av flex till nio
plus ett ensamt, och den ensamma knappen läser sig som en
eftertanke i stället för som ett språk bland tio. Fem kolumner
eftertanke i stället för som ett språk bland tio. Fem kolonner
och två gör tio jämnt i båda riktningarna. */}
<div className="mb-8 grid grid-cols-2 gap-2 sm:grid-cols-5">
{SPRAKEN.map((s) => (
@@ -162,8 +163,8 @@ export default function Sprak() {
<div className="font-mono text-[11px]" style={{ color: FARG.steel }}>
{nyckel}
</div>
<div className="mt-2 text-[15px] leading-[24px]" style={{ color: FARG.graphite }}>
{t(nyckel)}
<div className="mt-2 text-[15px] leading-[24px]" lang={valt} style={{ color: FARG.graphite }}>
{prov(nyckel)}
</div>
</li>
))}
@@ -172,54 +173,48 @@ export default function Sprak() {
{/* Varningen står under provet, inte över det. Den som just läst
fem meningar på sitt eget språk ska mötas av vad de INTE
garanterar — inte varnas i förväg och sedan lugnas av att
texten ser bra ut. */}
texten ser bra ut. Varningen själv står på PROVSPRÅKET,
precis som i drift: den riktar sig till den som skulle
arbeta på det språket. */}
{varning && (
<div className="mt-8 border-l-2 p-6" style={{ borderColor: FARG.varning, background: FARG.white }}>
<Etikett>Not reviewed {post.egetNamn}</Etikett>
<p className="mt-2 max-w-[760px] text-[14px] leading-[22px]" style={{ color: FARG.graphite }}>
<Etikett>{t("webb.sprak.ej_granskad")} {post.egetNamn}</Etikett>
<p className="mt-2 max-w-[760px] text-[14px] leading-[22px]" lang={valt} style={{ color: FARG.graphite }}>
{varning}
</p>
</div>
)}
{!varning && (
<div className="mt-8 border-l-2 p-6" style={{ borderColor: FARG.blue, background: FARG.white }}>
<Etikett>Reviewed {post.egetNamn}</Etikett>
<Etikett>{t("webb.sprak.granskad")} {post.egetNamn}</Etikett>
<p className="mt-2 max-w-[760px] text-[14px] leading-[22px]" style={{ color: FARG.graphite }}>
Procedure text in {post.egetNamn} has been read by a specialist working in the trade. Steps and
checks are shown in {post.egetNamn} throughout.
{t("webb.sprak.granskad.text", { sprak: post.egetNamn })}
</p>
</div>
)}
</Sektion>
{/* ---- Hur språket väljs ---- */}
<Sektion etikett="Operation" rubrik="How the language is chosen">
<Sektion etikett={t("webb.sprak.val.etikett")} rubrik={t("webb.sprak.val.rubrik")}>
<ol className="max-w-[680px]">
{[
["User preference", "What the individual has selected."],
["Organization setting", "The workshop's documentation language."],
["Browser language", "The first language the platform recognises."],
["English", "The default, and the source."],
].map(([rubrik, text], i) => (
<li key={rubrik} className="flex gap-6 border-t py-6" style={{ borderColor: FARG.lightSteel }}>
{[1, 2, 3, 4].map((steg, i) => (
<li key={steg} className="flex gap-6 border-t py-6" style={{ borderColor: FARG.lightSteel }}>
<span className="font-mono text-[13px] leading-[20px]" style={{ color: FARG.steel }}>
{String(i + 1).padStart(2, "0")}
</span>
<div>
<div className="text-[15px] font-semibold" style={{ color: FARG.graphite }}>
{rubrik}
{t(`webb.sprak.val.s${steg}.rubrik`)}
</div>
<div className="mt-2 text-[14px] leading-[20px]" style={{ color: FARG.steel }}>
{text}
{t(`webb.sprak.val.s${steg}.text`)}
</div>
</div>
</li>
))}
</ol>
<p className="mt-8 max-w-[680px] text-[14px] leading-[22px]" style={{ color: FARG.steel }}>
The organization ranks above the browser deliberately. A workshop in Germany with a Polish
technician needs one documentation language the case record must not change language depending on
who happened to write the line.
{t("webb.sprak.val.notering")}
</p>
</Sektion>
</Ram>
+53 -85
View File
@@ -9,10 +9,16 @@
// uppmaning att boka demo. En sida som försöker övertyga läser som
// marknadsföring; en sida som redovisar läser som dokumentation. Den
// senare är vad en teknisk chef fattar beslut utifrån.
//
// Texten hämtas ur språkkatalogen (webb.-nycklarna) på besökarens språk.
// Ordmärket och utläsningen är ALVA:s struktur och står kvar på engelska
// i varje språk — precis som fasnamnen i metodkorten nedan.
import { Link } from "react-router-dom";
import { ALVA, FASER, PLATTFORMSVERSION } from "@/alva/system";
import { Block, Etikett, FARG, Knapp, Rubrik, Symbol, Tabell } from "@/alva/komponenter";
import { useWebbSprak } from "@/alva/webbsprak";
import { oversattare } from "../../../../services/gemensam/sprak/index.mjs";
import { Ram } from "./Ram";
function Sektion({ etikett, rubrik, children }: { etikett: string; rubrik?: string; children: React.ReactNode }) {
@@ -32,6 +38,9 @@ function Sektion({ etikett, rubrik, children }: { etikett: string; rubrik?: stri
}
export default function Start() {
const sprak = useWebbSprak();
const t = oversattare(sprak) as (nyckel: string) => string;
return (
<Ram>
{/* ---- Hero. En mening. Ingen slogan. ---- */}
@@ -44,11 +53,11 @@ export default function Start() {
{ALVA.utlast}
</p>
<p className="mt-2 text-[13px] uppercase tracking-[0.08em]" style={{ color: FARG.steel }}>
{ALVA.position}
{t("webb.hero.position")}
</p>
<p className="mt-10 max-w-[620px] text-[18px] leading-[28px]" style={{ color: FARG.graphite }}>
{ALVA.definition}
{t("webb.hero.definition")}
</p>
{/* Lika breda, inte innehållsbreda. Två knappar bredvid varandra
@@ -58,20 +67,20 @@ export default function Start() {
fyllning mot kontur, som resten av systemet gör. */}
<div className="mt-10 grid max-w-[420px] grid-cols-2 gap-4">
<Link to="/alva/ansokan" className="block h-full">
<Knapp bred>Request account</Knapp>
<Knapp bred>{t("webb.ansok")}</Knapp>
</Link>
<Link to="/alva/logga-in" className="block h-full">
<Knapp bred variant="sekundar">Login</Knapp>
<Knapp bred variant="sekundar">{t("webb.loggain")}</Knapp>
</Link>
</div>
</div>
</section>
{/* ---- Metoden ---- */}
<Sektion etikett="Methodology" rubrik="The ALVA model">
{/* ---- Metoden. Fasnamnen är invarianta; syfte och avgränsning
är webbtext och följer besökarens språk. ---- */}
<Sektion etikett={t("webb.metod.etikett")} rubrik={t("webb.metod.rubrik")}>
<p className="mb-8 max-w-[680px] text-[15px] leading-[24px]" style={{ color: FARG.steel }}>
ALVA is a standardized method for systematic analysis, localization, verification and action. Every
decision is traceable, every conclusion verifiable, every action reproducible.
{t("webb.metod.ingress")}
</p>
<div className="grid gap-px md:grid-cols-4" style={{ background: FARG.lightSteel }}>
@@ -89,10 +98,10 @@ export default function Start() {
{f.namn}
</div>
<p className="mt-2 text-[14px] leading-[20px]" style={{ color: FARG.graphite }}>
{f.syfte}
{t(`webb.fas.${f.id}.syfte`)}
</p>
<p className="mt-4 border-t pt-4 text-[12px] leading-[18px]" style={{ borderColor: FARG.lightSteel, color: FARG.steel }}>
{f.avgransning}
{t(`webb.fas.${f.id}.avgransning`)}
</p>
</div>
))}
@@ -100,26 +109,19 @@ export default function Start() {
</Sektion>
{/* ---- Arbetsgång. Numrerad, inte illustrerad. ---- */}
<Sektion etikett="Operation" rubrik="How it works">
<Sektion etikett={t("webb.drift.etikett")} rubrik={t("webb.drift.rubrik")}>
<ol className="max-w-[680px]">
{[
["Create organization", "Registration is submitted and reviewed."],
["Users receive accounts", "Roles are assigned by the organization administrator."],
["Guided diagnostic procedures", "Each case follows a defined procedure."],
["Verification", "Root cause is confirmed before corrective action."],
["Documentation", "The record is generated from the case log."],
["Continuous improvement", "Verified outcomes refine subsequent procedures."],
].map(([rubrik, text], i) => (
<li key={rubrik} className="flex gap-6 border-t py-6" style={{ borderColor: FARG.lightSteel }}>
{[1, 2, 3, 4, 5, 6].map((steg, i) => (
<li key={steg} className="flex gap-6 border-t py-6" style={{ borderColor: FARG.lightSteel }}>
<span className="font-mono text-[13px] leading-[20px]" style={{ color: FARG.steel }}>
{String(i + 1).padStart(2, "0")}
</span>
<div>
<div className="text-[15px] font-semibold" style={{ color: FARG.graphite }}>
{rubrik}
{t(`webb.drift.s${steg}.rubrik`)}
</div>
<div className="mt-2 text-[14px] leading-[20px]" style={{ color: FARG.steel }}>
{text}
{t(`webb.drift.s${steg}.text`)}
</div>
</div>
</li>
@@ -128,34 +130,25 @@ export default function Start() {
</Sektion>
{/* ---- Organisationens eget lärande ---- */}
<Sektion etikett="Capability" rubrik="Enterprise learning">
<Sektion etikett={t("webb.larande.etikett")} rubrik={t("webb.larande.rubrik")}>
<div className="grid gap-8 md:grid-cols-2">
<div>
<p className="text-[15px] leading-[24px]" style={{ color: FARG.graphite }}>
The platform continuously refines diagnostic procedures using verified operational experience from
your own organization.
{t("webb.larande.p1")}
</p>
<p className="mt-4 text-[14px] leading-[22px]" style={{ color: FARG.steel }}>
Every completed diagnostic contributes to subsequent guidance. Only verified outcomes are used
a case closed without confirmed root cause contributes nothing, by design.
{t("webb.larande.p2")}
</p>
<p className="mt-4 text-[14px] leading-[22px]" style={{ color: FARG.steel }}>
The knowledge base belongs to the organization. It is derived from your cases, your procedures and
your documentation, and it is not aggregated across customers.
{t("webb.larande.p3")}
</p>
</div>
<Block rubrik="Derived from" beteckning="ALVA-SPEC-004">
<Block rubrik={t("webb.larande.block")} beteckning="ALVA-SPEC-004">
<ul className="text-[14px] leading-[24px]" style={{ color: FARG.graphite }}>
{[
"Verified root causes",
"Confirmed corrective actions",
"Recurring fault categories",
"Procedure completion records",
"Organization documentation",
].map((rad) => (
{[1, 2, 3, 4, 5].map((rad) => (
<li key={rad} className="flex gap-4">
<Symbol ikon="klar" />
{rad}
{t(`webb.larande.k${rad}`)}
</li>
))}
</ul>
@@ -171,62 +164,47 @@ export default function Start() {
Nu står frågorna i stället. En verkstadschef känner igen sina
egna frågor; ingen känner igen en innehållsförteckning. */}
<Sektion etikett="Reporting" rubrik="Quarterly improvement report">
<Sektion etikett={t("webb.rapport.etikett")} rubrik={t("webb.rapport.rubrik")}>
<p className="mb-8 max-w-[680px] text-[15px] leading-[24px]" style={{ color: FARG.steel }}>
Once a quarter, the platform answers six questions about the workshop&rsquo;s own work. Every
answer is derived from the case log nothing is estimated, and nothing is asked of anyone.
{t("webb.rapport.ingress")}
</p>
<ul className="grid max-w-[760px] gap-4">
{[
"Which faults are we now diagnosing correctly the first time?",
"How often does a suspected cause turn out to be the actual one?",
"Which faults keep coming back across the fleet?",
"Which procedure steps are being skipped, and why?",
"What does the workshop know now that it did not know last quarter?",
"Are cases being completed, verified and documented — or only completed?",
].map((fraga) => (
{[1, 2, 3, 4, 5, 6].map((fraga) => (
<li
key={fraga}
className="border-l-2 pl-6 text-[15px] leading-[24px]"
style={{ borderColor: FARG.lightSteel, color: FARG.graphite }}
>
{fraga}
{t(`webb.rapport.f${fraga}`)}
</li>
))}
</ul>
</Sektion>
{/* ---- Pris. Ingen onlinebetalning. ---- */}
<Sektion etikett="Commercial" rubrik="Licensing">
<Sektion etikett={t("webb.pris.etikett")} rubrik={t("webb.pris.rubrik")}>
<div className="grid gap-8 md:grid-cols-2">
<div>
<Tabell
kolumner={["Component", "Basis"]}
kolumner={[t("webb.pris.komponent"), t("webb.pris.grund")]}
rader={[
["Platform license", "Per organization, annually"],
["User licenses", "Per active user, monthly"],
["Enterprise modules", "Optional, per module"],
[t("webb.pris.plattform"), t("webb.pris.plattform.grund")],
[t("webb.pris.anvandare"), t("webb.pris.anvandare.grund")],
[t("webb.pris.moduler"), t("webb.pris.moduler.grund")],
]}
/>
<p className="mt-6 text-[13px] leading-[20px]" style={{ color: FARG.steel }}>
No online payment. No subscription sign-up. Invoicing follows review of the application.
{t("webb.pris.betalning")}
</p>
</div>
<Block rubrik="Activation sequence" beteckning="ALVA-PROC-0001">
<Block rubrik={t("webb.pris.aktivering")} beteckning="ALVA-PROC-0001">
<ol className="text-[14px] leading-[26px]" style={{ color: FARG.graphite }}>
{[
"Organization submits registration.",
"Application is reviewed.",
"Invoice is issued.",
"Payment is registered.",
"Organization is activated.",
"Additional users are billed monthly.",
].map((rad, i) => (
<li key={rad} className="flex gap-4">
{[1, 2, 3, 4, 5, 6].map((steg, i) => (
<li key={steg} className="flex gap-4">
<span className="font-mono text-[12px]" style={{ color: FARG.steel }}>
{String(i + 1).padStart(2, "0")}
</span>
{rad}
{t(`webb.pris.a${steg}`)}
</li>
))}
</ol>
@@ -235,34 +213,24 @@ export default function Start() {
</Sektion>
{/* ---- Kunskapskällor ---- */}
<Sektion etikett="Infrastructure" rubrik="Operational Knowledge Infrastructure">
<Sektion etikett={t("webb.kallor.etikett")} rubrik={t("webb.kallor.rubrik")}>
<div className="grid gap-8 md:grid-cols-2">
<div>
<p className="text-[15px] leading-[24px]" style={{ color: FARG.graphite }}>
ALVA uses the organization&rsquo;s own authorized knowledge sources. The architecture is
provider-agnostic: every source implements the same interface, and no provider is assumed.
{t("webb.kallor.p1")}
</p>
<p className="mt-4 text-[14px] leading-[22px]" style={{ color: FARG.steel }}>
A beta installation operates on internal documentation alone. External providers are enabled later
through separate connectors, without change to the platform core.
{t("webb.kallor.p2")}
</p>
</div>
<Block rubrik="Source resolution order" beteckning="ALVA-SPEC-011">
<Block rubrik={t("webb.kallor.block")} beteckning="ALVA-SPEC-011">
<ol className="text-[14px] leading-[24px]" style={{ color: FARG.graphite }}>
{[
"Internal company procedures",
"OEM documentation",
"Technical bulletins",
"Warranty information",
"Service manuals",
"Historical verified diagnostics",
"Organization best practices",
].map((rad, i) => (
{[1, 2, 3, 4, 5, 6, 7].map((rad, i) => (
<li key={rad} className="flex gap-4">
<span className="font-mono text-[12px]" style={{ color: FARG.steel }}>
{i + 1}
</span>
{rad}
{t(`webb.kallor.k${rad}`)}
</li>
))}
</ol>
@@ -277,10 +245,10 @@ export default function Start() {
</div>
<div className="grid max-w-[420px] grid-cols-2 gap-4">
<Link to="/alva/ansokan" className="block h-full">
<Knapp bred>Request account</Knapp>
<Knapp bred>{t("webb.ansok")}</Knapp>
</Link>
<Link to="/alva/logga-in" className="block h-full">
<Knapp bred variant="sekundar">Login</Knapp>
<Knapp bred variant="sekundar">{t("webb.loggain")}</Knapp>
</Link>
</div>
</div>