Add voting / proposal page

This commit is contained in:
MartinBraquet
2025-10-17 23:15:15 +02:00
parent 8516901032
commit 8a954d3c20
21 changed files with 745 additions and 8 deletions

View File

@@ -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"

View File

@@ -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<k> } = {
'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,

View File

@@ -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 }
}

39
backend/api/src/vote.ts Normal file
View File

@@ -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<string, number> = {
'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)
}
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -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,

View File

@@ -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

View File

@@ -10,7 +10,7 @@ export type Database = {
// Allows to automatically instantiate createClient with right options
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(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<string, unknown>[]
}
get_votes_with_results: {
Args: Record<PropertyKey, never>
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

View File

@@ -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,
]

View File

@@ -86,7 +86,6 @@ const bottomNav = (
toggleTheme: () => void
) =>
buildArray<Item>(
!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 }
)

View File

@@ -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<void>
className?: string
}) {
const user = useUser()
const { voteId, counts, onVoted, className } = props
const [loading, setLoading] = useState<VoteChoice | null>(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 (
<Button
size="xs"
disabled={disabled}
className={clsx('px-4 py-2 rounded-lg', color)}
onClick={onClick}
color={'gray-white'}
>
<div className="font-semibold mx-2">{count}</div>
<div className="text-sm">{title}</div>
</Button>
)
}
const priorities = [
{ label: 'Urgent', value: 3 },
{ label: 'High', value: 2 },
{ label: 'Medium', value: 1 },
{ label: 'Low', value: 0 },
] as const
return (
<Row className={clsx('gap-4 mt-2', className)}>
<div className="relative">
<VoteButton
color={clsx('bg-green-700 text-white hover:bg-green-500')}
count={counts.for}
title={'For'}
disabled={disabled}
onClick={() => handleVote('for')}
/>
{showPriority && (
<div className={clsx(
'absolute z-10 mt-2 w-40 rounded-md border border-ink-200 bg-white shadow-lg',
'dark:bg-ink-900'
)}>
{priorities.map((p) => (
<button
key={p.value}
className={clsx(
'w-full text-left px-3 py-2 text-sm hover:bg-ink-100',
'dark:hover:bg-ink-800'
)}
onClick={async () => {
setShowPriority(false)
await sendVote('for', p.value)
}}
>
{p.label} priority
</button>
))}
</div>
)}
</div>
<VoteButton
color={clsx('bg-yellow-700 text-white hover:bg-yellow-500')}
count={counts.abstain}
title={'Abstain'}
disabled={disabled}
onClick={() => handleVote('abstain')}
/>
<VoteButton
color={clsx('bg-red-700 text-white hover:bg-red-500')}
count={counts.against}
title={'Against'}
disabled={disabled}
onClick={() => handleVote('against')}
/>
</Row>
)
}

View File

@@ -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<string>('')
const [editor, setEditor] = useState<any>(null)
const hideButton = title.length == 0
console.debug('votes', votes)
return (
<Col className="mx-4">
<Title className="!mb-2 text-3xl">Proposals</Title>
{votes && votes.length > 0 && <Col className={'mt-4'}>
{votes.map((vote: Vote) => {
return (
<VoteItem key={vote.id} vote={vote} onVoted={refreshVotes}/>
)
})}
</Col>}
{user && <Col>
<Title className="!mb-2 text-3xl">Add a new proposal</Title>
<Input
value={title}
placeholder={'Title'}
className={'w-full mb-2'}
onChange={(e) => {
setTitle(e.target.value)
}}
/>
<VoteCreator
onEditor={(e) => setEditor(e)}
/>
{!hideButton && (
<Row className="right-1 justify-between gap-2">
<Button
size="xs"
onClick={async () => {
const data = {
title: title,
description: editor.getJSON() as JSONContent,
};
const newVote = await api('create-vote', data).catch(() => {
toast.error('Failed to create vote — try again or contact us...')
})
if (!newVote) return
toast.success('Vote created')
console.debug('Vote created', newVote)
refreshVotes()
}}
>
Submit
</Button>
</Row>
)}
</Col>
}
</Col>
)
}
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 (
<div className={'mb-2'}>
{/*<p>Description</p>*/}
<TextEditor
editor={editor}
onBlur={() => onBlur?.(editor)}
/>
</div>
)
}

View File

