949 lines
36 KiB
Vue
949 lines
36 KiB
Vue
<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">
|
|
<label
|
|
:class="[
|
|
'flex items-center gap-3 cursor-pointer item-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-white">
|
|
Make this non-negotiable
|
|
</span>
|
|
</label>
|
|
</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-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 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 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>
|