mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
feat(onboarding): M4 — Screen 3 (Templates) + finish handler
- packages/shared-branding/onboarding-templates.ts:
* 7 templates: Alltag / Arbeit / Health / Sport / Lernen / Entdecken
/ Erinnern — each with a phosphor icon name, German name/desc and
an ordered moduleIds list
* resolveModulesForTemplates() — deduplicates the union of selected
templates' modules (priority-ordered) and caps at 8 (2×4 grid)
- packages/shared-branding/onboarding-templates.spec.ts: 10 tests
covering order preservation, dedup-across-templates, cap honouring,
unknown-id tolerance
- /onboarding/templates/+page.svelte:
* Multi-select grid of 7 tiles (checkmark + primary border when on)
* Finish handler: runs resolveModulesForTemplates → creates a new
"Zuhause" scene with those apps → onboardingStatus.markComplete()
→ navigates to /
* Skip still marks complete (no scene — user lands on DEFAULT_HOME_APPS)
* Prefills selection from onboardingFlow store so back-nav is stable
With this, the 3-screen flow runs end-to-end for a new user:
signup → /onboarding/name → /look → /templates → / with a curated
home scene.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d1ac8a6ea9
commit
1198d01263
4 changed files with 579 additions and 0 deletions
|
|
@ -0,0 +1,351 @@
|
|||
<!--
|
||||
Onboarding — Screen 3: Templates.
|
||||
Multi-select of use-case templates. On Finish, the dedup'd union of
|
||||
picked templates' modules (capped at 8) is written to a fresh Home
|
||||
scene via `workbenchScenesStore.createScene`, then the flow is
|
||||
marked complete and we navigate home.
|
||||
|
||||
Skip path: no scene written (the hardcoded DEFAULT_HOME_APPS
|
||||
fallback in workbench-scenes kicks in on first liveQuery), onboarding
|
||||
still marked complete so the guard doesn't re-route.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import {
|
||||
House,
|
||||
Briefcase,
|
||||
Heart,
|
||||
Barbell,
|
||||
GraduationCap,
|
||||
Compass,
|
||||
Camera,
|
||||
Check,
|
||||
ArrowLeft,
|
||||
} from '@mana/shared-icons';
|
||||
import type { Component } from 'svelte';
|
||||
import {
|
||||
ONBOARDING_TEMPLATES,
|
||||
resolveModulesForTemplates,
|
||||
type OnboardingTemplateId,
|
||||
} from '@mana/shared-branding';
|
||||
import { workbenchScenesStore } from '$lib/stores/workbench-scenes.svelte';
|
||||
import { onboardingStatus } from '$lib/stores/onboarding-status.svelte';
|
||||
import { onboardingFlow } from '$lib/stores/onboarding-flow.svelte';
|
||||
|
||||
// Icon lookup — templates ship a phosphor name (string), the page
|
||||
// resolves it to the actual component. Keeps templates data-only
|
||||
// so they can be consumed by memoro/mobile later without pulling
|
||||
// in a web-only icon binding.
|
||||
const ICONS: Record<string, Component> = {
|
||||
House,
|
||||
Briefcase,
|
||||
Heart,
|
||||
Barbell,
|
||||
GraduationCap,
|
||||
Compass,
|
||||
Camera,
|
||||
};
|
||||
|
||||
// Prefill from the flow store so a back-nav preserves what the user
|
||||
// already picked. Start empty on first visit.
|
||||
let selected = $state<Set<OnboardingTemplateId>>(
|
||||
new Set(onboardingFlow.selectedTemplateIds as OnboardingTemplateId[])
|
||||
);
|
||||
let saving = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let selectedCount = $derived(selected.size);
|
||||
|
||||
function toggle(id: OnboardingTemplateId) {
|
||||
const next = new Set(selected);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
selected = next;
|
||||
onboardingFlow.setSelectedTemplateIds(Array.from(next));
|
||||
}
|
||||
|
||||
async function finish({ skip = false }: { skip?: boolean } = {}) {
|
||||
if (saving) return;
|
||||
saving = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
if (!skip && selected.size > 0) {
|
||||
// Preserve selection order. `selected` is a Set, but we inserted
|
||||
// in order toggled — that matches the user's mental priority.
|
||||
const modules = resolveModulesForTemplates(Array.from(selected));
|
||||
if (modules.length > 0) {
|
||||
await workbenchScenesStore.createScene({
|
||||
name: 'Zuhause',
|
||||
seedApps: modules.map((appId) => ({ appId })),
|
||||
setActive: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
await onboardingStatus.markComplete();
|
||||
onboardingFlow.reset();
|
||||
await goto('/');
|
||||
} catch (err) {
|
||||
console.error('[onboarding/templates] finish failed:', err);
|
||||
error = 'Konnte den Flow nicht abschließen. Versuch es noch mal.';
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBack() {
|
||||
await goto('/onboarding/look');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="screen">
|
||||
<div class="hero">
|
||||
<h1>Wofür willst du Mana nutzen?</h1>
|
||||
<p class="subtitle">
|
||||
Wähl aus, was zu dir passt. Wir stellen dir dazu passende Module auf deinen Startbildschirm —
|
||||
weitere kannst du jederzeit hinzufügen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
{#each ONBOARDING_TEMPLATES as tpl (tpl.id)}
|
||||
{@const Icon = ICONS[tpl.iconName]}
|
||||
{@const isSelected = selected.has(tpl.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="tile"
|
||||
class:selected={isSelected}
|
||||
onclick={() => toggle(tpl.id)}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
<div class="tile-icon">
|
||||
{#if Icon}
|
||||
<Icon size={24} weight={isSelected ? 'fill' : 'regular'} />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="tile-body">
|
||||
<div class="tile-name">{tpl.name}</div>
|
||||
<div class="tile-desc">{tpl.shortDescription}</div>
|
||||
</div>
|
||||
{#if isSelected}
|
||||
<div class="tile-check">
|
||||
<Check size={12} weight="bold" />
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="error" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="btn-ghost" onclick={handleBack} disabled={saving}>
|
||||
<ArrowLeft size={16} weight="bold" />
|
||||
<span>Zurück</span>
|
||||
</button>
|
||||
<div class="actions-right">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-ghost"
|
||||
onclick={() => finish({ skip: true })}
|
||||
disabled={saving}
|
||||
>
|
||||
Überspringen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
onclick={() => finish()}
|
||||
disabled={saving || selectedCount === 0}
|
||||
aria-label="Onboarding abschließen"
|
||||
>
|
||||
{saving ? 'Speichere…' : 'Fertig'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.screen {
|
||||
width: 100%;
|
||||
max-width: 680px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.75rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.5rem 0;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.9375rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.tile {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.875rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
background: hsl(var(--color-surface, var(--color-background)));
|
||||
border-radius: 0.875rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease,
|
||||
transform 0.15s ease,
|
||||
background 0.15s ease;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.tile:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: hsl(var(--color-primary) / 0.4);
|
||||
}
|
||||
|
||||
.tile.selected {
|
||||
border-color: hsl(var(--color-primary));
|
||||
background: hsl(var(--color-primary) / 0.08);
|
||||
box-shadow: 0 0 0 2px hsl(var(--color-primary) / 0.25);
|
||||
}
|
||||
|
||||
.tile-icon {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 0.625rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: hsl(var(--color-muted) / 0.4);
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.tile.selected .tile-icon {
|
||||
background: hsl(var(--color-primary) / 0.15);
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.tile-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tile-name {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.tile-desc {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.tile-check {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground, 0 0% 100%));
|
||||
box-shadow: 0 1px 4px hsl(0 0% 0% / 0.2);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-error, 0 84% 60%));
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.actions-right {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.625rem 1rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.9375rem;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: hsl(var(--color-muted) / 0.4);
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.btn-ghost:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground, 0 0% 100%));
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
border-radius: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.15s ease,
|
||||
box-shadow 0.15s ease,
|
||||
opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px hsl(var(--color-primary) / 0.35);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -84,3 +84,11 @@ export {
|
|||
type SpaceModuleId,
|
||||
type SpaceMetadata,
|
||||
} from './spaces';
|
||||
|
||||
// Onboarding templates (see docs/plans/onboarding-flow.md)
|
||||
export {
|
||||
ONBOARDING_TEMPLATES,
|
||||
resolveModulesForTemplates,
|
||||
type OnboardingTemplate,
|
||||
type OnboardingTemplateId,
|
||||
} from './onboarding-templates';
|
||||
|
|
|
|||
100
packages/shared-branding/src/onboarding-templates.spec.ts
Normal file
100
packages/shared-branding/src/onboarding-templates.spec.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
ONBOARDING_TEMPLATES,
|
||||
resolveModulesForTemplates,
|
||||
type OnboardingTemplateId,
|
||||
} from './onboarding-templates';
|
||||
|
||||
describe('ONBOARDING_TEMPLATES', () => {
|
||||
it('has exactly 7 templates in the expected order', () => {
|
||||
expect(ONBOARDING_TEMPLATES.map((t) => t.id)).toEqual([
|
||||
'alltag',
|
||||
'arbeit',
|
||||
'health',
|
||||
'sport',
|
||||
'lernen',
|
||||
'entdecken',
|
||||
'erinnern',
|
||||
]);
|
||||
});
|
||||
|
||||
it('every template has a name, description, icon, and at least 3 modules', () => {
|
||||
for (const tpl of ONBOARDING_TEMPLATES) {
|
||||
expect(tpl.name.length).toBeGreaterThan(0);
|
||||
expect(tpl.shortDescription.length).toBeGreaterThan(0);
|
||||
expect(tpl.iconName.length).toBeGreaterThan(0);
|
||||
expect(tpl.moduleIds.length).toBeGreaterThanOrEqual(3);
|
||||
}
|
||||
});
|
||||
|
||||
it('no template references duplicate module ids within itself', () => {
|
||||
for (const tpl of ONBOARDING_TEMPLATES) {
|
||||
expect(new Set(tpl.moduleIds).size).toBe(tpl.moduleIds.length);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveModulesForTemplates', () => {
|
||||
it('returns empty for empty selection', () => {
|
||||
expect(resolveModulesForTemplates([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns a single templates modules verbatim (under cap)', () => {
|
||||
expect(resolveModulesForTemplates(['alltag'])).toEqual([
|
||||
'todo',
|
||||
'calendar',
|
||||
'notes',
|
||||
'contacts',
|
||||
]);
|
||||
});
|
||||
|
||||
it('dedupes across templates in priority order', () => {
|
||||
// Alltag first: todo/calendar/notes/contacts
|
||||
// Arbeit second: todo/calendar/mail/chat/times/notes — only mail/chat/times are new
|
||||
const result = resolveModulesForTemplates(['alltag', 'arbeit']);
|
||||
expect(result).toEqual(['todo', 'calendar', 'notes', 'contacts', 'mail', 'chat', 'times']);
|
||||
});
|
||||
|
||||
it('preserves selection order (arbeit-first vs alltag-first)', () => {
|
||||
const a = resolveModulesForTemplates(['arbeit', 'alltag']);
|
||||
const b = resolveModulesForTemplates(['alltag', 'arbeit']);
|
||||
// Different first module confirms priority is the selection order,
|
||||
// not a fixed template order.
|
||||
expect(a[0]).toBe('todo'); // both start with todo, but arbeit's "mail" outranks alltag's "contacts"
|
||||
expect(a.indexOf('mail')).toBeLessThan(a.indexOf('contacts'));
|
||||
expect(b.indexOf('contacts')).toBeLessThan(b.indexOf('mail'));
|
||||
});
|
||||
|
||||
it('honours the cap and drops overflow modules (default cap = 8)', () => {
|
||||
// All 7 templates picked — union pre-dedup is ~27 modules.
|
||||
const allIds: OnboardingTemplateId[] = [
|
||||
'alltag',
|
||||
'arbeit',
|
||||
'health',
|
||||
'sport',
|
||||
'lernen',
|
||||
'entdecken',
|
||||
'erinnern',
|
||||
];
|
||||
const result = resolveModulesForTemplates(allIds);
|
||||
expect(result.length).toBe(8);
|
||||
// Alltag's four modules must all make it — they're in the first template.
|
||||
expect(result.slice(0, 4)).toEqual(['todo', 'calendar', 'notes', 'contacts']);
|
||||
});
|
||||
|
||||
it('respects a custom cap', () => {
|
||||
const result = resolveModulesForTemplates(['alltag', 'health'], 5);
|
||||
expect(result.length).toBe(5);
|
||||
expect(result).toEqual(['todo', 'calendar', 'notes', 'contacts', 'habits']);
|
||||
});
|
||||
|
||||
it('silently ignores unknown template ids', () => {
|
||||
const result = resolveModulesForTemplates([
|
||||
'alltag',
|
||||
'nonexistent' as OnboardingTemplateId,
|
||||
'arbeit',
|
||||
]);
|
||||
// Same as 'alltag' + 'arbeit' without the unknown one.
|
||||
expect(result).toEqual(['todo', 'calendar', 'notes', 'contacts', 'mail', 'chat', 'times']);
|
||||
});
|
||||
});
|
||||
120
packages/shared-branding/src/onboarding-templates.ts
Normal file
120
packages/shared-branding/src/onboarding-templates.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
/**
|
||||
* Onboarding templates — starter-packs a user picks on Screen 3 of the
|
||||
* first-login flow (/onboarding/templates). Each template is a named
|
||||
* use-case with an ordered list of module IDs. The flow's finish
|
||||
* handler deduplicates the union of picked templates' modules (keeping
|
||||
* the first occurrence in priority order), caps the total at 8, and
|
||||
* writes the result as the user's Home scene.
|
||||
*
|
||||
* All module IDs are verified against
|
||||
* apps/mana/apps/web/src/lib/app-registry/apps.ts — if a template
|
||||
* references a module that was removed, catch it in the test below
|
||||
* before the user sees an empty tile.
|
||||
*
|
||||
* See docs/plans/onboarding-flow.md for rationale (why these templates,
|
||||
* why multi-select, why a cap).
|
||||
*/
|
||||
|
||||
export type OnboardingTemplateId =
|
||||
| 'alltag'
|
||||
| 'arbeit'
|
||||
| 'health'
|
||||
| 'sport'
|
||||
| 'lernen'
|
||||
| 'entdecken'
|
||||
| 'erinnern';
|
||||
|
||||
export type OnboardingTemplate = {
|
||||
id: OnboardingTemplateId;
|
||||
/** German name rendered on the tile. */
|
||||
name: string;
|
||||
/** One-line description under the tile name. */
|
||||
shortDescription: string;
|
||||
/** Phosphor icon name — resolved by the consuming page. */
|
||||
iconName: 'House' | 'Briefcase' | 'Heart' | 'Barbell' | 'GraduationCap' | 'Compass' | 'Camera';
|
||||
/**
|
||||
* Module IDs in priority order (first = most important). Dedup
|
||||
* across templates keeps the earliest occurrence; cap = 8.
|
||||
*/
|
||||
moduleIds: string[];
|
||||
};
|
||||
|
||||
export const ONBOARDING_TEMPLATES: readonly OnboardingTemplate[] = [
|
||||
{
|
||||
id: 'alltag',
|
||||
name: 'Alltag',
|
||||
shortDescription: 'Aufgaben, Termine, Notizen, Kontakte',
|
||||
iconName: 'House',
|
||||
moduleIds: ['todo', 'calendar', 'notes', 'contacts'],
|
||||
},
|
||||
{
|
||||
id: 'arbeit',
|
||||
name: 'Arbeit',
|
||||
shortDescription: 'Produktivität für den Job',
|
||||
iconName: 'Briefcase',
|
||||
moduleIds: ['todo', 'calendar', 'mail', 'chat', 'times', 'notes'],
|
||||
},
|
||||
{
|
||||
id: 'health',
|
||||
name: 'Health',
|
||||
shortDescription: 'Gesundheit, Stimmung, Ernährung, Zyklus',
|
||||
iconName: 'Heart',
|
||||
moduleIds: ['habits', 'body', 'mood', 'food', 'period'],
|
||||
},
|
||||
{
|
||||
id: 'sport',
|
||||
name: 'Sport',
|
||||
shortDescription: 'Training, Ziele, Körper, Ernährung',
|
||||
iconName: 'Barbell',
|
||||
moduleIds: ['habits', 'body', 'food', 'goals', 'stretch'],
|
||||
},
|
||||
{
|
||||
id: 'lernen',
|
||||
name: 'Lernen',
|
||||
shortDescription: 'Skills, Quizzes, Notizen, Kontext',
|
||||
iconName: 'GraduationCap',
|
||||
moduleIds: ['skilltree', 'quiz', 'notes', 'library', 'kontext'],
|
||||
},
|
||||
{
|
||||
id: 'entdecken',
|
||||
name: 'Entdecken',
|
||||
shortDescription: 'Orte, Fotos, Musik, Wetter',
|
||||
iconName: 'Compass',
|
||||
moduleIds: ['places', 'citycorners', 'photos', 'music', 'wetter'],
|
||||
},
|
||||
{
|
||||
id: 'erinnern',
|
||||
name: 'Erinnern',
|
||||
shortDescription: 'Memoro, Journal, Fotos, Zitate',
|
||||
iconName: 'Camera',
|
||||
moduleIds: ['memoro', 'journal', 'photos', 'moodlit', 'quotes'],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Pick modules for the user's Home scene based on a multi-selection of
|
||||
* templates. Iterates templates in the order provided, collecting their
|
||||
* modules while skipping dups and stopping at the cap.
|
||||
*
|
||||
* Exported so the onboarding page and its test can share the same
|
||||
* logic — the page only has to worry about UI state.
|
||||
*/
|
||||
export function resolveModulesForTemplates(
|
||||
selectedIds: readonly OnboardingTemplateId[],
|
||||
cap = 8
|
||||
): string[] {
|
||||
const byId = new Map(ONBOARDING_TEMPLATES.map((t) => [t.id, t]));
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const id of selectedIds) {
|
||||
const tpl = byId.get(id);
|
||||
if (!tpl) continue;
|
||||
for (const moduleId of tpl.moduleIds) {
|
||||
if (seen.has(moduleId)) continue;
|
||||
seen.add(moduleId);
|
||||
out.push(moduleId);
|
||||
if (out.length >= cap) return out;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue