refactor: update app.vue and various components to improve routing paths, enhance UI consistency, and streamline layout for better user experience

This commit is contained in:
Jennie Robinson Faber 2025-09-11 11:51:48 +01:00
parent b6e8d3b7ec
commit 78af43770c
29 changed files with 1699 additions and 1990 deletions

1910
pages/tools/budget.vue Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,442 @@
<template>
<div>
<!-- No WizardSubnav for co-op setup tool -->
<section class="py-8 max-w-4xl mx-auto font-mono">
<!-- Header -->
<div class="mb-10 text-center">
<h1
class="text-3xl font-black text-black dark:text-white mb-4 leading-tight uppercase tracking-wide border-t-2 border-b-2 border-black dark:border-white py-4">
Budget Builder
</h1>
</div>
<!-- Completed State -->
<div v-if="isCompleted" class="text-center py-12 relative">
<!-- Dithered shadow background -->
<div class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
<div
class="relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white p-8">
<div
class="w-16 h-16 bg-black dark:bg-white border-2 border-black dark:border-white flex items-center justify-center mx-auto mb-4">
<UIcon
name="i-heroicons-check"
class="w-8 h-8 text-white dark:text-black" />
</div>
<h2
class="text-2xl font-bold text-black dark:text-white mb-2 uppercase tracking-wide">
You're all set!
</h2>
<p class="text-neutral-600 dark:text-neutral-400 mb-6">
Your co-op is configured and ready to go.
</p>
<div class="flex justify-center gap-4">
<button
class="export-btn"
@click="restartWizard"
:disabled="isResetting">
Start Over
</button>
<button class="export-btn primary" @click="navigateTo('/tools/budget')">
Go to Dashboard
</button>
</div>
</div>
</div>
<!-- Vertical Steps Layout -->
<div v-else class="space-y-4">
<!-- Step 1: Pay Policy -->
<div class="relative">
<!-- Dithered shadow for selected state -->
<div
v-if="focusedStep === 1"
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
<div
:class="[
'relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-neutral-500 overflow-hidden',
focusedStep === 1 ? 'item-selected' : '',
]">
<div
class="p-8 cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
@click="setFocusedStep(1)">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2"
:class="
policiesValid
? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-white'
: 'bg-white dark:bg-neutral-950 text-black dark:text-white border-black dark:border-white'
">
<UIcon
v-if="policiesValid"
name="i-heroicons-check"
class="w-4 h-4" />
<span v-else>1</span>
</div>
<div>
<h3
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide">
Choose pay approach
</h3>
</div>
</div>
<UIcon
name="i-heroicons-chevron-down"
class="w-6 h-6 text-black dark:text-white transition-transform font-bold"
:class="{ 'rotate-180': focusedStep === 1 }" />
</div>
</div>
<div
v-if="focusedStep === 1"
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-neutral-500">
<WizardPoliciesStep @save-status="handleSaveStatus" />
</div>
</div>
</div>
<!-- Step 2: Members -->
<div class="relative">
<!-- Dithered shadow for selected state -->
<div
v-if="focusedStep === 2"
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
<div
:class="[
'relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-neutral-500 overflow-hidden',
focusedStep === 2 ? 'item-selected' : '',
]">
<div
class="p-8 cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
@click="setFocusedStep(2)">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2"
:class="
membersValid
? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-neutral-500'
: 'bg-white dark:bg-neutral-950 text-black dark:text-white border-black dark:border-neutral-500'
">
<UIcon
v-if="membersValid"
name="i-heroicons-check"
class="w-4 h-4" />
<span v-else>2</span>
</div>
<div>
<h3
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide">
Add your team
</h3>
</div>
</div>
<UIcon
name="i-heroicons-chevron-down"
class="w-6 h-6 text-black dark:text-white transition-transform font-bold"
:class="{ 'rotate-180': focusedStep === 2 }" />
</div>
</div>
<div
v-if="focusedStep === 2"
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-neutral-500">
<WizardMembersStep @save-status="handleSaveStatus" />
</div>
</div>
</div>
<!-- Step 3: Costs -->
<div class="relative">
<!-- Dithered shadow for selected state -->
<div
v-if="focusedStep === 3"
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
<div
:class="[
'relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-neutral-500 overflow-hidden',
focusedStep === 3 ? 'item-selected' : '',
]">
<div
class="p-8 cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
@click="setFocusedStep(3)">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2"
:class="
costsValid
? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-neutral-500'
: 'bg-white dark:bg-neutral-950 text-black dark:text-white border-black dark:border-neutral-500'
">
<UIcon
v-if="costsValid"
name="i-heroicons-check"
class="w-4 h-4" />
<span v-else>3</span>
</div>
<div>
<h3
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide">
Expenses
</h3>
</div>
</div>
<UIcon
name="i-heroicons-chevron-down"
class="w-6 h-6 text-black dark:text-white transition-transform font-bold"
:class="{ 'rotate-180': focusedStep === 3 }" />
</div>
</div>
<div
v-if="focusedStep === 3"
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-neutral-500">
<WizardCostsStep @save-status="handleSaveStatus" />
</div>
</div>
</div>
<!-- Step 4: Revenue -->
<div class="relative">
<!-- Dithered shadow for selected state -->
<div
v-if="focusedStep === 4"
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
<div
:class="[
'relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-neutral-500 overflow-hidden',
focusedStep === 4 ? 'item-selected' : '',
]">
<div
class="p-8 cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
@click="setFocusedStep(4)">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2"
:class="
streamsValid
? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-neutral-500'
: 'bg-white dark:bg-neutral-950 text-black dark:text-white border-black dark:border-neutral-500'
">
<UIcon
v-if="streamsValid"
name="i-heroicons-check"
class="w-4 h-4" />
<span v-else>4</span>
</div>
<div>
<h3
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide">
Revenue streams
</h3>
</div>
</div>
<UIcon
name="i-heroicons-chevron-down"
class="w-6 h-6 text-black dark:text-white transition-transform font-bold"
:class="{ 'rotate-180': focusedStep === 4 }" />
</div>
</div>
<div
v-if="focusedStep === 4"
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-neutral-500">
<WizardRevenueStep @save-status="handleSaveStatus" />
</div>
</div>
</div>
<!-- Progress Actions -->
<div class="flex justify-between items-center pt-8">
<button
class="export-btn"
@click="resetWizard"
:disabled="isResetting">
Clear Data
</button>
<div class="flex items-center gap-4">
<!-- Save status -->
<div
class="flex items-center gap-2 text-sm font-mono uppercase tracking-wide">
<UIcon
v-if="saveStatus === 'saving'"
name="i-heroicons-arrow-path"
class="w-4 h-4 animate-spin text-neutral-500 dark:text-neutral-400" />
<UIcon
v-if="saveStatus === 'saved'"
name="i-heroicons-check-circle"
class="w-4 h-4 text-black dark:text-white" />
<span
v-if="saveStatus === 'saving'"
class="text-neutral-500 dark:text-neutral-400"
>Saving...</span
>
<span
v-if="saveStatus === 'saved'"
class="text-black dark:text-white"
>Saved</span
>
</div>
<UTooltip :text="incompleteSectionsText" :prevent="canComplete">
<button
class="export-btn primary"
:class="{ 'opacity-50 cursor-not-allowed': !canComplete }"
:disabled="!canComplete"
@click="canComplete ? completeWizard() : null">
Complete Setup
</button>
</UTooltip>
</div>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
// Store
const coop = useCoopBuilder();
// UI state
const focusedStep = ref(1);
const saveStatus = ref("");
const isResetting = ref(false);
const isCompleted = ref(false);
// Local validity flags for step headers
const membersValid = computed(() => {
// Valid if at least one member with a name and positive hours
return coop.members.value.some((m: any) => {
const hasName = typeof m.name === "string" && m.name.trim().length > 0;
const hours = Number((m as any).hoursPerMonth ?? 0);
return hasName && Number.isFinite(hours) && hours > 0;
});
});
const policiesValid = computed(() => {
// Placeholder policy validity; mark true when wage text or policy set exists
// Since policy not persisted yet in this store, consider valid when any member exists
return coop.members.value.length > 0;
});
const costsValid = computed(() => {
// Costs are optional, so always mark as valid for now
return true;
});
const streamsValid = computed(() => {
// Valid if all streams have name, category, and non-negative monthly
return (
coop.streams.value.length > 0 &&
coop.streams.value.every((s: any) => {
const monthly = Number((s as any).monthly ?? 0);
return (s.label || "").toString().trim().length > 0 && monthly >= 0;
})
);
});
// Check if we have basic data for scenario exploration
const hasBasicData = computed(() => {
return membersValid.value && (costsValid.value || streamsValid.value);
});
// Computed validation - all 4 steps must be valid
const canComplete = computed(() => {
return (
policiesValid.value &&
membersValid.value &&
costsValid.value &&
streamsValid.value
);
});
// Generate tooltip text for incomplete sections
const incompleteSectionsText = computed(() => {
if (canComplete.value) return "";
const incomplete = [];
if (!policiesValid.value) incomplete.push("Choose pay approach");
if (!membersValid.value) incomplete.push("Add team members");
if (!costsValid.value) incomplete.push("Add monthly costs");
if (!streamsValid.value) incomplete.push("Add revenue streams");
return `Complete these sections: ${incomplete.join(", ")}`;
});
// Save status handler
function handleSaveStatus(status: "saving" | "saved" | "error") {
saveStatus.value = status;
if (status === "saved") {
// Clear status after delay
setTimeout(() => {
if (saveStatus.value === "saved") {
saveStatus.value = "";
}
}, 2000);
}
}
// Step management
function setFocusedStep(step: number) {
console.log("Setting focused step to:", step, "Current:", focusedStep.value);
// Toggle if clicking on already focused step
if (focusedStep.value === step) {
focusedStep.value = 0; // Close the section
} else {
focusedStep.value = step; // Open the section
}
console.log("Focused step is now:", focusedStep.value);
}
function completeWizard() {
// Mark setup as complete and show restart button for testing
isCompleted.value = true;
}
async function resetWizard() {
isResetting.value = true;
// Reset centralized store
coop.reset();
saveStatus.value = "";
// Small delay for UX
await new Promise((resolve) => setTimeout(resolve, 300));
isResetting.value = false;
}
async function restartWizard() {
isResetting.value = true;
// Reset completion state
isCompleted.value = false;
focusedStep.value = 1;
// Reset centralized store
coop.reset();
saveStatus.value = "";
// Small delay for UX
await new Promise((resolve) => setTimeout(resolve, 300));
isResetting.value = false;
}
// SEO
useSeoMeta({
title: "Budget Builder",
description:
"Build your co-op's financial foundation: set up members, policies, costs, and revenue streams.",
});
</script>

