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

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>