1897 lines
61 KiB
Vue
1897 lines
61 KiB
Vue
<template>
|
|
<div>
|
|
<!-- Wizard Subnav -->
|
|
<WizardSubnav />
|
|
|
|
<div
|
|
class="template-wrapper bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100">
|
|
<!-- Export Controls -->
|
|
<div class="max-w-4xl mx-auto no-print no-pdf mb-8 py-4">
|
|
<div class="flex justify-end">
|
|
<div v-if="charterGenerated" class="flex items-center gap-4">
|
|
<UButton
|
|
variant="outline"
|
|
color="gray"
|
|
size="lg"
|
|
:disabled="!charterGenerated || !canGenerateCharter"
|
|
:class="[
|
|
!charterGenerated || !canGenerateCharter
|
|
? 'opacity-50 cursor-not-allowed'
|
|
: '',
|
|
]"
|
|
@click="copyPlainText">
|
|
<UIcon name="i-heroicons-clipboard" class="mr-2" />
|
|
Copy Text
|
|
</UButton>
|
|
<span
|
|
v-if="copySuccess"
|
|
class="text-green-600 dark:text-green-400 text-sm font-medium">
|
|
Copied
|
|
</span>
|
|
<UButton
|
|
variant="outline"
|
|
color="gray"
|
|
size="lg"
|
|
:disabled="!charterGenerated || !canGenerateCharter"
|
|
:class="[
|
|
!charterGenerated || !canGenerateCharter
|
|
? 'opacity-50 cursor-not-allowed'
|
|
: '',
|
|
]"
|
|
@click="exportMarkdown">
|
|
<UIcon name="i-heroicons-arrow-down-tray" class="mr-2" />
|
|
Markdown
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 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="">
|
|
<!-- Principles Section -->
|
|
<div class="section-card">
|
|
<div>
|
|
<h2
|
|
class="text-2xl font-bold text-neutral-800 mb-4"
|
|
id="principles-heading">
|
|
Define Your Principles
|
|
</h2>
|
|
</div>
|
|
|
|
<fieldset class="space-y-4">
|
|
<legend class="sr-only">
|
|
Select your technology principles
|
|
</legend>
|
|
<div class="grid md:grid-cols-2 gap-4 principle-grid">
|
|
<div
|
|
v-for="principle in principles"
|
|
:key="principle.id"
|
|
class="space-y-2 relative">
|
|
<!-- Dithered shadow for selected cards -->
|
|
<div
|
|
v-if="selectedPrinciples.includes(principle.id)"
|
|
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
|
|
<div
|
|
@click="togglePrincipleSelection(principle.id)"
|
|
:class="[
|
|
'relative p-4 border-1 transition-all cursor-pointer',
|
|
selectedPrinciples.includes(principle.id)
|
|
? 'principle-selected border-black dark:border-white bg-white dark:bg-neutral-950'
|
|
: 'border-neutral-200 hover:border-neutral-950 bg-white dark:bg-neutral-950',
|
|
]">
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div class="flex-1">
|
|
<div
|
|
:class="[
|
|
'principle-text-bg p-3 mb-3',
|
|
selectedPrinciples.includes(principle.id)
|
|
? 'selected'
|
|
: '',
|
|
]">
|
|
<h3 class="font-semibold text-lg mb-2">
|
|
{{ principle.name }}
|
|
</h3>
|
|
<p
|
|
:class="
|
|
selectedPrinciples.includes(principle.id)
|
|
? 'text-neutral-700'
|
|
: 'text-neutral-600'
|
|
"
|
|
class="text-sm">
|
|
{{ principle.description }}
|
|
</p>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<label
|
|
:class="[
|
|
'flex items-center gap-2 cursor-pointer principle-label-bg px-2 py-1',
|
|
selectedPrinciples.includes(principle.id)
|
|
? 'selected'
|
|
: '',
|
|
]"
|
|
@click.stop>
|
|
<input
|
|
type="checkbox"
|
|
:checked="
|
|
selectedPrinciples.includes(principle.id)
|
|
"
|
|
@change="togglePrincipleSelection(principle.id)"
|
|
@click.stop
|
|
class="w-4 h-4" />
|
|
<span class="text-sm font-medium"
|
|
>Include this principle</span
|
|
>
|
|
</label>
|
|
</div>
|
|
<div
|
|
v-if="selectedPrinciples.includes(principle.id)"
|
|
class="mt-2 flex">
|
|
<label
|
|
class="flex items-center gap-2 cursor-pointer principle-label-bg px-2 py-1 selected"
|
|
@click.stop>
|
|
<input
|
|
type="checkbox"
|
|
:checked="nonNegotiables.includes(principle.id)"
|
|
@change="toggleNonNegotiable(principle.id)"
|
|
@click.stop
|
|
class="w-4 h-4" />
|
|
<span class="text-sm font-medium text-red-600"
|
|
>Make this non-negotiable</span
|
|
>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</fieldset>
|
|
</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 mb-4">
|
|
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>
|
|
|
|
<!-- Weights Section -->
|
|
<div class="section-card">
|
|
<div>
|
|
<h2
|
|
class="text-2xl font-bold text-neutral-800"
|
|
id="weights-heading">
|
|
Set Evaluation Weights
|
|
</h2>
|
|
<p class="text-neutral-600 mb-4">
|
|
Adjust the importance of each criterion (0 = ignore, 5 =
|
|
critical).
|
|
</p>
|
|
<div
|
|
class="bg-neutral-50 border border-neutral-200 rounded-lg p-4 mb-6">
|
|
<p class="text-sm text-neutral-800">
|
|
Use the sliders or click the number values to set weights.
|
|
Higher weights mean the criterion is more important in your
|
|
vendor evaluation.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-4">
|
|
<div
|
|
v-for="weight in weightOptions"
|
|
:key="weight.id"
|
|
class="bg-neutral-50 p-6 rounded-lg">
|
|
<div
|
|
class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
<div class="flex-1">
|
|
<label
|
|
:for="`weight-${weight.id}`"
|
|
class="font-semibold text-lg mb-1 block"
|
|
>{{ weight.name }}</label
|
|
>
|
|
<p class="text-neutral-600 text-sm">
|
|
{{ weight.description }}
|
|
</p>
|
|
</div>
|
|
<div class="flex items-center gap-4">
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-sm text-neutral-500 w-4">0</span>
|
|
<input
|
|
:id="`weight-${weight.id}`"
|
|
type="range"
|
|
min="0"
|
|
max="5"
|
|
v-model.number="weights[weight.id]"
|
|
:aria-label="`${weight.name} importance weight`"
|
|
:aria-describedby="`weight-${weight.id}-desc`"
|
|
class="w-32 focus:outline-none focus:ring-2 focus:ring-neutral-500" />
|
|
<span class="text-sm text-neutral-500 w-4">5</span>
|
|
</div>
|
|
<div class="flex flex-col items-center">
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
max="5"
|
|
v-model.number="weights[weight.id]"
|
|
:aria-label="`${weight.name} weight value`"
|
|
class="w-16 text-center text-xl font-bold text-neutral-600 bg-white border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-500" />
|
|
<span class="text-xs text-neutral-400 mt-1"
|
|
>Weight</span
|
|
>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div :id="`weight-${weight.id}-desc`" class="sr-only">
|
|
Current weight: {{ weights[weight.id] }} out of 5.
|
|
{{
|
|
weights[weight.id] === 0
|
|
? "Ignored"
|
|
: weights[weight.id] === 5
|
|
? "Critical importance"
|
|
: `${weights[weight.id]} out of 5 importance`
|
|
}}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Generate Button -->
|
|
<div class="relative mb-8">
|
|
<!-- Dithered shadow -->
|
|
<div
|
|
class="absolute top-2 left-2 right-0 bottom-0 dither-shadow"></div>
|
|
|
|
<!-- Generate container -->
|
|
<div
|
|
class="relative bg-black dark:bg-white text-white dark:text-black border-2 border-black dark:border-white p-6">
|
|
<!-- Terminal header -->
|
|
<div
|
|
class="flex justify-between items-center mb-4 text-xs font-mono border-b border-white dark:border-black pb-2">
|
|
<span class="text-green-400 dark:text-green-600"
|
|
>CHARTER GENERATION:</span
|
|
>
|
|
<span>READY</span>
|
|
</div>
|
|
|
|
<div class="flex justify-between items-center">
|
|
<!-- Reset button -->
|
|
<div>
|
|
<button
|
|
@click="resetForm"
|
|
class="px-4 py-2 text-white dark:text-black text-sm font-mono hover:text-neutral-300 dark:hover:text-neutral-700 transition-colors focus:outline-none focus:ring-2 focus:ring-white dark:focus:ring-black"
|
|
title="Clear all form data and start over">
|
|
[RESET]
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Generate button -->
|
|
<div class="flex items-center gap-4">
|
|
<button
|
|
@click="generateCharter"
|
|
:disabled="!canGenerateCharter"
|
|
:class="[
|
|
'px-6 py-2 border-2 font-mono text-sm transition-all focus:outline-none focus:ring-2 focus:ring-green-400',
|
|
canGenerateCharter
|
|
? 'bg-green-400 dark:bg-green-600 text-black border-green-400 dark:border-green-600 hover:bg-green-500 dark:hover:bg-green-700'
|
|
: 'bg-neutral-600 text-neutral-400 border-neutral-600 cursor-not-allowed opacity-50',
|
|
]"
|
|
aria-describedby="generate-charter-help">
|
|
GENERATE CHARTER ►
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Help text -->
|
|
<div
|
|
id="generate-charter-help"
|
|
class="text-xs font-mono text-right mt-4 pt-2 border-t border-white dark:border-black opacity-75">
|
|
{{
|
|
canGenerateCharter
|
|
? "> READY TO GENERATE CHARTER"
|
|
: "> SELECT AT LEAST ONE PRINCIPLE"
|
|
}}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Generated Charter Output -->
|
|
<div
|
|
v-if="charterGenerated"
|
|
class="relative mb-8 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="
|
|
selectedPrinciples.filter((p) => !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 selectedPrinciples.filter(
|
|
(p) => !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">
|
|
{{ weights[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>
|
|
|
|
<section
|
|
class="mt-12 pt-8 border-t-2 border-neutral-900 dark:border-neutral-100">
|
|
<h3 class="text-xl font-bold text-neutral-800 mb-3">
|
|
Vendor Decision Log Template
|
|
</h3>
|
|
<div class="bg-neutral-50 p-6 rounded-lg font-mono text-sm">
|
|
<div class="space-y-1">
|
|
<p>
|
|
<strong>Tool Category:</strong> [e.g., Project Management]
|
|
</p>
|
|
<p>
|
|
<strong>Date:</strong>
|
|
{{ new Date().toLocaleDateString() }}
|
|
</p>
|
|
<p><strong>Participants:</strong> [Names]</p>
|
|
<p><strong>Options Evaluated:</strong> [List vendors]</p>
|
|
<p>
|
|
<strong>Eliminated (Non-Negotiables):</strong> [Vendor -
|
|
Reason]
|
|
</p>
|
|
<p><strong>Scores:</strong></p>
|
|
<p class="ml-4">• Option A: [Total weighted score]</p>
|
|
<p class="ml-4">• Option B: [Total weighted score]</p>
|
|
<p><strong>Decision:</strong> [Selected vendor]</p>
|
|
<p><strong>Rationale:</strong> [2-3 sentences]</p>
|
|
<p>
|
|
<strong>Safeguards/Mitigations:</strong> [If any risks]
|
|
</p>
|
|
<p>
|
|
<strong>Exit Strategy:</strong> [How we'd migrate away]
|
|
</p>
|
|
<p><strong>Review Date:</strong> [One year from today]</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted, watch } from "vue";
|
|
|
|
definePageMeta({
|
|
layout: false,
|
|
});
|
|
|
|
// State
|
|
const charterGenerated = ref(false);
|
|
const selectedPrinciples = ref([]);
|
|
const nonNegotiables = ref([]);
|
|
const copySuccess = ref(false);
|
|
|
|
const constraints = ref({
|
|
sso: "optional",
|
|
hosting: "either",
|
|
integrations: [],
|
|
support: "business",
|
|
timeline: "quarter",
|
|
});
|
|
|
|
const weights = ref({
|
|
privacy: 4,
|
|
accessibility: 5,
|
|
interop: 3,
|
|
lockin: 4,
|
|
opensource: 3,
|
|
cost: 4,
|
|
sustainability: 2,
|
|
localization: 2,
|
|
usability: 3,
|
|
});
|
|
|
|
// Data
|
|
|
|
const principles = [
|
|
{
|
|
id: "privacy",
|
|
icon: "🔒",
|
|
name: "Privacy & Data Minimization",
|
|
description:
|
|
"Collect only necessary data, encrypt everything, respect user autonomy",
|
|
},
|
|
{
|
|
id: "sovereignty",
|
|
icon: "🏛️",
|
|
name: "Data Sovereignty",
|
|
description:
|
|
"Data residency requirements, EU adequacy, local jurisdiction control",
|
|
},
|
|
{
|
|
id: "accessibility",
|
|
icon: "♿",
|
|
name: "Accessibility First",
|
|
description:
|
|
"WCAG 2.2 AA compliance, screen reader support, keyboard navigation",
|
|
},
|
|
{
|
|
id: "interop",
|
|
icon: "🔗",
|
|
name: "Interoperability",
|
|
description:
|
|
"Open standards, API access, webhook support, standard formats",
|
|
},
|
|
{
|
|
id: "portability",
|
|
icon: "📦",
|
|
name: "Data Portability",
|
|
description: "Clear export paths, no vendor lock-in, migration-friendly",
|
|
},
|
|
{
|
|
id: "opensource",
|
|
icon: "🌍",
|
|
name: "Open Source Preference",
|
|
description: "FOSS first, community-driven, transparent development",
|
|
},
|
|
{
|
|
id: "cost",
|
|
icon: "💰",
|
|
name: "Cost Sustainability",
|
|
description: "Predictable pricing, no surprise fees, budget-appropriate",
|
|
},
|
|
{
|
|
id: "environmental",
|
|
icon: "🌱",
|
|
name: "Environmental Impact",
|
|
description: "Green hosting, carbon neutral, efficient resource use",
|
|
},
|
|
{
|
|
id: "localization",
|
|
icon: "🌐",
|
|
name: "Localization Support",
|
|
description: "Multi-language, timezone awareness, cultural sensitivity",
|
|
},
|
|
{
|
|
id: "mutual-aid",
|
|
icon: "🤝",
|
|
name: "Community Health",
|
|
description: "Vendor supports mutual aid, fair labor, cooperative values",
|
|
},
|
|
];
|
|
|
|
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" },
|
|
];
|
|
|
|
const weightOptions = [
|
|
{
|
|
id: "privacy",
|
|
name: "Privacy & Data Control",
|
|
description:
|
|
"How much the vendor respects data sovereignty and minimization",
|
|
rubricDescription: "Data collection practices, encryption, user consent",
|
|
},
|
|
{
|
|
id: "accessibility",
|
|
name: "Accessibility",
|
|
description: "WCAG compliance and inclusive design practices",
|
|
rubricDescription: "Screen readers, keyboard nav, WCAG compliance",
|
|
},
|
|
{
|
|
id: "interop",
|
|
name: "Interoperability & Export",
|
|
description: "API access, webhooks, standard formats, data export",
|
|
rubricDescription: "APIs, webhooks, standard formats, integrations",
|
|
},
|
|
{
|
|
id: "lockin",
|
|
name: "Lock-in Risk",
|
|
description: "How hard it would be to migrate away (higher = riskier)",
|
|
rubricDescription:
|
|
"Migration difficulty, proprietary formats, switching costs",
|
|
},
|
|
{
|
|
id: "opensource",
|
|
name: "Community & Open Source",
|
|
description: "FOSS preference, community governance, transparency",
|
|
rubricDescription: "FOSS status, community governance, transparency",
|
|
},
|
|
{
|
|
id: "cost",
|
|
name: "Total Cost of Ownership",
|
|
description: "Including licenses, hosting, maintenance, training",
|
|
rubricDescription: "Licenses, hosting, training, ongoing maintenance",
|
|
},
|
|
{
|
|
id: "sustainability",
|
|
name: "Sustainability",
|
|
description: "Environmental impact, green hosting, efficiency",
|
|
rubricDescription: "Carbon footprint, green hosting, efficiency",
|
|
},
|
|
{
|
|
id: "localization",
|
|
name: "Localization & Support",
|
|
description: "Multi-language, timezone support, cultural awareness",
|
|
rubricDescription: "Multi-language, timezones, cultural awareness",
|
|
},
|
|
{
|
|
id: "usability",
|
|
name: "Usability",
|
|
description: "User experience, learning curve, day-to-day efficiency",
|
|
rubricDescription: "Learning curve, daily efficiency, user satisfaction",
|
|
},
|
|
];
|
|
|
|
// Computed
|
|
const sortedWeights = computed(() => {
|
|
return weightOptions
|
|
.filter((w) => weights.value[w.id] > 0)
|
|
.sort((a, b) => weights.value[b.id] - weights.value[a.id]);
|
|
});
|
|
|
|
const canGenerateCharter = computed(() => {
|
|
// Must have at least one principle selected to generate charter
|
|
return selectedPrinciples.value.length > 0;
|
|
});
|
|
|
|
// Methods
|
|
const togglePrincipleSelection = (principleId) => {
|
|
const idx = selectedPrinciples.value.indexOf(principleId);
|
|
if (idx === -1) {
|
|
selectedPrinciples.value.push(principleId);
|
|
} else {
|
|
selectedPrinciples.value.splice(idx, 1);
|
|
// Also remove from non-negotiables if it was there
|
|
const nonNegIdx = nonNegotiables.value.indexOf(principleId);
|
|
if (nonNegIdx !== -1) {
|
|
nonNegotiables.value.splice(nonNegIdx, 1);
|
|
}
|
|
}
|
|
};
|
|
|
|
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?")) {
|
|
selectedPrinciples.value = [];
|
|
nonNegotiables.value = [];
|
|
constraints.value = {
|
|
sso: "optional",
|
|
hosting: "either",
|
|
integrations: [],
|
|
support: "business",
|
|
timeline: "quarter",
|
|
};
|
|
weights.value = {
|
|
privacy: 4,
|
|
accessibility: 5,
|
|
interop: 3,
|
|
lockin: 4,
|
|
opensource: 3,
|
|
cost: 4,
|
|
sustainability: 2,
|
|
localization: 2,
|
|
usability: 3,
|
|
};
|
|
charterGenerated.value = false;
|
|
localStorage.removeItem("tech-charter-data");
|
|
}
|
|
};
|
|
|
|
const scrollToTop = () => {
|
|
document
|
|
.querySelector(".template-wrapper")
|
|
.scrollIntoView({ behavior: "smooth" });
|
|
};
|
|
|
|
const generateCharter = () => {
|
|
if (canGenerateCharter.value) {
|
|
charterGenerated.value = true;
|
|
autoSave(); // Save the completed form
|
|
setTimeout(() => {
|
|
document
|
|
.querySelector("#charter-title")
|
|
.scrollIntoView({ behavior: "smooth" });
|
|
}, 100);
|
|
}
|
|
};
|
|
|
|
// Load saved data
|
|
const loadSavedData = () => {
|
|
const saved = localStorage.getItem("tech-charter-data");
|
|
if (saved) {
|
|
try {
|
|
const parsedData = JSON.parse(saved);
|
|
selectedPrinciples.value = parsedData.selectedPrinciples || [];
|
|
nonNegotiables.value = parsedData.nonNegotiables || [];
|
|
constraints.value = { ...constraints.value, ...parsedData.constraints };
|
|
weights.value = { ...weights.value, ...parsedData.weights };
|
|
} catch (error) {
|
|
console.error("Error loading saved data:", error);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Auto-save data
|
|
const autoSave = () => {
|
|
const data = {
|
|
selectedPrinciples: selectedPrinciples.value,
|
|
nonNegotiables: nonNegotiables.value,
|
|
constraints: constraints.value,
|
|
weights: weights.value,
|
|
lastUpdated: new Date().toISOString(),
|
|
};
|
|
localStorage.setItem("tech-charter-data", JSON.stringify(data));
|
|
};
|
|
|
|
// Export functions
|
|
|
|
const exportText = () => {
|
|
const content = extractTextContent();
|
|
downloadFile(content, "tech_charter.txt", "text/plain");
|
|
};
|
|
|
|
const exportMarkdown = () => {
|
|
const content = convertToMarkdown();
|
|
downloadFile(content, "tech_charter.md", "text/markdown");
|
|
};
|
|
|
|
const copyToClipboard = async (text) => {
|
|
if (process.server || typeof window === "undefined") {
|
|
throw new Error("Clipboard not available on server");
|
|
}
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
await navigator.clipboard.writeText(text);
|
|
return;
|
|
}
|
|
const textarea = document.createElement("textarea");
|
|
textarea.value = text;
|
|
textarea.style.position = "fixed";
|
|
textarea.style.opacity = "0";
|
|
document.body.appendChild(textarea);
|
|
textarea.focus();
|
|
textarea.select();
|
|
const successful = document.execCommand("copy");
|
|
document.body.removeChild(textarea);
|
|
if (!successful) {
|
|
throw new Error("execCommand copy failed");
|
|
}
|
|
};
|
|
|
|
const copyPlainText = async () => {
|
|
const content = extractTextContent();
|
|
try {
|
|
await copyToClipboard(content);
|
|
copySuccess.value = true;
|
|
setTimeout(() => (copySuccess.value = false), 1500);
|
|
} catch (error) {
|
|
console.error("Copy failed:", error);
|
|
alert("Copy failed. Please try again or use the download option.");
|
|
}
|
|
};
|
|
|
|
const extractTextContent = () => {
|
|
const today = new Date().toLocaleDateString("en-US", {
|
|
year: "numeric",
|
|
month: "long",
|
|
day: "numeric",
|
|
});
|
|
|
|
let content = `TECHNOLOGY CHARTER\n==================\n\nGenerated ${today}\n\n`;
|
|
|
|
content += `PURPOSE\n-------\n\nThis 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.\n\n`;
|
|
|
|
if (
|
|
selectedPrinciples.value.filter((p) => !nonNegotiables.value.includes(p))
|
|
.length > 0
|
|
) {
|
|
content += `CORE PRINCIPLES\n---------------\n\n`;
|
|
selectedPrinciples.value
|
|
.filter((p) => !nonNegotiables.value.includes(p))
|
|
.forEach((pId) => {
|
|
const principle = principles.find((p) => p.id === pId);
|
|
if (principle) {
|
|
content += `• ${principle.name}\n`;
|
|
}
|
|
});
|
|
content += "\n";
|
|
}
|
|
|
|
if (nonNegotiables.value.length > 0) {
|
|
content += `NON-NEGOTIABLE REQUIREMENTS\n---------------------------\n\nAny vendor failing these requirements is automatically disqualified.\n\n`;
|
|
nonNegotiables.value.forEach((pId) => {
|
|
const principle = principles.find((p) => p.id === pId);
|
|
if (principle) {
|
|
content += `• ${principle.name}\n`;
|
|
}
|
|
});
|
|
content += "\n";
|
|
}
|
|
|
|
content += `TECHNICAL CONSTRAINTS\n---------------------\n\n`;
|
|
content += `• Authentication: ${
|
|
authOptions.find((o) => o.value === constraints.value.sso)?.label
|
|
}\n`;
|
|
content += `• Hosting: ${
|
|
hostingOptions.find((o) => o.value === constraints.value.hosting)?.label
|
|
}\n`;
|
|
if (constraints.value.integrations.length > 0) {
|
|
content += `• Required Integrations: ${constraints.value.integrations.join(
|
|
", "
|
|
)}\n`;
|
|
}
|
|
content += `• Support Level: ${
|
|
supportOptions.find((o) => o.value === constraints.value.support)?.label
|
|
}\n`;
|
|
content += `• Migration Timeline: ${
|
|
timelineOptions.find((o) => o.value === constraints.value.timeline)?.label
|
|
}\n\n`;
|
|
|
|
content += `EVALUATION RUBRIC\n-----------------\n\nScore each vendor option using these weighted criteria (0-5 scale):\n\n`;
|
|
sortedWeights.value.forEach((weight) => {
|
|
content += `${weight.name} (Weight: ${weights.value[weight.id]})\n${
|
|
weight.rubricDescription
|
|
}\n\n`;
|
|
});
|
|
|
|
return content;
|
|
};
|
|
|
|
const convertToMarkdown = () => {
|
|
const today = new Date().toLocaleDateString("en-US", {
|
|
year: "numeric",
|
|
month: "long",
|
|
day: "numeric",
|
|
});
|
|
|
|
let content = `# Technology Charter\n\n*Generated ${today}*\n\n`;
|
|
|
|
content += `## Purpose\n\nThis 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.\n\n`;
|
|
|
|
if (
|
|
selectedPrinciples.value.filter((p) => !nonNegotiables.value.includes(p))
|
|
.length > 0
|
|
) {
|
|
content += `## Core Principles\n\n`;
|
|
selectedPrinciples.value
|
|
.filter((p) => !nonNegotiables.value.includes(p))
|
|
.forEach((pId) => {
|
|
const principle = principles.find((p) => p.id === pId);
|
|
if (principle) {
|
|
content += `- ${principle.name}\n`;
|
|
}
|
|
});
|
|
content += "\n";
|
|
}
|
|
|
|
if (nonNegotiables.value.length > 0) {
|
|
content += `## Non-Negotiable Requirements\n\n**Any vendor failing these requirements is automatically disqualified.**\n\n`;
|
|
nonNegotiables.value.forEach((pId) => {
|
|
const principle = principles.find((p) => p.id === pId);
|
|
if (principle) {
|
|
content += `- **${principle.name}**\n`;
|
|
}
|
|
});
|
|
content += "\n";
|
|
}
|
|
|
|
content += `## Technical Constraints\n\n`;
|
|
content += `- Authentication: ${
|
|
authOptions.find((o) => o.value === constraints.value.sso)?.label
|
|
}\n`;
|
|
content += `- Hosting: ${
|
|
hostingOptions.find((o) => o.value === constraints.value.hosting)?.label
|
|
}\n`;
|
|
if (constraints.value.integrations.length > 0) {
|
|
content += `- Required Integrations: ${constraints.value.integrations.join(
|
|
", "
|
|
)}\n`;
|
|
}
|
|
content += `- Support Level: ${
|
|
supportOptions.find((o) => o.value === constraints.value.support)?.label
|
|
}\n`;
|
|
content += `- Migration Timeline: ${
|
|
timelineOptions.find((o) => o.value === constraints.value.timeline)?.label
|
|
}\n\n`;
|
|
|
|
content += `## Evaluation Rubric\n\nScore each vendor option using these weighted criteria (0-5 scale):\n\n`;
|
|
content += `| Criterion | Description | Weight |\n`;
|
|
content += `|-----------|-------------|--------|\n`;
|
|
sortedWeights.value.forEach((weight) => {
|
|
content += `| ${weight.name} | ${weight.rubricDescription} | ${
|
|
weights.value[weight.id]
|
|
} |\n`;
|
|
});
|
|
|
|
return content;
|
|
};
|
|
|
|
const downloadFile = (content, filename, type) => {
|
|
const blob = new Blob([content], { type });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = filename;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
// Load data on mount
|
|
onMounted(() => {
|
|
loadSavedData();
|
|
});
|
|
|
|
// Auto-save when data changes
|
|
watch([selectedPrinciples, nonNegotiables, constraints, weights], autoSave, {
|
|
deep: true,
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* Ubuntu font import */
|
|
@import url("https://fonts.googleapis.com/css2?family=Ubuntu:wght@300;400;500;700&family=Ubuntu+Mono:wght@400;700&display=swap");
|
|
@reference "tailwindcss";
|
|
|
|
/* rely on Tailwind bg utilities applied on wrapper */
|
|
.template-wrapper {
|
|
min-height: 100vh;
|
|
padding: 2rem;
|
|
position: relative;
|
|
}
|
|
|
|
/* Section styling */
|
|
.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;
|
|
}
|
|
|
|
.section-card::before {
|
|
content: "";
|
|
position: absolute;
|
|
top: 4px;
|
|
left: 4px;
|
|
right: -4px;
|
|
bottom: -4px;
|
|
background: black;
|
|
background-image: radial-gradient(white 1px, transparent 1px);
|
|
background-size: 2px 2px;
|
|
z-index: -1;
|
|
}
|
|
/* Dithered shadow effects */
|
|
.dither-shadow {
|
|
background: black;
|
|
background-image: radial-gradient(white 1px, transparent 1px);
|
|
background-size: 2px 2px;
|
|
}
|
|
|
|
html.dark .dither-shadow {
|
|
background: white;
|
|
background-image: radial-gradient(black 1px, transparent 1px);
|
|
}
|
|
|
|
.dither-shadow-header {
|
|
background: black;
|
|
background-image: radial-gradient(white 1px, transparent 1px);
|
|
background-size: 2px 2px;
|
|
opacity: 0.8;
|
|
}
|
|
|
|
html.dark .dither-shadow-header {
|
|
background: white;
|
|
background-image: radial-gradient(black 1px, transparent 1px);
|
|
}
|
|
|
|
.progress-dither {
|
|
background-image: radial-gradient(
|
|
circle at 50% 50%,
|
|
rgba(0, 0, 0, 0.1) 1px,
|
|
transparent 1px
|
|
);
|
|
background-size: 4px 4px;
|
|
}
|
|
.template-wrapper::before {
|
|
content: "";
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background-image: radial-gradient(
|
|
circle at 25% 25%,
|
|
black 1px,
|
|
transparent 1px
|
|
),
|
|
radial-gradient(circle at 75% 75%, black 1px, transparent 1px);
|
|
background-size: 8px 8px, 8px 8px;
|
|
background-position: 0 0, 4px 4px;
|
|
opacity: 0.1;
|
|
pointer-events: none;
|
|
z-index: -1;
|
|
}
|
|
|
|
/* Dark mode dither inversion */
|
|
html.dark .template-wrapper::before {
|
|
background-image: radial-gradient(
|
|
circle at 25% 25%,
|
|
white 1px,
|
|
transparent 1px
|
|
),
|
|
radial-gradient(circle at 75% 75%, white 1px, transparent 1px);
|
|
opacity: 0.12;
|
|
}
|
|
|
|
.document-page {
|
|
min-height: 11in;
|
|
background: white;
|
|
position: relative;
|
|
}
|
|
.document-page::before {
|
|
content: "";
|
|
position: absolute;
|
|
top: 4px;
|
|
left: 4px;
|
|
right: -4px;
|
|
bottom: -4px;
|
|
background: black;
|
|
background-image: radial-gradient(white 1px, transparent 1px);
|
|
background-size: 2px 2px;
|
|
z-index: -1;
|
|
}
|
|
.document-page::after {
|
|
content: "";
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
border: 1px solid black;
|
|
z-index: 1;
|
|
pointer-events: none;
|
|
}
|
|
|
|
/* Dark mode document page inversion */
|
|
html.dark .document-page {
|
|
background: #0a0a0a;
|
|
}
|
|
html.dark .document-page::before {
|
|
background: white;
|
|
background-image: radial-gradient(black 1px, transparent 1px);
|
|
}
|
|
html.dark .document-page::after {
|
|
border-color: white;
|
|
}
|
|
|
|
.export-controls {
|
|
margin: 0 auto 1.5rem;
|
|
background: white;
|
|
border: 1px solid black;
|
|
padding: 1rem;
|
|
position: relative;
|
|
}
|
|
.export-controls::before {
|
|
content: "";
|
|
position: absolute;
|
|
top: 2px;
|
|
left: 2px;
|
|
right: -2px;
|
|
bottom: -2px;
|
|
background: black;
|
|
background-image: radial-gradient(white 1px, transparent 1px);
|
|
background-size: 2px 2px;
|
|
z-index: -1;
|
|
}
|
|
|
|
/* Dark mode export controls */
|
|
html.dark .export-controls {
|
|
background: #0a0a0a;
|
|
border-color: white;
|
|
}
|
|
html.dark .export-controls::before {
|
|
background: white;
|
|
background-image: radial-gradient(black 1px, transparent 1px);
|
|
}
|
|
|
|
.export-content {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 1rem;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.export-section {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.export-buttons {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.export-title {
|
|
font-weight: 600;
|
|
color: #374151;
|
|
margin: 0;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
html.dark .export-title {
|
|
color: #e5e7eb;
|
|
}
|
|
|
|
.export-btn {
|
|
background: #f9fafb;
|
|
border: 1px solid #d1d5db;
|
|
color: #374151;
|
|
padding: 0.5rem 0.75rem;
|
|
border-radius: 6px;
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
text-decoration: none;
|
|
min-width: fit-content;
|
|
}
|
|
|
|
.export-btn:hover {
|
|
background: #f3f4f6;
|
|
border-color: #9ca3af;
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
.export-btn.primary {
|
|
background: #3b82f6;
|
|
border-color: #3b82f6;
|
|
color: white;
|
|
}
|
|
|
|
.export-btn.primary:hover {
|
|
background: #2563eb;
|
|
border-color: #2563eb;
|
|
}
|
|
|
|
.export-btn svg {
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.content-title {
|
|
font-size: 2.5rem;
|
|
font-weight: 700;
|
|
color: inherit;
|
|
text-align: center;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
@keyframes fadeIn {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(10px);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
|
|
.animate-fadeIn {
|
|
animation: fadeIn 0.5s ease-out;
|
|
}
|
|
|
|
/* Bitmap aesthetic overrides - remove all rounded corners */
|
|
* {
|
|
border-radius: 0 !important;
|
|
font-family: "Ubuntu", monospace !important;
|
|
}
|
|
|
|
/* Form fields with bitmap styling */
|
|
input,
|
|
textarea,
|
|
select {
|
|
border: 1px solid black !important;
|
|
background: white !important;
|
|
color: black !important;
|
|
font-family: "Ubuntu Mono", monospace !important;
|
|
}
|
|
|
|
input:focus,
|
|
textarea:focus,
|
|
select:focus {
|
|
outline: 2px solid black !important;
|
|
outline-offset: -2px !important;
|
|
background: white !important;
|
|
}
|
|
|
|
/* Dark mode form fields */
|
|
html.dark input,
|
|
html.dark textarea,
|
|
html.dark select {
|
|
border: 1px solid white !important;
|
|
background: #0a0a0a !important;
|
|
color: white !important;
|
|
}
|
|
|
|
html.dark input:focus,
|
|
html.dark textarea:focus,
|
|
html.dark select:focus {
|
|
outline: 2px solid white !important;
|
|
background: #0a0a0a !important;
|
|
}
|
|
|
|
/* Buttons with bitmap styling */
|
|
button:not(.export-btn) {
|
|
background: white !important;
|
|
border: 1px solid black !important;
|
|
color: black !important;
|
|
font-family: "Ubuntu Mono", monospace !important;
|
|
text-transform: uppercase !important;
|
|
font-weight: bold !important;
|
|
letter-spacing: 0.5px !important;
|
|
cursor: pointer !important;
|
|
}
|
|
|
|
button:not(.export-btn):hover {
|
|
background: black !important;
|
|
color: white !important;
|
|
transform: translateY(-1px) translateX(-1px) !important;
|
|
}
|
|
|
|
/* Dark mode buttons */
|
|
html.dark button:not(.export-btn) {
|
|
background: #0a0a0a !important;
|
|
border: 1px solid white !important;
|
|
color: white !important;
|
|
cursor: pointer !important;
|
|
}
|
|
|
|
html.dark button:not(.export-btn):hover {
|
|
background: white !important;
|
|
color: black !important;
|
|
}
|
|
|
|
/* Export buttons specifically */
|
|
.export-btn {
|
|
background: white !important;
|
|
border: 1px solid black !important;
|
|
color: black !important;
|
|
font-family: "Ubuntu Mono", monospace !important;
|
|
text-transform: uppercase !important;
|
|
font-weight: bold !important;
|
|
letter-spacing: 0.5px !important;
|
|
cursor: pointer !important;
|
|
}
|
|
|
|
.export-btn:hover {
|
|
background: black !important;
|
|
color: white !important;
|
|
transform: translateY(-1px) translateX(-1px) !important;
|
|
}
|
|
|
|
/* Dark mode export buttons */
|
|
html.dark .export-btn {
|
|
background: #0a0a0a !important;
|
|
border: 1px solid white !important;
|
|
color: white !important;
|
|
cursor: pointer !important;
|
|
}
|
|
|
|
html.dark .export-btn:hover {
|
|
background: white !important;
|
|
color: black !important;
|
|
}
|
|
|
|
.export-btn.primary {
|
|
background: black !important;
|
|
color: white !important;
|
|
}
|
|
|
|
.export-btn.primary:hover {
|
|
background: black !important;
|
|
transform: translateY(-1px) translateX(-1px) !important;
|
|
}
|
|
|
|
/* Checkbox and radio button styling */
|
|
input[type="checkbox"],
|
|
input[type="radio"] {
|
|
border: 2px solid black !important;
|
|
background: white !important;
|
|
}
|
|
|
|
input[type="checkbox"]:checked,
|
|
input[type="radio"]:checked {
|
|
background: black !important;
|
|
color: white !important;
|
|
}
|
|
|
|
/* Document titles */
|
|
h1,
|
|
h2,
|
|
h3,
|
|
h4,
|
|
h5,
|
|
h6 {
|
|
font-family: "Ubuntu", monospace !important;
|
|
color: inherit !important;
|
|
}
|
|
|
|
/* All text */
|
|
p,
|
|
span,
|
|
div {
|
|
color: inherit !important;
|
|
font-family: "Ubuntu", monospace !important;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.template-wrapper {
|
|
padding: 1rem;
|
|
}
|
|
|
|
.export-content {
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
}
|
|
|
|
.export-section {
|
|
justify-content: center;
|
|
}
|
|
|
|
.export-buttons {
|
|
justify-content: center;
|
|
}
|
|
|
|
.content-title {
|
|
font-size: 2rem;
|
|
}
|
|
|
|
/* Make principle cards full width on mobile */
|
|
.principle-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
/* Stack constraint buttons vertically on mobile */
|
|
.constraint-buttons {
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
}
|
|
|
|
.constraint-buttons button {
|
|
width: 100%;
|
|
justify-content: center;
|
|
}
|
|
|
|
/* Improve weight control layout on mobile */
|
|
.weight-control-mobile {
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
}
|
|
|
|
@media print {
|
|
body > *:not(.template-wrapper),
|
|
#__nuxt > *:not(.template-wrapper),
|
|
.export-controls,
|
|
.no-print,
|
|
.no-pdf,
|
|
button,
|
|
[class*="button"] {
|
|
display: none !important;
|
|
}
|
|
|
|
body *:not(.template-wrapper):not(.template-wrapper *) {
|
|
visibility: hidden !important;
|
|
}
|
|
|
|
.template-wrapper,
|
|
.template-wrapper * {
|
|
visibility: visible !important;
|
|
}
|
|
|
|
.template-wrapper {
|
|
background: white !important;
|
|
padding: 0 !important;
|
|
min-height: auto !important;
|
|
}
|
|
|
|
.document-page {
|
|
max-width: none !important;
|
|
width: 100% !important;
|
|
margin: 0 !important;
|
|
box-shadow: none !important;
|
|
border-radius: 0 !important;
|
|
}
|
|
|
|
.document-page::before {
|
|
content: "";
|
|
display: block;
|
|
height: 0.5in;
|
|
}
|
|
}
|
|
</style>
|