1038 lines
38 KiB
Vue
1038 lines
38 KiB
Vue
<template>
|
|
<div>
|
|
<!-- Wizard Subnav -->
|
|
<WizardSubnav />
|
|
|
|
<!-- 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 mb-4">Charter Purpose</h2>
|
|
<p class="text-neutral-600 mb-4">
|
|
Describe what this charter will guide and why it matters to your group.
|
|
</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 mb-4">
|
|
Define Your Principles & Importance
|
|
</h2>
|
|
<p class="text-neutral-600 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
|
|
? 'principle-selected border-2 border-black dark:border-white 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="[
|
|
'principle-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"
|
|
>
|
|
{{ 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"
|
|
>
|
|
<label
|
|
:class="[
|
|
'flex items-center gap-3 cursor-pointer principle-label-bg px-2 py-1',
|
|
nonNegotiables.includes(principle.id) ? 'selected' : '',
|
|
]"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
:checked="nonNegotiables.includes(principle.id)"
|
|
@change="toggleNonNegotiable(principle.id)"
|
|
class="w-4 h-4"
|
|
/>
|
|
<span class="text-sm font-medium text-red-600">
|
|
Make this non-negotiable
|
|
</span>
|
|
</label>
|
|
</div>
|
|
|
|
<!-- Show rubric description when selected -->
|
|
<div
|
|
v-if="principleWeights[principle.id] > 0"
|
|
class="mt-4 p-3 principle-label-bg selected border border-neutral-200"
|
|
>
|
|
<div class="text-xs font-bold uppercase text-neutral-500 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 mb-2"
|
|
id="constraints-heading"
|
|
>
|
|
Technical Constraints
|
|
</h2>
|
|
</div>
|
|
|
|
<div class="space-y-6">
|
|
<fieldset class="bg-neutral-50 p-6 rounded-lg">
|
|
<legend class="font-semibold text-lg">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 rounded-lg">
|
|
<legend class="font-semibold text-lg">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-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 rounded-lg">
|
|
<legend class="font-semibold text-lg">Required Integrations</legend>
|
|
<p class="text-sm text-neutral-600 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-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',
|
|
]"
|
|
>
|
|
{{ integration }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</fieldset>
|
|
|
|
<fieldset class="bg-neutral-50 p-6 rounded-lg">
|
|
<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-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 rounded-lg">
|
|
<legend class="font-semibold text-lg">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-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>
|
|
</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 based on our
|
|
shared values and operational needs. It ensures we choose tools that
|
|
support our mission while respecting our principles of autonomy,
|
|
sustainability, and mutual aid.
|
|
</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 based on our shared values and operational needs. It ensures we choose tools that support our mission while respecting our principles of autonomy, sustainability, and mutual aid."
|
|
);
|
|
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 & 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 & 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 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 based on our shared values and operational needs. It ensures we choose tools that support our mission while respecting our principles of autonomy, sustainability, and mutual aid.";
|
|
// 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;
|
|
}
|
|
|
|
/* Principle card selected styling - using dithered shadow and background */
|
|
.principle-selected {
|
|
position: relative;
|
|
background: white;
|
|
}
|
|
|
|
.principle-selected::after {
|
|
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;
|
|
z-index: 0;
|
|
}
|
|
|
|
.principle-selected > * {
|
|
position: relative;
|
|
z-index: 1;
|
|
}
|
|
|
|
/* Dark mode */
|
|
html.dark .principle-selected {
|
|
background: #0a0a0a;
|
|
}
|
|
|
|
html.dark .principle-selected::after {
|
|
background-image: repeating-linear-gradient(
|
|
45deg,
|
|
transparent 0px,
|
|
transparent 1px,
|
|
white 1px,
|
|
white 2px
|
|
);
|
|
}
|
|
|
|
/* Text background for better readability */
|
|
.principle-label-bg {
|
|
background: rgba(255, 255, 255, 0.85);
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.principle-label-bg.selected {
|
|
background: rgba(255, 255, 255, 0.95);
|
|
}
|
|
|
|
/* Dark mode text backgrounds */
|
|
html.dark .principle-text-bg {
|
|
background: rgba(10, 10, 10, 0.9);
|
|
}
|
|
|
|
html.dark .principle-text-bg.selected {
|
|
background: rgba(10, 10, 10, 0.95);
|
|
}
|
|
|
|
html.dark .principle-label-bg {
|
|
background: rgba(10, 10, 10, 0.85);
|
|
}
|
|
|
|
html.dark .principle-label-bg.selected {
|
|
background: rgba(10, 10, 10, 0.95);
|
|
}
|
|
|
|
/* Constraint button selected styling - black background */
|
|
button.constraint-selected {
|
|
background: black !important;
|
|
color: white !important;
|
|
}
|
|
|
|
button.constraint-selected:hover {
|
|
background: black !important;
|
|
color: white !important;
|
|
}
|
|
|
|
/* Dark mode */
|
|
html.dark button.constraint-selected {
|
|
background: white !important;
|
|
color: black !important;
|
|
}
|
|
|
|
html.dark button.constraint-selected:hover {
|
|
background: white !important;
|
|
color: black !important;
|
|
}
|
|
|
|
@keyframes fadeIn {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(10px);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
|
|
.animate-fadeIn {
|
|
animation: fadeIn 0.5s ease-out;
|
|
}
|
|
|
|
.content-title {
|
|
font-size: 2.5rem;
|
|
font-weight: 700;
|
|
color: inherit;
|
|
text-align: center;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
</style>
|