View file

@ -0,0 +1,8 @@
<template>
<CoopBuilderPage />
</template>
<script setup lang="ts">
// Reuse the existing coop builder content by importing it as a component
import CoopBuilderPage from "~/pages/coop-builder.vue";
</script>

View file

@ -0,0 +1,87 @@
<template>
<div class="space-y-8">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold">Dashboard</h1>
<div class="text-sm text-neutral-600">
Mode: {{ currentMode }}
</div>
</div>
<!-- Simple Core Metrics -->
<UCard>
<template #header>
<h3 class="text-lg font-medium">Core Metrics</h3>
</template>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="text-center">
<div class="text-3xl font-bold text-green-600">{{ runwayDisplay }}</div>
<div class="text-sm text-neutral-600">Runway</div>
</div>
<div class="text-center">
<div class="text-3xl font-bold text-blue-600">{{ coverageDisplay }}</div>
<div class="text-sm text-neutral-600">Coverage</div>
</div>
<div class="text-center">
<div class="text-3xl font-bold text-purple-600">{{ streamCount }}</div>
<div class="text-sm text-neutral-600">Revenue Streams</div>
</div>
</div>
</UCard>
<!-- Simple Member List -->
<UCard>
<template #header>
<h3 class="text-lg font-medium">Members ({{ memberCount }})</h3>
</template>
<div class="space-y-2">
<div v-for="(member, index) in membersList" :key="index" class="flex items-center justify-between p-2 border border-neutral-200 rounded">
<span class="font-medium">{{ member.name }}</span>
<span class="text-sm text-neutral-600">{{ member.relationship }}</span>
</div>
<div v-if="memberCount === 0" class="text-sm text-neutral-500 italic p-4">
No members configured yet.
</div>
</div>
</UCard>
</div>
</template>
<script setup lang="ts">
// Simple reactive data without complex computations
const currentMode = ref('minimum')
const runwayDisplay = ref('∞')
const coverageDisplay = ref('100%')
const streamCount = ref(0)
const memberCount = ref(0)
const membersList = ref([])
// Try to initialize with store data
onMounted(async () => {
try {
// Simple store access without composable
const membersStore = useMembersStore()
const streamsStore = useStreamsStore()
const policiesStore = usePoliciesStore()
// Update reactive values
currentMode.value = 'target' // Simplified - always use target mode
memberCount.value = membersStore.members?.length || 0
streamCount.value = streamsStore.streams?.length || 0
// Simple member list
membersList.value = membersStore.members?.map(m => ({
name: m.displayName || 'Unknown',
relationship: m.payRelationship || 'Unknown'
})) || []
console.log('Dashboard initialized:', {
mode: currentMode.value,
members: memberCount.value,
streams: streamCount.value
})
} catch (error) {
console.error('Error initializing simple dashboard:', error)
}
})
</script>

381
pages/tools/index.vue Normal file
View file

