From 8a954d3c2066fa505ddc0c43350f768b3fb450e5 Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Fri, 17 Oct 2025 23:15:15 +0200 Subject: [PATCH] Add voting / proposal page --- backend/api/package.json | 2 +- backend/api/src/app.ts | 4 + backend/api/src/create-vote.ts | 26 ++++++ backend/api/src/vote.ts | 39 ++++++++ backend/supabase/vote_results.sql | 66 +++++++++++++ backend/supabase/votes.sql | 26 ++++++ common/src/api/schema.ts | 21 +++++ common/src/envs/constants.ts | 2 +- common/src/supabase/schema.ts | 94 ++++++++++++++++++- web/components/love-page.tsx | 3 + web/components/nav/love-sidebar.tsx | 2 +- web/components/votes/vote-buttons.tsx | 129 ++++++++++++++++++++++++++ web/components/votes/vote-info.tsx | 111 ++++++++++++++++++++++ web/components/votes/vote-item.tsx | 51 ++++++++++ web/lib/supabase/votes.ts | 25 +++++ web/pages/about.tsx | 2 +- web/pages/organization.tsx | 1 + web/pages/vote.tsx | 36 +++++++ web/public/md/faq.md | 3 +- web/styles/globals.css | 71 ++++++++++++++ web/tailwind.config.js | 39 ++++++++ 21 files changed, 745 insertions(+), 8 deletions(-) create mode 100644 backend/api/src/create-vote.ts create mode 100644 backend/api/src/vote.ts create mode 100644 backend/supabase/vote_results.sql create mode 100644 backend/supabase/votes.sql create mode 100644 web/components/votes/vote-buttons.tsx create mode 100644 web/components/votes/vote-info.tsx create mode 100644 web/components/votes/vote-item.tsx create mode 100644 web/lib/supabase/votes.ts create mode 100644 web/pages/vote.tsx diff --git a/backend/api/package.json b/backend/api/package.json index 8afb895..b6132cf 100644 --- a/backend/api/package.json +++ b/backend/api/package.json @@ -18,7 +18,7 @@ "verify": "yarn --cwd=../.. verify", "verify:dir": "npx eslint . --max-warnings 0", "regen-types": "cd ../supabase && make ENV=prod regen-types", - "regen-types-dev": "cd ../supabase && make ENV=dev regen-types" + "regen-types-dev": "cd ../supabase && make ENV=dev regen-types-dev" }, "engines": { "node": ">=20.0.0" diff --git a/backend/api/src/app.ts b/backend/api/src/app.ts index 412ebb1..00080ce 100644 --- a/backend/api/src/app.ts +++ b/backend/api/src/app.ts @@ -56,6 +56,8 @@ import * as fs from "fs" import {sendSearchNotifications} from "api/send-search-notifications"; import {sendDiscordMessage} from "common/discord/core"; import {getMessagesCount} from "api/get-messages-count"; +import {createVote} from "api/create-vote"; +import {vote} from "api/vote"; const allowCorsUnrestricted: RequestHandler = cors({}) @@ -156,6 +158,8 @@ const handlers: { [k in APIPath]: APIHandler } = { 'create-comment': createComment, 'hide-comment': hideComment, 'create-compatibility-question': createCompatibilityQuestion, + 'create-vote': createVote, + 'vote': vote, 'compatible-profiles': getCompatibleProfilesHandler, 'search-location': searchLocation, 'search-near-city': searchNearCity, diff --git a/backend/api/src/create-vote.ts b/backend/api/src/create-vote.ts new file mode 100644 index 0000000..05fd291 --- /dev/null +++ b/backend/api/src/create-vote.ts @@ -0,0 +1,26 @@ +import { createSupabaseDirectClient } from 'shared/supabase/init' +import { getUser } from 'shared/utils' +import { APIHandler, APIError } from './helpers/endpoint' +import { insert } from 'shared/supabase/utils' +import { tryCatch } from 'common/util/try-catch' + +export const createVote: APIHandler< + 'create-vote' +> = async ({ title, description }, auth) => { + const creator = await getUser(auth.uid) + if (!creator) throw new APIError(401, 'Your account was not found') + + const pg = createSupabaseDirectClient() + + const { data, error } = await tryCatch( + insert(pg, 'votes', { + creator_id: creator.id, + title, + description, + }) + ) + + if (error) throw new APIError(401, 'Error creating question') + + return { data } +} diff --git a/backend/api/src/vote.ts b/backend/api/src/vote.ts new file mode 100644 index 0000000..636b130 --- /dev/null +++ b/backend/api/src/vote.ts @@ -0,0 +1,39 @@ +import { createSupabaseDirectClient } from 'shared/supabase/init' +import { getUser } from 'shared/utils' +import { APIHandler, APIError } from './helpers/endpoint' + +export const vote: APIHandler<'vote'> = async ({ voteId, choice, priority }, auth) => { + const user = await getUser(auth.uid) + if (!user) throw new APIError(401, 'Your account was not found') + + const pg = createSupabaseDirectClient() + + // Map string choice to smallint (-1, 0, 1) + const choiceMap: Record = { + 'for': 1, + 'abstain': 0, + 'against': -1, + } + const choiceVal = choiceMap[choice] + if (choiceVal === undefined) { + throw new APIError(400, 'Invalid choice') + } + + // Upsert the vote result to ensure one vote per user per vote + // Assuming table vote_results with unique (user_id, vote_id) + const query = ` + insert into vote_results (user_id, vote_id, choice, priority) + values ($1, $2, $3, $4) + on conflict (user_id, vote_id) + do update set choice = excluded.choice, + priority = excluded.priority + returning *; + ` + + try { + const result = await pg.one(query, [user.id, voteId, choiceVal, priority]) + return { data: result } + } catch (e) { + throw new APIError(500, 'Error recording vote', e as any) + } +} diff --git a/backend/supabase/vote_results.sql b/backend/supabase/vote_results.sql new file mode 100644 index 0000000..045511a --- /dev/null +++ b/backend/supabase/vote_results.sql @@ -0,0 +1,66 @@ +CREATE TABLE IF NOT EXISTS vote_results ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + created_time TIMESTAMPTZ DEFAULT now() NOT NULL, + user_id TEXT NOT NULL, + vote_id BIGINT NOT NULL, + choice smallint NOT NULL CHECK (choice IN (-1, 0, 1)), + priority smallint NOT NULL CHECK (priority IN (0, 1, 2, 3)), + UNIQUE (user_id, vote_id) -- ensures one vote per user +); + +-- Foreign Keys +alter table vote_results +add constraint vote_results_user_id_fkey foreign key (user_id) references users (id); + +alter table vote_results +add constraint vote_results_vote_id_fkey foreign key (vote_id) references votes (id); + +-- Row Level Security +ALTER TABLE vote_results ENABLE ROW LEVEL SECURITY; + +-- Policies +DROP POLICY IF EXISTS "public read" ON vote_results; +CREATE POLICY "public read" ON vote_results +FOR ALL USING (true); + +-- Indexes +DROP INDEX IF EXISTS user_id_idx; +CREATE INDEX user_id_idx ON vote_results (user_id); + +DROP INDEX IF EXISTS vote_id_idx; +CREATE INDEX vote_id_idx ON vote_results (vote_id); + +DROP INDEX IF EXISTS idx_vote_results_vote_choice; +CREATE INDEX idx_vote_results_vote_choice ON vote_results (vote_id, choice); + + +create or replace function get_votes_with_results() + returns table ( + id BIGINT, + title text, + description jsonb, + created_time timestamptz, + creator_id TEXT, + votes_for int, + votes_against int, + votes_abstain int, + priority int + ) +as $$ +SELECT + v.id, + v.title, + v.description, + v.created_time, + v.creator_id, + COALESCE(SUM(CASE WHEN r.choice = 1 THEN 1 ELSE 0 END), 0) AS votes_for, + COALESCE(SUM(CASE WHEN r.choice = -1 THEN 1 ELSE 0 END), 0) AS votes_against, + COALESCE(SUM(CASE WHEN r.choice = 0 THEN 1 ELSE 0 END), 0) AS votes_abstain, + coalesce(SUM(r.priority), 0) AS priority +FROM votes v + LEFT JOIN vote_results r ON v.id = r.vote_id +GROUP BY v.id +ORDER BY v.created_time DESC; +$$ language sql stable; + + diff --git a/backend/supabase/votes.sql b/backend/supabase/votes.sql new file mode 100644 index 0000000..20c2aa5 --- /dev/null +++ b/backend/supabase/votes.sql @@ -0,0 +1,26 @@ +CREATE TABLE IF NOT EXISTS votes ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + created_time TIMESTAMPTZ DEFAULT now() NOT NULL, + creator_id TEXT NOT NULL, + title TEXT NOT NULL, + description JSONB +); + +-- Foreign Keys +alter table votes +add constraint votes_creator_id_fkey foreign key (creator_id) references users (id); + +-- Row Level Security +ALTER TABLE votes ENABLE ROW LEVEL SECURITY; + +-- Policies +DROP POLICY IF EXISTS "public read" ON votes; +CREATE POLICY "public read" ON votes +FOR ALL USING (true); + +-- Indexes +DROP INDEX IF EXISTS creator_id_idx; +CREATE INDEX creator_id_idx ON votes (creator_id); + +DROP INDEX IF EXISTS idx_votes_created_time; +CREATE INDEX idx_votes_created_time ON votes (created_time DESC); diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts index 2c43424..028fea5 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -513,6 +513,27 @@ export const API = (_apiTypeCheck = { options: z.record(z.string(), z.number()), }), }, + 'create-vote': { + method: 'POST', + authed: true, + rateLimited: true, + returns: {} as any, + props: z.object({ + title: z.string().min(1), + description: contentSchema, + }), + }, + 'vote': { + method: 'POST', + authed: true, + rateLimited: true, + returns: {} as any, + props: z.object({ + voteId: z.number(), + priority: z.number(), + choice: z.enum(['for', 'abstain', 'against']), + }), + }, 'search-location': { method: 'POST', authed: true, diff --git a/common/src/envs/constants.ts b/common/src/envs/constants.ts index c247c06..38cce7f 100644 --- a/common/src/envs/constants.ts +++ b/common/src/envs/constants.ts @@ -2,7 +2,7 @@ import {DEV_CONFIG} from './dev' import {PROD_CONFIG} from './prod' import {isProd} from "common/envs/is-prod"; -export const MAX_DESCRIPTION_LENGTH = 16000 +export const MAX_DESCRIPTION_LENGTH = 100000 export const MAX_ANSWER_LENGTH = 240 export const ENV_CONFIG = isProd() ? PROD_CONFIG : DEV_CONFIG diff --git a/common/src/supabase/schema.ts b/common/src/supabase/schema.ts index 57be193..024e06e 100644 --- a/common/src/supabase/schema.ts +++ b/common/src/supabase/schema.ts @@ -10,7 +10,7 @@ export type Database = { // Allows to automatically instantiate createClient with right options // instead of createClient(URL, KEY) __InternalSupabase: { - PostgrestVersion: '13.0.4' + PostgrestVersion: '13.0.5' } public: { Tables: { @@ -471,7 +471,7 @@ export type Database = { geodb_city_id?: string | null has_kids?: number | null height_in_inches?: number | null - id?: number + id?: never is_smoker?: boolean | null is_vegetarian_or_vegan?: boolean | null last_modification_time?: string @@ -519,7 +519,7 @@ export type Database = { geodb_city_id?: string | null has_kids?: number | null height_in_inches?: number | null - id?: number + id?: never is_smoker?: boolean | null is_vegetarian_or_vegan?: boolean | null last_modification_time?: string @@ -700,6 +700,80 @@ export type Database = { } Relationships: [] } + vote_results: { + Row: { + choice: number + created_time: string + id: number + priority: number + user_id: string + vote_id: number + } + Insert: { + choice: number + created_time?: string + id?: never + priority: number + user_id: string + vote_id: number + } + Update: { + choice?: number + created_time?: string + id?: never + priority?: number + user_id?: string + vote_id?: number + } + Relationships: [ + { + foreignKeyName: 'vote_results_user_id_fkey' + columns: ['user_id'] + isOneToOne: false + referencedRelation: 'users' + referencedColumns: ['id'] + }, + { + foreignKeyName: 'vote_results_vote_id_fkey' + columns: ['vote_id'] + isOneToOne: false + referencedRelation: 'votes' + referencedColumns: ['id'] + } + ] + } + votes: { + Row: { + created_time: string + creator_id: string + description: Json | null + id: number + title: string + } + Insert: { + created_time?: string + creator_id: string + description?: Json | null + id?: never + title: string + } + Update: { + created_time?: string + creator_id?: string + description?: Json | null + id?: never + title?: string + } + Relationships: [ + { + foreignKeyName: 'votes_creator_id_fkey' + columns: ['creator_id'] + isOneToOne: false + referencedRelation: 'users' + referencedColumns: ['id'] + } + ] + } } Views: { [_ in never]: never @@ -733,6 +807,20 @@ export type Database = { Args: { p_question_id: number } Returns: Record[] } + get_votes_with_results: { + Args: Record + Returns: { + created_time: string + creator_id: string + description: Json + id: number + priority: number + title: string + votes_abstain: number + votes_against: number + votes_for: number + }[] + } gtrgm_compress: { Args: { '': unknown } Returns: unknown diff --git a/web/components/love-page.tsx b/web/components/love-page.tsx index 9eee7f7..bf67a4a 100644 --- a/web/components/love-page.tsx +++ b/web/components/love-page.tsx @@ -24,6 +24,7 @@ import {useProfile} from 'web/hooks/use-profile' import {Profile} from 'common/love/profile' import {NotificationsIcon, SolidNotificationsIcon} from './notifications-icon' import {IS_MAINTENANCE} from "common/constants"; +import {MdThumbUp} from "react-icons/md"; export function LovePage(props: { trackPageView: string | false @@ -113,10 +114,12 @@ const NotifsSolid = {name: 'Notifs', href: `/notifications`, icon: SolidNotifica const Messages = {name: 'Messages', href: '/messages', icon: PrivateMessagesIcon}; const Social = {name: 'Social', href: '/social', icon: LinkIcon}; const Organization = {name: 'Organization', href: '/organization', icon: GlobeAltIcon}; +const Vote = {name: 'Vote', href: '/vote', icon: MdThumbUp}; const base = [ About, faq, + Vote, Social, Organization, ] diff --git a/web/components/nav/love-sidebar.tsx b/web/components/nav/love-sidebar.tsx index 542e642..dcf4b01 100644 --- a/web/components/nav/love-sidebar.tsx +++ b/web/components/nav/love-sidebar.tsx @@ -86,7 +86,6 @@ const bottomNav = ( toggleTheme: () => void ) => buildArray( - !loggedIn && { name: 'Sign in', icon: LoginIcon, href: '/signin' }, { name: theme ?? 'auto', children: @@ -114,6 +113,7 @@ const bottomNav = ( ), onClick: toggleTheme, }, + !loggedIn && { name: 'Sign in', icon: LoginIcon, href: '/signin' }, loggedIn && { name: 'Sign out', icon: LogoutIcon, onClick: logout } ) diff --git a/web/components/votes/vote-buttons.tsx b/web/components/votes/vote-buttons.tsx new file mode 100644 index 0000000..9172e1d --- /dev/null +++ b/web/components/votes/vote-buttons.tsx @@ -0,0 +1,129 @@ +import { Row } from 'web/components/layout/row' +import { Button } from 'web/components/buttons/button' +import clsx from 'clsx' +import toast from 'react-hot-toast' +import { api } from 'web/lib/api' +import { useState } from 'react' +import {useUser} from "web/hooks/use-user"; + +export type VoteChoice = 'for' | 'abstain' | 'against' + +export function VoteButtons(props: { + voteId: number + counts: { for: number; abstain: number; against: number } + onVoted?: () => void | Promise + className?: string +}) { + const user = useUser() + const { voteId, counts, onVoted, className } = props + const [loading, setLoading] = useState(null) + const [showPriority, setShowPriority] = useState(false) + const disabled = loading !== null + + const sendVote = async (choice: VoteChoice, priority: number) => { + try { + setLoading(choice) + if (!user) { + toast.error('Please sign in to vote') + return + } + await api('vote', { voteId, choice, priority }) + toast.success(`Voted ${choice}${choice === 'for' ? ` with priority ${priority}` : ''}`) + await onVoted?.() + } catch (e) { + console.error(e) + toast.error('Failed to vote — please try again') + } finally { + setLoading(null) + } + } + + const handleVote = async (choice: VoteChoice) => { + if (choice === 'for') { + // Toggle the priority dropdown + setShowPriority((v) => !v) + return + } + // Default priority 0 for non-for choices + await sendVote(choice, 0) + } + +function VoteButton(props: { + color: string + count: number + title: string + disabled?: boolean + onClick?: () => void +}) { + const { color, count, title, disabled, onClick } = props + return ( + + ) +} + + const priorities = [ + { label: 'Urgent', value: 3 }, + { label: 'High', value: 2 }, + { label: 'Medium', value: 1 }, + { label: 'Low', value: 0 }, + ] as const + + return ( + +
+ handleVote('for')} + /> + {showPriority && ( +
+ {priorities.map((p) => ( + + ))} +
+ )} +
+ handleVote('abstain')} + /> + handleVote('against')} + /> +
+ ) +} diff --git a/web/components/votes/vote-info.tsx b/web/components/votes/vote-info.tsx new file mode 100644 index 0000000..4414fbd --- /dev/null +++ b/web/components/votes/vote-info.tsx @@ -0,0 +1,111 @@ +import {Col} from 'web/components/layout/col' +import {Row} from 'web/components/layout/row' +import {useUser} from 'web/hooks/use-user' +import {useGetter} from 'web/hooks/use-getter' +import {TextEditor, useTextEditor} from "web/components/widgets/editor"; +import {JSONContent} from "@tiptap/core"; +import {getVotes} from "web/lib/supabase/votes"; +import {MAX_DESCRIPTION_LENGTH} from "common/envs/constants"; +import {useEffect, useState} from "react"; +import {Button} from "web/components/buttons/button"; +import {Input} from "web/components/widgets/input"; +import {api} from "web/lib/api"; +import {Title} from "web/components/widgets/title"; +import toast from "react-hot-toast"; +import {Vote, VoteItem} from 'web/components/votes/vote-item' + +export function VoteComponent(props: {}) { + const user = useUser() + + const {data: votes, refresh: refreshVotes} = useGetter( + 'votes', + {}, + getVotes + ) + + const [title, setTitle] = useState('') + const [editor, setEditor] = useState(null) + + const hideButton = title.length == 0 + + console.debug('votes', votes) + + return ( + + Proposals + {votes && votes.length > 0 && + {votes.map((vote: Vote) => { + return ( + + ) + })} + } + {user && + Add a new proposal + { + setTitle(e.target.value) + }} + /> + setEditor(e)} + /> + {!hideButton && ( + + + + )} + + } + + ) +} + +interface BaseBioProps { + defaultValue?: any + onBlur?: (editor: any) => void + onEditor?: (editor: any) => void +} + +export function VoteCreator({defaultValue, onBlur, onEditor}: BaseBioProps) { + const editor = useTextEditor({ + // extensions: [StarterKit], + max: MAX_DESCRIPTION_LENGTH, + defaultValue: defaultValue, + placeholder: 'Please describe your proposal here', + }) + + useEffect(() => { + onEditor?.(editor) + }, [editor, onEditor]) + + return ( +
+ {/*

Description

*/} + onBlur?.(editor)} + /> +
+ ) +} diff --git a/web/components/votes/vote-item.tsx b/web/components/votes/vote-item.tsx new file mode 100644 index 0000000..c258346 --- /dev/null +++ b/web/components/votes/vote-item.tsx @@ -0,0 +1,51 @@ +import {Col} from 'web/components/layout/col' +import {Row} from 'web/components/layout/row' +import {Row as rowFor} from 'common/supabase/utils' +import {Content} from 'web/components/widgets/editor' +import {JSONContent} from '@tiptap/core' +import {VoteButtons} from 'web/components/votes/vote-buttons' +import {getVoteCreator} from "web/lib/supabase/votes"; +import {useEffect, useState} from "react"; +import Link from "next/link"; + +export type Vote = rowFor<'votes'> & { + votes_for: number + votes_against: number + votes_abstain: number + priority: number +} + +export function VoteItem(props: { + vote: Vote + onVoted?: () => void | Promise +}) { + const {vote, onVoted} = props + const [creator, setCreator] = useState(null) + useEffect(() => { + getVoteCreator(vote.creator_id).then(setCreator) + }, [vote.creator_id]) + console.debug('creator', creator) + return ( + + + +

{vote.title}

+ + + + {vote.priority && Priority: {vote.priority / vote.votes_for}} + {creator?.username && {creator.username}} + + +
+ + ) +} diff --git a/web/lib/supabase/votes.ts b/web/lib/supabase/votes.ts new file mode 100644 index 0000000..b84a050 --- /dev/null +++ b/web/lib/supabase/votes.ts @@ -0,0 +1,25 @@ +import {run} from 'common/supabase/utils' +import {db} from 'web/lib/supabase/db' + +export const getVotes = async () => { + const {data, error} = await db.rpc('get_votes_with_results' as any); + if (error) throw error; + + // data.forEach((vote: any) => { + // console.log(vote.title, vote.votes_for, vote.votes_against, vote.votes_abstain); + // }); + + return data +} + +export const getVoteCreator = async (creatorId: string) => { + const {data} = await run( + db + .from('users') + .select(`id, name, username`) + .eq('id', creatorId) + .limit(1) + ) + + return data[0] +} diff --git a/web/pages/about.tsx b/web/pages/about.tsx index 5ccf8e9..a36206d 100644 --- a/web/pages/about.tsx +++ b/web/pages/about.tsx @@ -61,7 +61,7 @@ export default function About() { Governed by the community, while ensuring no drift through our Governed and voted by the community, while ensuring no drift through our constitution.} /> diff --git a/web/pages/organization.tsx b/web/pages/organization.tsx index 830e921..1e6e83c 100644 --- a/web/pages/organization.tsx +++ b/web/pages/organization.tsx @@ -16,6 +16,7 @@ export default function Organization() { > + diff --git a/web/pages/vote.tsx b/web/pages/vote.tsx new file mode 100644 index 0000000..9eb24b8 --- /dev/null +++ b/web/pages/vote.tsx @@ -0,0 +1,36 @@ +import {LovePage} from 'web/components/love-page' +import {Col} from 'web/components/layout/col' +import {SEO} from 'web/components/SEO' +import {useUser} from 'web/hooks/use-user' +import {BackButton} from 'web/components/back-button' +import {CompassLoadingIndicator} from "web/components/widgets/loading-indicator"; +import {VoteComponent} from "web/components/votes/vote-info"; + + +export default function VotePage() { + const user = useUser() + + // console.log('user:', user) + + return ( + + + + + {user === undefined ? ( + + ) : ( + + + + )} + + ) +} diff --git a/web/public/md/faq.md b/web/public/md/faq.md index 3d7bff0..ca3b796 100644 --- a/web/public/md/faq.md +++ b/web/public/md/faq.md @@ -36,7 +36,7 @@ Martin continues to serve as an initiator and steward of Compass, but its direct Compass is run democratically under a [constitution](/constitution) that prevents central control and ensures long-term alignment with its mission. -* Major decisions (scope, funding, rules) are voted on by **active contributors**. +* Major decisions (scope, funding, rules) are [voted](/vote) on by **active contributors**. * The full constitution is **public and transparent**. * No corporate capture — Compass will always remain a community-owned project. @@ -103,6 +103,7 @@ We chose the name Compass because our goal is to help people orient themselves t * **Give Feedback**: [Fill out the suggestion form](https://forms.gle/tKnXUMAbEreMK6FC6) * **Join the Discussion**: [Discord Community](https://discord.gg/8Vd7jzqjun) +* **Vote on proposals**: [vote here](/vote) * **Contribute to Development**: [View the code on GitHub](https://github.com/CompassConnections/Compass) * **Donate**: [Support the infrastructure](/support) * **Spread the Word**: Tell friends and family who value depth and real connection. diff --git a/web/styles/globals.css b/web/styles/globals.css index 471c0f1..66283b2 100644 --- a/web/styles/globals.css +++ b/web/styles/globals.css @@ -105,11 +105,82 @@ --color-no-950: 17 17 17; --color-no-1000: 0 0 0; + --color-green-950: 5 46 22; /* darkest green */ + --color-green-900: 20 83 45; + --color-green-800: 22 101 52; + --color-green-700: 21 128 61; + --color-green-600: 22 163 74; + --color-green-500: 34 197 94; /* standard green */ + --color-green-400: 74 222 128; + --color-green-300: 110 231 183; /* vibrant but not neon */ + --color-green-200: 167 243 208; /* gentle mid-light tone */ + --color-green-100: 209 260 229; /* soft and airy */ + --color-green-50: 240 263 245; /* subtle, barely tinted background */ + + --color-red-950: 69 10 10; /* darkest red */ + --color-red-900: 127 29 29; + --color-red-800: 153 27 27; + --color-red-700: 185 28 28; + --color-red-600: 220 38 38; + --color-red-500: 239 68 68; /* standard red */ + --color-red-400: 248 113 113; + --color-red-300: 252 165 165; + --color-red-200: 254 202 202; + --color-red-100: 254 226 226; + --color-red-50: 254 242 242; /* lightest red */ + + --color-yellow-950: 66 50 3; /* darkest yellow */ + --color-yellow-900: 113 63 18; + --color-yellow-800: 146 64 14; + --color-yellow-700: 180 83 9; + --color-yellow-600: 217 119 6; + --color-yellow-500: 245 158 11; /* standard yellow */ + --color-yellow-400: 251 191 36; + --color-yellow-300: 252 211 77; + --color-yellow-200: 253 230 138; + --color-yellow-100: 254 243 199; + --color-yellow-50: 255 251 235; /* lightest yellow */ } .dark { color-scheme: dark; + --color-green-50: 240 253 244; /* lightest green */ + --color-green-100: 220 252 231; + --color-green-200: 187 247 208; + --color-green-300: 134 239 172; + --color-green-400: 74 222 128; + --color-green-500: 34 197 94; /* standard green */ + --color-green-600: 22 163 74; + --color-green-700: 21 128 61; + --color-green-800: 22 101 52; + --color-green-900: 20 83 45; + --color-green-950: 5 46 22; /* darkest green */ + + --color-red-50: 254 242 242; /* lightest red */ + --color-red-100: 254 226 226; + --color-red-200: 254 202 202; + --color-red-300: 252 165 165; + --color-red-400: 248 113 113; + --color-red-500: 239 68 68; /* standard red */ + --color-red-600: 220 38 38; + --color-red-700: 185 28 28; + --color-red-800: 153 27 27; + --color-red-900: 127 29 29; + --color-red-950: 69 10 10; /* darkest red */ + + --color-yellow-50: 255 251 235; /* lightest yellow */ + --color-yellow-100: 254 243 199; + --color-yellow-200: 253 230 138; + --color-yellow-300: 252 211 77; + --color-yellow-400: 251 191 36; + --color-yellow-500: 245 158 11; /* standard yellow */ + --color-yellow-600: 217 119 6; + --color-yellow-700: 180 83 9; + --color-yellow-800: 146 64 14; + --color-yellow-900: 113 63 18; + --color-yellow-950: 66 50 3; /* darkest yellow */ + --color-ink-1000: 255 255 255; /* white */ --color-ink-950: 250 250 250; /* #FAFAFA */ --color-ink-900: 242 242 242; /* #F2F2F2 */ diff --git a/web/tailwind.config.js b/web/tailwind.config.js index 65e48a7..782602e 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -350,6 +350,45 @@ module.exports = { 900: 'hsl(0,0%,10%)', 950: 'hsl(0,0%,5%)', }, + green: { + 50: 'rgb(var(--color-green-50) / )', + 100: 'rgb(var(--color-green-100) / )', + 200: 'rgb(var(--color-green-200) / )', + 300: 'rgb(var(--color-green-300) / )', + 400: 'rgb(var(--color-green-400) / )', + 500: 'rgb(var(--color-green-500) / )', + 600: 'rgb(var(--color-green-600) / )', + 700: 'rgb(var(--color-green-700) / )', + 800: 'rgb(var(--color-green-800) / )', + 900: 'rgb(var(--color-green-900) / )', + 950: 'rgb(var(--color-green-950) / )', + }, + yellow: { + 50: 'rgb(var(--color-yellow-50) / )', + 100: 'rgb(var(--color-yellow-100) / )', + 200: 'rgb(var(--color-yellow-200) / )', + 300: 'rgb(var(--color-yellow-300) / )', + 400: 'rgb(var(--color-yellow-400) / )', + 500: 'rgb(var(--color-yellow-500) / )', + 600: 'rgb(var(--color-yellow-600) / )', + 700: 'rgb(var(--color-yellow-700) / )', + 800: 'rgb(var(--color-yellow-800) / )', + 900: 'rgb(var(--color-yellow-900) / )', + 950: 'rgb(var(--color-yellow-950) / )', + }, + red: { + 50: 'rgb(var(--color-red-50) / )', + 100: 'rgb(var(--color-red-100) / )', + 200: 'rgb(var(--color-red-200) / )', + 300: 'rgb(var(--color-red-300) / )', + 400: 'rgb(var(--color-red-400) / )', + 500: 'rgb(var(--color-red-500) / )', + 600: 'rgb(var(--color-red-600) / )', + 700: 'rgb(var(--color-red-700) / )', + 800: 'rgb(var(--color-red-800) / )', + 900: 'rgb(var(--color-red-900) / )', + 950: 'rgb(var(--color-red-950) / )', + }, warning: '#F0D630', error: '#E70D3D', scarlet: {