@@ -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<void>
}) {
const {vote, onVoted} = props
const [creator, setCreator] = useState<any>(null)
useEffect(() => {
getVoteCreator(vote.creator_id).then(setCreator)
}, [vote.creator_id])
console.debug('creator', creator)
return (
<Col className={'mb-4 rounded-lg border border-ink-200 p-4'}>
<Row className={'mb-2'}>
<Col className={'flex-grow'}>
<p className={'text-2xl'}>{vote.title}</p>
<Col className='text-sm text-gray-500 italic'>
<Content className="w-full" content={vote.description as JSONContent}/>
</Col>
{vote.priority && <Col>Priority: {vote.priority / vote.votes_for}</Col>}
{creator?.username && <Col className={'mt-2 customlink text-right'}><Link href={`/${creator.username}`}>{creator.username}</Link></Col>}
<VoteButtons
voteId={vote.id}
counts={{
for: vote.votes_for,
abstain: vote.votes_abstain,
against: vote.votes_against,
}}
onVoted={onVoted}
/>
</Col>
</Row>
</Col>
)
}

25
web/lib/supabase/votes.ts Normal file
View File

@@ -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]
}

View File

@@ -61,7 +61,7 @@ export default function About() {
<AboutBlock
title="Democratic"
text={<span
className="customlink">Governed by the community, while ensuring no drift through our <Link
className="customlink">Governed and <Link href="/vote">voted</Link> by the community, while ensuring no drift through our <Link
href="/constitution">constitution</Link>.</span>}
/>

View File

@@ -16,6 +16,7 @@ export default function Organization() {
>
<GeneralButton url={'/support'} content={'Support'}/>
<GeneralButton url={'/constitution'} content={'Constitution'}/>
<GeneralButton url={'/vote'} content={'Proposals'}/>
<GeneralButton url={'/financials'} content={'Financials'}/>
<GeneralButton url={'/stats'} content={'Growth & Stats'}/>
<GeneralButton url={'/terms'} content={'Terms and Conditions'}/>

36
web/pages/vote.tsx Normal file
View File

@@ -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 (
<LovePage
trackPageView={'vote page'}
className={'relative p-2 sm:pt-0'}
>
<SEO
title={`Votes`}
description={'A place to vote on decisions'}
url={`/vote`}
/>
<BackButton className="-ml-2 mb-2 self-start"/>
{user === undefined ? (
<CompassLoadingIndicator/>
) : (
<Col className={'gap-4'}>
<VoteComponent/>
</Col>
)}
</LovePage>
)
}

View File

@@ -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.

View File

@@ -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 */

View File

@@ -350,6 +350,45 @@ module.exports = {
900: 'hsl(0,0%,10%)',
950: 'hsl(0,0%,5%)',
},
green: {
50: 'rgb(var(--color-green-50) / <alpha-value>)',
100: 'rgb(var(--color-green-100) / <alpha-value>)',
200: 'rgb(var(--color-green-200) / <alpha-value>)',
300: 'rgb(var(--color-green-300) / <alpha-value>)',
400: 'rgb(var(--color-green-400) / <alpha-value>)',
500: 'rgb(var(--color-green-500) / <alpha-value>)',
600: 'rgb(var(--color-green-600) / <alpha-value>)',
700: 'rgb(var(--color-green-700) / <alpha-value>)',
800: 'rgb(var(--color-green-800) / <alpha-value>)',
900: 'rgb(var(--color-green-900) / <alpha-value>)',
950: 'rgb(var(--color-green-950) / <alpha-value>)',
},
yellow: {
50: 'rgb(var(--color-yellow-50) / <alpha-value>)',
100: 'rgb(var(--color-yellow-100) / <alpha-value>)',
200: 'rgb(var(--color-yellow-200) / <alpha-value>)',
300: 'rgb(var(--color-yellow-300) / <alpha-value>)',
400: 'rgb(var(--color-yellow-400) / <alpha-value>)',
500: 'rgb(var(--color-yellow-500) / <alpha-value>)',
600: 'rgb(var(--color-yellow-600) / <alpha-value>)',
700: 'rgb(var(--color-yellow-700) / <alpha-value>)',
800: 'rgb(var(--color-yellow-800) / <alpha-value>)',
900: 'rgb(var(--color-yellow-900) / <alpha-value>)',
950: 'rgb(var(--color-yellow-950) / <alpha-value>)',
},
red: {
50: 'rgb(var(--color-red-50) / <alpha-value>)',
100: 'rgb(var(--color-red-100) / <alpha-value>)',
200: 'rgb(var(--color-red-200) / <alpha-value>)',
300: 'rgb(var(--color-red-300) / <alpha-value>)',
400: 'rgb(var(--color-red-400) / <alpha-value>)',
500: 'rgb(var(--color-red-500) / <alpha-value>)',
600: 'rgb(var(--color-red-600) / <alpha-value>)',
700: 'rgb(var(--color-red-700) / <alpha-value>)',
800: 'rgb(var(--color-red-800) / <alpha-value>)',
900: 'rgb(var(--color-red-900) / <alpha-value>)',
950: 'rgb(var(--color-red-950) / <alpha-value>)',
},
warning: '#F0D630',
error: '#E70D3D',
scarlet: {