@ -0,0 +1,381 @@
<template>
<section class="py-8 space-y-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-semibold">Compensation</h2>
<div class="flex items-center gap-2 mt-1">
<span class="text-xs font-mono">
Runway: {{ Math.round(metrics.runway) }}mo
</span>
</div>
</div>
<div class="flex gap-2">
<button
@click="onExport"
class="px-4 py-2 border-2 border-black bg-white font-bold uppercase text-sm hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] transition-shadow">
Export JSON
</button>
<button
@click="onImport"
class="px-4 py-2 border-2 border-black bg-white font-bold uppercase text-sm hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] transition-shadow">
Import JSON
</button>
</div>
</div>
<!-- Key Metrics Row -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<CoverageMeter
:funded-paid-hours="Math.round(metrics.totalTargetHours * 0.65)"
:target-hours="metrics.totalTargetHours"
description="Funded hours vs target capacity across all members." />
<ReserveMeter
:current-savings="savingsProgress.current"
:savings-target-months="savingsProgress.targetMonths"
:monthly-burn="getMonthlyBurn()"
:description="`${savingsProgress.progressPct.toFixed(0)}% of savings target reached. ${savingsProgress.gap > 0 ? 'Gap: ' + $format.currency(savingsProgress.gap) : 'Target achieved!'}`" />
</div>
<!-- Dashboard Components with Wizard Styling -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Needs Coverage Bars -->
<div class="border-2 border-black bg-white shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]">
<div class="border-b-2 border-black p-4">
<h3 class="text-lg font-bold uppercase">Member Coverage</h3>
</div>
<div class="p-4">
<NeedsCoverageBars />
</div>
</div>
<!-- Milestone-Runway Overlay -->
<div class="border-2 border-black bg-white shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]">
<div class="border-b-2 border-black p-4">
<h3 class="text-lg font-bold uppercase">Runway vs Milestones</h3>
</div>
<div class="p-4">
<MilestoneRunwayOverlay />
</div>
</div>
</div>
<!-- Alerts Section with Wizard Styling -->
<div class="border-2 border-black bg-white shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]">
<div class="border-b-2 border-black p-4">
<h3 class="text-lg font-bold uppercase">Alerts</h3>
</div>
<div class="p-4 space-y-4">
<!-- Concentration Risk Alert -->
<div
v-if="topSourcePct > 50"
class="border-2 border-red-600 bg-red-50 p-4">
<div class="flex items-start gap-3">
<span class="text-red-600 font-bold text-xl">!</span>
<div class="flex-1">
<h4 class="font-bold uppercase mb-1">Revenue Concentration Risk</h4>
<p class="text-sm mb-2">{{ topStreamName }} = {{ topSourcePct }}% of total consider balancing</p>
<button
@click="handleAlertNavigation('/tools/', 'concentration')"
class="text-sm underline font-bold">
VIEW DETAILS
</button>
</div>
</div>
</div>
<!-- Cushion Breach Alert -->
<div
v-if="alerts.cushionBreach"
class="border-2 border-orange-600 bg-orange-50 p-4">
<div class="flex items-start gap-3">
<span class="text-orange-600 font-bold text-xl">!</span>
<div class="flex-1">
<h4 class="font-bold uppercase mb-1">Cash Cushion Breach Forecast</h4>
<p class="text-sm mb-2">Projected to breach minimum cushion in week {{ cushionForecast.firstBreachWeek || 'unknown' }}</p>
<div class="flex gap-4">
<button
@click="handleAlertNavigation('/tools/cash', 'breach-forecast')"
class="text-sm underline font-bold">
VIEW CALENDAR
</button>
<button
@click="handleAlertNavigation('/tools/budget', 'expenses')"
class="text-sm underline font-bold">
ADJUST BUDGET
</button>
</div>
</div>
</div>
</div>
<!-- Savings Below Target Alert -->
<div
v-if="alerts.savingsBelowTarget"
class="border-2 border-yellow-600 bg-yellow-50 p-4">
<div class="flex items-start gap-3">
<span class="text-yellow-600 font-bold text-xl">!</span>
<div class="flex-1">
<h4 class="font-bold uppercase mb-1">Savings Below Target</h4>
<p class="text-sm mb-2">{{ savingsProgress.progressPct.toFixed(0) }}% of target reached. Build savings before increasing paid hours.</p>
<div class="flex gap-4">
<button
@click="handleAlertNavigation('/tools/budget', 'savings')"
class="text-sm underline font-bold">
VIEW PROGRESS
</button>
<button
@click="handleAlertNavigation('/tools/coop-builder', 'policies')"
class="text-sm underline font-bold">
ADJUST POLICIES
</button>
</div>
</div>
</div>
</div>
<!-- Over-Deferred Member Alert -->
<div
v-if="deferredAlert.show"
class="border-2 border-purple-600 bg-purple-50 p-4">
<div class="flex items-start gap-3">
<span class="text-purple-600 font-bold text-xl">!</span>
<div class="flex-1">
<h4 class="font-bold uppercase mb-1">Member Over-Deferred</h4>
<p class="text-sm mb-2">{{ deferredAlert.description }}</p>
<button
@click="handleAlertNavigation('/tools/coop-builder', 'members')"
class="text-sm underline font-bold">
REVIEW MEMBERS
</button>
</div>
</div>
</div>
<!-- Success message when no alerts -->
<div v-if="!alerts.cushionBreach && !alerts.savingsBelowTarget && topSourcePct <= 50 && !deferredAlert.show"
class="text-center py-8">
<span class="text-4xl font-bold"></span>
<p class="font-bold uppercase mt-2">All systems looking good!</p>
<p class="text-sm mt-1">No critical alerts at this time.</p>
</div>
</div>
</div>
<!-- Quick Actions with Wizard Styling -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<button
@click="navigateTo('/tools/cash-flow')"
class="border-2 border-black bg-white p-4 text-left hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] transition-shadow">
<div class="font-bold uppercase mb-1">Cash Flow Analysis</div>
<div class="text-sm">Detailed runway & one-time events</div>
</button>
<button
@click="navigateTo('/tools/budget')"
class="border-2 border-black bg-white p-4 text-left hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] transition-shadow">
<div class="font-bold uppercase mb-1">Budget Planning</div>
<div class="text-sm">Manage expenses & savings</div>
</button>
</div>
</section>
</template>
<script setup lang="ts">
// Dashboard page
const { $format } = useNuxtApp();
// Use real store data instead of fixtures
const membersStore = useMembersStore();
const policiesStore = usePoliciesStore();
const streamsStore = useStreamsStore();
const budgetStore = useBudgetStore();
const cashStore = useCashStore();
// Runway composable with operating mode integration
const { getDualModeRunway, getMonthlyBurn } = useRunway();
// Cushion forecast and savings progress
const { savingsProgress, cushionForecast, alerts } = useCushionForecast();
// Calculate metrics from real store data
const metrics = computed(() => {
const totalTargetHours = membersStore.members.reduce(
(sum, member) => sum + (member.capacity?.targetHours || 0),
0
);
const totalTargetRevenue = streamsStore.streams.reduce(
(sum, stream) => sum + (stream.targetMonthlyAmount || 0),
0
);
const totalOverheadCosts = budgetStore.overheadCosts.reduce(
(sum, cost) => sum + (cost.amount || 0),
0
);
// Use integrated runway calculations that respect operating mode
const currentMode = 'target'; // Always target mode now
const monthlyBurn = getMonthlyBurn(currentMode);
// Use actual cash store values with fallback
const cash = cashStore.currentCash || 50000;
const savings = cashStore.currentSavings || 15000;
const totalLiquid = cash + savings;
// Get dual-mode runway data
const runwayData = getDualModeRunway(cash, savings);
const runway = currentMode === 'target' ? runwayData.target : runwayData.minimum;
return {
totalTargetHours,
totalTargetRevenue,
monthlyPayroll: runwayData.minBurn, // Use actual calculated payroll
monthlyBurn,
runway,
runwayData, // Include dual-mode data
finances: {
currentBalances: {
cash: cashStore.currentCash,
savings: cashStore.currentSavings,
totalLiquid,
},
policies: {
equalHourlyWage: policiesStore.equalHourlyWage,
payrollOncostPct: policiesStore.payrollOncostPct,
savingsTargetMonths: policiesStore.savingsTargetMonths,
minCashCushionAmount: policiesStore.minCashCushionAmount,
},
deferredLiabilities: {
totalDeferred: membersStore.members.reduce(
(sum, m) =>
sum + (m.deferredHours || 0) * policiesStore.equalHourlyWage,
0
),
},
surplus: Math.max(0, totalTargetRevenue - monthlyBurn),
savingsGap: Math.max(
0,
policiesStore.savingsTargetMonths * monthlyBurn -
cashStore.currentSavings
),
},
};
});
// Calculate concentration metrics
const topSourcePct = computed(() => {
if (streamsStore.streams.length === 0) return 0;
const amounts = streamsStore.streams.map((s) => s.targetMonthlyAmount || 0);
const total = amounts.reduce((sum, amt) => sum + amt, 0);
return total > 0 ? Math.round((Math.max(...amounts) / total) * 100) : 0;
});
const topStreamName = computed(() => {
if (streamsStore.streams.length === 0) return 'No streams';
const amounts = streamsStore.streams.map((s) => s.targetMonthlyAmount || 0);
const maxAmount = Math.max(...amounts);
const topStream = streamsStore.streams.find(s => (s.targetMonthlyAmount || 0) === maxAmount);
return topStream?.name || 'Unknown stream';
});
const concentrationStatus = computed(() => {
if (topSourcePct.value > 50) return "red";
if (topSourcePct.value > 35) return "yellow";
return "green";
});
const concentrationColor = computed(() => {
if (topSourcePct.value > 50) return "text-red-600";
if (topSourcePct.value > 35) return "text-yellow-600";
return "text-green-600";
});
function getRunwayColor(months: number): string {
if (months >= 6) return 'text-green-600'
if (months >= 3) return 'text-yellow-600'
return 'text-red-600'
}
// Cash breach description
const cashBreachDescription = computed(() => {
// Check cash store for first breach week from projections
const breachWeek = cashStore.weeklyProjections.find(
(week) => week.breachesCushion
);
if (breachWeek) {
return `Week ${breachWeek.number} would drop below your minimum cushion.`;
}
return "No cushion breach currently projected.";
});
const onExport = () => {
const data = exportAll();
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "urgent-tools.json";
a.click();
URL.revokeObjectURL(url);
};
const onImport = async () => {
const input = document.createElement("input");
input.type = "file";
input.accept = "application/json";
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
const text = await file.text();
importAll(JSON.parse(text));
};
input.click();
};
const { exportAll, importAll } = useFixtureIO();
// Deferred alert logic
const deferredAlert = computed(() => {
const maxDeferredRatio = 1.5; // From CLAUDE.md - flag if >1.5× monthly payroll
const monthlyPayrollCost = getMonthlyBurn() * 0.7; // Estimate payroll as 70% of burn
const totalDeferred = membersStore.members.reduce(
(sum, m) => sum + (m.deferredHours || 0) * policiesStore.equalHourlyWage,
0
);
const deferredRatio = monthlyPayrollCost > 0 ? totalDeferred / monthlyPayrollCost : 0;
const show = deferredRatio > maxDeferredRatio;
const overDeferredMembers = membersStore.members.filter(m => {
const memberDeferred = (m.deferredHours || 0) * policiesStore.equalHourlyWage;
const memberMonthlyPay = m.monthlyPayPlanned || 0;
return memberDeferred > memberMonthlyPay * 2; // Member has >2 months of pay deferred
});
return {
show,
description: show
? `${overDeferredMembers.length} member(s) over deferred cap. Total: ${(deferredRatio * 100).toFixed(0)}% of monthly payroll.`
: ''
};
});
// Alert navigation with context
function handleAlertNavigation(path: string, section?: string) {
// Store alert context for target page to highlight relevant section
if (section) {
localStorage.setItem('urgent-tools-alert-context', JSON.stringify({ section, timestamp: Date.now() }));
}
navigateTo(path);
};
</script>

View file

