diff --git a/TASKS.md b/TASKS.md
index c648fc4..73279dc 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -28,7 +28,7 @@ file tracks how we build it.
| Phase | Theme | Status |
|---|---|---|
| 0 — Shared core | extract Contour's record engine into a package | ✅ package in Contour, on npm, used by FDPlight |
-| 1 — Project model and profile | base URI, profile format, import from an FDP | 🟡 all steps in the UI; a guided first-run flow not built |
+| 1 — Project model and profile | base URI, profile format, import from an FDP | ✅ settings, connect form and the optional guided setup |
| 2 — Multi-shape forms | merged shapes per type, form per entity | ✅ default and ERDERA shapes tested |
| 3 — Tree and wiring | entity tree, IRI minting, system triples, LDP | ✅ matches the RI (SPEC §9.5) |
| 4 — Import and edit | open an existing file-only FDP, lossless round trip | ✅ repository import, report, copy by URL |
@@ -42,6 +42,7 @@ Legend: ⬜ not started · 🟡 in progress · ✅ done.
### Log
+- 2026-10-23 — Optional guided setup for new projects (#42).
- 2026-10-23 — Profile documents: each type's merged SHACL at its profile IRI (#41).
- 2026-10-23 — ERDERA data services need `dct:type`; a hidden record shows a warning (#40).
- 2026-10-23 — Owner's decisions on the wizard, guide translations, profile documents, metrics and ERDERA follow-ups recorded (#39).
@@ -155,14 +156,17 @@ Legend: ⬜ not started · 🟡 in progress · ✅ done.
- [x] Scaffold imports the shared package and renders the default-profile
forms, with a live preview of the generated Turtle (#8).
-## Phase 1 — Project model and profile 🟡
+## Phase 1 — Project model and profile ✅
- [x] `Project` type (base URI, IRI mode, defaults, profile, entities) and its
JSON form `fdplight.json` (SPEC §3.1) — `src/core/profile.ts`,
`src/core/project.ts`. The entity tree is not yet persisted (open
decision 5).
-- [ ] New-project wizard, optional (decided 2026-10-23): a guided
- first-run flow the user may follow or skip. The steps also stay in the
+- [x] New-project wizard, optional (decided 2026-10-23): a guided
+ first-run flow the user may follow or skip (#42). Offered once on a
+ first visit, always available from the header; four steps
+ (repository and folder with the base URI, profile, Index ping,
+ summary); nothing changes before *Finish*. The steps also stay in the
settings bar and the connect form.
- [x] target repository (owner/name, branch): the connect form (#12);
- [x] **base URI**, proposed as `https://{owner}.github.io/{repo}/` plus
diff --git a/docs/data-steward-guide.md b/docs/data-steward-guide.md
index 1d91d8a..4d1db0f 100644
--- a/docs/data-steward-guide.md
+++ b/docs/data-steward-guide.md
@@ -85,6 +85,13 @@ of the selected entity (middle), and the **checks and the generated file**

+**Guided setup (optional).** On your first visit FDPlight offers a guided
+setup. It asks for the repository (owner, name, branch, folder), shows the
+base URI that follows from it, lets you pick a profile and whether to ping an
+FDP Index, and fills in the settings and the connect form when you click
+*Finish*. *Skip* leaves everything as it was. You can open it again from
+*Guided setup* in the header, or follow Steps 1 to 7 below by hand.
+
### Step 1 — Project settings

diff --git a/scripts/shoot.cjs b/scripts/shoot.cjs
index 86827f0..25ab874 100644
--- a/scripts/shoot.cjs
+++ b/scripts/shoot.cjs
@@ -26,6 +26,8 @@ async function main() {
const settle = () => page.waitForTimeout(700);
// ---- a new project ----
+ // The guided-setup offer is shown on a first visit only; the guide shows the editor.
+ await page.addInitScript(() => localStorage.setItem('fdplight.setup.offered', '1'));
await page.goto(APP);
await settle();
await shot('start');
diff --git a/src/App.test.ts b/src/App.test.ts
index b209c9e..987f68d 100644
--- a/src/App.test.ts
+++ b/src/App.test.ts
@@ -138,6 +138,55 @@ describe('App', () => {
}
});
+ it('offers an optional guided setup that fills in the settings on Finish', async () => {
+ localStorage.removeItem('fdplight.setup.offered');
+ const w = mount(App, { attachTo: document.body });
+ try {
+ expect(w.find('.offer').exists()).toBe(true);
+ await w.find('.offer__start').trigger('click');
+ expect(w.find('.offer').exists()).toBe(false);
+ expect(localStorage.getItem('fdplight.setup.offered')).toBe('1');
+ const dialog = () => w.find('[role="dialog"]');
+ const inputs = () => dialog().findAll('input');
+ await inputs()[0].setValue('erdera');
+ await inputs()[1].setValue('my-fdp');
+ await inputs()[3].setValue('metadata');
+ expect(dialog().find('.wizard__base code').text()).toBe('https://erdera.github.io/my-fdp/metadata/');
+ const next = () => dialog().findAll('button').find((b) => b.text() === 'Next')!.trigger('click');
+ await next();
+ await dialog().find('input[value="erdera"]').setValue(true);
+ await next();
+ await dialog().find('input[type="checkbox"]').setValue(true);
+ await next();
+ expect(dialog().text()).toContain('Repository erdera/my-fdp, branch main');
+ await dialog().findAll('button').find((b) => b.text() === 'Finish')!.trigger('click');
+ await flushPromises();
+ expect(w.find('[role="dialog"]').exists()).toBe(false);
+ expect(w.find('.settings select').element as HTMLSelectElement).toHaveProperty('value', 'erdera');
+ const values = w.findAll('.settings__field input').map((i) => (i.element as HTMLInputElement).value);
+ expect(values).toContain('https://erdera.github.io/my-fdp/metadata/');
+ expect(values).toContain('metadata/');
+ expect(values).toContain('https://home.fairdatapoint.org');
+ expect(w.findAll('.publish input').map((i) => (i.element as HTMLInputElement).value).slice(0, 2)).toEqual(['erdera', 'my-fdp']);
+ } finally {
+ w.unmount();
+ localStorage.setItem('fdplight.setup.offered', '1');
+ }
+ });
+
+ it('can skip the guided setup and remembers it', async () => {
+ localStorage.removeItem('fdplight.setup.offered');
+ const w = mount(App);
+ await w.find('.offer__dismiss').trigger('click');
+ expect(w.find('.offer').exists()).toBe(false);
+ expect(mount(App).find('.offer').exists()).toBe(false);
+ await w.find('.setup-link').trigger('click');
+ expect(w.find('[role="dialog"]').exists()).toBe(true);
+ await w.findAll('[role="dialog"] button').find((b) => b.text() === 'Skip')!.trigger('click');
+ expect(w.find('[role="dialog"]').exists()).toBe(false);
+ expect(w.find('.settings__field input').exists()).toBe(true);
+ });
+
it('marks entities with missing required metadata', async () => {
vi.useFakeTimers();
const w = mount(App);
diff --git a/src/App.vue b/src/App.vue
index ba58288..8dd50ea 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -6,6 +6,7 @@ import EntityTree from './components/EntityTree.vue';
import IndexStatus from './components/IndexStatus.vue';
import ProjectSettings from './components/ProjectSettings.vue';
import PublishPanel from './components/PublishPanel.vue';
+import SetupWizard from './components/SetupWizard.vue';
import { useI18n } from './composables/useI18n';
import { usePublish } from './composables/usePublish';
import { LOCALES } from './i18n';
@@ -25,6 +26,37 @@ const isRoot = computed(() => entity.value.parent === null);
const iri = computed(() => (state.settingsErrors.value.length ? '' : iriOf(project, entity.value)));
const type = computed(() => state.typeOf(entity.value));
+// The optional guided setup (TASKS Phase 1): offered once on a first visit,
+// always available from the header.
+const SETUP_KEY = 'fdplight.setup.offered';
+function setupOffered(): boolean {
+ try {
+ return localStorage.getItem(SETUP_KEY) === '1';
+ } catch {
+ return false;
+ }
+}
+const offerSetup = ref(!setupOffered() && !state.hasLocalWork());
+const wizardOpen = ref(false);
+let opener: HTMLElement | null = null;
+function dismissOffer(): void {
+ offerSetup.value = false;
+ try {
+ localStorage.setItem(SETUP_KEY, '1');
+ } catch {
+ /* storage unavailable: offered again next time */
+ }
+}
+function openWizard(): void {
+ opener = document.activeElement as HTMLElement | null;
+ dismissOffer();
+ wizardOpen.value = true;
+}
+function closeWizard(): void {
+ wizardOpen.value = false;
+ nextTick(() => opener?.focus());
+}
+
// The id field edits a draft; the rename happens on change / Enter.
const idDraft = ref(entity.value.id);
watch(entity, (e) => (idDraft.value = e.id));
@@ -67,6 +99,7 @@ function removeSelected() {