Compare commits

...

6 commits

Author SHA1 Message Date
flemming-it
7888b71f63 fix(l10n): align the stores dialog with the setup trust hint (Sie-form, plain language)
Some checks failed
Security / Security check (push) Failing after 1s
The regulated setup path sends the operator to the stores dialog to
pin a publisher key — but the field was labelled 'Angepinnter
Public-Key — PEM (optional)' with du-form developer prose (PEM,
vendor key) at exactly the trust-critical moment. Field now reads
'Signatur-Schlüssel anheften (PEM, optional)' with a plain-language
Sie-form explanation of what pinning does; the dialog intro and the
onboarding checklist follow (MCP jargon explained in the sentence,
developer-only follow-up cards labelled as such).

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-07-13 01:26:33 +02:00
flemming-it
ddac84ce8e feat: guided setup — persona re-audit fixes (grade-1 round)
- Regulated path finishes without a terminal: the signed-source
  state offers 'Add a signed source…' (stores dialog with pin-a-key)
  plus the per-module install buttons and a plain-language hint why
  pinning the publisher's key matters — instead of a hint with no
  affordance.
- Apply warnings (e.g. the empty-trusted-publishers caveat) surface
  selectable in the done state instead of being swallowed.
- Truthful preview: new lines state which machine is being set up
  (server/container targets configure THIS machine), that regulated
  profiles always get the hash-chained audit log (even with WORM
  off), and that the curated reading list is stored with the setup
  record.
- Language pass: onboarding checklist in Sie-form + 'System-KI'
  (was du-form + 'System-AI'), 'Audit-Sperre' jargon replaced,
  answers file moved to a private per-dialog temp dir.
- Screenshot harness: GUIDE_SHOTS_THEME=light for light-parity
  proof runs.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-07-13 01:20:59 +02:00
flemming-it
cf4024a4e2 test: deterministic operator-guide screenshot harness
integration_test/guide_shots_test.dart boots a hermetic hub
(HubFixture), seeds demo projects (open Bürgeramt; sealed
Ratsinformation and a setup.applied audit event only when the
runner confirms an isolated $HOME), launches the app in German +
dark mode, walks every sidebar page in Cmd order with
content-aware waits, opens the workspace switcher and the setup
wizard, and writes the guide PNGs via a driverless RepaintBoundary
capture. Driven by the platform repo's scripts/regen-studio-guide.sh.