@ -0,0 +1,130 @@
<template>
<div class="space-y-8">
<div class="">
<h1 class="font-bold text-2xl mb-4">Project Budget Estimate</h1>
<p class="text-neutral-600 dark:text-neutral-400 mx-auto mb-4">
This tool provides a rough estimate of what it would cost to build your
project using the pay policy you've set in the setup.
</p>
<div class="space-y-4">
<!-- Sustainable payroll toggle hidden - defaulting to theoretical maximum -->
<div class="hidden">
<span class="text-sm font-medium">Sustainable Payroll</span>
<USwitch v-model="useTheoreticalPayroll" size="md" />
<span class="text-sm font-medium">Theoretical Maximum</span>
</div>
</div>
</div>
<div v-if="membersWithPay.length === 0" class="text-center py-8">
<p class="text-neutral-600 dark:text-neutral-400 mb-4">
No team members set up yet.
</p>
<NuxtLink
to="/tools/coop-builder"
class="px-4 py-2 border-2 border-black dark:border-white bg-white dark:bg-black text-black dark:text-white font-bold hover:bg-neutral-100 dark:hover:bg-neutral-900">
Set up your team in Setup Wizard
</NuxtLink>
</div>
<ProjectBudgetEstimate
v-else
:members="membersWithPay"
:oncost-rate="coopStore.payrollOncostPct / 100"
:payroll-mode="useTheoreticalPayroll ? 'theoretical' : 'sustainable'" />
</div>
</template>
<script setup lang="ts">
import { allocatePayroll as allocatePayrollImpl } from "~/types/members";
const coopStore = useCoopBuilderStore();
const budgetStore = useBudgetStore();
// Toggle between sustainable and theoretical payroll modes - defaulting to theoretical maximum
const useTheoreticalPayroll = ref(true);
// Calculate member pay using different logic based on payroll mode
const membersWithPay = computed(() => {
// Use the member's desired hours (targetHours if available, otherwise hoursPerMonth)
const getHoursForMember = (member: any) => {
return member.capacity?.targetHours || member.hoursPerMonth || 0;
};
let allocatedMembers;
if (useTheoreticalPayroll.value) {
// Theoretical mode: Calculate true theoretical maximum without revenue constraints
const allMembers = coopStore.members.map((m: any) => ({
...m,
displayName: m.name,
monthlyPayPlanned: m.monthlyPayPlanned || 0,
minMonthlyNeeds: m.minMonthlyNeeds || 0,
hoursPerMonth: m.hoursPerMonth || 0,
}));
const payPolicy = {
relationship: coopStore.policy.relationship || ("equal-pay" as const),
};
// Calculate theoretical maximum budget: total hours × hourly wage
const totalHours = allMembers.reduce(
(sum, m) => sum + (m.hoursPerMonth || 0),
0
);
const hourlyWage = coopStore.equalHourlyWage || 0;
const theoreticalMaxBudget = totalHours * hourlyWage;
allocatedMembers = allocatePayrollImpl(
allMembers,
payPolicy,
theoreticalMaxBudget
);
} else {
// Sustainable mode: Use revenue-constrained allocation (current behavior)
const { allocatePayroll } = useCoopBuilder();
const sustainableMembers = allocatePayroll();
const today = new Date();
const currentMonthKey = `${today.getFullYear()}-${String(
today.getMonth() + 1
).padStart(2, "0")}`;
const payrollExpense = budgetStore.budgetWorksheet.expenses.find(
(item) =>
item.id === "expense-payroll-base" || item.id === "expense-payroll"
);
const actualPayrollBudget =
payrollExpense?.monthlyValues?.[currentMonthKey] || 0;
const theoreticalTotal = sustainableMembers.reduce(
(sum, m) => sum + (m.monthlyPayPlanned || 0),
0
);
const scaleFactor =
theoreticalTotal > 0 ? actualPayrollBudget / theoreticalTotal : 0;
allocatedMembers = sustainableMembers.map((member) => ({
...member,
monthlyPayPlanned: (member.monthlyPayPlanned || 0) * scaleFactor,
}));
}
return allocatedMembers
.map((member: any) => {
const hours = getHoursForMember(member);
return {
name: member.displayName || "Unnamed",
hoursPerMonth: hours,
monthlyPay: member.monthlyPayPlanned || 0,
};
})
.filter((m: any) => m.hoursPerMonth > 0); // Only include members with hours
});
// Set page meta
definePageMeta({
title: "Project Budget Estimate",
});
</script>

165
pages/tools/resources.vue Normal file
View file

@ -0,0 +1,165 @@
<template>
<div>
<div
class="template-container min-h-screen bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100 py-8">
<div class="max-w-6xl mx-auto px-4 relative">
<div class="mb-8">
<h1 class="text-3xl font-bold text-neutral-900 dark:text-white mb-2">
More Resources & Templates
</h1>
<p class="text-neutral-700 dark:text-neutral-200">
Additional tools, templates, and resources to support your
cooperative journey.
</p>
</div>
<div class="space-y-8">
<!-- External Templates Section -->
<section>
<h2
class="text-2xl font-semibold text-neutral-900 dark:text-white mb-4">
External Templates
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Miro Template -->
<div class="template-card">
<div
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
<div
class="relative bg-white dark:bg-neutral-950 border border-black dark:border-white p-6">
<h3
class="text-xl font-semibold text-neutral-900 dark:text-white mb-2">
Goals & Values Exercise
</h3>
<p class="text-neutral-700 dark:text-neutral-200 mb-4">
A Miro template to help your team align on shared goals and
values through collaborative exercises. Make sure to do this
WITH your full team!
</p>
<a
href="https://miro.com/miroverse/goals-values-exercise/"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center px-4 py-2 bg-black dark:bg-white text-white dark:text-black font-medium hover:opacity-90 transition-opacity">
Open in Miro
<svg
class="w-4 h-4 ml-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</div>
</div>
<!-- CommunityRule Templates -->
<div class="template-card">
<div
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
<div
class="relative bg-white dark:bg-neutral-950 border border-black dark:border-white p-6">
<h3
class="text-xl font-semibold text-neutral-900 dark:text-white mb-2">
CommunityRule Governance Templates
</h3>
<p class="text-neutral-700 dark:text-neutral-200 mb-4">
A collection of governance templates and patterns for
democratic organizations and communities.
</p>
<a
href="https://communityrule.info/"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center px-4 py-2 bg-black dark:bg-white text-white dark:text-black font-medium hover:opacity-90 transition-opacity">
Browse Templates
<svg
class="w-4 h-4 ml-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002 2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</div>
</div>
</div>
</section>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
// PDF downloads list with placeholder data
const pdfDownloads = [
{
id: "bylaws",
name: "Bylaws Wizard",
description: "Create comprehensive bylaws for your cooperative",
available: false,
url: "#",
},
{
id: "operating-agreement",
name: "Operating Agreement Wizard",
description: "Draft an operating agreement for your LLC cooperative",
available: false,
url: "#",
},
{
id: "articles",
name: "Articles of Incorporation",
description: "Template for articles of incorporation",
available: false,
url: "#",
},
{
id: "membership",
name: "Membership Agreement",
description: "Define membership terms and conditions",
available: false,
url: "#",
},
{
id: "patronage",
name: "Patronage Policy",
description: "Structure your patronage distribution system",
available: false,
url: "#",
},
{
id: "conflict",
name: "Conflict Resolution Process",
description: "Establish clear conflict resolution procedures",
available: false,
url: "#",
},
];
</script>
<style scoped>
.template-card {
position: relative;
}
.dither-tag {
background-image: repeating-linear-gradient(
135deg,
transparent,
transparent 1px,
currentColor 1px,
currentColor 2px
);
background-size: 4px 4px;
}
</style>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,279 @@
<template>
<div
class="template-container min-h-screen bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100 py-8"
style="font-family: 'Ubuntu', 'Ubuntu Mono', monospace">
<div class="max-w-6xl mx-auto px-4 relative">
<div class="mb-8">
<h1
class="text-3xl font-bold text-neutral-900 dark:text-white mb-2"
style="font-family: 'Ubuntu', monospace">
Document Templates
</h1>
<p class="text-neutral-700 dark:text-neutral-200">
Fillable forms for cooperative documents. Data saves locally in your
browser.
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-for="template in templates"
:key="template.id"
class="template-card h-full flex flex-col">
<!-- Dithered shadow background -->
<div class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
<!-- Main content -->
<div
class="relative bg-white dark:bg-neutral-950 border border-black dark:border-white p-6 h-full flex flex-col">
<div class="mb-4">
<h3
class="text-xl font-semibold text-neutral-900 dark:text-white">
{{ template.name }}
</h3>
</div>
<p class="text-neutral-700 dark:text-neutral-200 mb-4">
{{ template.description }}
</p>
<div class="flex flex-wrap gap-2 mb-4">
<span
v-for="tag in template.tags"
:key="tag"
class="px-2 py-1 text-xs font-medium bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-900 border border-black dark:border-white dither-tag">
{{ tag }}
</span>
</div>
<div class="text-sm text-neutral-700 dark:text-neutral-200 mb-4">
<div class="flex items-center gap-4">
<span>{{ template.estimatedTime }}</span>
<span>{{ template.fields }} fields</span>
</div>
</div>
<!-- Spacer to push buttons to bottom -->
<div class="flex-1"></div>
<div class="flex gap-2 mt-auto">
<NuxtLink
:to="template.path"
class="flex-1 px-4 py-2 bg-black dark:bg-white text-white dark:text-black border border-black dark:border-white hover:bg-black dark:hover:bg-white transition-colors text-center font-medium bitmap-button"
style="font-family: 'Ubuntu Mono', monospace">
START TEMPLATE
</NuxtLink>
<NuxtLink
v-if="hasData(template.id)"
:to="template.path"
class="px-4 py-2 bg-white dark:bg-neutral-950 text-black dark:text-white border border-black dark:border-white hover:bg-white dark:hover:bg-neutral-950 transition-colors bitmap-button"
title="Continue from saved data"
style="font-family: 'Ubuntu Mono', monospace">
RESUME
</NuxtLink>
</div>
</div>
</div>
</div>
<!-- Help Section -->
<div class="mt-12 help-section">
<!-- Dithered shadow background -->
<div class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
<!-- Main content -->
<div
class="relative bg-white dark:bg-neutral-950 border border-black dark:border-white p-6">
<h2
class="text-xl font-semibold text-neutral-900 dark:text-white mb-3"
style="font-family: 'Ubuntu', monospace">
How Templates Work
</h2>
<div
class="grid md:grid-cols-2 gap-6 text-neutral-900 dark:text-neutral-100">
<div>
<h3
class="font-medium mb-2 text-neutral-900 dark:text-white"
style="font-family: 'Ubuntu Mono', monospace">
FILL OUT FORMS
</h3>
<p class="text-sm text-neutral-700 dark:text-neutral-200">
Templates include form fields for all necessary information.
Data auto-saves as you type.
</p>
</div>
<div>
<h3
class="font-medium mb-2 text-neutral-900 dark:text-white"
style="font-family: 'Ubuntu Mono', monospace">
LOCAL STORAGE
</h3>
<p class="text-sm text-neutral-700 dark:text-neutral-200">
All data saves in your browser only. Nothing is sent to external
servers.
</p>
</div>
<div>
<h3
class="font-medium mb-2 text-neutral-900 dark:text-white"
style="font-family: 'Ubuntu Mono', monospace">
EXPORT OPTIONS
</h3>
<p class="text-sm text-neutral-700 dark:text-neutral-200">
Download as PDF (print), plain text, Markdown, or Word document.
</p>
</div>
<div>
<h3
class="font-medium mb-2 text-neutral-900 dark:text-white"
style="font-family: 'Ubuntu Mono', monospace">
RESUME ANYTIME
</h3>
<p class="text-sm text-neutral-700 dark:text-neutral-200">
Come back later and your progress will be saved. Clear browser
data to start fresh.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from "vue";
const templates = [
{
id: "membership-agreement",
name: "Membership Agreement",
description:
"A comprehensive agreement outlining member rights, responsibilities, decision-making processes, and financial arrangements for worker cooperatives.",
icon: "i-heroicons-user-group",
path: "/templates/membership-agreement",
tags: ["Legal", "Governance", "Membership"],
estimatedTime: "15-30 min",
fields: 25,
storageKey: "membership-agreement-data",
},
{
id: "conflict-resolution-framework",
name: "Conflict Resolution Framework",
description:
"A customizable framework for handling conflicts with restorative justice principles, clear processes, and organizational values alignment.",
icon: "i-heroicons-scale",
path: "/templates/conflict-resolution-framework",
tags: ["Governance", "Process", "Care"],
estimatedTime: "20-40 min",
fields: 35,
storageKey: "conflict-resolution-framework-data",
},
{
id: "tech-charter",
name: "Technology Charter",
description:
"Build technology decisions on cooperative values. Define principles, technical constraints, and evaluation criteria for vendor selection.",
icon: "i-heroicons-cog-6-tooth",
path: "/templates/tech-charter",
tags: ["Technology", "Decision-Making", "Governance"],
estimatedTime: "10-20 min",
fields: 20,
storageKey: "tech-charter-data",
},
{
id: "decision-framework",
name: "Decision Framework Helper",
description:
"Interactive tool to help determine the best decision-making approach based on urgency, expertise, stakes, and team dynamics.",
icon: "i-heroicons-light-bulb",
path: "/templates/decision-framework",
tags: ["Decision-Making", "Process", "Governance"],
estimatedTime: "5-10 min",
fields: 7,
storageKey: "decision-framework-data",
},
];
const hasData = (templateId) => {
const template = templates.find((t) => t.id === templateId);
if (!template?.storageKey) return false;
if (process.client) {
const saved = localStorage.getItem(template.storageKey);
return saved && saved !== "{}";
}
return false;
};
// Remove the JavaScript background handler since we're using CSS classes
useHead({
title: "Document Templates - Co-op Pay & Value Tool",
meta: [
{
name: "description",
content:
"Fillable document templates for worker cooperatives including membership agreements and governance documents.",
},
],
});
</script>
<style scoped>
/* Template index specific styles - no longer duplicated in main.css */
.dither-shadow-disabled {
background: black;
background-image: radial-gradient(white 1px, transparent 1px);
background-size: 2px 2px;
opacity: 0.4;
}
@media (prefers-color-scheme: dark) {
.dither-shadow-disabled {
background: white;
background-image: radial-gradient(black 1px, transparent 1px);
}
}
:global(.dark) .dither-shadow-disabled {
background: white;
background-image: radial-gradient(black 1px, transparent 1px);
}
/* Remove any inherited rounded corners */
.template-card > *,
.help-section > *,
button,
.px-4,
div[class*="border"] {
border-radius: 0 !important;
}
/* Button hover effects with bitmap feel */
.template-card .relative:hover {
transform: translateY(-1px);
transition: transform 0.1s ease;
}
/* Ensure sharp edges on all elements */
* {
border-radius: 0 !important;
font-family: "Ubuntu", monospace;
}
html.dark :deep(.text-neutral-700),
html.dark :deep(.text-neutral-500),
html.dark :deep(.bg-neutral-50),
html.dark :deep(.bg-neutral-100) {
color: white !important;
background-color: #0a0a0a !important;
}
:deep(.border-neutral-200),
:deep(.border-neutral-300) {
border-color: black !important;
}
</style>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,967 @@
<template>
<div>
<!-- Export Options - Top -->
<ExportOptions
:export-data="exportData"
filename="tech-charter"
title="Technology Charter" />
<div
class="template-wrapper bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100">
<!-- Document Container -->
<div class="document-page">
<div class="template-content">
<!-- Document Header -->
<div class="text-center mb-8">
<h1
class="text-3xl md:text-5xl font-bold uppercase text-neutral-900 dark:text-white m-0 py-4 border-t-2 border-b-2 border-neutral-900 dark:border-neutral-100">
Tech Charter
</h1>
</div>
<!-- Content -->
<div class="">
<!-- Purpose Section -->
<div class="section-card">
<div>
<h2
class="text-2xl font-bold text-neutral-800 dark:text-white font-display mb-4">
Charter Purpose
</h2>
<p class="text-neutral-600 dark:text-neutral-400 mb-4">
Describe what this charter will guide and why it matters to
you.
</p>
</div>
<div class="relative">
<textarea
v-model="charterPurpose"
class="w-full min-h-32 p-4 border-2 border-neutral-300 bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100 focus:border-black dark:focus:border-white transition-colors resize-y"
rows="4" />
</div>
</div>
<!-- Unified Principles & Importance Section -->
<div class="section-card">
<div>
<h2
class="text-2xl font-bold text-neutral-800 dark:text-white font-display mb-4">
Define Your Principles & Importance
</h2>
<p class="text-neutral-600 dark:text-neutral-400 mb-6">
Select principles and set their importance. Zero means
excluded, 5 means critical.
</p>
</div>
<div class="grid md:grid-cols-1 gap-4">
<div
v-for="principle in principles"
:key="principle.id"
class="relative">
<!-- Dithered shadow for selected cards -->
<div
v-if="principleWeights[principle.id] > 0"
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
<div
:class="[
'relative transition-all',
principleWeights[principle.id] > 0
? 'item-selected border-2 border-black dark:border-neutral-400 bg-white dark:bg-neutral-950'
: 'border border-black dark:border-white bg-transparent',
]">
<div class="p-6">
<div class="flex items-start gap-6">
<!-- Principle info -->
<div class="flex-1">
<div
:class="[
'item-text-bg mb-3',
principleWeights[principle.id] > 0
? 'selected'
: '',
]">
<h3 class="font-bold text-lg mb-2">
{{ principle.name }}
</h3>
<p
:class="
principleWeights[principle.id] > 0
? 'text-neutral-700'
: 'text-neutral-600'
"
class="text-sm dark:text-neutral-200">
{{ principle.description }}
</p>
</div>
</div>
<!-- Importance selector -->
<div class="flex flex-col items-center gap-2">
<label
class="text-xs font-bold text-neutral-500 uppercase tracking-wider">
Importance
</label>
<!-- Visual weight indicator -->
<div class="flex gap-1 mb-2">
<button
v-for="level in [0, 1, 2, 3, 4, 5]"
:key="level"
@click="setPrincipleWeight(principle.id, level)"
:class="[
'w-8 h-8 border-2 font-mono text-sm transition-all',
principleWeights[principle.id] >= level
? 'bg-black text-white border-black dark:bg-white dark:text-black dark:border-white'
: 'bg-white border-neutral-300 hover:border-neutral-500 dark:bg-neutral-950',
]"
:title="`Set importance to ${level}`">
{{ level }}
</button>
</div>
<!-- Weight value display -->
<div class="text-center">
<div class="text-2xl font-bold">
{{ principleWeights[principle.id] || 0 }}
</div>
<div class="text-xs text-neutral-500">
{{
getWeightLabel(
principleWeights[principle.id] || 0
)
}}
</div>
</div>
</div>
</div>
<!-- Non-negotiable toggle (only shows for weights > 0) -->
<div
v-if="principleWeights[principle.id] > 0"
class="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-800">
<UCheckbox
:model-value="nonNegotiables.includes(principle.id)"
@update:model-value="
(checked) =>
toggleNonNegotiableCheckbox(principle.id, checked)
"
label="Make this non-negotiable"
class="item-label-bg px-2 py-1" />
</div>
<!-- Show rubric description when selected -->
<div
v-if="principleWeights[principle.id] > 0"
class="mt-4 p-3 item-label-bg selected border border-neutral-200">
<div
class="text-xs font-bold uppercase text-neutral-800 dark:text-neutral-300 mb-1">
Evaluation Criteria:
</div>
<div class="text-sm">
{{ principle.rubricDescription }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Constraints Section -->
<div class="section-card">
<div>
<h2
class="text-2xl font-bold text-neutral-800 dark:text-white font-display mb-2"
id="constraints-heading">
Technical Constraints
</h2>
</div>
<div class="space-y-6">
<fieldset class="bg-neutral-50 dark:bg-neutral-800 p-6">
<legend class="font-semibold text-lg dark:text-neutral-200">
Authentication
</legend>
<div
class="flex flex-wrap gap-3 constraint-buttons"
role="radiogroup"
aria-labelledby="auth-heading">
<div
v-for="option in authOptions"
:key="option.value"
class="relative">
<!-- Dithered shadow for selected buttons -->
<div
v-if="constraints.sso === option.value"
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
<button
@click="constraints.sso = option.value"
:aria-pressed="constraints.sso === option.value"
role="radio"
:aria-checked="constraints.sso === option.value"
:class="[
'relative px-5 py-2 border-2 transition-all focus:outline-none focus:ring-1 focus:ring-black cursor-pointer',
constraints.sso === option.value
? 'constraint-selected border-black dark:border-white cursor-pointer !bg-black !text-white dark:!bg-white dark:!text-black'
: 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer',
]">
{{ option.label }}
</button>
</div>
</div>
</fieldset>
<fieldset class="bg-neutral-50 p-6 dark:bg-neutral-800">
<legend class="font-semibold text-lg dark:text-neutral-200">
Hosting Model
</legend>
<div
class="flex flex-wrap gap-3 constraint-buttons"
role="radiogroup"
aria-labelledby="hosting-heading">
<div
v-for="option in hostingOptions"
:key="option.value"
class="relative">
<!-- Dithered shadow for selected buttons -->
<div
v-if="constraints.hosting === option.value"
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
<button
@click="constraints.hosting = option.value"
:aria-pressed="constraints.hosting === option.value"
role="radio"
:aria-checked="constraints.hosting === option.value"
:class="[
'relative px-5 py-2 border-2 transition-all focus:outline-none focus:ring-1 focus:ring-black cursor-pointer',
constraints.hosting === option.value
? 'constraint-selected border-black dark:border-neutral-400 cursor-pointer '
: 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer',
]">
{{ option.label }}
</button>
</div>
</div>
</fieldset>
<fieldset class="bg-neutral-50 p-6 dark:bg-neutral-800">
<legend class="font-semibold text-lg dark:text-neutral-200">
Required Integrations
</legend>
<p
class="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
Select all that apply
</p>
<div class="flex flex-wrap gap-3 constraint-buttons">
<div
v-for="integration in integrationOptions"
:key="integration"
class="relative">
<!-- Dithered shadow for selected buttons -->
<div
v-if="constraints.integrations.includes(integration)"
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
<button
@click="toggleIntegration(integration)"
:aria-pressed="
constraints.integrations.includes(integration)
"
:class="[
'relative px-5 py-2 border-2 transition-all focus:outline-none focus:ring-1 focus:ring-black cursor-pointer',
constraints.integrations.includes(integration)
? 'constraint-selected border-black dark:border-neutral-400 cursor-pointer !bg-black !text-white dark:!bg-white dark:!text-black'
: 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer',
]">
{{ integration }}
</button>
</div>
</div>
</fieldset>
<fieldset class="bg-neutral-50 p-6 dark:bg-neutral-800">
<legend class="font-semibold text-lg">
Support Expectations
</legend>
<div
class="flex flex-wrap gap-3 constraint-buttons"
role="radiogroup"
aria-labelledby="support-heading">
<div
v-for="option in supportOptions"
:key="option.value"
class="relative">
<!-- Dithered shadow for selected buttons -->
<div
v-if="constraints.support === option.value"
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
<button
@click="constraints.support = option.value"
:aria-pressed="constraints.support === option.value"
role="radio"
:aria-checked="constraints.support === option.value"
:class="[
'relative px-5 py-2 border-2 transition-all focus:outline-none focus:ring-1 focus:ring-black cursor-pointer',
constraints.support === option.value
? 'constraint-selected border-black dark:border-neutral-400 cursor-pointer !bg-black !text-white dark:!bg-white dark:!text-black'
: 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer',
]">
{{ option.label }}
</button>
</div>
</div>
</fieldset>
<fieldset class="bg-neutral-50 p-6 dark:bg-neutral-800">
<legend class="font-semibold text-lg dark:text-neutral-200">
Migration Timeline
</legend>
<div
class="flex flex-wrap gap-3 constraint-buttons"
role="radiogroup"
aria-labelledby="timeline-heading">
<div
v-for="option in timelineOptions"
:key="option.value"
class="relative">
<!-- Dithered shadow for selected buttons -->
<div
v-if="constraints.timeline === option.value"
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
<button
@click="constraints.timeline = option.value"
:aria-pressed="constraints.timeline === option.value"
role="radio"
:aria-checked="constraints.timeline === option.value"
:class="[
'relative px-5 py-2 border-2 transition-all focus:outline-none focus:ring-1 focus:ring-black cursor-pointer',
constraints.timeline === option.value
? 'constraint-selected border-black dark:border-neutral-400 cursor-pointer !bg-black !text-white dark:!bg-white dark:!text-black'
: 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer',
]">
{{ option.label }}
</button>
</div>
</div>
</fieldset>
</div>
</div>
<!-- Reset Form Section -->
<div v-if="canGenerateCharter" class="text-center mt-8">
<button
@click="resetForm"
class="export-btn"
title="Clear all form data and start over">
<UIcon name="i-heroicons-arrow-path" />
Reset Form
</button>
</div>
</div>
</div>
<!-- Generated Charter Output -->
<div
v-if="charterGenerated"
class="relative animate-fadeIn"
role="main"
aria-label="Generated Technology Charter">
<!-- Dithered shadow -->
<div
class="absolute top-4 left-4 right-0 bottom-0 dither-shadow"></div>
<!-- Charter container -->
<div
class="relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white p-8">
<div
class="text-center mb-8 pb-6 border-b-2 border-black dark:border-white">
<h2
class="text-3xl font-bold text-neutral-800"
id="charter-title">
Technology Charter
</h2>
<p class="text-neutral-600 mt-2">
Generated
{{
new Date().toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})
}}
</p>
<div class="mt-4">
<button
@click="scrollToTop"
class="text-sm text-neutral-600 hover:text-neutral-800 underline focus:outline-none focus:ring-2 focus:ring-neutral-500 rounded">
Back to form
</button>
</div>
</div>
<div class="prose max-w-none">
<section class="mb-8">
<h3 class="text-xl font-bold text-neutral-800 mb-3">Purpose</h3>
<p class="text-neutral-700 leading-relaxed">
This charter guides our cooperative's technology decisions, so
that we can choose tools that don't contradict our values.
</p>
</section>
<section
class="mb-8"
v-if="
Object.keys(principleWeights).filter(
(p) =>
principleWeights[p] > 0 && !nonNegotiables.includes(p)
).length > 0
">
<h3 class="text-xl font-bold text-neutral-800 mb-3">
Core Principles
</h3>
<ul class="space-y-2">
<li
v-for="principleId in Object.keys(principleWeights).filter(
(p) =>
principleWeights[p] > 0 && !nonNegotiables.includes(p)
)"
:key="principleId"
class="flex items-start">
<span class="text-neutral-600 mr-2"></span>
<span>{{
principles.find((p) => p.id === principleId)?.name
}}</span>
</li>
</ul>
</section>
<section class="mb-8" v-if="nonNegotiables.length > 0">
<h3 class="text-xl font-bold text-neutral-800 mb-3">
Non-Negotiable Requirements
</h3>
<p class="text-red-600 font-semibold mb-3">
Any vendor failing these requirements is automatically
disqualified.
</p>
<ul class="space-y-2">
<li
v-for="principleId in nonNegotiables"
:key="principleId"
class="flex items-start text-red-600 font-semibold">
<span class="mr-2"></span>
<span>{{
principles.find((p) => p.id === principleId)?.name
}}</span>
</li>
</ul>
</section>
<section class="mb-8">
<h3 class="text-xl font-bold text-neutral-800 mb-3">
Technical Constraints
</h3>
<ul class="space-y-2">
<li class="flex items-start">
<span class="text-neutral-600 mr-2"></span>
<span
>Authentication:
{{
authOptions.find((o) => o.value === constraints.sso)
?.label
}}</span
>
</li>
<li class="flex items-start">
<span class="text-neutral-600 mr-2"></span>
<span
>Hosting:
{{
hostingOptions.find(
(o) => o.value === constraints.hosting
)?.label
}}</span
>
</li>
<li
v-if="constraints.integrations.length > 0"
class="flex items-start">
<span class="text-purple-600 mr-2"></span>
<span
>Required Integrations:
{{ constraints.integrations.join(", ") }}</span
>
</li>
<li class="flex items-start">
<span class="text-neutral-600 mr-2"></span>
<span
>Support Level:
{{
supportOptions.find(
(o) => o.value === constraints.support
)?.label
}}</span
>
</li>
<li class="flex items-start">
<span class="text-neutral-600 mr-2"></span>
<span
>Migration Timeline:
{{
timelineOptions.find(
(o) => o.value === constraints.timeline
)?.label
}}</span
>
</li>
</ul>
</section>
<section class="mb-8">
<h3 class="text-xl font-bold text-neutral-800 mb-3">
Evaluation Rubric
</h3>
<p class="text-neutral-700 mb-4">
Score each vendor option using these weighted criteria (0-5
scale):
</p>
<div class="overflow-x-auto">
<table class="w-full border-collapse">
<thead>
<tr class="bg-neutral-100">
<th
class="border-2 border-neutral-900 dark:border-neutral-100 p-3 text-left">
Criterion
</th>
<th
class="border-2 border-neutral-900 dark:border-neutral-100 p-3 text-left">
Description
</th>
<th
class="border-2 border-neutral-900 dark:border-neutral-100 p-3 text-center">
Weight
</th>
</tr>
</thead>
<tbody>
<tr
v-for="weight in sortedWeights"
:key="weight.id"
class="hover:bg-neutral-50">
<td
class="border-2 border-neutral-900 dark:border-neutral-100 p-3 font-semibold">
{{ weight.name }}
</td>
<td
class="border-2 border-neutral-900 dark:border-neutral-100 p-3 text-sm text-neutral-600">
{{ weight.rubricDescription }}
</td>
<td
class="border-2 border-neutral-900 dark:border-neutral-100 p-3 text-center font-bold text-neutral-600">
{{ principleWeights[weight.id] }}
</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="mb-8">
<h3 class="text-xl font-bold text-neutral-800 mb-3">
Decision Heuristics
</h3>
<ul class="space-y-2">
<li class="flex items-start">
<span class="text-neutral-600 mr-2"></span>
<span
>Any vendor failing a non-negotiable requirement is
automatically eliminated</span
>
</li>
<li class="flex items-start">
<span class="text-neutral-600 mr-2"></span>
<span
>Prefer open standards and clear data export over feature
abundance</span
>
</li>
<li class="flex items-start">
<span class="text-neutral-600 mr-2"></span>
<span
>When scores are within 10%, choose based on alignment
with cooperative values</span
>
</li>
<li class="flex items-start">
<span class="text-neutral-600 mr-2"></span>
<span
>Document all decisions in the Vendor Decision Log for
transparency</span
>
</li>
</ul>
</section>
<section class="mb-8">
<h3 class="text-xl font-bold text-neutral-800 mb-3">
Procurement Process
</h3>
<ol class="space-y-2 list-decimal list-inside">
<li>Identify need through collective discussion</li>
<li>Research 3-5 potential vendors/solutions</li>
<li>Eliminate any failing non-negotiables</li>
<li>Score remaining options using rubric</li>
<li>Trial top 2 options if possible</li>
<li>Make collective decision with documented rationale</li>
<li>Create migration/exit plan before commitment</li>
</ol>
</section>
<section class="mb-8">
<h3 class="text-xl font-bold text-neutral-800 mb-3">
Review & Accountability
</h3>
<ul class="space-y-2">
<li class="flex items-start">
<span class="text-neutral-600 mr-2"></span>
<span>Review this charter annually at minimum</span>
</li>
<li class="flex items-start">
<span class="text-neutral-600 mr-2"></span>
<span>Audit existing tools against charter quarterly</span>
</li>
<li class="flex items-start">
<span class="text-neutral-600 mr-2"></span>
<span
>Document any exceptions with clear justification</span
>
</li>
<li class="flex items-start">
<span class="text-neutral-600 mr-2"></span>
<span
>Share learnings with other cooperatives in our
network</span
>
</li>
</ul>
</section>
</div>
</div>
</div>
</div>
</div>
<!-- Export Options - Bottom -->
<ExportOptions
:export-data="exportData"
filename="tech-charter"
title="Technology Charter" />
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from "vue";
definePageMeta({
layout: false,
});
// State
const charterPurpose = ref(
"This charter guides our cooperative's technology decisions, so that we can choose tools that don't contradict our values."
);
const principleWeights = ref({});
const nonNegotiables = ref([]);
const charterGenerated = ref(false);
const constraints = ref({
sso: "optional",
hosting: "either",
integrations: [],
support: "business",
timeline: "quarter",
});
// Data - Unified principles with rubric descriptions
const principles = [
{
id: "privacy",
name: "Privacy and data control",
description: "Data minimization, encryption, sovereignty, and user consent",
rubricDescription:
"Data collection practices, encryption standards, jurisdiction control",
defaultWeight: 4,
},
{
id: "accessibility",
name: "Universal access",
description: "WCAG compliance, screen readers, keyboard navigation",
rubricDescription: "WCAG 2.2 AA, keyboard nav, screen reader support",
defaultWeight: 5,
},
{
id: "portability",
name: "Data freedom",
description: "Easy export, no vendor lock-in, migration-friendly",
rubricDescription:
"Export capabilities, proprietary formats, switching costs",
defaultWeight: 4,
},
{
id: "opensource",
name: "Open source and community",
description:
"FOSS preference, transparent development, community governance",
rubricDescription: "License type, community involvement, code transparency",
defaultWeight: 3,
},
{
id: "sustainability",
name: "Sustainable operations",
description: "Predictable costs, green hosting, efficient resource use",
rubricDescription:
"Total cost of ownership, carbon footprint, resource efficiency",
defaultWeight: 3,
},
{
id: "localization",
name: "Local support",
description: "Multi-language, timezone aware, cultural sensitivity",
rubricDescription: "Language options, cultural awareness, regional support",
defaultWeight: 2,
},
{
id: "usability",
name: "User experience",
description:
"Intuitive interface, minimal learning curve, daily efficiency",
rubricDescription:
"Onboarding time, user satisfaction, workflow integration",
defaultWeight: 3,
},
];
const authOptions = [
{ value: "required", label: "SSO required" },
{ value: "preferred", label: "SSO preferred" },
{ value: "optional", label: "SSO optional" },
];
const hostingOptions = [
{ value: "self", label: "Self-hosted only" },
{ value: "either", label: "Either" },
{ value: "managed", label: "Managed only" },
];
const integrationOptions = ["Slack", "OIDC/OAuth", "Webhooks", "REST API"];
const supportOptions = [
{ value: "community", label: "Community only OK" },
{ value: "business", label: "Business hours" },
{ value: "24-7", label: "24/7 required" },
];
const timelineOptions = [
{ value: "immediate", label: "This month" },
{ value: "quarter", label: "This quarter" },
{ value: "year", label: "This year" },
{ value: "exploring", label: "Just exploring" },
];
// Computed
const sortedWeights = computed(() => {
return principles
.filter((p) => principleWeights.value[p.id] > 0)
.sort(
(a, b) => principleWeights.value[b.id] - principleWeights.value[a.id]
);
});
const canGenerateCharter = computed(() => {
// At least one principle must have weight > 0
return Object.values(principleWeights.value).some((w) => w > 0);
});
const selectedPrincipleCount = computed(() => {
return Object.values(principleWeights.value).filter((w) => w > 0).length;
});
// Export data for the ExportOptions component
const exportData = computed(() => ({
charterPurpose: charterPurpose.value,
principleWeights: principleWeights.value,
nonNegotiables: nonNegotiables.value,
constraints: constraints.value,
principles: principles.filter((p) => principleWeights.value[p.id] > 0),
sortedWeights: sortedWeights.value,
summary: {
selectedPrincipleCount: selectedPrincipleCount.value,
nonNegotiableCount: nonNegotiables.value.length,
canGenerateCharter: canGenerateCharter.value,
},
exportedAt: new Date().toISOString(),
section: "tech-charter",
}));
// Methods
const setPrincipleWeight = (principleId, weight) => {
principleWeights.value[principleId] = weight;
// If setting to 0, remove from non-negotiables
if (weight === 0) {
const idx = nonNegotiables.value.indexOf(principleId);
if (idx !== -1) {
nonNegotiables.value.splice(idx, 1);
}
}
};
const getWeightLabel = (weight) => {
const labels = {
0: "Excluded",
1: "Low",
2: "Medium-Low",
3: "Medium",
4: "High",
5: "Critical",
};
return labels[weight] || "";
};
const toggleNonNegotiable = (principleId) => {
const idx = nonNegotiables.value.indexOf(principleId);
if (idx === -1) {
nonNegotiables.value.push(principleId);
} else {
nonNegotiables.value.splice(idx, 1);
}
};
const toggleIntegration = (integration) => {
const idx = constraints.value.integrations.indexOf(integration);
if (idx === -1) {
constraints.value.integrations.push(integration);
} else {
constraints.value.integrations.splice(idx, 1);
}
};
const toggleIntegrationCheckbox = (integration, checked) => {
if (checked) {
if (!constraints.value.integrations.includes(integration)) {
constraints.value.integrations.push(integration);
}
} else {
const idx = constraints.value.integrations.indexOf(integration);
if (idx !== -1) {
constraints.value.integrations.splice(idx, 1);
}
}
};
const toggleNonNegotiableCheckbox = (principleId, checked) => {
if (checked) {
if (!nonNegotiables.value.includes(principleId)) {
nonNegotiables.value.push(principleId);
}
} else {
const idx = nonNegotiables.value.indexOf(principleId);
if (idx !== -1) {
nonNegotiables.value.splice(idx, 1);
}
}
};
const resetForm = () => {
if (confirm("Are you sure you want to clear all form data and start over?")) {
charterPurpose.value =
"This charter guides our cooperative's technology decisions, so that we can choose tools that don't contradict our values.";
// Reset all principle weights to 0
principles.forEach((p) => {
principleWeights.value[p.id] = 0;
});
nonNegotiables.value = [];
constraints.value = {
sso: "optional",
hosting: "either",
integrations: [],
support: "business",
timeline: "quarter",
};
localStorage.removeItem("tech-charter-data");
}
};
const scrollToTop = () => {
document
.querySelector(".template-wrapper")
.scrollIntoView({ behavior: "smooth" });
};
// Load saved data
const loadSavedData = () => {
const saved = localStorage.getItem("tech-charter-data");
if (saved) {
try {
const parsedData = JSON.parse(saved);
if (parsedData.charterPurpose !== undefined) {
charterPurpose.value = parsedData.charterPurpose;
}
principleWeights.value = parsedData.principleWeights || {};
nonNegotiables.value = parsedData.nonNegotiables || [];
constraints.value = { ...constraints.value, ...parsedData.constraints };
} catch (error) {
console.error("Error loading saved data:", error);
}
}
};
// Auto-save data
const autoSave = () => {
const data = {
charterPurpose: charterPurpose.value,
principleWeights: principleWeights.value,
nonNegotiables: nonNegotiables.value,
constraints: constraints.value,
lastUpdated: new Date().toISOString(),
};
localStorage.setItem("tech-charter-data", JSON.stringify(data));
};
// Load data on mount
onMounted(() => {
// Initialize all principle weights to 0
principles.forEach((p) => {
principleWeights.value[p.id] = 0;
});
loadSavedData();
});
// Auto-save when data changes
watch(
[charterPurpose, principleWeights, nonNegotiables, constraints],
autoSave,
{
deep: true,
}
);
</script>
<style scoped>
@reference "tailwindcss";
/* Template-specific styles not in main.css */
.section-card {
@apply mb-8 relative;
}
.content-title {
font-size: 2.5rem;
font-weight: 700;
color: inherit;
text-align: center;
margin-bottom: 0.5rem;
}
</style>