Also fixes the hub fixture's binary resolution, dead since the
rename (it looked for 'fai' and ../chain_platform/): now $CHAIN_BIN,
'chain' on PATH, then ../fai_chain/target/{release,debug}/chain —
the integration tests actually run again instead of silently
skipping.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-07-13 00:47:41 +02:00
flemming-it
ed2c5ace26 test: generated nav manifest guards the sidebar truth
test/nav_manifest_test.dart derives docs/nav.generated.json from
the _pages list (order = Cmd numbers), the labelOf switch, and both
.arb files. On any nav change it regenerates the manifest and fails
once with instructions to commit + mirror it to
fai_chain/docs/studio/, where the platform repo's docs_consistency
gate checks the operator guide against it — cross-repo nav drift
becomes a red gate instead of quietly rotting docs.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-07-12 23:27:49 +02:00
flemming-it
09c901b75e feat: guided setup — free-text AI path (phase 1.2)
The wizard's first step now offers 'or just describe what you want
to do': the goal goes to the configured system AI, which maps it
onto the menu answers (validated against strict enum whitelists —
a hallucinated value can never reach the engine). The suggestion
comes back as an editable plain-language reflection ('this is how I
read your task') the operator can adjust step-by-step or take to
the same preview/apply the menu path uses. Trust rules per
guided-setup.md: suggestion only (never auto-apply), a privacy line
states whether the description is processed locally or sent to a
provider, and without a configured system AI the section explains
that the menu always works — no dead end.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-07-12 23:24:27 +02:00
flemming-it
1f1e050b42 feat: guided setup — clickable next steps, signature relaxation, fresh-install auto-open
Post-apply the wizard now renders real Studio actions instead of CLI
text: a start-hub button that polls until the daemon answers,
per-module install buttons (capability-name install via the hub's
store index) with done/progress states, and an open-the-starter-flow
button that navigates to the Flows page. Regulated plans explain in
plain language that modules come from a signed source; the preview
offers 'allow installing from the public store' as one deliberate,
reversible switch that re-assembles the plan (allow_unsigned_modules).

Fresh installs (no config, no setup-plan.yaml) auto-open the wizard
once per run — the wizard IS the onboarding — and it steps back once
a setup exists. The welcome CTA is framed honestly ('get started in
3 questions'), and after the wizard closes the onboarding checklist
remounts, re-probes, and says what the assistant already covered
(profile line from setup-plan.yaml) instead of acting as a second,
disconnected onboarding surface.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-07-12 23:18:41 +02:00
16 changed files with 2155 additions and 110 deletions

View file

@ -5,6 +5,64 @@ version + `kStudioVersion` in `lib/main.dart` stay in lockstep.
## Unreleased ## Unreleased
### Added (guided setup on grade-1 — steps A4/A5/B1 + doc automation)
- **Clickable next steps.** After apply, the wizard renders real
Studio actions instead of CLI text: start-hub button (polls until
the daemon answers), per-module install buttons with progress/done
states (capability-name install via the hub's store index), and an
open-the-starter-flow button navigating to Flows.
- **Signature dead end resolved.** Regulated plans explain in plain
language that modules come from a signed source; the preview offers
"allow installing from the public store" as one deliberate,
reversible switch (`allow_unsigned_modules`) that re-assembles the
plan. Air-gapped plans point to the offline-bundle path instead.
- **Fresh-install auto-open + honest framing.** On a fresh hub (no
config, no `setup-plan.yaml`) the wizard opens by itself, once per
run; the welcome CTA is now "In 3 Fragen loslegen" / "Get started
in 3 questions". After the wizard closes, the onboarding checklist
re-probes and states what the assistant already covered (applied
profile from `setup-plan.yaml`).
- **Free-text AI path (phase 1.2).** "Or just describe what you want
to do": the goal goes to the configured system AI, the reply is
validated against strict enum whitelists and comes back as an
editable "this is how I read your task" reflection feeding the same
preview/apply. Privacy line states local vs. provider processing;
without a system AI the menu path stands alone.
- **Nav manifest guard.** `test/nav_manifest_test.dart` generates
`docs/nav.generated.json` from the sidebar truth (ids, order = Cmd
numbers, DE+EN labels); the platform repo checks the operator guide
against the mirrored copy.
- **Guide screenshot harness.** `integration_test/guide_shots_test.dart`
boots a hermetic hub, seeds demo projects, walks every nav page,
the workspace switcher and the setup wizard, and writes the guide
PNGs — driven by the platform repo's `scripts/regen-studio-guide.sh`.
- **Fixed:** the integration-test hub fixture still looked for the
pre-rename `fai` binary and `chain_platform/` path, so its tests
silently skipped since the rename; it now resolves `$CHAIN_BIN`,
`chain` on PATH, and `../fai_chain/target/{release,debug}/chain`.
### Added (guided setup — persona re-audit fixes, grade-1 round)
- **Regulated path finishes without a terminal.** The post-apply
signed-source state now offers "Add a signed source…" (the stores
dialog with its pin-a-key field) plus the per-module install
buttons, instead of a hint with no affordance; a plain-language
trust hint explains why pinning the publisher's key matters.
- **Apply warnings surface.** Warning lines from `chain init --apply`
(e.g. the empty-trusted-publishers caveat) are shown selectable in
the done state instead of being swallowed on success.
- **Truthful preview.** New plan lines state which machine is being
set up (server/container targets configure THIS machine — said
explicitly), that regulated profiles get the hash-chained audit
log even when WORM is off, and that a curated reading list is
stored with the setup record.
- **Language pass.** Onboarding checklist switched to Sie-form and
"System-KI" (was du-form + "System-AI" next to the formal wizard);
"Audit-Sperre" jargon replaced with "Schreibschutz für das
Prüfprotokoll"; answers file now lives in a private per-dialog
temp dir instead of a fixed world-readable name.
### Added (multi-project, stage ③ — sealed areas) ### Added (multi-project, stage ③ — sealed areas)
- **Sealed-area connection switch.** The workspace switcher now lists - **Sealed-area connection switch.** The workspace switcher now lists

53
docs/nav.generated.json Normal file
View file

@ -0,0 +1,53 @@
{
"_generated": "by test/nav_manifest_test.dart — do not edit. Mirror to fai_chain/docs/studio/nav.generated.json (same content).",
"pages": [
{
"id": "welcome",
"cmd": 1,
"label_de": "Willkommen",
"label_en": "Welcome"
},
{
"id": "store",
"cmd": 2,
"label_de": "Store",
"label_en": "Store"
},
{
"id": "doctor",
"cmd": 3,
"label_de": "Diagnose",
"label_en": "Doctor"
},
{
"id": "flows",
"cmd": 4,
"label_de": "Flows",
"label_en": "Flows"
},
{
"id": "audit",
"cmd": 5,
"label_de": "Protokoll",
"label_en": "Audit"
},
{
"id": "approvals",
"cmd": 6,
"label_de": "Freigaben",
"label_en": "Approvals"
},
{
"id": "runs",
"cmd": 7,
"label_de": "Läufe",
"label_en": "Runs"
},
{
"id": "federation",
"cmd": 8,
"label_de": "Föderation",
"label_en": "Federation"
}
]
}

View file

@ -0,0 +1,249 @@
// Deterministic operator-guide screenshot harness (one command
// instead of the fragile cliclick/screencapture procedure).
//
// Boots a hermetic hub (HubFixture: temp data dir, free port),
// seeds deterministic demo data via the CLI (an open "Bürgeramt"
// project; optionally a sealed "Ratsinformation" when the runner
// confirms an isolated $HOME), launches the real app in German +
// dark mode against that hub, walks every sidebar destination in
// nav order, opens the workspace switcher and the guided-setup
// wizard, and writes the guide PNGs via a RepaintBoundary capture
// (driverless works headed on macOS with plain `flutter test`).
//
// Run through the platform repo's scripts/regen-studio-guide.sh,
// which isolates $HOME, builds the hub binary, and copies the
// PNGs into fai_chain/docs/studio/images/. Direct invocation:
//
// flutter test integration_test/guide_shots_test.dart -d macos
//
// Output lands in build/guide-shots/ unless GUIDE_SHOTS_OUT is
// set in the environment.
//
// Hermeticity notes:
// - SharedPreferences is mocked empty the operator's real Studio
// prefs are neither read nor written.
// - The app reconnects to the fixture hub with persist: false.
// - The sealed seed writes to $HOME/.chain/sealed/ by design, so
// it only runs when CHAIN_GUIDE_SHOTS_SEALED=1 confirms the
// caller isolated $HOME (the regen script does).
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart' show LogicalKeyboardKey;
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:chain_client_sdk/chain_client_sdk.dart' show HubEndpoint;
import 'package:chain_studio/data/hub.dart';
import 'package:chain_studio/main.dart';
import 'package:chain_studio/widgets/widgets.dart';
import '../test/integration/hub_fixture.dart';
final GlobalKey _shotKey = GlobalKey();
/// Pump a fixed number of frames instead of pumpAndSettle: the
/// sidebar clock ticks every second, so the tree never settles and
/// pumpAndSettle degenerates into its multi-minute timeout.
Future<void> _pumpFrames(WidgetTester tester, [int frames = 20]) async {
for (var i = 0; i < frames; i++) {
await tester.pump(const Duration(milliseconds: 100));
}
}
/// Pump until [finder] matches (page content arrived) or ~10 s
/// pass pages load their data from the hub asynchronously, and a
/// blind fixed wait raced the slower ones (the flows editor).
Future<void> _pumpUntil(
WidgetTester tester,
Finder finder, {
int maxFrames = 100,
}) async {
for (var i = 0; i < maxFrames; i++) {
if (finder.evaluate().isNotEmpty) return;
await tester.pump(const Duration(milliseconds: 100));
}
}
String get _outDir =>
Platform.environment['GUIDE_SHOTS_OUT'] ?? 'build/guide-shots';
/// Guide images ship dark; GUIDE_SHOTS_THEME=light exists for the
/// light-parity proof runs (release gate: both themes must hold).
ThemeModeValue get _theme =>
Platform.environment['GUIDE_SHOTS_THEME'] == 'light'
? ThemeModeValue.light
: ThemeModeValue.dark;
/// Rasterize the app's RepaintBoundary into `<outDir>/<name>.png`.
Future<void> _shot(WidgetTester tester, String name) async {
// Two extra frames so ripples/route transitions settle visually.
await tester.pump(const Duration(milliseconds: 120));
await tester.pump(const Duration(milliseconds: 120));
final boundary =
_shotKey.currentContext!.findRenderObject() as RenderRepaintBoundary;
final image = await boundary.toImage(pixelRatio: 2.0);
final bytes = await image.toByteData(format: ui.ImageByteFormat.png);
image.dispose();
final file = File('$_outDir/$name.png');
file.parent.createSync(recursive: true);
file.writeAsBytesSync(bytes!.buffer.asUint8List());
// ignore: avoid_print
print('guide-shot: ${file.path}');
}
Future<void> _cli(
String binary,
HubFixture fixture,
List<String> args,
) async {
final r = await Process.run(
binary,
args,
environment: {
...Platform.environment,
'CHAIN_DATA_DIR': fixture.tempDir.path,
'CHAIN_MODULES_DIR': '${fixture.tempDir.path}/modules',
},
);
if (r.exitCode != 0) {
fail('seed command `chain ${args.join(' ')}` failed: ${r.stderr}');
}
}
/// The guide's image set, in sidebar (= Cmd number) order. Must
/// stay aligned with docs/nav.generated.json the nav guard in
/// the platform repo keeps the guide itself honest.
const _pageShots = [
('welcome', '01-willkommen'),
('store', '02-store'),
('doctor', '03-diagnose'),
('flows', '04-flows'),
('audit', '05-protokoll'),
('approvals', '06-freigaben'),
('runs', '07-laeufe'),
('federation', '08-foederation'),
];
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('capture the operator-guide screenshots', (tester) async {
final fixture = await HubFixture.start(skipIfBinaryMissing: false);
addTearDown(fixture!.dispose);
final binary = (await HubFixture.binaryPath())!;
// Deterministic demo data. The slug is explicit the derived
// one would drop the umlaut ("brgeramt").
await _cli(binary, fixture, [
'project',
'create',
'Bürgeramt',
'--slug',
'buergeramt',
'--color',
'#2e8f9e',
]);
// The sealed area writes a manifest under $HOME/.chain/sealed/,
// so it needs the runner's confirmation that $HOME is isolated.
final sealedSeed =
Platform.environment['CHAIN_GUIDE_SHOTS_SEALED'] == '1';
if (sealedSeed) {
await _cli(binary, fixture, [
'project',
'create',
'Ratsinformation',
'--slug',
'ratsinformation',
'--isolation',
'sealed',
]);
// Seed one audit event the guide can show (setup.applied) by
// applying a minimal guided setup against the fixture's data
// dir. Writes $HOME/.chain/config.yaml, hence the isolated-
// $HOME guard: only the regen script's throwaway HOME is
// ever touched.
final answers = File('${fixture.tempDir.path}/seed-answers.yaml');
answers.writeAsStringSync(
'scenario: trying-out\nintent: hello-world\ntarget: this-laptop\n',
);
await _cli(binary, fixture, [
'init',
'--answers',
answers.path,
'--apply',
'--force',
]);
}
// Hermetic prefs: never read or write the operator's real ones.
SharedPreferences.setMockInitialValues({});
await tester.pumpWidget(
RepaintBoundary(
key: _shotKey,
child: StudioApp(
initialThemeMode: _theme,
initialLocale: const Locale('de'),
),
),
);
await HubService.instance.reconnect(
HubEndpoint(host: '127.0.0.1', port: fixture.port),
authToken: null,
persist: false,
);
await _pumpFrames(tester, 30);
final shell =
tester.state<StudioShellState>(find.byType(StudioShell));
// 0108: every sidebar destination, in Cmd order. Where a page
// loads slower content from the hub, wait for a sentinel that
// proves the content arrived before capturing.
for (final (id, shotName) in _pageShots) {
shell.navigateTo(id);
await _pumpFrames(tester);
if (id == 'flows') {
// The imported sample flows populate the file list.
await _pumpUntil(tester, find.textContaining('extract-summarize'));
} else if (id == 'audit') {
// The seeded setup.applied event (isolated-$HOME runs).
await _pumpUntil(tester, find.textContaining('setup.applied'));
}
await _shot(tester, shotName);
}
// 09: the workspace switcher, opened (shows the demo projects;
// with the sealed seed also the shield entry).
shell.navigateTo('audit');
await _pumpFrames(tester);
final switcher = find.byType(ChainWorkspaceSwitcher);
if (switcher.evaluate().isNotEmpty) {
await tester.tap(switcher.first);
await _pumpFrames(tester);
await _shot(tester, '09-projekt-umschalter');
// Close the menu again before moving on.
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await _pumpFrames(tester, 5);
}
// 11: the guided-setup wizard, step 1 (localized option cards).
// 10 (inside a sealed area) still needs the manual procedure
// it requires switching to a second, isolated hub instance.
shell.navigateTo('welcome');
await _pumpFrames(tester);
final cta = find.text('In 3 Fragen loslegen');
if (cta.evaluate().isNotEmpty) {
await tester.tap(cta.first);
await _pumpFrames(tester);
await _shot(tester, '11-einrichtung');
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await _pumpFrames(tester, 5);
}
});
}

View file

@ -4,7 +4,7 @@
"errModuleDownloadHint": "Die Bundle-URL des Moduls ist nicht erreichbar (Statuscode unten). Der Hub läuft — URL/Host korrigieren oder anderen Store wählen.", "errModuleDownloadHint": "Die Bundle-URL des Moduls ist nicht erreichbar (Statuscode unten). Der Hub läuft — URL/Host korrigieren oder anderen Store wählen.",
"storeFromSource": "aus {store}", "storeFromSource": "aus {store}",
"guidedSetupTitle": "Setup-Assistent", "guidedSetupTitle": "Setup-Assistent",
"guidedSetupIntro": "Beantworte drei Fragen — Ch∆In stellt passende Konfiguration, Modul-Set und Start-Flow zusammen.", "guidedSetupIntro": "Beantworten Sie drei Fragen — Ch∆In stellt passende Konfiguration, Modul-Set und Beispiel-Flow zusammen.",
"guidedSetupScenario": "Szenario", "guidedSetupScenario": "Szenario",
"guidedSetupIntent": "Aufgabe", "guidedSetupIntent": "Aufgabe",
"guidedSetupTarget": "Wo läuft es?", "guidedSetupTarget": "Wo läuft es?",
@ -72,14 +72,14 @@
}, },
"welcomeChecklistHeader": "ERSTE SCHRITTE", "welcomeChecklistHeader": "ERSTE SCHRITTE",
"welcomeChecklistBody": "Vier Schritte zu einem funktionierenden Hub. Live-Zustand — nach jeder Änderung aktualisieren.", "welcomeChecklistBody": "Vier Schritte zu einem funktionierenden Hub. Live-Zustand — nach jeder Änderung aktualisieren.",
"welcomeChecklistAi": "System-AI konfigurieren", "welcomeChecklistAi": "System-KI konfigurieren",
"welcomeChecklistAiHint": "Einstellungen → System-AI. Wird für die KI-Suche und Fehlererklärungen gebraucht.", "welcomeChecklistAiHint": "Einstellungen → System-KI. Wird für die KI-Suche und Fehlererklärungen gebraucht.",
"welcomeChecklistMcp": "Öffentliche Capability-Quelle hinzufügen", "welcomeChecklistMcp": "Öffentliche Capability-Quelle hinzufügen",
"welcomeChecklistMcpHint": "Im Store eine MCP-Quelle hinzufügen oder unter Einstellungen → MCP-Clients konfigurieren.", "welcomeChecklistMcpHint": "Verbindet einen öffentlichen Werkzeug-Katalog (MCP) — dessen Bausteine erscheinen dann im Store. Dort hinzufügen oder unter Einstellungen → MCP-Clients.",
"welcomeChecklistModule": "Ein Text-Modul installieren", "welcomeChecklistModule": "Ein Text-Modul installieren",
"welcomeChecklistModuleHint": "Im Store ein Modul aus der Kategorie deiner Wahl installieren. Module sind sandboxed Ch∆In-Komponenten.", "welcomeChecklistModuleHint": "Im Store ein Modul aus der Kategorie Ihrer Wahl installieren. Module sind isolierte (sandboxed) Ch∆In-Komponenten.",
"welcomeChecklistFlow": "Einen gespeicherten Flow starten", "welcomeChecklistFlow": "Einen gespeicherten Flow starten",
"welcomeChecklistFlowHint": "Flows → einen wählen → Starten. Studio bringt einige Demo-Flows mit; auch ein eigener tut's.", "welcomeChecklistFlowHint": "Flows → einen wählen → Starten. Studio bringt einige Demo-Flows mit; ein eigener funktioniert genauso.",
"welcomeChecklistAllDone": "Alle vier Schritte erledigt.", "welcomeChecklistAllDone": "Alle vier Schritte erledigt.",
"welcomeChecklistDismiss": "Checkliste ausblenden", "welcomeChecklistDismiss": "Checkliste ausblenden",
"welcomeChecklistRefresh": "Aktualisieren", "welcomeChecklistRefresh": "Aktualisieren",
@ -349,7 +349,7 @@
"storeReloadTooltip": "Aktualisieren", "storeReloadTooltip": "Aktualisieren",
"storesManagerButton": "Store hinzufügen", "storesManagerButton": "Store hinzufügen",
"storesManagerTitle": "Module-Stores", "storesManagerTitle": "Module-Stores",
"storesManagerIntro": "Der Hub führt die Module aller Stores unten zu einem Index zusammen. Füge die Index-URL eines Stores hinzu, damit seine Module hier installierbar werden.", "storesManagerIntro": "Der Hub führt die Module aller Stores unten zu einem Index zusammen. Fügen Sie die Index-URL eines Stores hinzu, damit seine Module hier installierbar werden.",
"storesManagerAddSection": "Store hinzufügen", "storesManagerAddSection": "Store hinzufügen",
"storesManagerNameLabel": "Name", "storesManagerNameLabel": "Name",
"storesManagerUrlLabel": "Index-URL (https:// oder file://)", "storesManagerUrlLabel": "Index-URL (https:// oder file://)",
@ -360,8 +360,8 @@
"storesManagerRemoveFailed": "Store konnte nicht entfernt werden", "storesManagerRemoveFailed": "Store konnte nicht entfernt werden",
"storesManagerSuggested": "Vorgeschlagene Stores", "storesManagerSuggested": "Vorgeschlagene Stores",
"storesManagerAddShort": "Hinzufügen", "storesManagerAddShort": "Hinzufügen",
"storesManagerPinnedKeyLabel": "Angepinnter Public-Key — PEM (optional)", "storesManagerPinnedKeyLabel": "Signatur-Schlüssel anheften (PEM, optional)",
"storesManagerPinnedKeyHint": "Signierte Module dieses Stores werden gegen diesen Key geprüft statt gegen den eingebauten Vendor-Key. Füge einen PEM-Public-Key ein.", "storesManagerPinnedKeyHint": "Nur Module, die mit diesem Schlüssel signiert sind, werden aus dieser Quelle angenommen. Fügen Sie den öffentlichen Schlüssel (PEM) des Herausgebers ein — statt des eingebauten Herstellerschlüssels.",
"storesManagerNotAvailable": "Noch nicht verfügbar", "storesManagerNotAvailable": "Noch nicht verfügbar",
"storesManagerNotAvailableHint": "Der Anbieter hat diesen Store-Index noch nicht veröffentlicht. Sobald er das tut, ist er installierbar.", "storesManagerNotAvailableHint": "Der Anbieter hat diesen Store-Index noch nicht veröffentlicht. Sobald er das tut, ist er installierbar.",
"storesSuggestedReclaimDesc": "Recl∆Im — Module für Rechts- und Pflichten-Reform-Mapping", "storesSuggestedReclaimDesc": "Recl∆Im — Module für Rechts- und Pflichten-Reform-Mapping",
@ -886,16 +886,16 @@
"maintenanceResetConfirmTitle": "Hub zurücksetzen?", "maintenanceResetConfirmTitle": "Hub zurücksetzen?",
"maintenanceResetConfirmBody": "Stoppt jeden Daemon, verschiebt ~/.chain/ in ein Backup mit Zeitstempel und startet sauber neu. Das Backup ist vollständig wiederherstellbar. Fortfahren?", "maintenanceResetConfirmBody": "Stoppt jeden Daemon, verschiebt ~/.chain/ in ein Backup mit Zeitstempel und startet sauber neu. Das Backup ist vollständig wiederherstellbar. Fortfahren?",
"maintenanceResetConfirmButton": "Zurücksetzen", "maintenanceResetConfirmButton": "Zurücksetzen",
"welcomeChecklistAllSetTitle": "Du bist eingerichtet.", "welcomeChecklistAllSetTitle": "Sie sind eingerichtet.",
"welcomeChecklistAllSetBody": "Drei Stränge, an denen du als nächstes ziehen kannst:", "welcomeChecklistAllSetBody": "Drei Stränge, an denen Sie als Nächstes ziehen können:",
"welcomeChecklistNextAuditTitle": "Audit-Log lesen", "welcomeChecklistNextAuditTitle": "Audit-Log lesen",
"welcomeChecklistNextAuditBody": "Jede Installation, jeder Flow-Lauf, jede Freigabe landet im hash-verketteten Log. Im Protokoll-Tab siehst du, was dein Hub gemacht hat.", "welcomeChecklistNextAuditBody": "Jede Installation, jeder Flow-Lauf, jede Freigabe landet im hash-verketteten Protokoll. Im Protokoll-Tab sehen Sie, was Ihr Hub gemacht hat.",
"welcomeChecklistNextAuditButton": "Protokoll öffnen", "welcomeChecklistNextAuditButton": "Protokoll öffnen",
"welcomeChecklistNextTodayTitle": "Daily-Today-Story einrichten", "welcomeChecklistNextTodayTitle": "Daily-Today-Story einrichten",
"welcomeChecklistNextTodayBody": "tools/today/propose.sh per Cron laufen lassen und die Editorial-Karte im Store füllt sich mit operator-kuratierten Stories.", "welcomeChecklistNextTodayBody": "Für Fortgeschrittene: das Skript tools/today/propose.sh regelmäßig (Cron) laufen lassen — die Editorial-Karte im Store füllt sich dann mit kuratierten Stories.",
"welcomeChecklistNextTodayButton": "Today-Doku öffnen", "welcomeChecklistNextTodayButton": "Today-Doku öffnen",
"welcomeChecklistNextModuleTitle": "Eigenes Modul bauen", "welcomeChecklistNextModuleTitle": "Eigenes Modul bauen",
"welcomeChecklistNextModuleBody": "Mit `chain new module <name>` ein Rust+WASM-Scaffold erzeugen. Das module-sdk übernimmt die WIT-Plumbing; du schreibst nur ein typisiertes invoke.", "welcomeChecklistNextModuleBody": "Für Entwickler: Mit `chain new module <name>` entsteht ein fertiges Modul-Gerüst (Rust+WASM). Das module-sdk übernimmt die technische Anbindung; Sie schreiben nur die eigentliche Logik.",
"welcomeChecklistNextModuleButton": "Doku lesen", "welcomeChecklistNextModuleButton": "Doku lesen",
"auditGroupToday": "HEUTE", "auditGroupToday": "HEUTE",
"auditGroupYesterday": "GESTERN", "auditGroupYesterday": "GESTERN",
@ -1704,7 +1704,7 @@
"setupReviewTitle": "Das wird Ch∆In einrichten", "setupReviewTitle": "Das wird Ch∆In einrichten",
"setupChooseFreeText": "Oder beschreiben Sie einfach, was Sie vorhaben", "setupChooseFreeText": "Oder beschreiben Sie einfach, was Sie vorhaben",
"setupScenTryingOut": "Erst mal ausprobieren", "setupScenTryingOut": "Erst mal ausprobieren",
"setupScenTryingOutSub": "Ein unkomplizierter Arbeitsplatz auf diesem Rechner — ohne Signaturen, ohne Audit-Sperre.", "setupScenTryingOutSub": "Ein unkomplizierter Arbeitsplatz auf diesem Rechner — ohne Signaturpflicht, ohne Schreibschutz für das Prüfprotokoll.",
"setupScenTeamHub": "Ein Team-Server", "setupScenTeamHub": "Ein Team-Server",
"setupScenTeamHubSub": "Ein abgesicherter Hub für mehrere Personen — signierte Module, Ressourcen-Grenzen.", "setupScenTeamHubSub": "Ein abgesicherter Hub für mehrere Personen — signierte Module, Ressourcen-Grenzen.",
"setupScenRegulated": "Regulierter Produktivbetrieb", "setupScenRegulated": "Regulierter Produktivbetrieb",
@ -1747,7 +1747,53 @@
"setupApplied": "Fertig — Ch∆In ist eingerichtet.", "setupApplied": "Fertig — Ch∆In ist eingerichtet.",
"setupNextTitle": "Nächste Schritte", "setupNextTitle": "Nächste Schritte",
"setupNextHubStart": "Hub starten", "setupNextHubStart": "Hub starten",
"setupNextInstall": "Die Module installieren", "setupHubRunning": "Der Hub läuft.",
"setupNextRunFlow": "Den Beispiel-Flow ausführen", "setupStartHubFirst": "Starten Sie zuerst den Hub — dann lassen sich die Module hier installieren.",
"setupStartCta": "In 3 Fragen loslegen" "setupActionInstall": "{module} installieren",
"@setupActionInstall": {"placeholders": {"module": {"type": "String"}}},
"setupActionInstalled": "{module} ist installiert.",
"@setupActionInstalled": {"placeholders": {"module": {"type": "String"}}},
"setupActionOpenFlow": "Beispiel-Flow „{flow}“ öffnen",
"@setupActionOpenFlow": {"placeholders": {"flow": {"type": "String"}}},
"setupModulesOfflineHint": "Die Module kommen ohne Internet als Offline-Paket auf den Server — die Anleitung dazu steht in der Dokumentation unter „Betrieb ohne Internet“.",
"setupModulesSignedHint": "Die Module kommen aus einer eigenen, signierten Quelle — nicht aus dem öffentlichen Store.",
"setupSigPublicStoreNotice": "Diese Einrichtung lässt nur signierte Module zu. Der öffentliche Store liefert zurzeit unsignierte Pakete — die vorbereiteten Module kommen daher aus einer eigenen, signierten Quelle.",
"setupAllowUnsigned": "Installation aus dem öffentlichen Store erlauben",
"setupAllowUnsignedSub": "Lockert die Signaturpflicht bewusst. Gut zum Ausprobieren — für den regulierten Betrieb später wieder aktivieren.",
"setupStartCta": "In 3 Fragen loslegen",
"setupPlanRunbookLocal": "Eingerichtet wird dieser Rechner; der Hub läuft lokal und wird bei Bedarf gestartet.",
"setupPlanRunbookService": "Eingerichtet wird dieser Rechner: Der Hub startet künftig automatisch beim Hochfahren (Hintergrund-Dienst). Für einen anderen Server führen Sie die Einrichtung dort aus.",
"setupPlanRunbookAirgap": "Vorgesehen für einen isolierten Server ohne Internet — Module und Updates kommen als Offline-Paket.",
"setupPlanRunbookContainer": "Vorgesehen für den Betrieb im Container — ein fertiges Compose-Beispiel wird mitgeliefert.",
"setupPlanAuditChain": "Jeder Schritt landet in einem lückenlosen, hash-verketteten Prüfprotokoll — Manipulation wird erkennbar.",
"setupPlanDocs": "Eine passende Leseliste ({n} Kapitel) wird mit dem Einrichtungs-Protokoll unter ~/.chain/ gespeichert.",
"@setupPlanDocs": {"placeholders": {"n": {"type": "int"}}},
"setupTrustedPublishersHint": "Wichtig: Hinterlegen Sie beim Hinzufügen der Quelle den Schlüssel des Herausgebers („Signatur-Schlüssel anheften“) — sonst akzeptiert der Hub jede formal gültige Signatur, egal von wem.",
"setupAddSignedSource": "Signierte Quelle hinzufügen…",
"setupApplyNotes": "Hinweise aus der Einrichtung",
"setupFreeTextHint": "z. B.: Eingehende Anträge vorprüfen und unvollständige markieren",
"setupFreeTextSuggest": "Vorschlagen lassen",
"setupFreeTextPrivacyLocal": "Ihre Beschreibung wird lokal auf diesem Rechner verarbeitet ({model}).",
"@setupFreeTextPrivacyLocal": {"placeholders": {"model": {"type": "String"}}},
"setupFreeTextPrivacyRemote": "Ihre Beschreibung wird an die eingerichtete System-KI gesendet: {model} ({provider}).",
"@setupFreeTextPrivacyRemote": {"placeholders": {"model": {"type": "String"}, "provider": {"type": "String"}}},
"setupFreeTextUnavailable": "Für den Freitext-Weg braucht Ch∆In eine eingerichtete System-KI (Einstellungen → System-KI). Die Auswahl oben funktioniert immer — ganz ohne KI.",
"setupFreeTextParseError": "Die System-KI hat keinen verwertbaren Vorschlag geliefert. Wählen Sie oben aus dem Menü — oder versuchen Sie es noch einmal.",
"setupReflectionTitle": "So verstehe ich Ihre Aufgabe",
"setupReflectionScenario": "Worum es geht: {label}",
"@setupReflectionScenario": {"placeholders": {"label": {"type": "String"}}},
"setupReflectionIntent": "Erste Aufgabe: {label}",
"@setupReflectionIntent": {"placeholders": {"label": {"type": "String"}}},
"setupReflectionTarget": "Umgebung: {label}",
"@setupReflectionTarget": {"placeholders": {"label": {"type": "String"}}},
"setupReflectionApproval": "Vor jedem KI-Schritt wird ein Mensch um Freigabe gebeten.",
"setupReflectionDataLocal": "Daten bleiben vollständig im Haus.",
"setupReflectionEditHint": "Es wird nichts ohne Ihre Bestätigung geändert. Passen Sie die Punkte an oder gehen Sie direkt zur Vorschau.",
"setupReflectionAdjust": "Anpassen",
"setupReflectionToPreview": "Zur Vorschau",
"setupProfileDev": "unkomplizierter Arbeitsplatz",
"setupProfileEnterprise": "abgesicherter Team-Betrieb",
"setupProfileAirgapped": "vollständig lokaler, regulierter Betrieb",
"welcomeChecklistSetupDone": "Die Grundeinrichtung ({profile}) hat der Einrichtungs-Assistent bereits erledigt — hier sehen Sie, was auf dem Hub schon passiert ist.",
"@welcomeChecklistSetupDone": {"placeholders": {"profile": {"type": "String"}}}
} }