204
pages/tools/wizards.vue Normal file
View file

@ -0,0 +1,204 @@
<template>
<div>
<div
class="template-container min-h-screen bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100 py-8">
<div class="max-w-6xl mx-auto px-4 relative">
<div class="mb-8">
<h1 class="text-3xl font-bold text-neutral-900 dark:text-white mb-2">
Wizards
</h1>
<p class="text-neutral-700 dark:text-neutral-200">
Fillable forms for cooperative documents. Data saves locally in your
browser.
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-for="template in templates"
:key="template.id"
class="template-card h-full flex flex-col">
<div
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
<div
class="relative bg-white dark:bg-neutral-950 border border-black dark:border-white p-6 h-full flex flex-col">
<div class="mb-4">
<h3
class="text-xl font-semibold text-neutral-900 dark:text-white">
{{ template.name }}
</h3>
</div>
<p class="text-neutral-700 dark:text-neutral-200 mb-4">
{{ template.description }}
</p>
<div class="flex flex-wrap gap-2 mb-4">
<span
v-for="tag in template.tags"
:key="tag"
class="px-2 py-1 text-xs font-medium bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-900 border border-black dark:border-white dither-tag">
{{ tag }}
</span>
</div>
<div class="text-sm text-neutral-700 dark:text-neutral-200 mb-4">
<div class="flex items-center gap-4">
<span>{{ template.estimatedTime }}</span>
<span>{{ template.fields }} fields</span>
</div>
</div>
<div class="flex-1"></div>
<div class="flex gap-2 mt-auto">
<NuxtLink
:to="template.path"
class="flex-1 px-4 py-2 bg-black dark:bg-white text-white dark:text-black text-center font-medium tracking-wider hover:underline"
style="font-family: 'Ubuntu Mono', monospace">
START WIZARD
</NuxtLink>
<NuxtLink
v-if="hasData(template.id)"
:to="template.path"
class="px-4 py-2 bg-white dark:bg-neutral-950 text-black dark:text-white border border-black dark:border-white hover:bg-white dark:hover:bg-neutral-950 transition-colors bitmap-button"
title="Continue from saved data"
style="font-family: 'Ubuntu Mono', monospace">
RESUME
</NuxtLink>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from "vue";
const templates = [
{
id: "membership-agreement",
name: "Membership Agreement",
description:
"An agreement outlining member rights, responsibilities, decision-making processes, and financial arrangements.",
icon: "i-heroicons-user-group",
path: "/templates/membership-agreement",
tags: ["Legal", "Governance", "Membership"],
estimatedTime: "15-30 min",
fields: 25,
storageKey: "membership-agreement-data",
},
{
id: "conflict-resolution-framework",
name: "Conflict Resolution",
description:
"A framework for handling conflicts with restorative justice principles, clear processes, and organizational values alignment.",
icon: "i-heroicons-scale",
path: "/templates/conflict-resolution-framework",
tags: ["Governance", "Process", "Care"],
estimatedTime: "20-40 min",
fields: 35,
storageKey: "conflict-resolution-framework-data",
},
{
id: "tech-charter",
name: "Technology Charter",
description:
"How do you decide what technology and tools align with your values? This wizard helps you define principles, technical constraints, and evaluation criteria for tech selection.",
icon: "i-heroicons-cog-6-tooth",
path: "/templates/tech-charter",
tags: ["Technology", "Decision-Making", "Governance"],
estimatedTime: "10-20 min",
fields: 20,
storageKey: "tech-charter-data",
},
{
id: "decision-framework",
name: "Decision Framework Helper",
description:
"Need help deciding how to decide? This wizard guides you towards a decision-making approach based on urgency, expertise, stakes, and team dynamics.",
icon: "i-heroicons-light-bulb",
path: "/templates/decision-framework",
tags: ["Decision-Making", "Process", "Governance"],
estimatedTime: "5-10 min",
fields: 7,
storageKey: "decision-framework-data",
},
];
const hasData = (templateId) => {
const template = templates.find((t) => t.id === templateId);
if (!template?.storageKey) return false;
if (process.client) {
const saved = localStorage.getItem(template.storageKey);
return saved && saved !== "{}";
}
return false;
};
useHead({
title: "Wizards - Co-op Pay & Value Tool",
meta: [
{
name: "description",
content:
"Interactive wizards for worker cooperatives including membership agreements and governance documents.",
},
],
});
</script>
<style scoped>
.template-card {
@apply relative;
font-family: "Ubuntu", monospace;
}
.help-section {
@apply relative;
}
.coming-soon {
opacity: 0.7;
}
.dither-tag {
position: relative;
background: white;
}
.dither-tag::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image: repeating-linear-gradient(
45deg,
transparent 0px,
transparent 1px,
black 1px,
black 2px
);
opacity: 0.1;
pointer-events: none;
}
.bitmap-button {
font-family: "Ubuntu Mono", monospace !important;
text-transform: uppercase;
font-weight: bold;
letter-spacing: 0.5px;
position: relative;
}
.bitmap-button:hover::after {
content: "";
position: absolute;
top: 1px;
left: 1px;
right: -1px;
bottom: -1px;
border: 1px solid black;
background: white;
z-index: -1;
}
</style>