View file

@ -378,8 +378,8 @@
"storesManagerRemoveFailed": "Could not remove store", "storesManagerRemoveFailed": "Could not remove store",
"storesManagerSuggested": "Suggested stores", "storesManagerSuggested": "Suggested stores",
"storesManagerAddShort": "Add", "storesManagerAddShort": "Add",
"storesManagerPinnedKeyLabel": "Pinned public key — PEM (optional)", "storesManagerPinnedKeyLabel": "Pin a signing key (PEM, optional)",
"storesManagerPinnedKeyHint": "Signed modules from this store verify against this key instead of the built-in vendor key. Paste a PEM public key.", "storesManagerPinnedKeyHint": "Only modules signed with this key are accepted from this source. Paste the publisher's public key (PEM) — used instead of the built-in vendor key.",
"storesManagerNotAvailable": "Not available yet", "storesManagerNotAvailable": "Not available yet",
"storesManagerNotAvailableHint": "The provider hasn't published this store index yet. It will become installable once they do.", "storesManagerNotAvailableHint": "The provider hasn't published this store index yet. It will become installable once they do.",
"storesSuggestedReclaimDesc": "Recl∆Im — modules for law & duty reform-mapping", "storesSuggestedReclaimDesc": "Recl∆Im — modules for law & duty reform-mapping",
@ -1743,7 +1743,7 @@
"setupReviewTitle": "This is what Ch∆In will set up", "setupReviewTitle": "This is what Ch∆In will set up",
"setupChooseFreeText": "Or just describe what you want to do", "setupChooseFreeText": "Or just describe what you want to do",
"setupScenTryingOut": "Just trying it out", "setupScenTryingOut": "Just trying it out",
"setupScenTryingOutSub": "A relaxed workspace on this machine — no signing, no audit locking.", "setupScenTryingOutSub": "A straightforward workstation on this machine — no signature requirement, no write-protected audit log.",
"setupScenTeamHub": "A shared team hub", "setupScenTeamHub": "A shared team hub",
"setupScenTeamHubSub": "A secured hub for several people — signed modules, resource limits.", "setupScenTeamHubSub": "A secured hub for several people — signed modules, resource limits.",
"setupScenRegulated": "Regulated production", "setupScenRegulated": "Regulated production",
@ -1786,7 +1786,53 @@
"setupApplied": "Done — Ch∆In is set up.", "setupApplied": "Done — Ch∆In is set up.",
"setupNextTitle": "Next steps", "setupNextTitle": "Next steps",
"setupNextHubStart": "Start the hub", "setupNextHubStart": "Start the hub",
"setupNextInstall": "Install the modules", "setupHubRunning": "The hub is running.",
"setupNextRunFlow": "Run the example flow", "setupStartHubFirst": "Start the hub first — then the modules can be installed right here.",
"setupStartCta": "Get started in 3 questions" "setupActionInstall": "Install {module}",
"@setupActionInstall": {"placeholders": {"module": {"type": "String"}}},
"setupActionInstalled": "{module} is installed.",
"@setupActionInstalled": {"placeholders": {"module": {"type": "String"}}},
"setupActionOpenFlow": "Open the “{flow}” example flow",
"@setupActionOpenFlow": {"placeholders": {"flow": {"type": "String"}}},
"setupModulesOfflineHint": "The modules arrive as an offline bundle, without internet — the documentation explains this under “running without internet”.",
"setupModulesSignedHint": "The modules come from your own, signed source — not from the public store.",
"setupSigPublicStoreNotice": "This setup only accepts signed modules. The public store currently ships unsigned bundles — the prepared modules therefore come from your own, signed source.",
"setupAllowUnsigned": "Allow installing from the public store",
"setupAllowUnsignedSub": "Deliberately relaxes the signature requirement. Fine for trying things out — re-enable it for regulated operation.",
"setupStartCta": "Get started in 3 questions",
"setupPlanRunbookLocal": "This machine is being set up; the hub runs locally and starts on demand.",
"setupPlanRunbookService": "This machine is being set up: the hub will start automatically on boot (background service). For a different server, run the setup there.",
"setupPlanRunbookAirgap": "Intended for an isolated server without internet — modules and updates arrive as offline bundles.",
"setupPlanRunbookContainer": "Intended for container operation — a ready-made compose example is included.",
"setupPlanAuditChain": "Every step lands in a gapless, hash-chained audit log — tampering becomes detectable.",
"setupPlanDocs": "A matching reading list ({n} chapters) is stored with the setup record under ~/.chain/.",
"@setupPlanDocs": {"placeholders": {"n": {"type": "int"}}},
"setupTrustedPublishersHint": "Important: when adding the source, pin the publisher's key (\"pin signing key\") — otherwise the hub accepts any formally valid signature, no matter whose.",
"setupAddSignedSource": "Add a signed source…",
"setupApplyNotes": "Notes from the setup",
"setupFreeTextHint": "e.g.: pre-screen incoming applications and flag incomplete ones",
"setupFreeTextSuggest": "Suggest a setup",
"setupFreeTextPrivacyLocal": "Your description is processed locally on this machine ({model}).",
"@setupFreeTextPrivacyLocal": {"placeholders": {"model": {"type": "String"}}},
"setupFreeTextPrivacyRemote": "Your description is sent to the configured system AI: {model} ({provider}).",
"@setupFreeTextPrivacyRemote": {"placeholders": {"model": {"type": "String"}, "provider": {"type": "String"}}},
"setupFreeTextUnavailable": "The free-text path needs a configured system AI (Settings → System AI). The choices above always work — no AI required.",
"setupFreeTextParseError": "The system AI did not return a usable suggestion. Pick from the menu above — or try again.",
"setupReflectionTitle": "This is how I read your task",
"setupReflectionScenario": "What it is about: {label}",
"@setupReflectionScenario": {"placeholders": {"label": {"type": "String"}}},
"setupReflectionIntent": "First task: {label}",
"@setupReflectionIntent": {"placeholders": {"label": {"type": "String"}}},
"setupReflectionTarget": "Environment: {label}",
"@setupReflectionTarget": {"placeholders": {"label": {"type": "String"}}},
"setupReflectionApproval": "A human is asked for approval before every AI step.",
"setupReflectionDataLocal": "Data never leaves the building.",
"setupReflectionEditHint": "Nothing is changed without your confirmation. Adjust any point, or go straight to the preview.",
"setupReflectionAdjust": "Adjust",
"setupReflectionToPreview": "To the preview",
"setupProfileDev": "straightforward workstation",
"setupProfileEnterprise": "hardened team operation",
"setupProfileAirgapped": "fully local, regulated operation",
"welcomeChecklistSetupDone": "The setup assistant already took care of the basics ({profile}) — this list shows what has actually happened on your hub.",
"@welcomeChecklistSetupDone": {"placeholders": {"profile": {"type": "String"}}}
} }

View file

@ -1685,13 +1685,13 @@ abstract class AppLocalizations {
/// No description provided for @storesManagerPinnedKeyLabel. /// No description provided for @storesManagerPinnedKeyLabel.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Pinned public key — PEM (optional)'** /// **'Pin a signing key (PEM, optional)'**
String get storesManagerPinnedKeyLabel; String get storesManagerPinnedKeyLabel;
/// No description provided for @storesManagerPinnedKeyHint. /// No description provided for @storesManagerPinnedKeyHint.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Signed modules from this store verify against this key instead of the built-in vendor key. Paste a PEM public key.'** /// **'Only modules signed with this key are accepted from this source. Paste the publisher\'s public key (PEM) — used instead of the built-in vendor key.'**
String get storesManagerPinnedKeyHint; String get storesManagerPinnedKeyHint;
/// No description provided for @storesManagerNotAvailable. /// No description provided for @storesManagerNotAvailable.
@ -5092,7 +5092,7 @@ abstract class AppLocalizations {
/// No description provided for @setupScenTryingOutSub. /// No description provided for @setupScenTryingOutSub.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'A relaxed workspace on this machine — no signing, no audit locking.'** /// **'A straightforward workstation on this machine — no signature requirement, no write-protected audit log.'**
String get setupScenTryingOutSub; String get setupScenTryingOutSub;
/// No description provided for @setupScenTeamHub. /// No description provided for @setupScenTeamHub.
@ -5329,23 +5329,239 @@ abstract class AppLocalizations {
/// **'Start the hub'** /// **'Start the hub'**
String get setupNextHubStart; String get setupNextHubStart;
/// No description provided for @setupNextInstall. /// No description provided for @setupHubRunning.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Install the modules'** /// **'The hub is running.'**
String get setupNextInstall; String get setupHubRunning;
/// No description provided for @setupNextRunFlow. /// No description provided for @setupStartHubFirst.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Run the example flow'** /// **'Start the hub first — then the modules can be installed right here.'**
String get setupNextRunFlow; String get setupStartHubFirst;
/// No description provided for @setupActionInstall.
///
/// In en, this message translates to:
/// **'Install {module}'**
String setupActionInstall(String module);
/// No description provided for @setupActionInstalled.
///
/// In en, this message translates to:
/// **'{module} is installed.'**
String setupActionInstalled(String module);
/// No description provided for @setupActionOpenFlow.
///
/// In en, this message translates to:
/// **'Open the “{flow}” example flow'**
String setupActionOpenFlow(String flow);
/// No description provided for @setupModulesOfflineHint.
///
/// In en, this message translates to:
/// **'The modules arrive as an offline bundle, without internet — the documentation explains this under “running without internet”.'**
String get setupModulesOfflineHint;
/// No description provided for @setupModulesSignedHint.
///
/// In en, this message translates to:
/// **'The modules come from your own, signed source — not from the public store.'**
String get setupModulesSignedHint;
/// No description provided for @setupSigPublicStoreNotice.
///
/// In en, this message translates to:
/// **'This setup only accepts signed modules. The public store currently ships unsigned bundles — the prepared modules therefore come from your own, signed source.'**
String get setupSigPublicStoreNotice;
/// No description provided for @setupAllowUnsigned.
///
/// In en, this message translates to:
/// **'Allow installing from the public store'**
String get setupAllowUnsigned;
/// No description provided for @setupAllowUnsignedSub.
///
/// In en, this message translates to:
/// **'Deliberately relaxes the signature requirement. Fine for trying things out — re-enable it for regulated operation.'**
String get setupAllowUnsignedSub;
/// No description provided for @setupStartCta. /// No description provided for @setupStartCta.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Get started in 3 questions'** /// **'Get started in 3 questions'**
String get setupStartCta; String get setupStartCta;
/// No description provided for @setupPlanRunbookLocal.
///
/// In en, this message translates to:
/// **'This machine is being set up; the hub runs locally and starts on demand.'**
String get setupPlanRunbookLocal;
/// No description provided for @setupPlanRunbookService.
///
/// In en, this message translates to:
/// **'This machine is being set up: the hub will start automatically on boot (background service). For a different server, run the setup there.'**
String get setupPlanRunbookService;
/// No description provided for @setupPlanRunbookAirgap.
///
/// In en, this message translates to:
/// **'Intended for an isolated server without internet — modules and updates arrive as offline bundles.'**
String get setupPlanRunbookAirgap;
/// No description provided for @setupPlanRunbookContainer.
///
/// In en, this message translates to:
/// **'Intended for container operation — a ready-made compose example is included.'**
String get setupPlanRunbookContainer;
/// No description provided for @setupPlanAuditChain.
///
/// In en, this message translates to:
/// **'Every step lands in a gapless, hash-chained audit log — tampering becomes detectable.'**
String get setupPlanAuditChain;
/// No description provided for @setupPlanDocs.
///
/// In en, this message translates to:
/// **'A matching reading list ({n} chapters) is stored with the setup record under ~/.chain/.'**
String setupPlanDocs(int n);
/// No description provided for @setupTrustedPublishersHint.
///
/// In en, this message translates to:
/// **'Important: when adding the source, pin the publisher\'s key (\"pin signing key\") — otherwise the hub accepts any formally valid signature, no matter whose.'**
String get setupTrustedPublishersHint;
/// No description provided for @setupAddSignedSource.
///
/// In en, this message translates to:
/// **'Add a signed source…'**
String get setupAddSignedSource;
/// No description provided for @setupApplyNotes.
///
/// In en, this message translates to:
/// **'Notes from the setup'**
String get setupApplyNotes;
/// No description provided for @setupFreeTextHint.
///
/// In en, this message translates to:
/// **'e.g.: pre-screen incoming applications and flag incomplete ones'**
String get setupFreeTextHint;
/// No description provided for @setupFreeTextSuggest.
///
/// In en, this message translates to:
/// **'Suggest a setup'**
String get setupFreeTextSuggest;
/// No description provided for @setupFreeTextPrivacyLocal.
///
/// In en, this message translates to:
/// **'Your description is processed locally on this machine ({model}).'**
String setupFreeTextPrivacyLocal(String model);
/// No description provided for @setupFreeTextPrivacyRemote.
///
/// In en, this message translates to:
/// **'Your description is sent to the configured system AI: {model} ({provider}).'**
String setupFreeTextPrivacyRemote(String model, String provider);
/// No description provided for @setupFreeTextUnavailable.
///
/// In en, this message translates to:
/// **'The free-text path needs a configured system AI (Settings → System AI). The choices above always work — no AI required.'**
String get setupFreeTextUnavailable;
/// No description provided for @setupFreeTextParseError.
///
/// In en, this message translates to:
/// **'The system AI did not return a usable suggestion. Pick from the menu above — or try again.'**
String get setupFreeTextParseError;
/// No description provided for @setupReflectionTitle.
///
/// In en, this message translates to:
/// **'This is how I read your task'**
String get setupReflectionTitle;
/// No description provided for @setupReflectionScenario.
///
/// In en, this message translates to:
/// **'What it is about: {label}'**
String setupReflectionScenario(String label);
/// No description provided for @setupReflectionIntent.
///
/// In en, this message translates to:
/// **'First task: {label}'**
String setupReflectionIntent(String label);
/// No description provided for @setupReflectionTarget.
///
/// In en, this message translates to:
/// **'Environment: {label}'**
String setupReflectionTarget(String label);
/// No description provided for @setupReflectionApproval.
///
/// In en, this message translates to:
/// **'A human is asked for approval before every AI step.'**
String get setupReflectionApproval;
/// No description provided for @setupReflectionDataLocal.
///
/// In en, this message translates to:
/// **'Data never leaves the building.'**
String get setupReflectionDataLocal;
/// No description provided for @setupReflectionEditHint.
///
/// In en, this message translates to:
/// **'Nothing is changed without your confirmation. Adjust any point, or go straight to the preview.'**
String get setupReflectionEditHint;
/// No description provided for @setupReflectionAdjust.
///
/// In en, this message translates to:
/// **'Adjust'**
String get setupReflectionAdjust;
/// No description provided for @setupReflectionToPreview.
///
/// In en, this message translates to:
/// **'To the preview'**
String get setupReflectionToPreview;
/// No description provided for @setupProfileDev.
///
/// In en, this message translates to:
/// **'straightforward workstation'**
String get setupProfileDev;
/// No description provided for @setupProfileEnterprise.
///
/// In en, this message translates to:
/// **'hardened team operation'**
String get setupProfileEnterprise;
/// No description provided for @setupProfileAirgapped.
///
/// In en, this message translates to:
/// **'fully local, regulated operation'**
String get setupProfileAirgapped;
/// No description provided for @welcomeChecklistSetupDone.
///
/// In en, this message translates to:
/// **'The setup assistant already took care of the basics ({profile}) — this list shows what has actually happened on your hub.'**
String welcomeChecklistSetupDone(String profile);
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -25,7 +25,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get guidedSetupIntro => String get guidedSetupIntro =>
'Beantworte drei Fragen — Ch∆In stellt passende Konfiguration, Modul-Set und Start-Flow zusammen.'; 'Beantworten Sie drei Fragen — Ch∆In stellt passende Konfiguration, Modul-Set und Beispiel-Flow zusammen.';
@override @override
String get guidedSetupScenario => 'Szenario'; String get guidedSetupScenario => 'Szenario';
@ -235,32 +235,32 @@ class AppLocalizationsDe extends AppLocalizations {
'Vier Schritte zu einem funktionierenden Hub. Live-Zustand — nach jeder Änderung aktualisieren.'; 'Vier Schritte zu einem funktionierenden Hub. Live-Zustand — nach jeder Änderung aktualisieren.';
@override @override
String get welcomeChecklistAi => 'System-AI konfigurieren'; String get welcomeChecklistAi => 'System-KI konfigurieren';
@override @override
String get welcomeChecklistAiHint => String get welcomeChecklistAiHint =>
'Einstellungen → System-AI. Wird für die KI-Suche und Fehlererklärungen gebraucht.'; 'Einstellungen → System-KI. Wird für die KI-Suche und Fehlererklärungen gebraucht.';
@override @override
String get welcomeChecklistMcp => 'Öffentliche Capability-Quelle hinzufügen'; String get welcomeChecklistMcp => 'Öffentliche Capability-Quelle hinzufügen';
@override @override
String get welcomeChecklistMcpHint => String get welcomeChecklistMcpHint =>
'Im Store eine MCP-Quelle hinzufügen oder unter Einstellungen → MCP-Clients konfigurieren.'; 'Verbindet einen öffentlichen Werkzeug-Katalog (MCP) — dessen Bausteine erscheinen dann im Store. Dort hinzufügen oder unter Einstellungen → MCP-Clients.';
@override @override
String get welcomeChecklistModule => 'Ein Text-Modul installieren'; String get welcomeChecklistModule => 'Ein Text-Modul installieren';
@override @override
String get welcomeChecklistModuleHint => String get welcomeChecklistModuleHint =>
'Im Store ein Modul aus der Kategorie deiner Wahl installieren. Module sind sandboxed Ch∆In-Komponenten.'; 'Im Store ein Modul aus der Kategorie Ihrer Wahl installieren. Module sind isolierte (sandboxed) Ch∆In-Komponenten.';
@override @override
String get welcomeChecklistFlow => 'Einen gespeicherten Flow starten'; String get welcomeChecklistFlow => 'Einen gespeicherten Flow starten';
@override @override
String get welcomeChecklistFlowHint => String get welcomeChecklistFlowHint =>
'Flows → einen wählen → Starten. Studio bringt einige Demo-Flows mit; auch ein eigener tut\'s.'; 'Flows → einen wählen → Starten. Studio bringt einige Demo-Flows mit; ein eigener funktioniert genauso.';
@override @override
String get welcomeChecklistAllDone => 'Alle vier Schritte erledigt.'; String get welcomeChecklistAllDone => 'Alle vier Schritte erledigt.';
@ -869,7 +869,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get storesManagerIntro => String get storesManagerIntro =>
'Der Hub führt die Module aller Stores unten zu einem Index zusammen. Füge die Index-URL eines Stores hinzu, damit seine Module hier installierbar werden.'; 'Der Hub führt die Module aller Stores unten zu einem Index zusammen. Fügen Sie die Index-URL eines Stores hinzu, damit seine Module hier installierbar werden.';
@override @override
String get storesManagerAddSection => 'Store hinzufügen'; String get storesManagerAddSection => 'Store hinzufügen';
@ -907,11 +907,11 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get storesManagerPinnedKeyLabel => String get storesManagerPinnedKeyLabel =>
'Angepinnter Public-Key — PEM (optional)'; 'Signatur-Schlüssel anheften (PEM, optional)';
@override @override
String get storesManagerPinnedKeyHint => String get storesManagerPinnedKeyHint =>
'Signierte Module dieses Stores werden gegen diesen Key geprüft statt gegen den eingebauten Vendor-Key. Füge einen PEM-Public-Key ein.'; 'Nur Module, die mit diesem Schlüssel signiert sind, werden aus dieser Quelle angenommen. Fügen Sie den öffentlichen Schlüssel (PEM) des Herausgebers ein — statt des eingebauten Herstellerschlüssels.';
@override @override
String get storesManagerNotAvailable => 'Noch nicht verfügbar'; String get storesManagerNotAvailable => 'Noch nicht verfügbar';
@ -1619,18 +1619,18 @@ class AppLocalizationsDe extends AppLocalizations {
String get maintenanceResetConfirmButton => 'Zurücksetzen'; String get maintenanceResetConfirmButton => 'Zurücksetzen';
@override @override
String get welcomeChecklistAllSetTitle => 'Du bist eingerichtet.'; String get welcomeChecklistAllSetTitle => 'Sie sind eingerichtet.';
@override @override
String get welcomeChecklistAllSetBody => String get welcomeChecklistAllSetBody =>
'Drei Stränge, an denen du als nächstes ziehen kannst:'; 'Drei Stränge, an denen Sie als Nächstes ziehen können:';
@override @override
String get welcomeChecklistNextAuditTitle => 'Audit-Log lesen'; String get welcomeChecklistNextAuditTitle => 'Audit-Log lesen';
@override @override
String get welcomeChecklistNextAuditBody => String get welcomeChecklistNextAuditBody =>
'Jede Installation, jeder Flow-Lauf, jede Freigabe landet im hash-verketteten Log. Im Protokoll-Tab siehst du, was dein Hub gemacht hat.'; 'Jede Installation, jeder Flow-Lauf, jede Freigabe landet im hash-verketteten Protokoll. Im Protokoll-Tab sehen Sie, was Ihr Hub gemacht hat.';
@override @override
String get welcomeChecklistNextAuditButton => 'Protokoll öffnen'; String get welcomeChecklistNextAuditButton => 'Protokoll öffnen';
@ -1640,7 +1640,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get welcomeChecklistNextTodayBody => String get welcomeChecklistNextTodayBody =>
'tools/today/propose.sh per Cron laufen lassen und die Editorial-Karte im Store füllt sich mit operator-kuratierten Stories.'; 'Für Fortgeschrittene: das Skript tools/today/propose.sh regelmäßig (Cron) laufen lassen — die Editorial-Karte im Store füllt sich dann mit kuratierten Stories.';
@override @override
String get welcomeChecklistNextTodayButton => 'Today-Doku öffnen'; String get welcomeChecklistNextTodayButton => 'Today-Doku öffnen';
@ -1650,7 +1650,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get welcomeChecklistNextModuleBody => String get welcomeChecklistNextModuleBody =>
'Mit `chain new module <name>` ein Rust+WASM-Scaffold erzeugen. Das module-sdk übernimmt die WIT-Plumbing; du schreibst nur ein typisiertes invoke.'; 'Für Entwickler: Mit `chain new module <name>` entsteht ein fertiges Modul-Gerüst (Rust+WASM). Das module-sdk übernimmt die technische Anbindung; Sie schreiben nur die eigentliche Logik.';
@override @override
String get welcomeChecklistNextModuleButton => 'Doku lesen'; String get welcomeChecklistNextModuleButton => 'Doku lesen';
@ -2991,7 +2991,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get setupScenTryingOutSub => String get setupScenTryingOutSub =>
'Ein unkomplizierter Arbeitsplatz auf diesem Rechner — ohne Signaturen, ohne Audit-Sperre.'; 'Ein unkomplizierter Arbeitsplatz auf diesem Rechner — ohne Signaturpflicht, ohne Schreibschutz für das Prüfprotokoll.';
@override @override
String get setupScenTeamHub => 'Ein Team-Server'; String get setupScenTeamHub => 'Ein Team-Server';
@ -3138,11 +3138,157 @@ class AppLocalizationsDe extends AppLocalizations {
String get setupNextHubStart => 'Hub starten'; String get setupNextHubStart => 'Hub starten';
@override @override
String get setupNextInstall => 'Die Module installieren'; String get setupHubRunning => 'Der Hub läuft.';
@override @override
String get setupNextRunFlow => 'Den Beispiel-Flow ausführen'; String get setupStartHubFirst =>
'Starten Sie zuerst den Hub — dann lassen sich die Module hier installieren.';
@override
String setupActionInstall(String module) {
return '$module installieren';
}
@override
String setupActionInstalled(String module) {
return '$module ist installiert.';
}
@override
String setupActionOpenFlow(String flow) {
return 'Beispiel-Flow „$flow“ öffnen';
}
@override
String get setupModulesOfflineHint =>
'Die Module kommen ohne Internet als Offline-Paket auf den Server — die Anleitung dazu steht in der Dokumentation unter „Betrieb ohne Internet“.';
@override
String get setupModulesSignedHint =>
'Die Module kommen aus einer eigenen, signierten Quelle — nicht aus dem öffentlichen Store.';
@override
String get setupSigPublicStoreNotice =>
'Diese Einrichtung lässt nur signierte Module zu. Der öffentliche Store liefert zurzeit unsignierte Pakete — die vorbereiteten Module kommen daher aus einer eigenen, signierten Quelle.';
@override
String get setupAllowUnsigned =>
'Installation aus dem öffentlichen Store erlauben';
@override
String get setupAllowUnsignedSub =>
'Lockert die Signaturpflicht bewusst. Gut zum Ausprobieren — für den regulierten Betrieb später wieder aktivieren.';
@override @override
String get setupStartCta => 'In 3 Fragen loslegen'; String get setupStartCta => 'In 3 Fragen loslegen';
@override
String get setupPlanRunbookLocal =>
'Eingerichtet wird dieser Rechner; der Hub läuft lokal und wird bei Bedarf gestartet.';
@override
String get setupPlanRunbookService =>
'Eingerichtet wird dieser Rechner: Der Hub startet künftig automatisch beim Hochfahren (Hintergrund-Dienst). Für einen anderen Server führen Sie die Einrichtung dort aus.';
@override
String get setupPlanRunbookAirgap =>
'Vorgesehen für einen isolierten Server ohne Internet — Module und Updates kommen als Offline-Paket.';
@override
String get setupPlanRunbookContainer =>
'Vorgesehen für den Betrieb im Container — ein fertiges Compose-Beispiel wird mitgeliefert.';
@override
String get setupPlanAuditChain =>
'Jeder Schritt landet in einem lückenlosen, hash-verketteten Prüfprotokoll — Manipulation wird erkennbar.';
@override
String setupPlanDocs(int n) {
return 'Eine passende Leseliste ($n Kapitel) wird mit dem Einrichtungs-Protokoll unter ~/.chain/ gespeichert.';
}
@override
String get setupTrustedPublishersHint =>
'Wichtig: Hinterlegen Sie beim Hinzufügen der Quelle den Schlüssel des Herausgebers („Signatur-Schlüssel anheften“) — sonst akzeptiert der Hub jede formal gültige Signatur, egal von wem.';
@override
String get setupAddSignedSource => 'Signierte Quelle hinzufügen…';
@override
String get setupApplyNotes => 'Hinweise aus der Einrichtung';
@override
String get setupFreeTextHint =>
'z. B.: Eingehende Anträge vorprüfen und unvollständige markieren';
@override
String get setupFreeTextSuggest => 'Vorschlagen lassen';
@override
String setupFreeTextPrivacyLocal(String model) {
return 'Ihre Beschreibung wird lokal auf diesem Rechner verarbeitet ($model).';
}
@override
String setupFreeTextPrivacyRemote(String model, String provider) {
return 'Ihre Beschreibung wird an die eingerichtete System-KI gesendet: $model ($provider).';
}
@override
String get setupFreeTextUnavailable =>
'Für den Freitext-Weg braucht Ch∆In eine eingerichtete System-KI (Einstellungen → System-KI). Die Auswahl oben funktioniert immer — ganz ohne KI.';
@override
String get setupFreeTextParseError =>
'Die System-KI hat keinen verwertbaren Vorschlag geliefert. Wählen Sie oben aus dem Menü — oder versuchen Sie es noch einmal.';
@override
String get setupReflectionTitle => 'So verstehe ich Ihre Aufgabe';
@override
String setupReflectionScenario(String label) {
return 'Worum es geht: $label';
}
@override
String setupReflectionIntent(String label) {
return 'Erste Aufgabe: $label';
}
@override
String setupReflectionTarget(String label) {
return 'Umgebung: $label';
}
@override
String get setupReflectionApproval =>
'Vor jedem KI-Schritt wird ein Mensch um Freigabe gebeten.';
@override
String get setupReflectionDataLocal => 'Daten bleiben vollständig im Haus.';
@override
String get setupReflectionEditHint =>
'Es wird nichts ohne Ihre Bestätigung geändert. Passen Sie die Punkte an oder gehen Sie direkt zur Vorschau.';
@override
String get setupReflectionAdjust => 'Anpassen';
@override
String get setupReflectionToPreview => 'Zur Vorschau';
@override
String get setupProfileDev => 'unkomplizierter Arbeitsplatz';
@override
String get setupProfileEnterprise => 'abgesicherter Team-Betrieb';
@override
String get setupProfileAirgapped =>
'vollständig lokaler, regulierter Betrieb';
@override
String welcomeChecklistSetupDone(String profile) {
return 'Die Grundeinrichtung ($profile) hat der Einrichtungs-Assistent bereits erledigt — hier sehen Sie, was auf dem Hub schon passiert ist.';
}
} }

View file

@ -925,12 +925,11 @@ class AppLocalizationsEn extends AppLocalizations {
String get storesManagerAddShort => 'Add'; String get storesManagerAddShort => 'Add';
@override @override
String get storesManagerPinnedKeyLabel => String get storesManagerPinnedKeyLabel => 'Pin a signing key (PEM, optional)';
'Pinned public key — PEM (optional)';
@override @override
String get storesManagerPinnedKeyHint => String get storesManagerPinnedKeyHint =>
'Signed modules from this store verify against this key instead of the built-in vendor key. Paste a PEM public key.'; 'Only modules signed with this key are accepted from this source. Paste the publisher\'s public key (PEM) — used instead of the built-in vendor key.';
@override @override
String get storesManagerNotAvailable => 'Not available yet'; String get storesManagerNotAvailable => 'Not available yet';
@ -2993,7 +2992,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get setupScenTryingOutSub => String get setupScenTryingOutSub =>
'A relaxed workspace on this machine — no signing, no audit locking.'; 'A straightforward workstation on this machine — no signature requirement, no write-protected audit log.';
@override @override
String get setupScenTeamHub => 'A shared team hub'; String get setupScenTeamHub => 'A shared team hub';
@ -3136,11 +3135,155 @@ class AppLocalizationsEn extends AppLocalizations {
String get setupNextHubStart => 'Start the hub'; String get setupNextHubStart => 'Start the hub';
@override @override
String get setupNextInstall => 'Install the modules'; String get setupHubRunning => 'The hub is running.';
@override @override
String get setupNextRunFlow => 'Run the example flow'; String get setupStartHubFirst =>
'Start the hub first — then the modules can be installed right here.';
@override
String setupActionInstall(String module) {
return 'Install $module';
}
@override
String setupActionInstalled(String module) {
return '$module is installed.';
}
@override
String setupActionOpenFlow(String flow) {
return 'Open the “$flow” example flow';
}
@override
String get setupModulesOfflineHint =>
'The modules arrive as an offline bundle, without internet — the documentation explains this under “running without internet”.';
@override
String get setupModulesSignedHint =>
'The modules come from your own, signed source — not from the public store.';
@override
String get setupSigPublicStoreNotice =>
'This setup only accepts signed modules. The public store currently ships unsigned bundles — the prepared modules therefore come from your own, signed source.';
@override
String get setupAllowUnsigned => 'Allow installing from the public store';
@override
String get setupAllowUnsignedSub =>
'Deliberately relaxes the signature requirement. Fine for trying things out — re-enable it for regulated operation.';
@override @override
String get setupStartCta => 'Get started in 3 questions'; String get setupStartCta => 'Get started in 3 questions';
@override
String get setupPlanRunbookLocal =>
'This machine is being set up; the hub runs locally and starts on demand.';
@override
String get setupPlanRunbookService =>
'This machine is being set up: the hub will start automatically on boot (background service). For a different server, run the setup there.';
@override
String get setupPlanRunbookAirgap =>
'Intended for an isolated server without internet — modules and updates arrive as offline bundles.';
@override
String get setupPlanRunbookContainer =>
'Intended for container operation — a ready-made compose example is included.';
@override
String get setupPlanAuditChain =>
'Every step lands in a gapless, hash-chained audit log — tampering becomes detectable.';
@override
String setupPlanDocs(int n) {
return 'A matching reading list ($n chapters) is stored with the setup record under ~/.chain/.';
}
@override
String get setupTrustedPublishersHint =>
'Important: when adding the source, pin the publisher\'s key (\"pin signing key\") — otherwise the hub accepts any formally valid signature, no matter whose.';
@override
String get setupAddSignedSource => 'Add a signed source…';
@override
String get setupApplyNotes => 'Notes from the setup';
@override
String get setupFreeTextHint =>
'e.g.: pre-screen incoming applications and flag incomplete ones';
@override
String get setupFreeTextSuggest => 'Suggest a setup';
@override
String setupFreeTextPrivacyLocal(String model) {
return 'Your description is processed locally on this machine ($model).';
}
@override
String setupFreeTextPrivacyRemote(String model, String provider) {
return 'Your description is sent to the configured system AI: $model ($provider).';
}
@override
String get setupFreeTextUnavailable =>
'The free-text path needs a configured system AI (Settings → System AI). The choices above always work — no AI required.';
@override
String get setupFreeTextParseError =>
'The system AI did not return a usable suggestion. Pick from the menu above — or try again.';
@override
String get setupReflectionTitle => 'This is how I read your task';
@override
String setupReflectionScenario(String label) {
return 'What it is about: $label';
}
@override
String setupReflectionIntent(String label) {
return 'First task: $label';
}
@override
String setupReflectionTarget(String label) {
return 'Environment: $label';
}
@override
String get setupReflectionApproval =>
'A human is asked for approval before every AI step.';
@override
String get setupReflectionDataLocal => 'Data never leaves the building.';
@override
String get setupReflectionEditHint =>
'Nothing is changed without your confirmation. Adjust any point, or go straight to the preview.';
@override
String get setupReflectionAdjust => 'Adjust';
@override
String get setupReflectionToPreview => 'To the preview';
@override
String get setupProfileDev => 'straightforward workstation';
@override
String get setupProfileEnterprise => 'hardened team operation';
@override
String get setupProfileAirgapped => 'fully local, regulated operation';
@override
String welcomeChecklistSetupDone(String profile) {
return 'The setup assistant already took care of the basics ($profile) — this list shows what has actually happened on your hub.';
}
} }

View file

@ -22,9 +22,44 @@ import '../theme/tokens.dart';
import '../widgets/guided_setup_dialog.dart'; import '../widgets/guided_setup_dialog.dart';
import '../widgets/widgets.dart'; import '../widgets/widgets.dart';
class WelcomePage extends StatelessWidget { class WelcomePage extends StatefulWidget {
const WelcomePage({super.key}); const WelcomePage({super.key});
@override
State<WelcomePage> createState() => _WelcomePageState();
}
class _WelcomePageState extends State<WelcomePage> {
/// Bumped when the guided-setup dialog closes so the onboarding
/// checklist remounts and re-probes the assistant may have just
/// installed the modules the checklist looks for.
int _checklistEpoch = 0;
/// One auto-open per app run: a fresh install that dismisses the
/// wizard should not have it spring back on every rebuild.
static bool _autoOpenedThisRun = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _maybeAutoOpen());
}
/// On a fresh hub (no config, no recorded setup plan) the wizard
/// IS the onboarding open it rather than hoping the operator
/// finds the button. Once a setup exists it steps back for good.
Future<void> _maybeAutoOpen() async {
if (_autoOpenedThisRun || !mounted) return;
if (!GuidedSetupDialog.isFreshInstall()) return;
_autoOpenedThisRun = true;
await _openSetup();
}
Future<void> _openSetup() async {
await GuidedSetupDialog.show(context);
if (mounted) setState(() => _checklistEpoch++);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@ -65,11 +100,12 @@ class WelcomePage extends StatelessWidget {
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
// High-emphasis: the guided setup is the fastest path // High-emphasis: the guided setup is the fastest path
// to a working first run, so lead with a filled button // to a working first run, so lead with a filled button
// rather than a low-key outlined one. // rather than a low-key outlined one. Honest framing:
// "get started in 3 questions", not tool-speak.
child: FilledButton.icon( child: FilledButton.icon(
onPressed: () => GuidedSetupDialog.show(context), onPressed: _openSetup,
icon: const Icon(Icons.auto_fix_high), icon: const Icon(Icons.auto_fix_high),
label: Text(AppLocalizations.of(context)!.guidedSetupTitle), label: Text(l.setupStartCta),
), ),
), ),
const SizedBox(height: ChainSpace.xxl), const SizedBox(height: ChainSpace.xxl),
@ -81,7 +117,7 @@ class WelcomePage extends StatelessWidget {
const _HubDownHero(), const _HubDownHero(),
const SizedBox(height: ChainSpace.xxl), const SizedBox(height: ChainSpace.xxl),
] else ] else
const _OnboardingChecklist(), _OnboardingChecklist(key: ValueKey(_checklistEpoch)),
const _PillarRow(), const _PillarRow(),
const SizedBox(height: ChainSpace.xxl), const SizedBox(height: ChainSpace.xxl),
const _SectionLabel(textKey: _SectionLabelKey.trust), const _SectionLabel(textKey: _SectionLabelKey.trust),
@ -515,7 +551,7 @@ const String _kChecklistDismissedKey = 'welcome.checklist.dismissed';
/// parallel; failures stay false and the checklist still /// parallel; failures stay false and the checklist still
/// renders (with the "Refresh" button for retry). /// renders (with the "Refresh" button for retry).
class _OnboardingChecklist extends StatefulWidget { class _OnboardingChecklist extends StatefulWidget {
const _OnboardingChecklist(); const _OnboardingChecklist({super.key});
@override @override
State<_OnboardingChecklist> createState() => _OnboardingChecklistState(); State<_OnboardingChecklist> createState() => _OnboardingChecklistState();
@ -530,6 +566,12 @@ class _OnboardingChecklistState extends State<_OnboardingChecklist> {
bool _moduleOk = false; bool _moduleOk = false;
bool _flowOk = false; bool _flowOk = false;
/// Profile the guided setup applied (from `setup-plan.yaml`), or
/// null when the assistant never ran. Connects the two onboarding
/// surfaces: the checklist says what the assistant already covered
/// instead of ignoring it.
String? _setupProfile;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -541,6 +583,7 @@ class _OnboardingChecklistState extends State<_OnboardingChecklist> {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_dismissed = prefs.getBool(_kChecklistDismissedKey) ?? false; _dismissed = prefs.getBool(_kChecklistDismissedKey) ?? false;
_setupProfile = GuidedSetupDialog.appliedSetupProfile();
_initialised = true; _initialised = true;
}); });
if (!_dismissed) { if (!_dismissed) {
@ -598,6 +641,13 @@ class _OnboardingChecklistState extends State<_OnboardingChecklist> {
setState(() => _dismissed = true); setState(() => _dismissed = true);
} }
/// Localized plain-language label for the applied setup profile.
String _profileLabel(AppLocalizations l) => switch (_setupProfile) {
'enterprise' => l.setupProfileEnterprise,
'air-gapped' => l.setupProfileAirgapped,
_ => l.setupProfileDev,
};
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (!_initialised || _dismissed) return const SizedBox.shrink(); if (!_initialised || _dismissed) return const SizedBox.shrink();
@ -674,6 +724,27 @@ class _OnboardingChecklistState extends State<_OnboardingChecklist> {
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),
), ),
// The guided setup already ran: say so, so the checklist
// reads as "what's left", not as a second, disconnected
// onboarding that ignores the assistant's work.
if (_setupProfile != null) ...[
const SizedBox(height: ChainSpace.sm),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.check_circle, size: 16, color: ChainColors.success),
const SizedBox(width: 6),
Expanded(
child: Text(
l.welcomeChecklistSetupDone(_profileLabel(l)),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
],
const SizedBox(height: ChainSpace.md), const SizedBox(height: ChainSpace.md),
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(

View file

@ -9,15 +9,74 @@
// plain-language summary never the CLI's English prose. Every // plain-language summary never the CLI's English prose. Every
// option carries a one-line explanation of what it configures. // option carries a one-line explanation of what it configures.
import 'dart:async' show unawaited;
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../data/error_presentation.dart'; import '../data/error_presentation.dart';
import '../data/hub.dart';
import '../data/system_actions.dart'; import '../data/system_actions.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../main.dart' show StudioShellState;
import '../theme/tokens.dart'; import '../theme/tokens.dart';
import 'chain_stores_dialog.dart';
/// Allowed wire values per answer an AI suggestion is validated
/// against these; anything else is rejected as a parse failure so a
/// hallucinated enum can never reach the engine.
const _kScenarioValues = [
'trying-out',
'team-hub',
'regulated-production',
'building-modules',
];
const _kIntentValues = [
'hello-world',
'extract-text',
'extract-summarize',
'classify-documents',
'build-module',
];
const _kTargetValues = [
'this-laptop',
'home-server',
'air-gapped-server',
'container',
];
/// Extract + validate the system AI's setup suggestion: the first
/// JSON object in [text] with valid enum values for all three
/// answers. Returns null when nothing safe could be parsed.
@visibleForTesting
Map<String, dynamic>? parseSetupSuggestion(String text) {
final start = text.indexOf('{');
final end = text.lastIndexOf('}');
if (start < 0 || end <= start) return null;
final Object? decoded;
try {
decoded = jsonDecode(text.substring(start, end + 1));
} on FormatException {
return null;
}
if (decoded is! Map<String, dynamic>) return null;
final scenario = decoded['scenario'];
final intent = decoded['intent'];
final target = decoded['target'];
if (!_kScenarioValues.contains(scenario) ||
!_kIntentValues.contains(intent) ||
!_kTargetValues.contains(target)) {
return null;
}
return {
'scenario': scenario,
'intent': intent,
'target': target,
'require_approval': decoded['require_approval'] == true,
'data_must_stay_local': decoded['data_must_stay_local'] == true,
};
}
/// A selectable setup option: the stable kebab wire value plus the /// A selectable setup option: the stable kebab wire value plus the
/// localized label + one-line explanation resolved at build time. /// localized label + one-line explanation resolved at build time.
@ -29,12 +88,80 @@ class _Option {
} }
class GuidedSetupDialog extends StatefulWidget { class GuidedSetupDialog extends StatefulWidget {
const GuidedSetupDialog({super.key}); /// The shell, resolved by [show] before the dialog route detaches
/// from the shell's subtree. Drives the post-apply "open the
/// starter flow" navigation; null in tests.
final StudioShellState? shell;
static Future<void> show(BuildContext context) => showDialog<void>( /// Test seam: start on the review step with this parsed plan
/// instead of shelling out to `chain init --plan-json`.
@visibleForTesting
final Map<String, dynamic>? debugPlan;
/// Test seam: combined with [debugPlan], start in the applied
/// (next-steps) state.
@visibleForTesting
final bool debugApplied;
/// Test seam: skip the live system-AI probe so the free-text
/// section renders its deterministic "no AI configured" state.
@visibleForTesting
final bool debugSkipAiProbe;
const GuidedSetupDialog({
super.key,
this.shell,
this.debugPlan,
this.debugApplied = false,
this.debugSkipAiProbe = false,
});
static Future<void> show(BuildContext context) {
final shell = StudioShellState.of(context);
return showDialog<void>(
context: context, context: context,
builder: (_) => const GuidedSetupDialog(), builder: (_) => GuidedSetupDialog(shell: shell),
); );
}
/// Path of the operator dir (`~/.chain`), or null when no home
/// directory can be resolved.
static String? _chainDir() {
final home =
Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'];
if (home == null) return null;
return '$home${Platform.pathSeparator}.chain';
}
/// True on a fresh install: neither a config nor a recorded setup
/// plan exists yet. Drives the welcome page's auto-open.
static bool isFreshInstall() {
final dir = _chainDir();
if (dir == null) return false;
final sep = Platform.pathSeparator;
return !File('$dir${sep}config.yaml').existsSync() &&
!File('$dir${sep}setup-plan.yaml').existsSync();
}
/// The `profile:` of the recorded setup plan (`setup-plan.yaml`),
/// or null when the guided setup never applied one. Lets the
/// onboarding checklist say "the assistant covered this" instead
/// of presenting a second, disconnected onboarding surface.
static String? appliedSetupProfile() {
final dir = _chainDir();
if (dir == null) return null;
final f = File('$dir${Platform.pathSeparator}setup-plan.yaml');
if (!f.existsSync()) return null;
try {
for (final line in f.readAsLinesSync()) {
final m = RegExp(r'^profile:\s*(\S+)').firstMatch(line.trim());
if (m != null) return m.group(1);
}
} on FileSystemException {
return null;
}
return null;
}
@override @override
State<GuidedSetupDialog> createState() => _GuidedSetupDialogState(); State<GuidedSetupDialog> createState() => _GuidedSetupDialogState();
@ -71,19 +198,89 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
String _target = 'this-laptop'; String _target = 'this-laptop';
bool _requireApproval = false; bool _requireApproval = false;
bool _dataLocal = false; bool _dataLocal = false;
bool _allowUnsigned = false;
bool _busy = false; bool _busy = false;
Map<String, dynamic>? _plan; // parsed SetupPlan from --plan-json Map<String, dynamic>? _plan; // parsed SetupPlan from --plan-json
bool _applied = false; bool _applied = false;
// Post-apply action state: hub reachability + per-module install
// progress, so the next steps are buttons that report back instead
// of CLI commands to retype.
bool _hubUp = false;
bool _startingHub = false;
final Set<String> _installing = {};
final Set<String> _installed = {};
// Free-text (AI) path state. The menu path is always complete on
// its own; the AI merely PRE-SELECTS answers for review it never
// applies anything (trust rule from guided-setup.md).
final TextEditingController _goalCtl = TextEditingController();
SystemAiStatus? _aiStatus; // null until probed / when unreachable
bool _aiProbed = false;
bool _suggestBusy = false;
bool _showReflection = false;
@override
void initState() {
super.initState();
final seeded = widget.debugPlan;
if (seeded != null) {
_plan = seeded;
_step = _totalSteps;
_applied = widget.debugApplied;
_aiProbed = true; // tests: skip the live probe
} else if (widget.debugSkipAiProbe) {
_aiProbed = true;
} else {
unawaited(_probeAi());
}
}
@override
void dispose() {
_goalCtl.dispose();
try {
_answersDir?.deleteSync(recursive: true);
} on FileSystemException {
// Best-effort cleanup; the OS temp reaper covers the rest.
}
super.dispose();
}
/// Probe whether a system AI is configured the free-text path
/// needs one; without it the menu path stands alone (no dead end).
Future<void> _probeAi() async {
SystemAiStatus? status;
try {
status = await HubService.instance.systemAiStatus();
} catch (_) {
status = null;
}
if (!mounted) return;
setState(() {
_aiStatus = status;
_aiProbed = true;
});
}
String _answersYaml() => String _answersYaml() =>
'scenario: $_scenario\n' 'scenario: $_scenario\n'
'intent: $_intent\n' 'intent: $_intent\n'
'target: $_target\n' 'target: $_target\n'
'require_approval: $_requireApproval\n' 'require_approval: $_requireApproval\n'
'data_must_stay_local: $_dataLocal\n'; 'data_must_stay_local: $_dataLocal\n'
'allow_unsigned_modules: $_allowUnsigned\n';
/// Per-dialog private temp dir for the answers file a fixed
/// name in the shared system temp would be world-readable and
/// racy on multi-user machines. Cleaned up in [dispose].
Directory? _answersDir;
Future<String> _writeAnswers() async { Future<String> _writeAnswers() async {
final f = File('${Directory.systemTemp.path}/chain-setup-answers.yaml'); _answersDir ??= await Directory.systemTemp.createTemp('chain-setup-');
final f = File(
'${_answersDir!.path}${Platform.pathSeparator}answers.yaml',
);
await f.writeAsString(_answersYaml()); await f.writeAsString(_answersYaml());
return f.path; return f.path;
} }
@ -121,6 +318,11 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
} }
} }
/// Warning lines the apply emitted on success (e.g. the empty
/// trusted_publishers caveat). Swallowing them made the wizard
/// claim more than the config delivers show them instead.
String _applyWarnings = '';
Future<void> _apply() async { Future<void> _apply() async {
setState(() => _busy = true); setState(() => _busy = true);
final path = await _writeAnswers(); final path = await _writeAnswers();
@ -130,22 +332,147 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
if (!mounted) return; if (!mounted) return;
setState(() => _busy = false); setState(() => _busy = false);
if (r.ok) { if (r.ok) {
setState(() => _applied = true); final warnings = r.stderr
.split('\n')
.where((line) => line.toLowerCase().contains('warn'))
.join('\n')
.trim();
setState(() {
_applied = true;
_applyWarnings = warnings;
});
unawaited(_probeHub());
} else { } else {
showFaiProcessError(context, 'chain init --apply', r.stdout, r.stderr); showFaiProcessError(context, 'chain init --apply', r.stdout, r.stderr);
} }
} }
/// Refresh the "is the hub reachable" signal driving the
/// post-apply action rows.
Future<void> _probeHub() async {
final up = await HubService.instance
.healthy()
.catchError((Object _) => false);
if (mounted) setState(() => _hubUp = up);
}
/// Start the local daemon, then poll until it answers (the start
/// command returns before the gRPC endpoint is up). A failed start
/// often just means "already running", so probe before reporting.
Future<void> _startHub() async {
setState(() => _startingHub = true);
final r = await SystemActions.chainDaemon(['start']);
var up = false;
for (var i = 0; i < 6 && !up; i++) {
await Future<void>.delayed(const Duration(milliseconds: 700));
up = await HubService.instance
.healthy()
.catchError((Object _) => false);
}
if (!mounted) return;
setState(() {
_startingHub = false;
_hubUp = up;
});
if (!up) {
await showFaiProcessErrorDialog(
context,
'chain daemon start',
r.stdout,
r.stderr,
);
}
}
/// Structured prompt for the system AI. English, fixed shape, and
/// the reply is validated against the enum whitelists the model
/// only ever pre-selects menu answers, it cannot inject config.
String _suggestionPrompt(String goal) =>
'You configure the Ch∆In workflow platform. Map the operator\'s '
'goal to setup answers.\n'
'Goal: """$goal"""\n'
'Reply with ONLY one JSON object, no prose, of this exact shape:\n'
'{"scenario":"trying-out|team-hub|regulated-production|building-modules",'
'"intent":"hello-world|extract-text|extract-summarize|classify-documents|build-module",'
'"target":"this-laptop|home-server|air-gapped-server|container",'
'"require_approval":true|false,"data_must_stay_local":true|false}\n'
'Pick the closest match for each field.';
/// Ask the system AI to map the free-text goal onto the menu
/// answers, then show the editable reflection. Never applies
/// the suggestion only pre-selects; preview + apply stay manual.
Future<void> _suggest() async {
final goal = _goalCtl.text.trim();
if (goal.isEmpty) return;
setState(() => _suggestBusy = true);
final l = AppLocalizations.of(context)!;
try {
final r = await HubService.instance.askAi(_suggestionPrompt(goal));
if (!mounted) return;
setState(() => _suggestBusy = false);
if (r.errorKind.isNotEmpty) {
await showChainErrorDialog(context, 'system-ai', r.text);
return;
}
final parsed = parseSetupSuggestion(r.text);
if (parsed == null) {
await showChainErrorDialog(
context,
'system-ai',
'${l.setupFreeTextParseError}\n\n${r.text}',
);
return;
}
setState(() {
_scenario = parsed['scenario'] as String;
_intent = parsed['intent'] as String;
_target = parsed['target'] as String;
_requireApproval = parsed['require_approval'] as bool;
_dataLocal = parsed['data_must_stay_local'] as bool;
_showReflection = true;
});
} catch (e) {
if (!mounted) return;
setState(() => _suggestBusy = false);
await showChainErrorDialog(context, 'system-ai', e);
}
}
/// Install one plan module by capability name the hub resolves
/// the bundle URL from its store index.
Future<void> _install(String module) async {
setState(() => _installing.add(module));
try {
await HubService.instance.installModule(source: module);
if (mounted) setState(() => _installed.add(module));
} catch (e) {
if (mounted) {
await showChainErrorDialog(context, 'install $module', e);
}
} finally {
if (mounted) setState(() => _installing.remove(module));
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
final reviewing = _step >= _totalSteps; final reviewing = _step >= _totalSteps;
final title = reviewing
? l.setupReviewTitle
: _showReflection
? l.setupReflectionTitle
: _stepTitle(l);
return AlertDialog( return AlertDialog(
title: Text(reviewing ? l.setupReviewTitle : _stepTitle(l)), title: Text(title),
content: SizedBox( content: SizedBox(
width: 480, width: 480,
child: SingleChildScrollView( child: SingleChildScrollView(
child: reviewing ? _reviewStep(l) : _answerStep(l), child: reviewing
? _reviewStep(l)
: _showReflection
? _reflectionView(l)
: _answerStep(l),
), ),
), ),
actions: _actions(l), actions: _actions(l),
@ -201,35 +528,92 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
onChanged: (v) => setState(() => _dataLocal = v), onChanged: (v) => setState(() => _dataLocal = v),
), ),
], ],
// The free-text alternative lives on the first step: describe
// the goal, the system AI pre-selects the menu answers. Menu
// path always stands alone (air-gap-safe, no dead end).
if (_step == 0 && _aiProbed) ...[
const SizedBox(height: ChainSpace.md),
Text(
l.setupChooseFreeText,
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: ChainSpace.sm),
if (_aiStatus?.enabled == true) ...[
TextField(
controller: _goalCtl,
minLines: 2,
maxLines: 4,
decoration: InputDecoration(
hintText: l.setupFreeTextHint,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: ChainSpace.xs),
// Trust rule: be transparent about where the description
// goes before the operator types anything sensitive.
Text(
_aiIsLocal()
? l.setupFreeTextPrivacyLocal(_aiStatus?.model ?? '')
: l.setupFreeTextPrivacyRemote(
_aiStatus?.model ?? '',
_aiStatus?.provider ?? '',
),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: ChainSpace.sm),
Align(
alignment: Alignment.centerLeft,
child: FilledButton.tonalIcon(
onPressed: _suggestBusy ? null : _suggest,
icon: _suggestBusy
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.auto_awesome, size: 18),
label: Text(l.setupFreeTextSuggest),
),
),
] else
_hintRow(l.setupFreeTextUnavailable),
],
], ],
); );
} }
Widget _reviewStep(AppLocalizations l) { /// True when the configured system AI runs on this machine the
final plan = _plan; /// privacy line says so instead of naming a cloud provider.
if (plan == null) return const SizedBox.shrink(); bool _aiIsLocal() {
final s = _aiStatus;
if (s == null) return false;
return s.provider == 'ollama' ||
s.endpoint.contains('localhost') ||
s.endpoint.contains('127.0.0.1');
}
/// Editable reflection of the AI suggestion: "this is how I read
/// your task", in the same localized labels the menu uses. The
/// operator either adjusts (steps, pre-selected) or goes straight
/// to the same preview the menu path uses. Nothing auto-applies.
Widget _reflectionView(AppLocalizations l) {
final theme = Theme.of(context); final theme = Theme.of(context);
final lines = _planLines(l, plan); String labelFor(List<_Option> opts, String value) =>
opts.firstWhere((o) => o.value == value, orElse: () => opts.first)
.label(l);
final lines = <String>[
l.setupReflectionScenario(labelFor(_scenarios, _scenario)),
l.setupReflectionIntent(labelFor(_intents, _intent)),
l.setupReflectionTarget(labelFor(_targets, _target)),
if (_requireApproval) l.setupReflectionApproval,
if (_dataLocal) l.setupReflectionDataLocal,
];
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (_applied) ...[
Row(
children: [
Icon(Icons.check_circle, color: ChainColors.success, size: 20),
const SizedBox(width: ChainSpace.sm),
Text(l.setupApplied, style: theme.textTheme.titleSmall),
],
),
const SizedBox(height: ChainSpace.md),
Text(l.setupNextTitle, style: theme.textTheme.labelLarge),
const SizedBox(height: 4),
_next(l.setupNextHubStart),
if ((plan['modules'] as List?)?.isNotEmpty ?? false)
_next(l.setupNextInstall),
_next(l.setupNextRunFlow),
] else ...[
for (final line in lines) for (final line in lines)
Padding( Padding(
padding: const EdgeInsets.only(bottom: 6), padding: const EdgeInsets.only(bottom: 6),
@ -243,6 +627,83 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
], ],
), ),
), ),
const SizedBox(height: ChainSpace.xs),
Text(
l.setupReflectionEditHint,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
);
}
Widget _reviewStep(AppLocalizations l) {
final plan = _plan;
if (plan == null) return const SizedBox.shrink();
final theme = Theme.of(context);
final lines = _planLines(l, plan);
final modules = (plan['modules'] as List?)?.cast<String>() ?? const [];
final airGapped = (plan['profile'] as String? ?? '') == 'air-gapped';
final sigGate = plan['require_signatures'] == true;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_applied)
..._appliedSteps(l, plan)
else ...[
for (final line in lines)
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('· ', style: theme.textTheme.bodyMedium),
Expanded(
child: Text(line, style: theme.textTheme.bodyMedium),
),
],
),
),
// Signature dead end (SEC-01): a regulated connected plan
// refuses the public store's unsigned bundles — the very
// installs suggested after apply. Explain it and offer the
// relaxation as one deliberate, reversible switch. The plan
// is re-assembled on toggle so the preview stays truthful.
if (!airGapped && modules.isNotEmpty && (sigGate || _allowUnsigned)) ...[
const SizedBox(height: ChainSpace.sm),
if (sigGate)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.verified_user_outlined,
size: 16,
color: theme.colorScheme.tertiary,
),
const SizedBox(width: 6),
Expanded(
child: Text(
l.setupSigPublicStoreNotice,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.tertiary,
),
),
),
],
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(l.setupAllowUnsigned),
subtitle: Text(l.setupAllowUnsignedSub),
value: _allowUnsigned,
onChanged: (v) {
setState(() => _allowUnsigned = v);
_goReview();
},
),
],
if (_configExists()) ...[ if (_configExists()) ...[
const SizedBox(height: ChainSpace.sm), const SizedBox(height: ChainSpace.sm),
Row( Row(
@ -266,13 +727,147 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
); );
} }
Widget _next(String label) => Padding( /// Post-apply next steps as real Studio actions: start the hub,
padding: const EdgeInsets.only(top: 4), /// install each plan module, open the starter flow no CLI text.
List<Widget> _appliedSteps(AppLocalizations l, Map<String, dynamic> plan) {
final theme = Theme.of(context);
final modules = (plan['modules'] as List?)?.cast<String>() ?? const [];
final airGapped = (plan['profile'] as String? ?? '') == 'air-gapped';
final sigGate = plan['require_signatures'] == true;
final flow = plan['starter_flow'] as String? ?? '';
return [
Row(
children: [
Icon(Icons.check_circle, color: ChainColors.success, size: 20),
const SizedBox(width: ChainSpace.sm),
Text(l.setupApplied, style: theme.textTheme.titleSmall),
],
),
const SizedBox(height: ChainSpace.md),
Text(l.setupNextTitle, style: theme.textTheme.labelLarge),
const SizedBox(height: ChainSpace.sm),
// 1 the hub. Everything else needs it.
if (_hubUp)
_doneRow(l.setupHubRunning)
else
_actionRow(
FilledButton.tonalIcon(
onPressed: _startingHub ? null : _startHub,
icon: _startingHub
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.play_arrow, size: 18),
label: Text(l.setupNextHubStart),
),
),
// 2 the plan's modules. On a signature-strict plan the path
// stays clickable: add a signed source (the stores dialog with
// its pin-a-key field), then install no terminal, no dead end.
if (modules.isNotEmpty) ...[
if (airGapped)
_hintRow(l.setupModulesOfflineHint)
else ...[
if (sigGate) ...[
_hintRow(l.setupModulesSignedHint),
_hintRow(l.setupTrustedPublishersHint),
_actionRow(
OutlinedButton.icon(
onPressed: () => ChainStoresDialog.show(context),
icon: const Icon(Icons.add_moderator_outlined, size: 18),
label: Text(l.setupAddSignedSource),
),
),
],
if (!_hubUp) _hintRow(l.setupStartHubFirst),
for (final m in modules)
_installed.contains(m)
? _doneRow(l.setupActionInstalled(m))
: _actionRow(
FilledButton.tonalIcon(
onPressed: (!_hubUp || _installing.contains(m))
? null
: () => _install(m),
icon: _installing.contains(m)
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.download, size: 18),
label: Text(l.setupActionInstall(m)),
),
),
],
],
// 3 the starter flow (auto-imported on first hub start).
if (flow.isNotEmpty)
_actionRow(
OutlinedButton.icon(
onPressed: () {
Navigator.of(context).pop();
widget.shell?.navigateTo('flows');
},
icon: const Icon(Icons.account_tree_outlined, size: 18),
label: Text(l.setupActionOpenFlow(flow)),
),
),
// Honesty: warnings the apply emitted (e.g. the empty
// trusted-publishers caveat), selectable so they can go
// verbatim into a compliance note.
if (_applyWarnings.isNotEmpty) ...[
const SizedBox(height: ChainSpace.sm),
Text(l.setupApplyNotes, style: theme.textTheme.labelLarge),
const SizedBox(height: 4),
SelectableText(
_applyWarnings,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
];
}
Widget _actionRow(Widget child) => Padding(
padding: const EdgeInsets.only(top: ChainSpace.xs),
child: Align(alignment: Alignment.centerLeft, child: child),
);
Widget _doneRow(String label) => Padding(
padding: const EdgeInsets.only(top: ChainSpace.xs),
child: Row( child: Row(
children: [ children: [
Icon(Icons.arrow_right, size: 18, color: Theme.of(context).colorScheme.primary), Icon(Icons.check_circle, size: 18, color: ChainColors.success),
const SizedBox(width: 4), const SizedBox(width: 6),
Expanded(child: Text(label, style: Theme.of(context).textTheme.bodyMedium)), Expanded(
child: Text(label, style: Theme.of(context).textTheme.bodyMedium),
),
],
),
);
Widget _hintRow(String text) => Padding(
padding: const EdgeInsets.only(top: ChainSpace.xs),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.info_outline,
size: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 6),
Expanded(
child: Text(
text,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
], ],
), ),
); );
@ -280,8 +875,9 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
/// Build the localized plain-language plan lines from the structured /// Build the localized plain-language plan lines from the structured
/// SetupPlan (never the CLI prose). /// SetupPlan (never the CLI prose).
List<String> _planLines(AppLocalizations l, Map<String, dynamic> plan) { List<String> _planLines(AppLocalizations l, Map<String, dynamic> plan) {
final profile = plan['profile'] as String? ?? '';
final lines = <String>[]; final lines = <String>[];
switch (plan['profile'] as String? ?? '') { switch (profile) {
case 'air-gapped': case 'air-gapped':
lines.add(l.setupPlanIntroAirgapped); lines.add(l.setupPlanIntroAirgapped);
case 'enterprise': case 'enterprise':
@ -289,13 +885,34 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
default: default:
lines.add(l.setupPlanIntroDev); lines.add(l.setupPlanIntroDev);
} }
// Where this actually lands: the wizard always configures the
// machine Studio runs on say so, especially when the operator
// picked a server/container target.
switch (plan['runbook'] as String? ?? '') {
case 'service':
lines.add(l.setupPlanRunbookService);
case 'air-gap-transfer':
lines.add(l.setupPlanRunbookAirgap);
case 'container':
lines.add(l.setupPlanRunbookContainer);
default:
lines.add(l.setupPlanRunbookLocal);
}
if (plan['require_signatures'] == true) lines.add(l.setupPlanSignatures); if (plan['require_signatures'] == true) lines.add(l.setupPlanSignatures);
// The audit chain is always on for regulated profiles the
// scenario card promises a tamper-evident log, so the preview
// states what is delivered (and the WORM line covers the rest).
if (profile == 'enterprise' || profile == 'air-gapped') {
lines.add(l.setupPlanAuditChain);
}
if (plan['worm_audit'] == true) lines.add(l.setupPlanWorm); if (plan['worm_audit'] == true) lines.add(l.setupPlanWorm);
if (plan['approval_step'] == true) lines.add(l.setupPlanApproval); if (plan['approval_step'] == true) lines.add(l.setupPlanApproval);
final modules = (plan['modules'] as List?)?.cast<String>() ?? const []; final modules = (plan['modules'] as List?)?.cast<String>() ?? const [];
if (modules.isNotEmpty) lines.add(l.setupPlanModules(modules.join(', '))); if (modules.isNotEmpty) lines.add(l.setupPlanModules(modules.join(', ')));
final flow = plan['starter_flow'] as String? ?? ''; final flow = plan['starter_flow'] as String? ?? '';
if (flow.isNotEmpty) lines.add(l.setupPlanFlow(flow)); if (flow.isNotEmpty) lines.add(l.setupPlanFlow(flow));
final docs = (plan['curated_docs'] as List?)?.length ?? 0;
if (docs > 0) lines.add(l.setupPlanDocs(docs));
lines.add(l.setupPlanFileChanged('~/.chain/config.yaml')); lines.add(l.setupPlanFileChanged('~/.chain/config.yaml'));
return lines; return lines;
} }
@ -321,6 +938,23 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
), ),
]; ];
} }
if (_showReflection && _step < _totalSteps) {
// AI reflection: adjust step-by-step (answers stay pre-selected)
// or continue to the same preview the menu path uses.
return [
TextButton(
onPressed: () => setState(() => _showReflection = false),
child: Text(l.setupReflectionAdjust),
),
FilledButton(
onPressed: () {
setState(() => _showReflection = false);
_goReview();
},
child: Text(l.setupReflectionToPreview),
),
];
}
if (_step < _totalSteps) { if (_step < _totalSteps) {
return [ return [
TextButton( TextButton(

View file

@ -13,4 +13,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009
COCOAPODS: 1.16.2 COCOAPODS: 1.17.0

View file

@ -172,6 +172,11 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.3.5" version: "0.3.5"
flutter_driver:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_highlight: flutter_highlight:
dependency: transitive dependency: transitive
description: description:
@ -219,6 +224,11 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
fuchsia_remote_debug_protocol:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
google_cloud: google_cloud:
dependency: transitive dependency: transitive
description: description:
@ -291,6 +301,11 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
integration_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
intl: intl:
dependency: "direct main" dependency: "direct main"
description: description:
@ -435,6 +450,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.8" version: "2.1.8"
process:
dependency: transitive
description:
name: process
sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744
url: "https://pub.dev"
source: hosted
version: "5.0.5"
protobuf: protobuf:
dependency: transitive dependency: transitive
description: description:
@ -544,6 +567,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" version: "1.4.1"
sync_http:
dependency: transitive
description:
name: sync_http
sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961"
url: "https://pub.dev"
source: hosted
version: "0.3.1"
term_glyph: term_glyph:
dependency: transitive dependency: transitive
description: description:
@ -664,6 +695,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
webdriver:
dependency: transitive
description:
name: webdriver
sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
win32: win32:
dependency: transitive dependency: transitive
description: description:

View file

@ -50,6 +50,8 @@ dependencies:
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
integration_test:
sdk: flutter
flutter_lints: ^6.0.0 flutter_lints: ^6.0.0
# Local-development override: while editing the swappable flow # Local-development override: while editing the swappable flow

View file

@ -26,6 +26,41 @@ Widget _host() => MaterialApp(
), ),
); );
/// Host that opens the dialog pre-seeded on the review / applied
/// step with [plan] (the parsed `--plan-json` shape), bypassing the
/// CLI subprocess.
Widget _seededHost(Map<String, dynamic> plan, {bool applied = false}) =>
MaterialApp(
locale: const Locale('de'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (context) => Scaffold(
body: Center(
child: ElevatedButton(
onPressed: () => showDialog<void>(
context: context,
builder: (_) =>
GuidedSetupDialog(debugPlan: plan, debugApplied: applied),
),
child: const Text('open'),
),
),
),
),
);
Map<String, dynamic> _regulatedPlan({bool signed = true}) => {
'profile': 'enterprise',
'modules': ['text.extract', 'text.summarize'],
'starter_flow': 'extract-summarize',
'runbook': 'service',
'curated_docs': <String>[],
'require_signatures': signed,
'worm_audit': false,
'approval_step': true,
};
void main() { void main() {
testWidgets('step 1 shows scenario options in German with explanations', ( testWidgets('step 1 shows scenario options in German with explanations', (
tester, tester,
@ -75,4 +110,183 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Schritt 2 von 3'), findsOneWidget); expect(find.text('Schritt 2 von 3'), findsOneWidget);
}); });
testWidgets(
'review of a regulated plan explains the signature dead end and offers the relaxation switch',
(tester) async {
await tester.pumpWidget(_seededHost(_regulatedPlan()));
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
// The plain-language notice + the deliberate one-click switch.
expect(
find.textContaining('Der öffentliche Store liefert zurzeit unsignierte Pakete'),
findsOneWidget,
);
expect(
find.text('Installation aus dem öffentlichen Store erlauben'),
findsOneWidget,
);
// No raw CLI prose anywhere.
expect(find.textContaining('chain install'), findsNothing);
},
);
testWidgets(
'applied state renders clickable next steps — start hub, per-module install, open flow',
(tester) async {
await tester.pumpWidget(
_seededHost(_regulatedPlan(signed: false), applied: true),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
expect(find.text('Fertig — Ch∆In ist eingerichtet.'), findsOneWidget);
// Hub not probed up in tests the start action + the
// install-needs-hub hint show, and install buttons exist
// (disabled) for each plan module.
expect(find.text('Hub starten'), findsOneWidget);
expect(
find.textContaining('Starten Sie zuerst den Hub'),
findsOneWidget,
);
expect(find.text('text.extract installieren'), findsOneWidget);
expect(find.text('text.summarize installieren'), findsOneWidget);
expect(
find.text('Beispiel-Flow „extract-summarize“ öffnen'),
findsOneWidget,
);
// No CLI text in the GUI path.
expect(find.textContaining('chain serve'), findsNothing);
expect(find.textContaining('chain install'), findsNothing);
},
);
testWidgets(
'applied regulated (signed) state stays clickable — signed-source dialog, trust hint, install buttons',
(tester) async {
await tester.pumpWidget(_seededHost(_regulatedPlan(), applied: true));
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
// The plain-language story + the pin-the-publisher trust hint.
expect(find.textContaining('signierten Quelle'), findsOneWidget);
expect(
find.textContaining('Schlüssel des Herausgebers'),
findsOneWidget,
);
// No dead end: the stores dialog (with its pin-a-key field) is
// one click away, and the installs stay available for after
// the source is added.
expect(find.text('Signierte Quelle hinzufügen…'), findsOneWidget);
expect(find.text('text.extract installieren'), findsOneWidget);
expect(find.textContaining('chain store add'), findsNothing);
},
);
testWidgets(
'review preview names the machine being set up and the audit chain',
(tester) async {
await tester.pumpWidget(_seededHost(_regulatedPlan()));
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
// Runbook honesty: a "home server" target still configures
// THIS machine the preview must say so.
expect(
find.textContaining('Eingerichtet wird dieser Rechner'),
findsOneWidget,
);
// The regulated promise is stated even when WORM is off:
// the hash-chained audit log line is always there.
expect(
find.textContaining('hash-verketteten Prüfprotokoll'),
findsOneWidget,
);
},
);
testWidgets(
'step 1 offers the free-text path with an honest no-AI fallback hint',
(tester) async {
await tester.pumpWidget(
MaterialApp(
locale: const Locale('de'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (context) => Scaffold(
body: Center(
child: ElevatedButton(
onPressed: () => showDialog<void>(
context: context,
builder: (_) =>
const GuidedSetupDialog(debugSkipAiProbe: true),
),
child: const Text('open'),
),
),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
expect(
find.text('Oder beschreiben Sie einfach, was Sie vorhaben'),
findsOneWidget,
);
// No system AI configured honest fallback: the menu always
// works; no dead end, no disabled mystery button.
expect(
find.textContaining('Die Auswahl oben funktioniert immer'),
findsOneWidget,
);
expect(find.text('Vorschlagen lassen'), findsNothing);
},
);
group('parseSetupSuggestion', () {
test('accepts a valid JSON object embedded in prose', () {
final r = parseSetupSuggestion(
'Sure! Here is the mapping:\n'
'{"scenario":"regulated-production","intent":"classify-documents",'
'"target":"home-server","require_approval":true,'
'"data_must_stay_local":false}\n'
'Let me know if you need anything else.',
);
expect(r, isNotNull);
expect(r!['scenario'], 'regulated-production');
expect(r['intent'], 'classify-documents');
expect(r['target'], 'home-server');
expect(r['require_approval'], true);
expect(r['data_must_stay_local'], false);
});
test('rejects hallucinated enum values', () {
expect(
parseSetupSuggestion(
'{"scenario":"world-domination","intent":"hello-world",'
'"target":"this-laptop"}',
),
isNull,
);
});
test('rejects non-JSON replies', () {
expect(parseSetupSuggestion('I cannot help with that.'), isNull);
expect(parseSetupSuggestion('{broken json'), isNull);
});
test('missing booleans default to false', () {
final r = parseSetupSuggestion(
'{"scenario":"trying-out","intent":"hello-world",'
'"target":"this-laptop"}',
);
expect(r, isNotNull);
expect(r!['require_approval'], false);
expect(r['data_must_stay_local'], false);
});
});
} }

View file

@ -71,13 +71,13 @@ class HubFixture {
if (binary == null) { if (binary == null) {
if (skipIfBinaryMissing) { if (skipIfBinaryMissing) {
markTestSkipped( markTestSkipped(
'Hub fixture skipped: no `fai` binary found on PATH or in ' 'Hub fixture skipped: no `chain` binary found via \$CHAIN_BIN, '
'../chain_platform/target/release/fai. Build it with ' 'PATH, or ../fai_chain/target/{release,debug}/chain. Build it '
'`cargo build --release` and re-run.', 'with `cargo build` in the platform repo and re-run.',
); );
return null; return null;
} }
throw StateError('fai binary not found'); throw StateError('chain binary not found');
} }
final tempDir = await Directory.systemTemp.createTemp('chain_studio_test_'); final tempDir = await Directory.systemTemp.createTemp('chain_studio_test_');
@ -157,27 +157,37 @@ class HubFixture {
} }
} }
/// Look for `fai` first on PATH (production-ish), then in /// Resolved hub binary path, for tests that also drive the CLI
/// the standard `target/release/` build output relative to /// against the fixture's data dir (e.g. seeding projects).
/// the platform checkout (developer flow). Returns null when static Future<String?> binaryPath() => _resolveBinary();
/// neither path resolves.
/// Look for the hub binary: $CHAIN_BIN override first, then
/// `chain` on PATH (production-ish), then the release/debug
/// build outputs relative to the platform checkout (developer
/// flow, repo dir `fai_chain`). Returns null when nothing
/// resolves.
static Future<String?> _resolveBinary() async { static Future<String?> _resolveBinary() async {
final fromEnv = Platform.environment['CHAIN_BIN'];
if (fromEnv != null && fromEnv.isNotEmpty && File(fromEnv).existsSync()) {
return fromEnv;
}
final pathResult = await Process.run( final pathResult = await Process.run(
Platform.isWindows ? 'where' : 'which', Platform.isWindows ? 'where' : 'which',
['fai'], [Platform.isWindows ? 'chain.exe' : 'chain'],
); );
if (pathResult.exitCode == 0) { if (pathResult.exitCode == 0) {
final s = (pathResult.stdout as String).trim(); final s = (pathResult.stdout as String).trim();
if (s.isNotEmpty) return s.split('\n').first.trim(); if (s.isNotEmpty) return s.split('\n').first.trim();
} }
final candidate = Platform.isWindows final exe = Platform.isWindows ? 'chain.exe' : 'chain';
? '../chain_platform/target/release/fai.exe' for (final dir in ['release', 'debug']) {
: '../chain_platform/target/release/fai'; final f = File('../fai_chain/target/$dir/$exe');
final f = File(candidate);
if (await f.exists()) { if (await f.exists()) {
return f.absolute.path; return f.absolute.path;
} }
}
return null; return null;
} }

118
test/nav_manifest_test.dart Normal file
View file

@ -0,0 +1,118 @@
// Nav-manifest guard. The sidebar truth lives in three places in
// this repo: the `_pages` list (ids + order = Cmd numbers), the
// `labelOf` switch (id l10n key), and the two .arb files (key
// DE/EN label). The operator guide documenting that nav lives in
// the PLATFORM repo (fai_chain/docs/studio/operator-guide.md),
// which this repo's CI cannot see — so the truth is exported as a
// committed manifest, `docs/nav.generated.json`, and the platform
// repo's docs_consistency test checks the guide against it.
//
// This test derives the manifest from the sources. When it differs
// from the committed file it REWRITES the file and fails once with
// instructions commit the regenerated manifest and mirror it to
// fai_chain/docs/studio/nav.generated.json so the cross-repo guard
// stays truthful. Add/rename/reorder a page and forget the guide:
// the platform gate goes loudly red instead of the docs quietly
// rotting.
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
const _manifestPath = 'docs/nav.generated.json';
const _mirrorHint =
'fai_chain/docs/studio/nav.generated.json (same content)';
/// Extract the ordered page ids from the `_pages` list in main.dart.
List<String> _pageIds(String mainDart) {
final block = RegExp(
r'static const _pages = <_NavPage>\[(.*?)\n \];',
dotAll: true,
).firstMatch(mainDart);
if (block == null) {
fail('could not locate the _pages block in lib/main.dart');
}
return RegExp(r"id: '([a-z-]+)'")
.allMatches(block.group(1)!)
.map((m) => m.group(1)!)
.toList();
}
/// Extract the id l10n-key mapping from the `labelOf` switch.
Map<String, String> _labelKeys(String mainDart) {
final keys = <String, String>{};
for (final m in RegExp(
r"case '([a-z-]+)':\s*\n\s*return l\.(nav\w+);",
).allMatches(mainDart)) {
keys[m.group(1)!] = m.group(2)!;
}
if (keys.isEmpty) {
fail('could not parse the labelOf switch in lib/main.dart');
}
return keys;
}
Map<String, dynamic> _arb(String path) =>
jsonDecode(File(path).readAsStringSync()) as Map<String, dynamic>;
void main() {
test('docs/nav.generated.json matches the sidebar truth', () {
final mainDart = File('lib/main.dart').readAsStringSync();
final ids = _pageIds(mainDart);
final labelKeys = _labelKeys(mainDart);
final de = _arb('lib/l10n/app_de.arb');
final en = _arb('lib/l10n/app_en.arb');
final pages = <Map<String, dynamic>>[];
for (var i = 0; i < ids.length; i++) {
final key = labelKeys[ids[i]];
expect(
key,
isNotNull,
reason:
"page '${ids[i]}' has no labelOf case — add it so the "
'sidebar can localize the label',
);
final labelDe = de[key] as String?;
final labelEn = en[key] as String?;
expect(
labelDe,
isNotNull,
reason: "l10n key '$key' missing from app_de.arb",
);
expect(
labelEn,
isNotNull,
reason: "l10n key '$key' missing from app_en.arb",
);
pages.add({
'id': ids[i],
'cmd': i + 1,
'label_de': labelDe,
'label_en': labelEn,
});
}
const encoder = JsonEncoder.withIndent(' ');
final manifest =
'${encoder.convert({
'_generated': 'by test/nav_manifest_test.dart — do not edit. '
'Mirror to $_mirrorHint.',
'pages': pages,
})}\n';
final file = File(_manifestPath);
final current = file.existsSync() ? file.readAsStringSync() : null;
if (current != manifest) {
file.parent.createSync(recursive: true);
file.writeAsStringSync(manifest);
fail(
'the sidebar nav changed: $_manifestPath was regenerated. '
'Commit it, mirror it to $_mirrorHint, and update the '
'operator guide — the platform repo\'s docs_consistency '
'test checks the guide against this manifest.',
);
}
});
}