Compare commits

...
138 changed files with 1924 additions and 705 deletions

No files matched your search

+3 -2
View File
@@ -11027,14 +11027,15 @@
},
"outcome": {
"description": "What happened to the reported content or account. Required for actioned, where no_violation is refused. Optional for no_violation, where it can only be no_violation. Refused for duplicate. The reporter is told it",
"x-enumNames": ["removed", "restricted", "action_taken", "no_violation"],
"x-enumNames": ["removed", "warned", "restricted", "action_taken", "no_violation"],
"x-enumDescriptions": [
"The reported content, profile field or community was removed",
"The account responsible for the reported item was warned",
"The reported account was restricted and the content was left up",
"Action was taken under the rules, and the reporter is not told which",
"The reported content or account does not break the rules and was left as it is"
],
"enum": ["removed", "restricted", "action_taken", "no_violation"],
"enum": ["removed", "warned", "restricted", "action_taken", "no_violation"],
"type": "string"
},
"duplicate_of": {
@@ -98,11 +98,23 @@ pub fn field_label(for_id: &str, text: &str) -> Markup {
}
}
pub fn required_mark() -> Markup {
html! {
span class="text-red-500 ml-0.5" { "*" }
}
}
fn required_label(for_id: &str, text: &str) -> Markup {
html! {
label for=(for_id) class=(FIELD_LABEL_CLASS) { (text) (required_mark()) }
}
}
pub fn internal_note_field(id_prefix: &str, help: &str) -> Markup {
let id = format!("{id_prefix}-internal-note");
html! {
div class="space-y-1" {
(field_label(&id, "Internal note"))
(required_label(&id, "Internal note"))
input type="text" id=(id) name="internal_note" required
maxlength=(INTERNAL_NOTE_MAX_CHARS) class=(FIELD_INPUT_CLASS);
p class=(FIELD_HELP_CLASS) { (help) }
@@ -147,14 +159,25 @@ pub fn statement_field(id_prefix: &str, help: &str) -> Markup {
}
pub fn radio_group(legend: &str, name: &str, id_prefix: &str, options: &[(&str, &str)]) -> Markup {
checked_radio_group(legend, name, id_prefix, options, None)
}
fn checked_radio_group(
legend: &str,
name: &str,
id_prefix: &str,
options: &[(&str, &str)],
checked: Option<&str>,
) -> Markup {
html! {
fieldset class="space-y-1" {
legend class=(FIELD_LABEL_CLASS) { (legend) }
legend class=(FIELD_LABEL_CLASS) { (legend) (required_mark()) }
div class="flex flex-wrap gap-x-4 gap-y-1" {
@for (value, label) in options {
@let id = format!("{id_prefix}-{name}-{value}");
div class="flex items-center gap-2" {
input type="radio" id=(id) name=(name) value=(value) required
checked[checked == Some(*value)]
class="h-4 w-4 accent-neutral-900";
label for=(id) class="text-sm text-neutral-900" { (label) }
}
@@ -181,7 +204,7 @@ pub fn reason_select(
let id = format!("{id_prefix}-reason");
html! {
div class="space-y-1" {
(field_label(&id, "Reason"))
(required_label(&id, "Reason"))
select id=(id) name="reason_code" required class=(FIELD_INPUT_CLASS) {
option value="" disabled selected { "Choose a reason" }
@for (handling, group) in HANDLING_GROUPS
@@ -226,13 +249,14 @@ pub fn enforcement_fields(fields: &DecisionFields<'_>) -> Markup {
let reports_id = format!("{prefix}-report-ids");
let messages_id = format!("{prefix}-evidence");
let rights_holder_id = format!("{prefix}-rights-holder");
let from_report = !fields.report_ids.is_empty();
html! {
div class="space-y-4" data-enforcement-fields="" {
(reason_select(prefix, fields.kind, fields.allow_restricted))
(radio_group("How did we find this?", "source", prefix, &[
(checked_radio_group("How did we find this?", "source", prefix, &[
("report", "A report"),
("own_review", "Our own review"),
]))
], from_report.then_some("report")))
div class="space-y-1" {
(radio_group("Automated tools", "automation", prefix, &[
("none", "Not involved"),
@@ -243,7 +267,10 @@ pub fn enforcement_fields(fields: &DecisionFields<'_>) -> Markup {
}
}
div class="space-y-1" {
(field_label(&reports_id, "Reports"))
label for=(reports_id) class=(FIELD_LABEL_CLASS) {
"Reports"
span data-show-for-source="report" hidden[!from_report] { (required_mark()) }
}
textarea id=(reports_id) name="report_ids" rows="2"
placeholder="One report ID per line" class=(FIELD_INPUT_CLASS) {
(fields.report_ids.join("\n"))
@@ -257,13 +284,19 @@ pub fn enforcement_fields(fields: &DecisionFields<'_>) -> Markup {
class=(FIELD_INPUT_CLASS) {
(messages.join("\n"))
}
p class=(FIELD_HELP_CLASS) data-hide-for-codes=(restricted_codes) {
"Messages this user sent in a linked report are included automatically. Without any messages, add a note to the user."
}
p class=(FIELD_HELP_CLASS) data-show-for-codes=(restricted_codes) hidden {
"Messages this user sent in a linked report are included automatically. At least one message is required."
}
}
}
div data-hide-for-codes=(restricted_codes) {
(statement_field(prefix, "Shown to the user. Never name or describe other people. Not sent for child safety or violent extremism decisions."))
}
div class="space-y-1" data-show-for-codes=(COPYRIGHT_REASON_CODE) hidden {
(field_label(&rights_holder_id, "Rights holder or right concerned"))
(required_label(&rights_holder_id, "Rights holder or right concerned"))
input type="text" id=(rights_holder_id) name="rights_holder" required disabled
maxlength=(RIGHTS_HOLDER_MAX_CHARS) class=(FIELD_INPUT_CLASS);
p class=(FIELD_HELP_CLASS) {
@@ -626,7 +659,43 @@ mod tests {
assert!(html.contains(marker), "missing {marker}\n{html}");
}
assert!(!html.contains(r#"name="automation" value="automated""#));
assert!(!html.contains(r#"checked"#));
assert_eq!(html.matches("checked").count(), 1);
assert!(html.contains(r#"name="source" value="report" required checked"#));
assert!(html.contains(r#"<span data-show-for-source="report"><span"#));
}
#[test]
fn fieldset_without_a_report_leaves_the_source_open() {
let html = enforcement_fields(&DecisionFields {
id_prefix: "limit",
kind: EnforcementKindType::FeatureLimit,
report_ids: &[],
messages: Some(&[]),
user_flags: None,
allow_restricted: true,
})
.into_string();
assert!(!html.contains("checked"));
assert!(html.contains(r#"<span data-show-for-source="report" hidden><span"#));
}
#[test]
fn required_fields_carry_a_mark() {
let html = render(EnforcementKindType::FeatureLimit, 0);
let mark = r#"<span class="text-red-500 ml-0.5">*</span>"#;
for label in [
"Reason",
"How did we find this?",
"Automated tools",
"Internal note",
"Rights holder or right concerned",
] {
assert!(html.contains(&format!("{label}{mark}")), "unmarked {label}");
}
for label in ["Messages<", "Note to the user<"] {
assert!(html.contains(label), "marked {label}");
}
assert_eq!(html.matches(mark).count(), 6);
}
#[test]
@@ -38,6 +38,7 @@ pub struct Message {
pub channel_nsfw: Option<bool>,
pub channel_content_warning_level: Option<i32>,
pub channel_content_warning_text: Option<String>,
pub guild_id: Option<String>,
pub guild_nsfw: Option<bool>,
pub attachments: Vec<Attachment>,
pub missing_attachments: Vec<MissingAttachment>,
@@ -85,6 +86,7 @@ fn message_from_value(value: &Value) -> Message {
channel_content_warning_text: value["channel_content_warning_text"]
.as_str()
.map(ToOwned::to_owned),
guild_id: value_id(&value["guild_id"]),
guild_nsfw: value["guild_nsfw"].as_bool(),
attachments,
missing_attachments,
@@ -110,6 +110,48 @@ fn render_missing_attachments(msg: &Message) -> Markup {
}
}
fn copy_button(value: &str, label: &str) -> Markup {
html! {
button type="button"
class="cursor-pointer text-blue-600 hover:underline"
data-copy-value=(value)
onclick="window.__adminCopyToClipboard && window.__adminCopyToClipboard(this.dataset.copyValue, this, 'Copied')" {
(label)
}
}
}
fn message_meta(msg: &Message, jump_link: Option<&str>, take_action: Option<&str>) -> Markup {
html! {
div class="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-neutral-400 text-xs" {
span class="select-all" { (msg.id) }
@if let Some(href) = take_action {
a href=(href) class="text-blue-600 hover:underline" { "Take action" }
}
@if let Some(link) = jump_link {
(copy_button(link, "Copy link"))
}
(copy_button(&msg.id, "Copy message ID"))
@if !msg.channel_id.is_empty() {
(copy_button(&msg.channel_id, "Copy channel ID"))
}
}
}
}
fn message_jump_link(web_app_endpoint: &str, msg: &Message) -> Option<String> {
if msg.channel_id.is_empty() {
return None;
}
Some(format!(
"{}/channels/{}/{}/{}",
web_app_endpoint.trim_end_matches('/'),
msg.guild_id.as_deref().unwrap_or("@me"),
msg.channel_id,
msg.id
))
}
fn message_row(
base_path: &str,
avatar_url: &str,
@@ -117,7 +159,7 @@ fn message_row(
include_delete: bool,
is_highlighted: bool,
is_grouped: bool,
take_action: Option<&str>,
meta: &Markup,
) -> Markup {
let hover = if is_highlighted {
" hover:bg-amber-100"
@@ -151,12 +193,7 @@ fn message_row(
(render_other_attachments(msg, has_content_or_images))
}
(render_missing_attachments(msg))
div class="mt-1 text-neutral-400 text-xs" {
span class="" { (msg.id) }
@if let Some(href) = take_action {
a href=(href) class="ml-2 text-blue-600 hover:underline" { "Take action" }
}
}
(meta)
@if include_delete && !has_images {
div class="absolute top-0 right-2 hidden group-hover:block" {
button type="button"
@@ -237,12 +274,7 @@ fn message_row(
(render_other_attachments(msg, has_content_or_images))
}
(render_missing_attachments(msg))
div class="mt-1 text-neutral-400 text-xs" {
span class="" { (msg.id) }
@if let Some(href) = take_action {
a href=(href) class="ml-2 text-blue-600 hover:underline" { "Take action" }
}
}
(meta)
}
@if include_delete && !has_images {
div class="absolute top-1 right-2 hidden group-hover:block" {
@@ -287,10 +319,12 @@ pub fn message_list(
action.report_id,
Some((&msg.channel_id, &msg.id)),
));
@let jump_link = message_jump_link(&config.web_app_endpoint, msg);
@let meta = message_meta(msg, jump_link.as_deref(), take_action_href.as_deref());
(message_row(
base_path, &avatar_url, msg,
include_delete, is_highlighted, is_grouped,
take_action_href.as_deref(),
&meta,
))
}
}
@@ -319,3 +353,50 @@ const MESSAGE_DELETE_PREFILL_SCRIPT: &str = r#"(function() {
form.querySelector('[name="basis"]').focus({preventScroll: true});
});
})();"#;
#[cfg(test)]
mod tests {
use super::*;
use crate::templates::components::message_data::ordered_messages;
use serde_json::json;
fn message(guild_id: serde_json::Value, channel_id: serde_json::Value) -> Message {
ordered_messages(&[json!({
"id": "1560373081531219968",
"channel_id": channel_id,
"guild_id": guild_id,
"author_id": "1",
"author_username": "someone",
})])
.remove(0)
}
#[test]
fn jump_link_uses_guild_id_and_web_app_endpoint() {
let msg = message(json!("42"), json!("7"));
assert_eq!(
message_jump_link("https://web.fluxer.example/", &msg).as_deref(),
Some("https://web.fluxer.example/channels/42/7/1560373081531219968")
);
}
#[test]
fn jump_link_falls_back_to_me_for_dms() {
let msg = message(json!(null), json!("7"));
assert_eq!(
message_jump_link("https://web.fluxer.example", &msg).as_deref(),
Some("https://web.fluxer.example/channels/@me/7/1560373081531219968")
);
}
#[test]
fn meta_without_channel_skips_link_and_channel_copy() {
let msg = message(json!(null), json!(null));
assert_eq!(message_jump_link("https://web.fluxer.example", &msg), None);
let html = message_meta(&msg, None, None).into_string();
assert!(html.contains(r#"data-copy-value="1560373081531219968""#));
assert!(html.contains("Copy message ID"));
assert!(!html.contains("Copy link"));
assert!(!html.contains("Copy channel ID"));
}
}
+1 -1
View File
@@ -663,7 +663,7 @@ pub const ENFORCEMENT_FIELDS_SCRIPT: &str = r#"
syncReason(form, control.value);
} else if (control instanceof HTMLSelectElement && (control.name === 'resolution' || control.name === 'duration_hours')) {
syncChoice(form, control.name, control.value);
} else if (control instanceof HTMLInputElement && control.type === 'radio' && (control.name === 'basis' || control.name === 'limit')) {
} else if (control instanceof HTMLInputElement && control.type === 'radio' && (control.name === 'basis' || control.name === 'limit' || control.name === 'source')) {
syncChoice(form, control.name, control.value);
}
});
@@ -601,13 +601,14 @@ fn resolve_report_form(base: &str, report_id: &str, csrf_token: &str) -> Markup
class={(FORM_CONTROL_CLASS) " h-8 appearance-none px-3 py-1.5 pr-10"} {
option value="" selected disabled { "Choose one" }
option value="removed" { "Content removed" }
option value="warned" { "Warning given" }
option value="restricted" { "Account restricted" }
option value="action_taken" { "Other action taken" }
}
(select_chevron())
}
p class="text-neutral-500 text-xs" {
"What happened to the reported content or account. The reporter is told it. Other action taken, such as a warning, tells them we acted but not how."
"What happened to the reported content or account. The reporter is told it. Warning given and other action taken tell them we acted but not how."
}
}
fieldset class="flex flex-col gap-3" data-show-for-resolution="duplicate" hidden disabled {
@@ -18,7 +18,7 @@ use crate::{
enforcement_fields::{
BasisFields, DecisionFields, FIELD_HELP_CLASS, FIELD_INPUT_CLASS,
FIELD_LABEL_CLASS, basis_fields, deletion_days_field, enforcement_fields,
field_label, internal_note_field, network_block_fields,
field_label, internal_note_field, network_block_fields, required_mark,
},
form::{checkbox, csrf_input, danger_button, form_actions, submit_button},
page_container::card_with_header,
@@ -550,7 +550,7 @@ fn limit_card(base: &str, user: &AdminUser, csrf_token: &str, prefill: &Decision
allow_restricted: true,
}))
fieldset class="space-y-1" {
legend class=(FIELD_LABEL_CLASS) { "Limit" }
legend class=(FIELD_LABEL_CLASS) { "Limit" (required_mark()) }
div class="flex flex-wrap gap-x-4 gap-y-1" {
@for limit in ENFORCEMENT_LIMITS {
@let id = format!("limit-{}", limit.value);
+11
View File
@@ -466,6 +466,16 @@ async fn moderation_tab_without_a_restriction_offers_suspend_and_warn_with_prefi
assert!(suspend.contains(&format!(">{REPORT_ID}</textarea>")));
assert!(suspend.contains(&format!(">{CHANNEL_ID}/{MESSAGE_ID}</textarea>")));
assert!(section(&html, "?action=warn").contains("Send warning"));
let limit = between(&html, "?action=limit_features", "?action=warn");
for expected in [
format!(">{REPORT_ID}</textarea>"),
format!(">{CHANNEL_ID}/{MESSAGE_ID}</textarea>"),
r#"name="source" value="report" required checked"#.to_owned(),
r#"Limit<span class="text-red-500 ml-0.5">*</span></legend>"#.to_owned(),
r#"Internal note<span class="text-red-500 ml-0.5">*</span></label>"#.to_owned(),
] {
assert!(limit.contains(&expected), "missing {expected}\n{limit}");
}
}
fn between<'a>(html: &'a str, start: &str, end: &str) -> &'a str {
@@ -509,6 +519,7 @@ async fn moderation_tab_offers_close_and_limit_features() {
assert!(limit.contains(expected), "missing {expected}\n{limit}");
}
assert!(!limit.contains(r#"<option value="11""#));
assert!(!limit.contains("checked"));
}
#[tokio::test]
@@ -186,6 +186,8 @@ async fn report_resolve_sends_notify_reporter_from_the_checkbox() {
assert_eq!(duplicate.body["notify_reporter"], json!(true));
assert!(duplicate.body.get("outcome").is_none());
assert_eq!(duplicate.body["duplicate_of"], json!("1400000000000000001"));
let warned = submit(&uri, "resolution=actioned&outcome=warned").await;
assert_eq!(warned.body["outcome"], json!("warned"));
let other_action = submit(&uri, "resolution=actioned&outcome=action_taken").await;
assert_eq!(other_action.body["outcome"], json!("action_taken"));
}
@@ -33,6 +33,8 @@ interface ReporterNoticeResult {
skipped: ReporterNoticeSkipReason | null;
}
type ReporterToldOutcome = Exclude<ReportItemOutcome, 'warned'>;
function skippedNotice(reason: ReporterNoticeSkipReason): ReporterNoticeResult {
return {dmSent: false, emailSent: false, dsaEmailSent: false, skipped: reason};
}
@@ -59,17 +61,18 @@ export class ReporterResolutionNotifier {
outcome: ReportItemOutcome,
): Promise<ReporterNoticeResult> {
const comment = publicComment ?? '';
const told = outcome === 'warned' ? 'action_taken' : outcome;
if (report.reporterId) {
return this.notifyAccountReporter(report, report.reporterId, comment, outcome);
return this.notifyAccountReporter(report, report.reporterId, comment, told);
}
return this.notifyDsaReporter(report, comment, outcome);
return this.notifyDsaReporter(report, comment, told);
}
private async notifyAccountReporter(
report: IARSubmission,
reporterId: UserID,
comment: string,
outcome: ReportItemOutcome,
outcome: ReporterToldOutcome,
): Promise<ReporterNoticeResult> {
const {users: userRepository, email: emailService} = this.deps.apiContext.services;
const reportId = report.reportId.toString();
@@ -115,7 +118,7 @@ export class ReporterResolutionNotifier {
private async notifyDsaReporter(
report: IARSubmission,
comment: string,
outcome: ReportItemOutcome,
outcome: ReporterToldOutcome,
): Promise<ReporterNoticeResult> {
const email = report.reporterEmail;
if (!email) {
@@ -134,7 +137,7 @@ export class ReporterResolutionNotifier {
reporter: User,
reportId: string,
comment: string,
outcome: ReportItemOutcome,
outcome: ReporterToldOutcome,
): Promise<boolean> {
const systemDm = this.deps.systemDm;
if (!systemDm) {
@@ -333,7 +333,7 @@ describe('Admin staff notifications', () => {
expect(dm?.content).toContain('We found that what you reported broke our rules, and we took action.');
expect(dm?.content).not.toMatch(/warn|restricted|suspend/i);
const metadata = await auditMetadata('resolve_report', reportId);
expect(metadata?.get('outcome')).toBe('action_taken');
expect(metadata?.get('outcome')).toBe('warned');
expect(metadata?.get('reporter_dm_sent')).toBe('true');
});
@@ -347,6 +347,18 @@ describe('Admin staff notifications', () => {
]);
});
test('a DSA notice resolved with a warning emails other action taken', async () => {
const {email, reportId} = await fileDsaGuildNotice();
await clearTestEmails(harness);
await resolve(reportId, {outcome: 'warned'});
const sent = await listTestEmails(harness, {recipient: email});
expect(sent.map((entry) => [entry.type, entry.metadata.outcome])).toEqual([
['dsa_report_resolved', 'action_taken'],
]);
const metadata = await auditMetadata('resolve_report', reportId);
expect(metadata?.get('outcome')).toBe('warned');
});
test('a DSA notice resolved with notify_reporter false gets the decision email without the comment', async () => {
const {email, reportId} = await fileDsaGuildNotice();
await clearTestEmails(harness);
@@ -239,7 +239,7 @@ function reportOutcome(
(removeEvidence && evidence.some((message) => message.message_id === messageId));
if (removed) return 'removed';
}
if (kind === 'warning') return 'action_taken';
if (kind === 'warning') return 'warned';
return RESTRICTING_KINDS.has(kind) ? 'restricted' : null;
}
@@ -57,6 +57,7 @@ import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {resolveSmtpTlsMode, type SmtpTlsMode, smtpTlsModeFromSecure} from '@fluxer/constants/src/SmtpConstants';
import {ConflictError} from '@fluxer/errors/src/domains/core/ConflictError';
import {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUnavailableError';
import {normalizeThemeColor} from '@fluxer/instance_bootstrap/src/ThemeColor';
import {
InstanceConfigResponse,
InstanceConfigUpdateRequest,
@@ -414,14 +415,6 @@ function normalizeOptionalPublicString(value: string | null | undefined, fallbac
return value === undefined ? fallback : normalizeOptionalString(value);
}
function normalizeThemeColor(value: unknown): string | null {
const match = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(normalizeOptionalString(value) ?? '');
if (match === null) return null;
const digits = match[1].toLowerCase();
const color = `#${digits.length === 3 ? digits.replace(/./g, '$&$&') : digits}`;
return color === '#000000' ? null : color;
}
function getDefaultPremiumProductName(): string {
return Config.instance.selfHosted ? 'Premium' : 'Plutonium';
}
+94 -206
View File
@@ -2,24 +2,17 @@
import {mkdirSync, writeFileSync} from 'node:fs';
import {dirname, join, relative} from 'node:path';
interface ColorFamily {
hue: number;
saturation: number;
useSaturationFactor: boolean;
}
interface ScaleStop {
name: string;
position?: number;
}
interface Scale {
family: string;
range: [number, number];
curve: 'linear' | 'easeIn' | 'easeOut' | 'easeInOut';
stops: Array<ScaleStop>;
}
import {
BRAND_PRESSED_RATIO,
brandTintMix,
COLOR_FAMILIES,
COLOR_SCALES,
type ColorFamily,
type Scale,
scaleStopLightness,
THEME_PALETTES,
} from '@app/features/theme/utils/ColorSystemPalette';
import {ThemeTypes} from '@fluxer/constants/src/UserConstants';
interface TokenDef {
name?: string;
@@ -295,142 +288,8 @@ const LIGHT_CODE_TOKENS: Array<TokenDef> = [
{name: '--ansi-bg-bright-white', value: 'color-mix(in srgb, hsl(0, 0%, 90%) 90%, var(--code-block-bg) 10%)'},
];
const CONFIG: Config = {
families: {
neutralDark: {hue: 258, saturation: 10, useSaturationFactor: true},
neutralLight: {hue: 220, saturation: 10, useSaturationFactor: true},
brand: {hue: 242, saturation: 70, useSaturationFactor: true},
link: {hue: 198, saturation: 92, useSaturationFactor: true},
accentPurple: {hue: 278, saturation: 85, useSaturationFactor: true},
statusOnline: {hue: 152, saturation: 72, useSaturationFactor: true},
legacyDark: {hue: 220, saturation: 13, useSaturationFactor: true},
legacyLink: {hue: 210, saturation: 100, useSaturationFactor: true},
legacyAccentPurple: {hue: 270, saturation: 80, useSaturationFactor: true},
legacyStatusOnline: {hue: 142, saturation: 76, useSaturationFactor: true},
statusIdle: {hue: 45, saturation: 93, useSaturationFactor: true},
statusDnd: {hue: 0, saturation: 84, useSaturationFactor: true},
statusOffline: {hue: 218, saturation: 11, useSaturationFactor: true},
statusDanger: {hue: 1, saturation: 77, useSaturationFactor: true},
textCode: {hue: 340, saturation: 50, useSaturationFactor: true},
brandIcon: {hue: 38, saturation: 92, useSaturationFactor: true},
},
scales: {
darkSurface: {
family: 'neutralDark',
range: [5, 24],
curve: 'easeOut',
stops: [
{name: '--background-primary', position: 0},
{name: '--background-secondary', position: 0.16},
{name: '--background-secondary-lighter', position: 0.22},
{name: '--background-secondary-alt', position: 0.28},
{name: '--background-tertiary', position: 0.4},
{name: '--background-channel-header', position: 0.34},
{name: '--guild-list-foreground', position: 0.38},
{name: '--background-header-secondary', position: 0.5},
{name: '--background-header-primary', position: 0.5},
{name: '--background-textarea', position: 0.3},
{name: '--background-header-primary-hover', position: 0.85},
],
},
coalSurface: {
family: 'neutralDark',
range: [1, 12],
curve: 'easeOut',
stops: [
{name: '--background-primary', position: 0},
{name: '--background-secondary', position: 0.16},
{name: '--background-secondary-alt', position: 0.28},
{name: '--background-tertiary', position: 0.4},
{name: '--background-channel-header', position: 0.34},
{name: '--guild-list-foreground', position: 0.38},
{name: '--background-header-secondary', position: 0.5},
{name: '--background-header-primary', position: 0.5},
{name: '--background-textarea', position: 0.3},
{name: '--background-header-primary-hover', position: 0.85},
],
},
darkText: {
family: 'neutralDark',
range: [60, 96],
curve: 'easeInOut',
stops: [
{name: '--text-tertiary-secondary', position: 0},
{name: '--text-tertiary-muted', position: 0.2},
{name: '--text-tertiary', position: 0.38},
{name: '--text-primary-muted', position: 0.55},
{name: '--text-chat-muted', position: 0.55},
{name: '--text-secondary', position: 0.72},
{name: '--text-chat', position: 0.82},
{name: '--text-primary', position: 1},
],
},
lightSurface: {
family: 'neutralLight',
range: [86, 98.5],
curve: 'easeIn',
stops: [
{name: '--background-header-primary-hover', position: 0},
{name: '--background-header-primary', position: 0.12},
{name: '--background-header-secondary', position: 0.2},
{name: '--guild-list-foreground', position: 0.35},
{name: '--background-tertiary', position: 0.42},
{name: '--background-channel-header', position: 0.5},
{name: '--background-secondary-alt', position: 0.63},
{name: '--background-secondary', position: 0.74},
{name: '--background-secondary-lighter', position: 0.83},
{name: '--background-textarea', position: 0.88},
{name: '--background-primary', position: 1},
],
},
lightText: {
family: 'neutralLight',
range: [15, 54],
curve: 'easeOut',
stops: [
{name: '--text-primary', position: 0},
{name: '--text-chat', position: 0.08},
{name: '--text-secondary', position: 0.28},
{name: '--text-chat-muted', position: 0.45},
{name: '--text-primary-muted', position: 0.45},
{name: '--text-tertiary', position: 0.6},
{name: '--text-tertiary-secondary', position: 0.75},
{name: '--text-tertiary-muted', position: 0.85},
],
},
legacyDarkSurface: {
family: 'legacyDark',
range: [5, 26],
curve: 'easeOut',
stops: [
{name: '--background-primary', position: 0},
{name: '--background-secondary', position: 0.16},
{name: '--background-secondary-lighter', position: 0.22},
{name: '--background-secondary-alt', position: 0.28},
{name: '--background-tertiary', position: 0.4},
{name: '--background-channel-header', position: 0.34},
{name: '--guild-list-foreground', position: 0.38},
{name: '--background-header-secondary', position: 0.5},
{name: '--background-header-primary', position: 0.5},
{name: '--background-textarea', position: 0.3},
{name: '--background-header-primary-hover', position: 0.85},
],
},
legacyDarkText: {
family: 'legacyDark',
range: [52, 96],
curve: 'easeInOut',
stops: [
{name: '--text-tertiary-secondary', position: 0},
{name: '--text-tertiary-muted', position: 0.2},
{name: '--text-tertiary', position: 0.38},
{name: '--text-primary-muted', position: 0.55},
{name: '--text-chat-muted', position: 0.55},
{name: '--text-secondary', position: 0.72},
{name: '--text-chat', position: 0.82},
{name: '--text-primary', position: 1},
],
},
},
families: COLOR_FAMILIES,
scales: COLOR_SCALES,
tokens: {
root: [
{scale: 'darkSurface'},
@@ -447,7 +306,7 @@ hsl(258, calc(10% * var(--saturation-factor)), 2%) 10%
{name: '--panel-control-divider', family: 'neutralDark', saturation: 30, lightness: 55, alpha: 0.35},
{name: '--panel-control-highlight', value: 'hsla(0, 0%, 100%, 0.04)'},
{name: '--background-modifier-hover', family: 'neutralDark', lightness: 100, alpha: 0.05},
{name: '--background-modifier-selected', family: 'neutralDark', lightness: 100, alpha: 0.1},
{name: '--background-modifier-selected', ...THEME_PALETTES[ThemeTypes.DARK].selectedOverlay},
{name: '--background-modifier-accent', family: 'neutralDark', saturation: 13, lightness: 80, alpha: 0.15},
{name: '--background-modifier-accent-focus', family: 'neutralDark', saturation: 13, lightness: 80, alpha: 0.22},
{name: '--control-button-normal-bg', value: 'transparent'},
@@ -480,6 +339,41 @@ hsl(258, calc(10% * var(--saturation-factor)), 2%) 10%
lightness: 100,
instanceOverride: '--instance-brand-primary-fill',
},
{
name: '--brand-primary-shade',
hue: 0,
saturation: 0,
lightness: 0,
instanceOverride: '--instance-brand-primary-shade',
},
{
name: '--brand-primary-pressed',
value: `color-mix(in srgb, var(--brand-primary) ${BRAND_PRESSED_RATIO}%, var(--brand-primary-shade))`,
},
{
name: '--brand-primary-foreground',
family: 'brand',
lightness: 55,
instanceOverride: '--instance-brand-primary-foreground',
},
{
name: '--brand-primary-light-fill',
hue: 0,
saturation: 0,
lightness: 100,
instanceOverride: '--instance-brand-primary-light-fill',
},
{
name: '--brand-tint',
value: brandTintMix(THEME_PALETTES[ThemeTypes.DARK].brandTint, 'rest'),
instanceOverride: '--instance-brand-tint',
},
{
name: '--brand-tint-hover',
value: brandTintMix(THEME_PALETTES[ThemeTypes.DARK].brandTint, 'hover'),
instanceOverride: '--instance-brand-tint-hover',
},
{name: '--brand-tint-border', value: 'var(--brand-primary)', instanceOverride: '--instance-brand-tint-border'},
{name: '--status-online', family: 'statusOnline', lightness: 40},
{name: '--status-idle', family: 'statusIdle', lightness: 50},
{name: '--status-dnd', family: 'statusDnd', lightness: 60},
@@ -500,6 +394,19 @@ hsl(258, calc(10% * var(--saturation-factor)), 2%) 10%
lightness: 98,
instanceOverride: '--instance-text-on-brand-primary',
},
{
name: '--text-on-brand-primary-light',
hue: 0,
saturation: 0,
lightness: 98,
instanceOverride: '--instance-text-on-brand-primary-light',
},
{
name: '--text-on-brand-tint',
value: 'var(--text-on-brand-primary)',
instanceOverride: '--instance-text-on-brand-tint',
},
{name: '--text-on-dark', hue: 0, saturation: 0, lightness: 98},
...DARK_CODE_TOKENS,
{name: '--text-selection', hue: 198, saturation: 92, useSaturationFactor: true, lightness: 70, alpha: 0.35},
{name: '--markup-mention-text', value: 'var(--text-link)'},
@@ -623,7 +530,7 @@ hsl(245, calc(100% * var(--saturation-factor)), 80%) 40%
{name: '--panel-control-divider', family: 'neutralLight', saturation: 30, lightness: 35, alpha: 0.2},
{name: '--panel-control-highlight', value: 'hsla(0, 0%, 100%, 0.65)'},
{name: '--background-modifier-hover', family: 'neutralLight', saturation: 10, lightness: 10, alpha: 0.05},
{name: '--background-modifier-selected', family: 'neutralLight', saturation: 10, lightness: 10, alpha: 0.1},
{name: '--background-modifier-selected', ...THEME_PALETTES[ThemeTypes.LIGHT].selectedOverlay},
{name: '--background-modifier-accent', family: 'neutralLight', saturation: 10, lightness: 40, alpha: 0.22},
{name: '--background-modifier-accent-focus', family: 'neutralLight', saturation: 10, lightness: 40, alpha: 0.32},
{name: '--control-button-normal-bg', value: 'transparent'},
@@ -667,6 +574,22 @@ hsl(245, calc(100% * var(--saturation-factor)), 80%) 40%
{name: '--plutonium-text', value: 'var(--text-on-brand-primary)'},
{name: '--plutonium-icon', family: 'brandIcon', lightness: 45},
{name: '--invite-verified-icon-color', value: 'var(--brand-primary)'},
{
name: '--brand-tint',
value: brandTintMix(THEME_PALETTES[ThemeTypes.LIGHT].brandTint, 'rest'),
instanceOverride: '--instance-brand-tint',
},
{
name: '--brand-tint-hover',
value: brandTintMix(THEME_PALETTES[ThemeTypes.LIGHT].brandTint, 'hover'),
instanceOverride: '--instance-brand-tint-hover',
},
{
name: '--brand-tint-border',
value: 'color-mix(in srgb, var(--brand-primary) 45%, transparent 55%)',
instanceOverride: '--instance-brand-tint-border',
},
{name: '--text-on-brand-tint', value: 'var(--brand-primary)', instanceOverride: '--instance-text-on-brand-tint'},
{name: '--border-color', family: 'neutralLight', lightness: 40, alpha: 0.15},
{name: '--border-color-hover', family: 'neutralLight', lightness: 40, alpha: 0.25},
{name: '--border-color-focus', hue: 210, saturation: 90, useSaturationFactor: true, lightness: 50, alpha: 0.4},
@@ -707,8 +630,10 @@ hsl(245, calc(100% * var(--saturation-factor)), 80%) 40%
],
coal: [
{scale: 'coalSurface'},
{name: '--background-secondary', value: 'var(--background-primary)'},
{name: '--background-secondary-lighter', value: 'var(--background-primary)'},
...Object.entries(THEME_PALETTES[ThemeTypes.COAL].surfaceAliases).map(([name, target]) => ({
name,
value: `var(${target})`,
})),
{
name: '--panel-control-bg',
value: `color-mix(
@@ -721,7 +646,7 @@ hsl(258, calc(10% * var(--saturation-factor)), 0%) 10%
{name: '--panel-control-divider', family: 'neutralDark', saturation: 20, lightness: 25, alpha: 0.28},
{name: '--panel-control-highlight', value: 'hsla(0, 0%, 100%, 0.06)'},
{name: '--background-modifier-hover', family: 'neutralDark', lightness: 100, alpha: 0.04},
{name: '--background-modifier-selected', family: 'neutralDark', lightness: 100, alpha: 0.08},
{name: '--background-modifier-selected', ...THEME_PALETTES[ThemeTypes.COAL].selectedOverlay},
{name: '--background-modifier-accent', family: 'neutralDark', saturation: 10, lightness: 65, alpha: 0.18},
{name: '--background-modifier-accent-focus', family: 'neutralDark', saturation: 10, lightness: 70, alpha: 0.26},
{name: '--control-button-normal-bg', value: 'transparent'},
@@ -776,7 +701,7 @@ hsl(220, calc(13% * var(--saturation-factor)), 2%) 10%
{name: '--panel-control-border', family: 'legacyDark', saturation: 30, lightness: 65, alpha: 0.45},
{name: '--panel-control-divider', family: 'legacyDark', saturation: 30, lightness: 55, alpha: 0.35},
{name: '--background-modifier-hover', family: 'legacyDark', lightness: 100, alpha: 0.05},
{name: '--background-modifier-selected', family: 'legacyDark', lightness: 100, alpha: 0.1},
{name: '--background-modifier-selected', ...THEME_PALETTES[ThemeTypes.DARK_LEGACY].selectedOverlay},
{name: '--background-modifier-accent', family: 'legacyDark', saturation: 13, lightness: 80, alpha: 0.15},
{name: '--background-modifier-accent-focus', family: 'legacyDark', saturation: 13, lightness: 80, alpha: 0.22},
{name: '--control-button-hover-bg', family: 'legacyDark', lightness: 22},
@@ -820,48 +745,13 @@ interface OutputToken {
instanceOverride?: string;
}
function clamp01(value: number): number {
return Math.min(1, Math.max(0, value));
}
function applyCurve(curve: Scale['curve'], t: number): number {
switch (curve) {
case 'easeIn':
return t * t;
case 'easeOut':
return 1 - (1 - t) * (1 - t);
case 'easeInOut':
if (t < 0.5) {
return 2 * t * t;
}
return 1 - 2 * (1 - t) * (1 - t);
default:
return t;
}
}
function buildScaleTokens(scale: Scale): Array<OutputToken> {
const lastIndex = Math.max(scale.stops.length - 1, 1);
const tokens: Array<OutputToken> = [];
for (let i = 0; i < scale.stops.length; i++) {
const stop = scale.stops[i];
let pos: number;
if (stop.position !== undefined) {
pos = clamp01(stop.position);
} else {
pos = i / lastIndex;
}
const eased = applyCurve(scale.curve, pos);
let lightness = scale.range[0] + (scale.range[1] - scale.range[0]) * eased;
lightness = Math.round(lightness * 1000) / 1000;
tokens.push({
type: 'tone',
name: stop.name,
family: scale.family,
lightness,
});
}
return tokens;
return scale.stops.map((stop, index) => ({
type: 'tone',
name: stop.name,
family: scale.family,
lightness: scaleStopLightness(scale, index),
}));
}
function expandTokens(defs: Array<TokenDef>, scales: Record<string, Scale>): Array<OutputToken> {
@@ -881,6 +771,7 @@ function expandTokens(defs: Array<TokenDef>, scales: Record<string, Scale>): Arr
type: 'literal',
name: def.name!,
value: def.value.trim(),
instanceOverride: def.instanceOverride,
});
} else {
tokens.push({
@@ -945,11 +836,8 @@ function formatTone(token: OutputToken, families: Record<string, ColorFamily>):
}
function formatValue(token: OutputToken, families: Record<string, ColorFamily>): string {
if (token.type === 'tone') {
const tone = formatTone(token, families);
return token.instanceOverride ? `var(${token.instanceOverride}, ${tone})` : tone;
}
return token.value!.trim();
const value = token.type === 'tone' ? formatTone(token, families) : token.value!.trim();
return token.instanceOverride ? `var(${token.instanceOverride}, ${value})` : value;
}
function renderBlock(selector: string, tokens: Array<OutputToken>, families: Record<string, ColorFamily>): string {
+2 -2
View File
@@ -201,7 +201,7 @@ html.platform-native .fluxer-boot-app {
.fluxer-boot-item[data-selected='1'] .fluxer-boot-fluxer-icon {
border-radius: 30%;
background-color: var(--brand-primary);
color: white;
color: var(--brand-primary-fill);
}
.fluxer-boot-action-icon {
display: flex;
@@ -218,7 +218,7 @@ html.platform-native .fluxer-boot-app {
.fluxer-boot-item[data-selected='1'] .fluxer-boot-action-icon {
border-radius: 30%;
background-color: var(--brand-primary);
color: white;
color: var(--brand-primary-fill);
}
.fluxer-boot-glyph-lg {
height: 1.75rem;
@@ -137,5 +137,5 @@
height: 1.25rem;
border-radius: 50%;
background-color: var(--brand-primary);
color: white;
color: var(--brand-primary-fill);
}
@@ -298,7 +298,7 @@
}
.channelItemAutocompleteHighlight {
box-shadow: 0 0 0 0.125rem var(--brand-primary);
box-shadow: 0 0 0 0.125rem var(--brand-primary-foreground);
}
.categoryContent {
@@ -27,5 +27,5 @@
}
:global(.theme-light) .dropIndicatorValid {
background-color: var(--brand-primary);
background-color: var(--brand-primary-foreground);
}
@@ -13,7 +13,7 @@
}
:global(.theme-light) .channelBadgeSelectedIcon {
color: #fff;
color: var(--brand-primary-fill);
}
.favoriteItemContainer {
@@ -85,7 +85,7 @@
align-items: center;
justify-content: center;
border-radius: 624.9375rem;
background-color: white;
background-color: var(--brand-primary-fill);
font-size: 0.75rem;
font-weight: 600;
color: var(--brand-primary);
@@ -219,7 +219,7 @@
.guildIconSelected.guildIconNoImage {
border-radius: 30%;
background-color: var(--brand-primary);
color: white;
color: var(--brand-primary-fill);
}
@media (hover: hover) and (pointer: fine) {
@@ -231,7 +231,7 @@
.guildListItem.contextMenuHover .guildIconNoImage {
border-radius: 30%;
background-color: var(--brand-primary);
color: white;
color: var(--brand-primary-fill);
}
}
@@ -363,7 +363,7 @@
.fluxerButtonIconSelected {
background-color: var(--brand-primary);
color: white;
color: var(--brand-primary-fill);
}
@media (hover: hover) and (pointer: fine) {
@@ -374,7 +374,7 @@
.fluxerButtonIcon:hover,
.fluxerButton.contextMenuHover .fluxerButtonIcon {
background-color: var(--brand-primary);
color: white;
color: var(--brand-primary-fill);
}
}
@@ -782,10 +782,10 @@
:global(.theme-light) .dropIndicatorTop::before,
:global(.theme-light) .dropIndicatorBottom::after,
:global(.theme-light) .guildListDropZoneActive::after {
background: var(--brand-primary);
background: var(--brand-primary-foreground);
}
:global(.theme-light) .combinePreviewGrid {
background-color: color-mix(in srgb, var(--background-secondary) 92%, var(--brand-primary) 8%);
outline-color: var(--brand-primary);
outline-color: var(--brand-primary-foreground);
}
@@ -37,7 +37,7 @@ export const NAGBAR_TONES: Readonly<Record<NagbarToneKind, NagbarTone>> = Object
}),
[NagbarToneKind.DANGER]: Object.freeze({
backgroundColor: 'var(--status-danger)',
textColor: 'var(--text-on-brand-primary)',
textColor: 'var(--text-on-dark)',
}),
[NagbarToneKind.ALERT]: Object.freeze({backgroundColor: '#ea580c', textColor: '#ffffff'}),
[NagbarToneKind.PREMIUM]: Object.freeze({backgroundColor: '#f97316', textColor: '#ffffff'}),
@@ -22,7 +22,7 @@
height: 0.125rem;
width: 100%;
border-radius: 624.9375rem;
background-color: var(--brand-primary);
background-color: var(--brand-primary-foreground);
transition:
transform 150ms,
opacity 150ms;
@@ -19,5 +19,5 @@
.iconTextSelected {
height: 1.25rem;
width: 1.25rem;
color: white;
color: var(--brand-primary-fill);
}
@@ -66,7 +66,9 @@ export const DiscoveryButton = observer(() => {
animate={{borderRadius: isHovering || isSelected ? '30%' : '50%'}}
initial={{borderRadius: isHovering || isSelected ? '30%' : '50%'}}
transition={{duration: Accessibility.useReducedMotion ? 0 : 0.07, ease: 'easeOut'}}
style={isSelected ? {backgroundColor: 'var(--brand-primary)', color: 'white'} : undefined}
style={
isSelected ? {backgroundColor: 'var(--brand-primary)', color: 'var(--brand-primary-fill)'} : undefined
}
data-flx="app.sidebar-nav.discovery-button.div--2"
>
<CompassIcon
@@ -269,10 +269,10 @@
:global(.theme-light) .dropIndicatorTop::before,
:global(.theme-light) .dropIndicatorBottom::after {
background: var(--brand-primary);
background: var(--brand-primary-foreground);
}
:global(.theme-light) .dropIndicatorInside .collapsedFolder,
:global(.theme-light) .dropIndicatorInside .folderHeaderButton {
outline-color: var(--brand-primary);
outline-color: var(--brand-primary-foreground);
}
@@ -15,7 +15,7 @@
width: 6rem;
height: 6rem;
margin-bottom: 1.5rem;
color: var(--brand-primary);
color: var(--brand-primary-foreground);
}
.content {
@@ -157,12 +157,12 @@
.editIcon {
height: 1.25rem;
width: 1.25rem;
color: white;
color: var(--brand-primary-fill);
}
.editLabel {
font-weight: 500;
color: white;
color: var(--brand-primary-fill);
}
.section {
@@ -32,7 +32,7 @@
padding: 0.25rem 0.5rem;
background-color: var(--brand-primary);
border-radius: 624.9375rem;
color: white;
color: var(--brand-primary-fill);
font-size: 0.875rem;
line-height: 1;
}
@@ -205,7 +205,7 @@
.item[data-selected='true'] .fluxerIcon {
border-radius: 30%;
background-color: var(--brand-primary);
color: white;
color: var(--brand-primary-fill);
}
.fluxerIconGlyph {
@@ -248,7 +248,7 @@
.item[data-selected='true'] .actionIcon {
border-radius: 30%;
background-color: var(--brand-primary);
color: white;
color: var(--brand-primary-fill);
}
.actionIconGlyph {
@@ -270,6 +270,21 @@ describe('runtimeSnapshotFromDiscovery', () => {
).toThrow(/appPublic\.branding\.icon_url/u);
});
test('treats a black or malformed theme colour as unset and normalises the rest', () => {
const snapshot = runtimeSnapshotFromDiscovery(parsedDocument('t.test'));
const themeColorOf = (theme_color: unknown) =>
requireRuntimeConfigSnapshot({
...snapshot,
appPublic: {...snapshot.appPublic, branding: {...snapshot.appPublic.branding, theme_color}},
}).appPublic.branding.theme_color;
for (const unset of ['#000000', '#000', '000000', '', 'rgb(1, 2, 3)', 'rebeccapurple', 0, null]) {
expect(themeColorOf(unset)).toBeNull();
}
expect(themeColorOf('#FF0000')).toBe('#ff0000');
expect(themeColorOf(' f00 ')).toBe('#ff0000');
expect(themeColorOf('#000001')).toBe('#000001');
});
test('carries the advertised domain migration', () => {
const domainMigration = {
enabled: true,
@@ -14,6 +14,7 @@ import {
} from '@fluxer/instance_bootstrap/src/Discovery';
import {InstanceEndpointKind, normalizeInstanceEndpoint} from '@fluxer/instance_bootstrap/src/EndpointNormalization';
import {CanonicalNetworkProtocol} from '@fluxer/instance_bootstrap/src/NetworkOrigin';
import {normalizeThemeColor} from '@fluxer/instance_bootstrap/src/ThemeColor';
import {
AGE_POLICY_ACTIONS,
type InstanceAgePolicy,
@@ -413,7 +414,7 @@ function readAppPublic(value: unknown): InstanceAppPublic {
logo_url: readNullableAbsoluteHttpUrl(branding, 'logo_url', 'appPublic.branding.logo_url'),
wordmark_url: readNullableAbsoluteHttpUrl(branding, 'wordmark_url', 'appPublic.branding.wordmark_url'),
favicon_url: readNullableAbsoluteHttpUrl(branding, 'favicon_url', 'appPublic.branding.favicon_url'),
theme_color: readNullableNonEmptyString(branding, 'theme_color', 'appPublic.branding.theme_color'),
theme_color: normalizeThemeColor(branding.theme_color),
status_page_url: readOptionalText(branding, 'status_page_url'),
status_page_incident_history_url: readOptionalText(branding, 'status_page_incident_history_url'),
premium_product_name: readOptionalText(branding, 'premium_product_name') ?? DEFAULT_PREMIUM_PRODUCT_NAME,
@@ -6,7 +6,7 @@
--account-profile-slot-width: 14.875rem;
--account-profile-instance-badge-size: 3rem;
--account-profile-state-badge-size: 2.25rem;
--account-profile-name-color: var(--text-on-brand-primary);
--account-profile-name-color: var(--text-on-dark);
--account-profile-domain-height: 0;
--account-profile-domain-gap: 0;
--account-profile-name-gap: 0.625rem;
@@ -100,7 +100,7 @@
.noAccounts {
margin-bottom: 1rem;
color: var(--text-on-brand-primary);
color: var(--text-on-dark);
font-size: 0.9375rem;
font-weight: 600;
line-height: 1.25rem;
@@ -49,7 +49,7 @@
.checkIcon {
height: 0.625rem;
width: 0.625rem;
color: white;
color: var(--brand-primary-fill);
}
.code {
@@ -157,7 +157,7 @@ img.entityIcon {
.themeIcon {
height: 2.5rem;
width: 2.5rem;
color: white;
color: var(--brand-primary-fill);
}
.form {
@@ -98,7 +98,7 @@
width: 1.5rem;
height: 1.5rem;
border: 0.1875rem solid var(--background-modifier-accent);
border-top-color: var(--brand-primary);
border-top-color: var(--brand-primary-foreground);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@@ -53,5 +53,5 @@
.audioIcon {
height: 1rem;
width: 1rem;
color: white;
color: var(--brand-primary-fill);
}
@@ -58,11 +58,11 @@
}
:global(.theme-light) .gifButton:hover {
border-color: var(--brand-primary);
border-color: var(--brand-primary-foreground);
}
:global(.theme-light) .gifButtonSelected {
border-color: var(--brand-primary);
border-color: var(--brand-primary-foreground);
}
.gifVideoWrapper {
@@ -317,5 +317,5 @@
:global(.theme-light) .dropIndicatorLeft::before,
:global(.theme-light) .dropIndicatorRight::after {
background: var(--brand-primary);
background: var(--brand-primary-foreground);
}
@@ -155,7 +155,7 @@
background: none;
border: none;
padding: 0 0.75rem;
color: white;
color: var(--brand-primary-fill);
font-size: 0.875rem;
font-weight: 500;
overflow: hidden;
@@ -172,7 +172,7 @@
background: none;
border: none;
padding: 0 0.75rem;
color: white;
color: var(--brand-primary-fill);
font-size: 0.875rem;
font-weight: 600;
flex-shrink: 0;
@@ -270,6 +270,16 @@
text-shadow: 0 0.0625rem 0.0625rem rgba(0, 0, 0, 0.06);
}
.gridItemCategoryEmpty .gridItemIcon,
.gridItemCategoryEmpty .gridItemCategoryTitleText {
color: var(--text-on-brand-primary-light);
}
.gridItemFavorites .gridItemIcon,
.gridItemFavorites .gridItemCategoryTitleText {
color: var(--text-on-brand-primary);
}
.gif {
width: 100%;
height: 100%;
@@ -445,6 +455,8 @@
color: var(--text-primary);
}
:global(.theme-light) .gridItemCategoryEmpty .gridItemCategoryTitleText,
:global(.theme-light) .gridItemCategoryEmpty .gridItemIcon,
:global(.theme-light) .gridItemFavorites .gridItemCategoryTitleText,
:global(.theme-light) .gridItemFavorites .gridItemIcon {
color: var(--text-on-brand-primary);
@@ -459,8 +471,8 @@
}
:global(.theme-light) .gridItemFocused {
border-color: var(--brand-primary);
box-shadow: inset 0 0 0 0.125rem var(--brand-primary);
border-color: var(--brand-primary-foreground);
box-shadow: inset 0 0 0 0.125rem var(--brand-primary-foreground);
}
:global(.theme-light) .gridItemFavorites .gridItemBackdrop {
@@ -195,7 +195,7 @@
}
:global(.theme-light) .progressBar {
background-color: var(--brand-primary);
background-color: var(--brand-primary-foreground);
}
.thContent {
@@ -138,7 +138,7 @@
.audioIcon {
height: 3rem;
width: 3rem;
color: white;
color: var(--brand-primary-fill);
flex-shrink: 0;
}
@@ -153,7 +153,7 @@
.audioDuration {
font-family: var(--font-mono);
font-size: 1.125rem;
color: white;
color: var(--brand-primary-fill);
}
.audioFilename {
@@ -164,14 +164,14 @@
text-align: center;
font-weight: 600;
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.9);
color: color-mix(in srgb, var(--brand-primary-fill) 90%, transparent);
}
.audioBadge {
padding: 0.125rem 0.5rem;
border-radius: 0.375rem;
background-color: rgba(0, 0, 0, 0.2);
background-color: color-mix(in srgb, var(--brand-primary-shade) 20%, transparent);
font-weight: 700;
font-size: 0.75rem;
color: white;
color: var(--brand-primary-fill);
}
@@ -36,16 +36,16 @@
}
.reactionMe .reactionButton {
background-color: color-mix(in srgb, var(--brand-primary) 36%, var(--background-secondary) 64%);
border-color: var(--brand-primary);
color: var(--text-on-brand-primary);
background-color: var(--brand-tint);
border-color: var(--brand-tint-border);
color: var(--text-on-brand-tint);
}
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
.reactionMe
.reactionButton:hover {
background-color: color-mix(in srgb, var(--brand-primary) 48%, var(--background-secondary) 52%);
border-color: var(--brand-primary);
background-color: var(--brand-tint-hover);
border-color: var(--brand-tint-border);
}
.reactionInner {
@@ -80,7 +80,7 @@
}
.reactionMe .countWrapper {
color: var(--text-on-brand-primary);
color: var(--text-on-brand-tint);
}
:global(.theme-light) .reactionButton {
@@ -96,20 +96,20 @@
}
:global(.theme-light) .reactionMe .reactionButton {
background-color: color-mix(in srgb, var(--brand-primary) 12%, var(--background-primary) 88%);
border-color: color-mix(in srgb, var(--brand-primary) 45%, transparent 55%);
color: var(--brand-primary);
background-color: var(--brand-tint);
border-color: var(--brand-tint-border);
color: var(--text-on-brand-tint);
}
:global(.theme-light:where(.window-focused, .unfocused-fully-interactive):not(.window-focus-activation-guard))
.reactionMe
.reactionButton:hover {
background-color: color-mix(in srgb, var(--brand-primary) 18%, var(--background-primary) 82%);
border-color: var(--brand-primary);
background-color: var(--brand-tint-hover);
border-color: var(--brand-primary-foreground);
}
:global(.theme-light) .reactionMe .countWrapper {
color: var(--brand-primary);
color: var(--text-on-brand-tint);
}
.reactionContainerDisabled {
@@ -127,8 +127,8 @@
.reactionMe .reactionButtonDisabled,
.reactionMe .reactionButtonDisabled:hover {
background-color: color-mix(in srgb, var(--brand-primary) 36%, var(--background-secondary) 64%);
border-color: var(--brand-primary);
background-color: var(--brand-tint);
border-color: var(--brand-tint-border);
}
:global(.theme-light) .reactionButtonDisabled,
@@ -139,8 +139,8 @@
:global(.theme-light) .reactionMe .reactionButtonDisabled,
:global(.theme-light) .reactionMe .reactionButtonDisabled:hover {
background-color: color-mix(in srgb, var(--brand-primary) 12%, var(--background-primary) 88%);
border-color: color-mix(in srgb, var(--brand-primary) 45%, transparent 55%);
background-color: var(--brand-tint);
border-color: var(--brand-tint-border);
}
.reactionPickerButton {
@@ -110,5 +110,5 @@
:global(.theme-light) .progressBarIndeterminate,
:global(.theme-light) .progressBarNormal {
background-color: var(--brand-primary);
background-color: var(--brand-primary-foreground);
}
@@ -170,7 +170,7 @@
}
:global(.theme-light) .tabButtonActive {
color: var(--brand-primary);
color: var(--brand-primary-foreground);
}
.tabButtonInactive {
@@ -367,7 +367,7 @@
}
.newGroupIconWhite {
color: white;
color: var(--brand-primary-fill);
}
.newGroupContent {
@@ -555,12 +555,13 @@
.quickActionButtonActive {
background-color: var(--brand-primary);
color: white;
color: var(--brand-primary-fill);
}
@media (hover: hover) and (pointer: fine) {
.quickActionButtonActive:hover {
background-color: var(--brand-primary-light);
color: var(--brand-primary-light-fill);
}
}
@@ -35,7 +35,7 @@
}
:global(.theme-light) .heroIcon {
color: var(--brand-primary);
color: var(--brand-primary-foreground);
}
.title {
@@ -90,7 +90,7 @@
:global(.theme-light) .dmListHeaderShortcut kbd {
background-color: hsl(220, calc(13% * var(--saturation-factor)), 18.44%);
color: var(--text-on-brand-primary);
color: var(--text-on-dark);
box-shadow: 0 0.0625rem 0 rgba(0, 0, 0, 0.4);
}
@@ -280,7 +280,7 @@
.clickableItemSelected .clickableItemIcon {
background-color: var(--brand-primary);
color: white;
color: var(--brand-primary-fill);
}
@media (hover: hover) and (pointer: fine) {
@@ -313,7 +313,7 @@
.sendIcon {
height: 1.5rem;
width: 1.5rem;
color: white;
color: var(--brand-primary-fill);
}
.clickableItemText {
@@ -725,7 +725,7 @@
.mobileSpecialButtonSelected .mobileSpecialButtonIcon {
background-color: var(--brand-primary);
color: white;
color: var(--brand-primary-fill);
}
.mobileSpecialButtonText {
@@ -403,13 +403,17 @@
.languageItemSelected {
background-color: var(--brand-primary);
color: #fff;
color: var(--brand-primary-fill);
}
.languageItemSelected.languageItemHighlighted {
background-color: var(--brand-primary);
}
.languageItemSelected .languageOptionCanonical {
color: inherit;
}
.languageItemIndicator {
position: absolute;
top: 50%;
@@ -322,7 +322,7 @@ export const collectGalleryImages = ({
};
export const getBorderColor = (color: number | undefined) => {
if (color === undefined || color === 0) {
return 'var(--brand-primary)';
return 'var(--brand-primary-foreground)';
}
return ColorUtils.int2rgb(color);
};
@@ -149,7 +149,7 @@
border: none;
border-radius: var(--radius-full);
background-color: rgba(0, 0, 0, 0.6);
color: var(--text-on-brand-primary);
color: var(--text-on-dark);
cursor: pointer;
z-index: 2;
transition: background-color 150ms ease;
@@ -14,5 +14,5 @@
font-weight: 600;
font-size: 0.875rem;
line-height: 1;
color: var(--text-on-brand-primary);
color: var(--text-on-dark);
}
@@ -10,7 +10,7 @@
--action-toolbar-item-size: 2rem;
--action-toolbar-item-border-width: 0px;
--action-toolbar-item-background: rgba(0, 0, 0, 0.75);
--action-toolbar-item-color: var(--text-on-brand-primary);
--action-toolbar-item-color: var(--text-on-dark);
}
.overlayButton svg {
@@ -25,5 +25,5 @@
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
.overlayButton.overlayButtonHover:hover
svg {
color: color-mix(in srgb, var(--text-on-brand-primary) 95%, transparent);
color: color-mix(in srgb, var(--text-on-dark) 95%, transparent);
}
@@ -46,11 +46,15 @@
inset: -0.1875rem;
border-radius: var(--radius-full);
border: 0.125rem solid transparent;
border-top-color: var(--text-on-brand-primary);
border-top-color: var(--brand-primary-foreground);
animation: spin 0.8s linear infinite;
pointer-events: none;
}
.containerActive .loadingSpinner {
border-top-color: var(--text-on-brand-primary);
}
@keyframes spin {
from {
transform: rotate(0deg);
@@ -84,7 +88,7 @@
}
:global(.theme-light) .waveformBarPast {
background-color: var(--brand-primary);
background-color: var(--brand-primary-foreground);
}
.timestamp {
@@ -211,13 +211,13 @@
.actionButtonAccept {
--action-toolbar-item-background: var(--friend-action-brand-background);
--action-toolbar-item-color: color-mix(in srgb, var(--brand-primary) 76%, var(--text-primary) 24%);
--action-toolbar-item-color: color-mix(in srgb, var(--brand-primary-foreground) 76%, var(--text-primary) 24%);
}
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
.actionButtonAccept:hover {
--action-toolbar-item-background: var(--friend-action-brand-hover-background);
--action-toolbar-item-color: color-mix(in srgb, var(--brand-primary) 82%, var(--text-primary) 18%);
--action-toolbar-item-color: color-mix(in srgb, var(--brand-primary-foreground) 82%, var(--text-primary) 18%);
}
.actionButtonIgnore {
@@ -41,5 +41,5 @@
}
:global(.theme-light) .progressBar {
background-color: var(--brand-primary);
background-color: var(--brand-primary-foreground);
}
@@ -114,7 +114,7 @@
.actionButton:hover {
border-color: var(--brand-primary);
background-color: var(--brand-primary);
color: white;
color: var(--brand-primary-fill);
}
.deleteButton:hover {
@@ -61,10 +61,10 @@
}
:global(.theme-light) .card:hover {
border-color: var(--brand-primary);
border-color: var(--brand-primary-foreground);
}
:global(.theme-light) .cardIcon {
background: color-mix(in srgb, var(--brand-primary) 12%, transparent);
color: var(--brand-primary);
color: var(--brand-primary-foreground);
}
@@ -36,7 +36,7 @@
padding: 0.375rem 0.75rem;
border-radius: 624.9375rem;
background-color: var(--brand-primary);
color: white;
color: var(--brand-primary-fill);
font-size: 0.875rem;
}
@@ -570,14 +570,14 @@
}
:global(.theme-light) .cardUnreadLabel {
color: var(--brand-primary);
color: var(--brand-primary-foreground);
}
.newBadge {
padding: 0 0.375rem;
border-radius: 0.25rem;
background-color: var(--status-danger);
color: var(--text-on-brand-primary);
color: var(--text-on-dark);
font-size: 0.6875rem;
font-weight: 700;
line-height: 1.125rem;
@@ -625,7 +625,7 @@
padding: 0.5rem;
border-radius: 50%;
background-color: rgb(0 0 0 / 0.6);
color: var(--text-on-brand-primary);
color: var(--text-on-dark);
transform: translate(-50%, -50%);
}
@@ -664,14 +664,14 @@
}
.reactionActive {
background-color: color-mix(in srgb, var(--brand-primary) 36%, var(--background-secondary) 64%);
border-color: var(--brand-primary);
color: var(--text-on-brand-primary);
background-color: var(--brand-tint);
border-color: var(--brand-tint-border);
color: var(--text-on-brand-tint);
}
.reactionActive:hover:not(:disabled) {
background-color: color-mix(in srgb, var(--brand-primary) 48%, var(--background-secondary) 52%);
border-color: var(--brand-primary);
background-color: var(--brand-tint-hover);
border-color: var(--brand-primary-foreground);
}
:global(.theme-light) .reaction {
@@ -681,9 +681,9 @@
}
:global(.theme-light) .reactionActive {
background-color: color-mix(in srgb, var(--brand-primary) 12%, var(--background-primary) 88%);
border-color: color-mix(in srgb, var(--brand-primary) 45%, transparent 55%);
color: var(--brand-primary);
background-color: var(--brand-tint);
border-color: var(--brand-tint-border);
color: var(--text-on-brand-tint);
}
.notice {
@@ -13,7 +13,7 @@ import {isPinnedPost} from '@app/features/forum/utils/ForumPostList';
import {MESSAGE_COUNT_DESCRIPTOR as D_POST_COUNT_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import * as ReactionCommands from '@app/features/messaging/commands/ReactionCommands';
import {Message} from '@app/features/messaging/models/MessagingMessage';
import {buildFitInsideMediaProxyURL} from '@app/features/messaging/utils/MediaProxyUtils';
import {buildFitInsideMediaProxyURL, resolvePreferredImageFormat} from '@app/features/messaging/utils/MediaProxyUtils';
import Navigation from '@app/features/navigation/state/Navigation';
import {buildMessageNotificationBody} from '@app/features/notification/utils/MessageNotificationPreview';
import Permission from '@app/features/permissions/state/Permission';
@@ -154,6 +154,7 @@ export const ForumPostCard = observer(({forum, post, grid}: ForumPostCardProps)
);
const thumbnail = media
? buildFitInsideMediaProxyURL(media.src, {
format: resolvePreferredImageFormat(),
width: grid ? GRID_THUMBNAIL_SIZE : LIST_THUMBNAIL_SIZE,
height: grid ? GRID_THUMBNAIL_SIZE : LIST_THUMBNAIL_SIZE,
})
@@ -7,31 +7,31 @@
.badge {
flex-shrink: 0;
--guild-badge-seal: white;
--guild-badge-seal: var(--brand-primary-fill);
--guild-badge-icon: var(--brand-primary);
}
.badgeOnLightSurface {
flex-shrink: 0;
--guild-badge-seal: white;
--guild-badge-seal: var(--brand-primary-fill);
--guild-badge-icon: var(--brand-primary);
}
:global(.theme-light) .badgeOnLightSurface {
--guild-badge-seal: var(--brand-primary);
--guild-badge-icon: white;
--guild-badge-icon: var(--brand-primary-fill);
}
.badgeBanner {
flex-shrink: 0;
filter: drop-shadow(0 0.0625rem 0.1875rem rgba(0, 0, 0, 0.9));
--guild-badge-seal: white;
--guild-badge-seal: var(--brand-primary-fill);
--guild-badge-icon: var(--brand-primary);
}
.badgeBannerDark {
flex-shrink: 0;
filter: drop-shadow(0 0.0625rem 0.1875rem rgba(0, 0, 0, 0.9));
--guild-badge-seal: white;
--guild-badge-seal: var(--brand-primary-fill);
--guild-badge-icon: var(--brand-primary);
}
@@ -204,6 +204,10 @@
color: var(--brand-primary-fill);
}
.overwriteItemSelected .lockIcon {
color: inherit;
}
.roleDot {
width: 0.75rem;
height: 0.75rem;
@@ -114,12 +114,14 @@ import {
} from '@app/features/input/state/KeybindResolution';
import {
DEFAULT_KEYBOARD_SHORTCUTS_OVERLAY_COMBO,
getKeyboardShortcutsOverlayComboForCurrentLayout,
DEFAULT_START_DM_CALL_COMBO,
getKeyboardLayoutDefaultCombosForCurrentLayout,
keyCombosEqual,
SHIFTED_SLASH_FALLBACK_KEYBOARD_SHORTCUTS_OVERLAY_COMBO,
} from '@app/features/input/utils/KeyboardShortcutLayoutUtils';
import AppStorage from '@app/features/platform/state/PersistentStorage';
import {awaitHydration, makePersistent} from '@app/features/platform/utils/MobXPersistence';
import {isApplePlatform} from '@app/features/platform/utils/PlatformHelpers';
import {initializeStore} from '@app/features/platform/utils/StoreInitialization';
import {makeSyncedField} from '@app/features/user/state/SyncedField';
import UserSettings from '@app/features/user/state/UserSettings';
@@ -222,6 +224,7 @@ const createBuiltinDisableMarker = (
const getDefaultKeybinds = (
i18n: KeybindLabelI18n,
keyboardShortcutsOverlayCombo: KeyCombo = DEFAULT_KEYBOARD_SHORTCUTS_OVERLAY_COMBO,
startDmCallCombo: KeyCombo = DEFAULT_START_DM_CALL_COMBO,
): ReadonlyArray<KeybindConfig> =>
[
{
@@ -360,7 +363,7 @@ const getDefaultKeybinds = (
{
action: 'nav_history_back',
label: i18n._(MOVE_BACK_THROUGH_VIEWED_CHANNEL_HISTORY_DESCRIPTOR),
combo: {key: '[', ctrlOrMeta: true},
combo: isApplePlatform() ? {key: '[', ctrlOrMeta: true} : {key: 'ArrowLeft', alt: true},
ignoreWhileTyping: true,
editableFocusBehavior: 'allow',
section: 'navigation',
@@ -368,7 +371,7 @@ const getDefaultKeybinds = (
{
action: 'nav_history_forward',
label: i18n._(MOVE_FORWARD_THROUGH_VIEWED_CHANNEL_HISTORY_DESCRIPTOR),
combo: {key: ']', ctrlOrMeta: true},
combo: isApplePlatform() ? {key: ']', ctrlOrMeta: true} : {key: 'ArrowRight', alt: true},
ignoreWhileTyping: true,
editableFocusBehavior: 'allow',
section: 'navigation',
@@ -412,13 +415,13 @@ const getDefaultKeybinds = (
{
action: 'nav_guild_tab_prev',
label: i18n._(SWITCH_TO_PREVIOUS_COMMUNITY_OR_DMS_DESCRIPTOR),
combo: {key: 'Tab', ctrlOrMeta: true, shift: true},
combo: {key: 'Tab', ctrl: true, shift: true},
section: 'navigation',
},
{
action: 'nav_guild_tab_next',
label: i18n._(SWITCH_TO_NEXT_COMMUNITY_OR_DMS_DESCRIPTOR),
combo: {key: 'Tab', ctrlOrMeta: true},
combo: {key: 'Tab', ctrl: true},
section: 'navigation',
},
{
@@ -666,7 +669,7 @@ const getDefaultKeybinds = (
{
action: 'voice_start_dm_call',
label: i18n._(START_A_CALL_IN_A_DM_OR_GROUP_DESCRIPTOR),
combo: {key: '`', code: 'Backquote', ctrl: true},
combo: {...startDmCallCombo},
section: 'voice_and_video',
},
{
@@ -899,6 +902,7 @@ class Keybind {
private i18n: I18n | null = null;
private initialized = false;
private keyboardShortcutsOverlayCombo: KeyCombo = {...DEFAULT_KEYBOARD_SHORTCUTS_OVERLAY_COMBO};
private startDmCallCombo: KeyCombo = {...DEFAULT_START_DM_CALL_COMBO};
private holdSources = new HoldSources(() => this.pushToTalkReleaseDelay);
constructor() {
@@ -1011,9 +1015,10 @@ class Keybind {
async refreshKeyboardShortcutLayout(): Promise<void> {
try {
const combo = await getKeyboardShortcutsOverlayComboForCurrentLayout();
if (!combo) return;
this.setKeyboardShortcutsOverlayCombo(combo);
const combos = await getKeyboardLayoutDefaultCombosForCurrentLayout();
if (!combos) return;
this.setKeyboardShortcutsOverlayCombo(combos.keyboardShortcutsOverlay);
this.setStartDmCallCombo(combos.startDmCall);
} catch {}
}
@@ -1028,8 +1033,15 @@ class Keybind {
});
}
private setStartDmCallCombo(combo: KeyCombo): void {
if (keyCombosEqual(this.startDmCallCombo, combo)) return;
runInAction(() => {
this.startDmCallCombo = {...combo};
});
}
private get defaultKeybinds(): ReadonlyArray<KeybindConfig> {
return getDefaultKeybinds(this.getLabelI18n(), this.keyboardShortcutsOverlayCombo);
return getDefaultKeybinds(this.getLabelI18n(), this.keyboardShortcutsOverlayCombo, this.startDmCallCombo);
}
private get runtimeDispatchDefaults(): ReadonlyArray<KeybindConfig> {
@@ -211,6 +211,7 @@ export function formatPrimaryKeySymbol(i18n: I18n, rawKey: string): string {
if (rawKey === 'Enter' || rawKey === 'Return') return isMacPlatform() ? '⏎' : i18n._(ENTER_DESCRIPTOR);
if (rawKey === 'Tab') return i18n._(TAB_DESCRIPTOR);
if (rawKey === 'Backquote' || rawKey === '`') return '`';
if (rawKey === 'Quote') return "'";
if (rawKey === '[' || rawKey === ']') return rawKey;
if (/^Key[A-Z]$/.test(rawKey)) return rawKey.slice(3);
if (/^Digit[0-9]$/.test(rawKey)) return rawKey.slice(5);
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {KeyCombo} from '@app/features/input/state/InputKeybind';
import {isPrintableShortcutKey} from '@fluxer/desktop_ipc/src/GlobalShortcutKeyNames';
interface KeyboardLayoutMapReader {
get(code: string): string | undefined;
@@ -18,6 +19,12 @@ export interface KeyboardShortcutsOverlayFallbackEvent {
export const DEFAULT_KEYBOARD_SHORTCUTS_OVERLAY_COMBO: KeyCombo = {key: '/', ctrlOrMeta: true};
export const SHIFTED_SLASH_FALLBACK_KEYBOARD_SHORTCUTS_OVERLAY_COMBO: KeyCombo = {key: '.', ctrlOrMeta: true};
export const DEFAULT_START_DM_CALL_COMBO: KeyCombo = {key: '', code: 'Quote', ctrl: true};
interface KeyboardLayoutDefaultCombos {
keyboardShortcutsOverlay: KeyCombo;
startDmCall: KeyCombo;
}
const NON_NUMPAD_PRINTABLE_LAYOUT_CODES = [
'Backquote',
@@ -110,16 +117,25 @@ function getKeyboardShortcutsOverlayComboForLayoutMap(layoutMap: KeyboardLayoutM
return copyKeyCombo(SHIFTED_SLASH_FALLBACK_KEYBOARD_SHORTCUTS_OVERLAY_COMBO);
}
function getStartDmCallComboForLayoutMap(layoutMap: KeyboardLayoutMapReader): KeyCombo {
const key = layoutMap.get('Quote');
if (!isPrintableShortcutKey(key)) return copyKeyCombo(DEFAULT_START_DM_CALL_COMBO);
return {...DEFAULT_START_DM_CALL_COMBO, key};
}
function getNavigatorKeyboardLayoutProvider(): KeyboardLayoutProvider | null {
if (typeof navigator === 'undefined') return null;
return (navigator as NavigatorWithKeyboardLayout).keyboard ?? null;
}
export async function getKeyboardShortcutsOverlayComboForCurrentLayout(): Promise<KeyCombo | null> {
export async function getKeyboardLayoutDefaultCombosForCurrentLayout(): Promise<KeyboardLayoutDefaultCombos | null> {
const keyboard = getNavigatorKeyboardLayoutProvider();
if (typeof keyboard?.getLayoutMap !== 'function') return null;
const layoutMap = await keyboard.getLayoutMap();
return getKeyboardShortcutsOverlayComboForLayoutMap(layoutMap);
return {
keyboardShortcutsOverlay: getKeyboardShortcutsOverlayComboForLayoutMap(layoutMap),
startDmCall: getStartDmCallComboForLayoutMap(layoutMap),
};
}
export function shouldUseKeyboardShortcutsOverlayFallbackFromEvent(
@@ -144,7 +144,7 @@
position: absolute;
inset: 0;
margin: auto;
color: var(--text-on-brand-primary);
color: var(--text-on-dark);
}
.overflowCount {
@@ -109,10 +109,14 @@
}
.statusIndicatorActive .statusIcon {
color: var(--brand-primary-fill);
color: var(--brand-primary-light-fill);
}
:global(.theme-light) .statusIndicatorActive {
border-color: var(--brand-primary);
background-color: var(--brand-primary);
}
:global(.theme-light) .statusIndicatorActive .statusIcon {
color: var(--brand-primary-fill);
}
@@ -95,7 +95,7 @@
.breadcrumbActive .breadcrumbNumber {
background: var(--brand-primary);
color: white;
color: var(--brand-primary-fill);
}
.breadcrumbLabel {
@@ -21,7 +21,7 @@
}
:global(.theme-light) .tile {
color: var(--brand-primary);
color: var(--brand-primary-foreground);
}
.title {
@@ -143,7 +143,7 @@
}
:global(.theme-light) .checkIcon {
color: var(--brand-primary);
color: var(--brand-primary-foreground);
}
.emptyState {
@@ -50,7 +50,7 @@
}
:global(.theme-light) .optionSelected {
border-color: var(--brand-primary);
border-color: var(--brand-primary-foreground);
background-color: color-mix(in srgb, var(--brand-primary) 10%, transparent);
}
@@ -71,7 +71,7 @@
}
:global(.theme-light) .optionIconSelected {
color: var(--brand-primary);
color: var(--brand-primary-foreground);
}
.optionText {
@@ -103,7 +103,7 @@
}
:global(.theme-light) .checkIcon {
color: var(--brand-primary);
color: var(--brand-primary-foreground);
}
.footer {
@@ -43,7 +43,7 @@
}
:global(.theme-light) .optionSelected {
border-color: var(--brand-primary);
border-color: var(--brand-primary-foreground);
background-color: color-mix(in srgb, var(--brand-primary) 10%, transparent);
}
@@ -64,7 +64,7 @@
}
:global(.theme-light) .optionIconSelected {
color: var(--brand-primary);
color: var(--brand-primary-foreground);
}
.optionText {
@@ -94,5 +94,5 @@
}
:global(.theme-light) .checkIcon {
color: var(--brand-primary);
color: var(--brand-primary-foreground);
}
@@ -34,7 +34,7 @@
}
:global(.theme-light) .optionSelected {
border-color: var(--brand-primary);
border-color: var(--brand-primary-foreground);
background-color: color-mix(in srgb, var(--brand-primary) 10%, transparent);
}
@@ -55,7 +55,7 @@
}
:global(.theme-light) .optionIconSelected {
color: var(--brand-primary);
color: var(--brand-primary-foreground);
}
.optionText {
@@ -85,5 +85,5 @@
}
:global(.theme-light) .checkIcon {
color: var(--brand-primary);
color: var(--brand-primary-foreground);
}
@@ -140,7 +140,7 @@
}
:global(.theme-light) .checkIcon {
color: var(--brand-primary);
color: var(--brand-primary-foreground);
}
.emptyState {
@@ -16,7 +16,7 @@
align-items: center;
justify-content: center;
border-radius: 0.5rem;
border: 0.125rem dashed var(--brand-primary);
border: 0.125rem dashed var(--brand-primary-foreground);
background: color-mix(in srgb, var(--brand-primary) 10%, transparent);
}
@@ -28,13 +28,13 @@
}
.dragIcon {
color: var(--brand-primary);
color: var(--brand-primary-foreground);
}
.dragText {
font-weight: 500;
font-size: 1.125rem;
color: var(--brand-primary);
color: var(--brand-primary-foreground);
}
.freeUserContainer {
@@ -131,7 +131,7 @@
border: none;
background: none;
font-size: 0.75rem;
color: var(--brand-primary);
color: var(--brand-primary-foreground);
cursor: pointer;
}
@@ -218,7 +218,7 @@
animation: spin 1s linear infinite;
border-radius: 50%;
border: 0.125rem solid var(--background-modifier-accent);
border-top-color: var(--brand-primary);
border-top-color: var(--brand-primary-foreground);
}
@keyframes spin {
@@ -314,7 +314,7 @@
}
.selectedIcon {
color: white;
color: var(--brand-primary-fill);
}
.statsText {
@@ -291,7 +291,7 @@ const BackgroundItem: React.FC<BackgroundItemProps> = React.memo(
ref={visibilityRef}
className={styles.backgroundItem}
style={{
borderColor: isSelected ? 'var(--brand-primary)' : 'var(--background-modifier-accent)',
borderColor: isSelected ? 'var(--brand-primary-foreground)' : 'var(--background-modifier-accent)',
}}
onClick={handleClick}
onKeyDown={handleKeyDown}
@@ -68,7 +68,7 @@ export function useThemeCssVariables({
]);
useLayoutEffect(() => {
if (instanceThemeColor === null) return;
const variables = getInstanceBrandVariables(instanceThemeColor);
const variables = getInstanceBrandVariables(instanceThemeColor, effectiveTheme, saturationFactor);
if (variables === null) return;
const htmlNode = document.documentElement;
for (const name of INSTANCE_BRAND_VARIABLES) {
@@ -79,7 +79,7 @@ export function useThemeCssVariables({
htmlNode.style.removeProperty(name);
}
};
}, [instanceThemeColor]);
}, [instanceThemeColor, effectiveTheme, saturationFactor]);
useLayoutEffect(() => {
const htmlNode = document.documentElement;
return () => {
@@ -25,7 +25,7 @@
@media (hover: hover) {
.deleteButton:hover {
background-color: var(--status-danger);
color: var(--text-on-brand-primary);
color: var(--text-on-dark);
}
}
@@ -117,7 +117,7 @@
}
.playButton svg {
color: var(--text-on-brand-primary);
color: var(--text-on-dark);
}
.audioPlaceholder {
@@ -152,6 +152,6 @@
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-on-brand-primary);
color: var(--text-on-dark);
line-height: 1;
}
@@ -0,0 +1,259 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {ThemeTypes} from '@fluxer/constants/src/UserConstants';
export interface ColorFamily {
hue: number;
saturation: number;
useSaturationFactor: boolean;
}
interface ScaleStop {
name: string;
position?: number;
}
export interface Scale {
family: string;
range: [number, number];
curve: 'linear' | 'easeIn' | 'easeOut' | 'easeInOut';
stops: Array<ScaleStop>;
}
export interface ColorTone {
family?: string;
hue?: number;
saturation?: number;
lightness?: number;
alpha?: number;
useSaturationFactor?: boolean;
}
export type ColorSystemTheme =
| typeof ThemeTypes.DARK
| typeof ThemeTypes.DARK_LEGACY
| typeof ThemeTypes.COAL
| typeof ThemeTypes.LIGHT;
interface BrandTint {
surface: string;
rest: number;
hover: number;
}
interface ThemePalette {
surfaceScale: string;
surfaceAliases: Record<string, string>;
selectedOverlay: ColorTone;
brandTint: BrandTint;
}
export const COLOR_FAMILIES: Record<string, ColorFamily> = {
neutralDark: {hue: 258, saturation: 10, useSaturationFactor: true},
neutralLight: {hue: 220, saturation: 10, useSaturationFactor: true},
brand: {hue: 242, saturation: 70, useSaturationFactor: true},
link: {hue: 198, saturation: 92, useSaturationFactor: true},
accentPurple: {hue: 278, saturation: 85, useSaturationFactor: true},
statusOnline: {hue: 152, saturation: 72, useSaturationFactor: true},
legacyDark: {hue: 220, saturation: 13, useSaturationFactor: true},
legacyLink: {hue: 210, saturation: 100, useSaturationFactor: true},
legacyAccentPurple: {hue: 270, saturation: 80, useSaturationFactor: true},
legacyStatusOnline: {hue: 142, saturation: 76, useSaturationFactor: true},
statusIdle: {hue: 45, saturation: 93, useSaturationFactor: true},
statusDnd: {hue: 0, saturation: 84, useSaturationFactor: true},
statusOffline: {hue: 218, saturation: 11, useSaturationFactor: true},
statusDanger: {hue: 1, saturation: 77, useSaturationFactor: true},
textCode: {hue: 340, saturation: 50, useSaturationFactor: true},
brandIcon: {hue: 38, saturation: 92, useSaturationFactor: true},
};
export const COLOR_SCALES: Record<string, Scale> = {
darkSurface: {
family: 'neutralDark',
range: [5, 24],
curve: 'easeOut',
stops: [
{name: '--background-primary', position: 0},
{name: '--background-secondary', position: 0.16},
{name: '--background-secondary-lighter', position: 0.22},
{name: '--background-secondary-alt', position: 0.28},
{name: '--background-tertiary', position: 0.4},
{name: '--background-channel-header', position: 0.34},
{name: '--guild-list-foreground', position: 0.38},
{name: '--background-header-secondary', position: 0.5},
{name: '--background-header-primary', position: 0.5},
{name: '--background-textarea', position: 0.3},
{name: '--background-header-primary-hover', position: 0.85},
],
},
coalSurface: {
family: 'neutralDark',
range: [1, 12],
curve: 'easeOut',
stops: [
{name: '--background-primary', position: 0},
{name: '--background-secondary', position: 0.16},
{name: '--background-secondary-alt', position: 0.28},
{name: '--background-tertiary', position: 0.4},
{name: '--background-channel-header', position: 0.34},
{name: '--guild-list-foreground', position: 0.38},
{name: '--background-header-secondary', position: 0.5},
{name: '--background-header-primary', position: 0.5},
{name: '--background-textarea', position: 0.3},
{name: '--background-header-primary-hover', position: 0.85},
],
},
darkText: {
family: 'neutralDark',
range: [60, 96],
curve: 'easeInOut',
stops: [
{name: '--text-tertiary-secondary', position: 0},
{name: '--text-tertiary-muted', position: 0.2},
{name: '--text-tertiary', position: 0.38},
{name: '--text-primary-muted', position: 0.55},
{name: '--text-chat-muted', position: 0.55},
{name: '--text-secondary', position: 0.72},
{name: '--text-chat', position: 0.82},
{name: '--text-primary', position: 1},
],
},
lightSurface: {
family: 'neutralLight',
range: [86, 98.5],
curve: 'easeIn',
stops: [
{name: '--background-header-primary-hover', position: 0},
{name: '--background-header-primary', position: 0.12},
{name: '--background-header-secondary', position: 0.2},
{name: '--guild-list-foreground', position: 0.35},
{name: '--background-tertiary', position: 0.42},
{name: '--background-channel-header', position: 0.5},
{name: '--background-secondary-alt', position: 0.63},
{name: '--background-secondary', position: 0.74},
{name: '--background-secondary-lighter', position: 0.83},
{name: '--background-textarea', position: 0.88},
{name: '--background-primary', position: 1},
],
},
lightText: {
family: 'neutralLight',
range: [15, 54],
curve: 'easeOut',
stops: [
{name: '--text-primary', position: 0},
{name: '--text-chat', position: 0.08},
{name: '--text-secondary', position: 0.28},
{name: '--text-chat-muted', position: 0.45},
{name: '--text-primary-muted', position: 0.45},
{name: '--text-tertiary', position: 0.6},
{name: '--text-tertiary-secondary', position: 0.75},
{name: '--text-tertiary-muted', position: 0.85},
],
},
legacyDarkSurface: {
family: 'legacyDark',
range: [5, 26],
curve: 'easeOut',
stops: [
{name: '--background-primary', position: 0},
{name: '--background-secondary', position: 0.16},
{name: '--background-secondary-lighter', position: 0.22},
{name: '--background-secondary-alt', position: 0.28},
{name: '--background-tertiary', position: 0.4},
{name: '--background-channel-header', position: 0.34},
{name: '--guild-list-foreground', position: 0.38},
{name: '--background-header-secondary', position: 0.5},
{name: '--background-header-primary', position: 0.5},
{name: '--background-textarea', position: 0.3},
{name: '--background-header-primary-hover', position: 0.85},
],
},
legacyDarkText: {
family: 'legacyDark',
range: [52, 96],
curve: 'easeInOut',
stops: [
{name: '--text-tertiary-secondary', position: 0},
{name: '--text-tertiary-muted', position: 0.2},
{name: '--text-tertiary', position: 0.38},
{name: '--text-primary-muted', position: 0.55},
{name: '--text-chat-muted', position: 0.55},
{name: '--text-secondary', position: 0.72},
{name: '--text-chat', position: 0.82},
{name: '--text-primary', position: 1},
],
},
};
const DARK_BRAND_TINT: BrandTint = {surface: '--background-secondary', rest: 36, hover: 48};
export const THEME_PALETTES: Record<ColorSystemTheme, ThemePalette> = {
[ThemeTypes.DARK]: {
surfaceScale: 'darkSurface',
surfaceAliases: {},
selectedOverlay: {family: 'neutralDark', lightness: 100, alpha: 0.1},
brandTint: DARK_BRAND_TINT,
},
[ThemeTypes.COAL]: {
surfaceScale: 'coalSurface',
surfaceAliases: {
'--background-secondary': '--background-primary',
'--background-secondary-lighter': '--background-primary',
},
selectedOverlay: {family: 'neutralDark', lightness: 100, alpha: 0.08},
brandTint: DARK_BRAND_TINT,
},
[ThemeTypes.DARK_LEGACY]: {
surfaceScale: 'legacyDarkSurface',
surfaceAliases: {},
selectedOverlay: {family: 'legacyDark', lightness: 100, alpha: 0.1},
brandTint: DARK_BRAND_TINT,
},
[ThemeTypes.LIGHT]: {
surfaceScale: 'lightSurface',
surfaceAliases: {},
selectedOverlay: {family: 'neutralLight', saturation: 10, lightness: 10, alpha: 0.1},
brandTint: {surface: '--background-primary', rest: 12, hover: 18},
},
};
export const BRAND_PRESSED_RATIO = 90;
export function getThemePalette(theme: string): ThemePalette {
return THEME_PALETTES[theme as ColorSystemTheme] ?? THEME_PALETTES[ThemeTypes.DARK];
}
export function brandTintMix(tint: BrandTint, state: 'rest' | 'hover'): string {
const ratio = tint[state];
return `color-mix(in srgb, var(--brand-primary) ${ratio}%, var(${tint.surface}) ${100 - ratio}%)`;
}
function clamp01(value: number): number {
return Math.min(1, Math.max(0, value));
}
function applyCurve(curve: Scale['curve'], t: number): number {
switch (curve) {
case 'easeIn':
return t * t;
case 'easeOut':
return 1 - (1 - t) * (1 - t);
case 'easeInOut':
if (t < 0.5) {
return 2 * t * t;
}
return 1 - 2 * (1 - t) * (1 - t);
default:
return t;
}
}
export function scaleStopLightness(scale: Scale, index: number): number {
const lastIndex = Math.max(scale.stops.length - 1, 1);
const stop = scale.stops[index];
const position = stop.position !== undefined ? clamp01(stop.position) : index / lastIndex;
const eased = applyCurve(scale.curve, position);
const lightness = scale.range[0] + (scale.range[1] - scale.range[0]) * eased;
return Math.round(lightness * 1000) / 1000;
}
@@ -0,0 +1,150 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {BRAND_PRESSED_RATIO, THEME_PALETTES} from '@app/features/theme/utils/ColorSystemPalette';
import {
contrastRatio,
FOREGROUND_BRAND_TINT_RATIO,
FOREGROUND_SELF_TINT_RATIO,
getInstanceBrandVariables,
hslToRgb,
MIN_TEXT_CONTRAST,
mixRgb,
resolveThemeSurface,
resolveThemeSurfaces,
} from '@app/features/theme/utils/InstanceBrandColors';
import {describe, expect, test} from 'vitest';
type Rgb = readonly [number, number, number];
const MIN_UI_CONTRAST = 3;
const THEMES = Object.keys(THEME_PALETTES);
const SATURATION_FACTORS = [1, 0.5, 0];
const NAMED_COLORS = [
'#ffffff',
'#f0f0f0',
'#ffff00',
'#00ff00',
'#00ffff',
'#ff00ff',
'#ff0000',
'#0000ff',
'#e53935',
'#4641d9',
'#7f7f7f',
'#000001',
'#0b1a3a',
'#ffd1dc',
];
function hex(rgb: Rgb): string {
return `#${rgb.map((channel) => Math.round(channel).toString(16).padStart(2, '0')).join('')}`;
}
function gridColors(): Array<string> {
const colors: Array<string> = [];
for (let hue = 0; hue < 360; hue += 30) {
for (const saturation of [100, 35]) {
for (const lightness of [3, 15, 30, 50, 70, 85, 97]) {
colors.push(hex(hslToRgb(hue, saturation, lightness)));
}
}
}
return colors;
}
function parseColor(value: string): Rgb {
const hsl = /^hsl\(([\d.]+), ([\d.]+)%, ([\d.]+)%\)$/.exec(value);
if (hsl) return hslToRgb(Number(hsl[1]), Number(hsl[2]), Number(hsl[3]));
const rgb = /^rgb\(([\d.]+), ([\d.]+), ([\d.]+)\)$/.exec(value);
if (rgb) return [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])];
throw new Error(`Unparseable colour ${value}`);
}
function minContrast(foreground: Rgb, backgrounds: ReadonlyArray<Rgb>): number {
return Math.min(...backgrounds.map((background) => contrastRatio(foreground, background)));
}
function sameColor(first: Rgb, second: Rgb): boolean {
return first.every((channel, index) => Math.abs(channel - second[index]) < 0.01);
}
const COLORS = [...NAMED_COLORS, ...gridColors()];
const CASES = THEMES.flatMap((theme) =>
SATURATION_FACTORS.flatMap((saturationFactor) => COLORS.map((color) => ({theme, saturationFactor, color}))),
);
describe('getInstanceBrandVariables', () => {
test('rejects anything but a lowercase six digit hex colour', () => {
expect(getInstanceBrandVariables('#FFF', 'dark', 1)).toBeNull();
expect(getInstanceBrandVariables('red', 'dark', 1)).toBeNull();
});
test('keeps the operator colour as the solid fill', () => {
expect(getInstanceBrandVariables('#ffff00', 'light', 1)?.['--instance-brand-primary']).toBe('hsl(60, 100%, 50%)');
expect(getInstanceBrandVariables('#e53935', 'dark', 0.5)?.['--instance-brand-primary']).toBe(
'hsl(1.36, 38.6%, 55.29%)',
);
});
test('treats an unknown theme like the dark root palette', () => {
expect(getInstanceBrandVariables('#ffff00', 'unknown', 1)).toEqual(getInstanceBrandVariables('#ffff00', 'dark', 1));
});
test.each(CASES)(
'$color in $theme at saturation $saturationFactor meets contrast minimums',
({theme, saturationFactor, color}) => {
const variables = getInstanceBrandVariables(color, theme, saturationFactor);
expect(variables).not.toBeNull();
const v = Object.fromEntries(
Object.entries(variables!).map(([name, value]) => [name.replace('--instance-', ''), parseColor(value)]),
);
const surfaces = resolveThemeSurfaces(theme, saturationFactor);
const stops = surfaces.slice(0, surfaces.length / 2);
const pressed = mixRgb(v['brand-primary'], v['brand-primary-shade'], BRAND_PRESSED_RATIO);
const solidFills = [v['brand-primary'], v['brand-secondary'], pressed];
expect(minContrast(v['text-on-brand-primary'], solidFills)).toBeGreaterThanOrEqual(MIN_TEXT_CONTRAST);
expect(minContrast(v['brand-primary-fill'], solidFills)).toBeGreaterThanOrEqual(MIN_TEXT_CONTRAST);
expect(contrastRatio(v['text-on-brand-primary-light'], v['brand-primary-light'])).toBeGreaterThanOrEqual(
MIN_TEXT_CONTRAST,
);
expect(contrastRatio(v['brand-primary-light-fill'], v['brand-primary-light'])).toBeGreaterThanOrEqual(
MIN_TEXT_CONTRAST,
);
for (const name of ['brand-primary-foreground', 'brand-primary-light']) {
const foreground = v[name];
const tinted = [
...stops.map((stop) => mixRgb(foreground, stop, FOREGROUND_SELF_TINT_RATIO)),
...stops.map((stop) => mixRgb(v['brand-primary'], stop, FOREGROUND_BRAND_TINT_RATIO)),
];
expect(minContrast(foreground, surfaces)).toBeGreaterThanOrEqual(MIN_TEXT_CONTRAST);
expect(minContrast(foreground, tinted)).toBeGreaterThanOrEqual(MIN_TEXT_CONTRAST);
}
expect(minContrast(v['brand-tint-border'], surfaces)).toBeGreaterThanOrEqual(MIN_UI_CONTRAST);
expect(minContrast(v['text-on-brand-tint'], [v['brand-tint'], v['brand-tint-hover']])).toBeGreaterThanOrEqual(
MIN_TEXT_CONTRAST,
);
const tint = THEME_PALETTES[theme as keyof typeof THEME_PALETTES].brandTint;
const tintSurface = resolveThemeSurface(theme, tint.surface, saturationFactor);
const matchingRatio = (target: Rgb, max: number) =>
Array.from({length: max + 1}, (_, ratio) => max - ratio).find((ratio) =>
sameColor(target, mixRgb(v['brand-primary'], tintSurface, ratio)),
);
expect(matchingRatio(v['brand-tint'], tint.rest)).toBeDefined();
expect(matchingRatio(v['brand-tint-hover'], tint.hover)).toBeDefined();
},
);
test('keeps the stylesheet tint ratios whenever the text already passes', () => {
const variables = getInstanceBrandVariables('#4641d9', 'dark', 1)!;
const surface = resolveThemeSurface('dark', THEME_PALETTES.dark.brandTint.surface, 1);
const primary = parseColor(variables['--instance-brand-primary']);
expect(sameColor(parseColor(variables['--instance-brand-tint']), mixRgb(primary, surface, 36))).toBe(true);
expect(sameColor(parseColor(variables['--instance-brand-tint-hover']), mixRgb(primary, surface, 48))).toBe(true);
});
test('reads light text on the dark reaction tint even when the brand needs dark text', () => {
const variables = getInstanceBrandVariables('#e53935', 'dark', 1)!;
expect(variables['--instance-text-on-brand-primary']).toBe('rgb(0, 0, 0)');
expect(variables['--instance-text-on-brand-tint']).toBe('hsl(0, 0%, 98%)');
});
});
@@ -1,28 +1,66 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
COLOR_FAMILIES,
COLOR_SCALES,
type ColorTone,
getThemePalette,
scaleStopLightness,
} from '@app/features/theme/utils/ColorSystemPalette';
export const INSTANCE_BRAND_VARIABLES = [
'--instance-brand-primary',
'--instance-brand-secondary',
'--instance-brand-primary-shade',
'--instance-brand-primary-light',
'--instance-brand-primary-foreground',
'--instance-brand-primary-fill',
'--instance-brand-primary-light-fill',
'--instance-text-on-brand-primary',
'--instance-text-on-brand-primary-light',
'--instance-brand-tint',
'--instance-brand-tint-hover',
'--instance-brand-tint-border',
'--instance-text-on-brand-tint',
] as const;
type InstanceBrandVariable = (typeof INSTANCE_BRAND_VARIABLES)[number];
const HEX_COLOR_PATTERN = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
export const MIN_TEXT_CONTRAST = 4.5;
const TARGET_CONTRAST = MIN_TEXT_CONTRAST + 0.05;
export const FOREGROUND_SELF_TINT_RATIO = 15;
export const FOREGROUND_BRAND_TINT_RATIO = 22;
function parseHexColor(value: string): [number, number, number] | null {
const trimmed = value.trim();
if (!HEX_COLOR_PATTERN.test(trimmed)) return null;
const digits =
trimmed.length === 4
? trimmed
.slice(1)
.split('')
.map((digit) => digit + digit)
.join('')
: trimmed.slice(1);
type Rgb = readonly [number, number, number];
interface Color {
css: string;
rgb: Rgb;
}
interface Hsl {
hue: number;
saturation: number;
lightness: number;
}
interface OnColor {
text: Color;
fill: Color;
dark: boolean;
}
const HEX_COLOR_PATTERN = /^#[0-9a-f]{6}$/;
const WHITE: Rgb = [255, 255, 255];
const BLACK: Rgb = [0, 0, 0];
function round2(value: number): number {
return Math.round(value * 100) / 100;
}
function parseHexColor(value: string): Rgb | null {
if (!HEX_COLOR_PATTERN.test(value)) return null;
const digits = value.slice(1);
return [
Number.parseInt(digits.slice(0, 2), 16),
Number.parseInt(digits.slice(2, 4), 16),
@@ -30,7 +68,7 @@ function parseHexColor(value: string): [number, number, number] | null {
];
}
function rgbToHsl(red: number, green: number, blue: number): [number, number, number] {
function rgbToHsl([red, green, blue]: Rgb): Hsl {
const r = red / 255;
const g = green / 255;
const b = blue / 255;
@@ -38,7 +76,7 @@ function rgbToHsl(red: number, green: number, blue: number): [number, number, nu
const min = Math.min(r, g, b);
const lightness = (max + min) / 2;
const delta = max - min;
if (delta === 0) return [0, 0, lightness * 100];
if (delta === 0) return {hue: 0, saturation: 0, lightness: lightness * 100};
const saturation = delta / (1 - Math.abs(2 * lightness - 1));
let hue: number;
if (max === r) {
@@ -48,36 +86,225 @@ function rgbToHsl(red: number, green: number, blue: number): [number, number, nu
} else {
hue = (r - g) / delta + 4;
}
return [(hue * 60 + 360) % 360, saturation * 100, lightness * 100];
return {hue: (hue * 60 + 360) % 360, saturation: saturation * 100, lightness: lightness * 100};
}
function relativeLuminance(red: number, green: number, blue: number): number {
export function hslToRgb(hue: number, saturation: number, lightness: number): Rgb {
const s = Math.min(100, Math.max(0, saturation)) / 100;
const l = Math.min(100, Math.max(0, lightness)) / 100;
const chroma = (1 - Math.abs(2 * l - 1)) * s;
const h = (((hue % 360) + 360) % 360) / 60;
const x = chroma * (1 - Math.abs((h % 2) - 1));
const m = l - chroma / 2;
const [r, g, b] =
h < 1
? [chroma, x, 0]
: h < 2
? [x, chroma, 0]
: h < 3
? [0, chroma, x]
: h < 4
? [0, x, chroma]
: h < 5
? [x, 0, chroma]
: [chroma, 0, x];
return [(r + m) * 255, (g + m) * 255, (b + m) * 255];
}
export function mixRgb(first: Rgb, second: Rgb, firstPercent: number): Rgb {
const ratio = firstPercent / 100;
return [
first[0] * ratio + second[0] * (1 - ratio),
first[1] * ratio + second[1] * (1 - ratio),
first[2] * ratio + second[2] * (1 - ratio),
];
}
function relativeLuminance(rgb: Rgb): number {
const linear = (channel: number) => {
const value = channel / 255;
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
const value = Math.round(channel) / 255;
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * linear(red) + 0.7152 * linear(green) + 0.0722 * linear(blue);
return 0.2126 * linear(rgb[0]) + 0.7152 * linear(rgb[1]) + 0.0722 * linear(rgb[2]);
}
function formatNumber(value: number): string {
return String(Math.round(value * 100) / 100);
export function contrastRatio(first: Rgb, second: Rgb): number {
const a = relativeLuminance(first);
const b = relativeLuminance(second);
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
}
function hsl(hue: number, saturation: number, lightness: number): string {
return `hsl(${formatNumber(hue)}, calc(${formatNumber(saturation)}% * var(--saturation-factor)), ${formatNumber(lightness)}%)`;
function hslColor({hue, saturation, lightness}: Hsl): Color {
const h = round2(hue);
const s = round2(saturation);
const l = round2(lightness);
return {css: `hsl(${h}, ${s}%, ${l}%)`, rgb: hslToRgb(h, s, l)};
}
export function getInstanceBrandVariables(themeColor: string): Record<InstanceBrandVariable, string> | null {
function rgbColor(rgb: Rgb): Color {
const [r, g, b] = rgb.map(round2);
return {css: `rgb(${r}, ${g}, ${b})`, rgb: [r, g, b]};
}
function resolveTone(tone: ColorTone, saturationFactor: number): Rgb {
const family = tone.family ? COLOR_FAMILIES[tone.family] : undefined;
const hue = round2(tone.hue ?? family?.hue ?? 0);
const saturation = round2(tone.saturation ?? family?.saturation ?? 0);
const useFactor = tone.useSaturationFactor ?? family?.useSaturationFactor ?? false;
return hslToRgb(hue, useFactor ? saturation * saturationFactor : saturation, round2(tone.lightness ?? 0));
}
export function resolveThemeSurface(theme: string, name: string, saturationFactor: number): Rgb {
const palette = getThemePalette(theme);
const scale = COLOR_SCALES[palette.surfaceScale];
const target = palette.surfaceAliases[name] ?? name;
const index = scale.stops.findIndex((stop) => stop.name === target);
if (index === -1) throw new Error(`${target} is not a stop of ${palette.surfaceScale}`);
return resolveTone({family: scale.family, lightness: scaleStopLightness(scale, index)}, saturationFactor);
}
export function resolveThemeSurfaces(theme: string, saturationFactor: number): Array<Rgb> {
const palette = getThemePalette(theme);
const scale = COLOR_SCALES[palette.surfaceScale];
const overlay = resolveTone(palette.selectedOverlay, saturationFactor);
const overlayPercent = (palette.selectedOverlay.alpha ?? 1) * 100;
const stops = scale.stops.map((_, index) =>
resolveTone({family: scale.family, lightness: scaleStopLightness(scale, index)}, saturationFactor),
);
return [...stops, ...stops.map((stop) => mixRgb(overlay, stop, overlayPercent))];
}
function isLightTheme(theme: string, saturationFactor: number): boolean {
return relativeLuminance(resolveThemeSurface(theme, '--background-primary', saturationFactor)) > 0.5;
}
function passes(foreground: Rgb, backgrounds: ReadonlyArray<Rgb>): boolean {
return backgrounds.every((background) => contrastRatio(foreground, background) >= TARGET_CONTRAST);
}
function shiftLightness(start: Hsl, lighter: boolean, accept: (color: Color) => boolean): Color {
const initial = hslColor(start);
if (accept(initial)) return initial;
const step = lighter ? 1 : -1;
const limit = lighter ? 10000 : 0;
let failing = Math.round(start.lightness * 100);
let passing = limit;
for (let value = failing + step * 100; lighter ? value < limit : value > limit; value += step * 100) {
if (accept(hslColor({...start, lightness: value / 100}))) {
passing = value;
break;
}
failing = value;
}
while (Math.abs(passing - failing) > 1) {
const middle = Math.round((passing + failing) / 2);
if (accept(hslColor({...start, lightness: middle / 100}))) {
passing = middle;
} else {
failing = middle;
}
}
return hslColor({...start, lightness: passing / 100});
}
function neutral(lightness: number): Color {
return hslColor({hue: 0, saturation: 0, lightness});
}
function pickOnColor(background: Rgb): OnColor {
const light = {text: neutral(98), fill: neutral(100), dark: false};
const dark = {text: neutral(8), fill: neutral(8), dark: true};
const best = contrastRatio(light.text.rgb, background) >= contrastRatio(dark.text.rgb, background) ? light : dark;
if (contrastRatio(best.text.rgb, background) >= TARGET_CONTRAST) return best;
const pure = rgbColor(contrastRatio(WHITE, background) >= contrastRatio(BLACK, background) ? WHITE : BLACK);
return {text: pure, fill: pure, dark: pure.rgb[0] === 0};
}
function readableOnSurfaces(start: Hsl, theme: string, saturationFactor: number, primary: Rgb): Color {
const surfaces = resolveThemeSurfaces(theme, saturationFactor);
const stops = surfaces.slice(0, surfaces.length / 2);
const brandTints = stops.map((stop) => mixRgb(primary, stop, FOREGROUND_BRAND_TINT_RATIO));
return shiftLightness(start, !isLightTheme(theme, saturationFactor), (color) => {
const selfTints = stops.map((stop) => mixRgb(color.rgb, stop, FOREGROUND_SELF_TINT_RATIO));
return passes(color.rgb, surfaces) && passes(color.rgb, selfTints) && passes(color.rgb, brandTints);
});
}
function largestPassingRatio(text: Rgb, primary: Rgb, surface: Rgb, ratio: number): number {
for (let candidate = ratio; candidate >= 0; candidate--) {
if (contrastRatio(text, mixRgb(primary, surface, candidate)) >= TARGET_CONTRAST) return candidate;
}
return -1;
}
function brandTint(
brand: Hsl,
primary: Rgb,
theme: string,
saturationFactor: number,
): {rest: Color; hover: Color; text: Color} {
const tint = getThemePalette(theme).brandTint;
const surface = resolveThemeSurface(theme, tint.surface, saturationFactor);
if (isLightTheme(theme, saturationFactor)) {
const rest = rgbColor(mixRgb(primary, surface, tint.rest));
const hover = rgbColor(mixRgb(primary, surface, tint.hover));
const text = shiftLightness(brand, false, (color) => passes(color.rgb, [rest.rgb, hover.rgb]));
return {rest, hover, text};
}
const options = [neutral(98), neutral(8), rgbColor(WHITE), rgbColor(BLACK)].map((text) => {
const hover = largestPassingRatio(text.rgb, primary, surface, tint.hover);
const rest = Math.min(largestPassingRatio(text.rgb, primary, surface, tint.rest), hover);
return {text, rest, hover};
});
const best =
options.find((option) => option.rest === tint.rest && option.hover === tint.hover) ??
options.reduce((current, option) => (option.rest + option.hover > current.rest + current.hover ? option : current));
return {
rest: rgbColor(mixRgb(primary, surface, best.rest)),
hover: rgbColor(mixRgb(primary, surface, best.hover)),
text: best.text,
};
}
export function getInstanceBrandVariables(
themeColor: string,
theme: string,
saturationFactor: number,
): Record<InstanceBrandVariable, string> | null {
const rgb = parseHexColor(themeColor);
if (rgb === null) return null;
const [hue, saturation, lightness] = rgbToHsl(...rgb);
const luminance = relativeLuminance(...rgb);
const prefersDarkText = (luminance + 0.05) / 0.05 > 1.05 / (luminance + 0.05);
const base = rgbToHsl(rgb);
const brand = {...base, saturation: base.saturation * saturationFactor};
const primary = hslColor(brand);
const onPrimary = pickOnColor(primary.rgb);
const secondary = shiftLightness(
{hue: brand.hue, saturation: brand.saturation * (6 / 7), lightness: brand.lightness * 0.89},
onPrimary.dark,
(color) => passes(onPrimary.text.rgb, [color.rgb]) && passes(onPrimary.fill.rgb, [color.rgb]),
);
const shade = rgbColor(onPrimary.dark ? WHITE : BLACK);
const foreground = readableOnSurfaces(brand, theme, saturationFactor, primary.rgb);
const light = readableOnSurfaces(
{...brand, lightness: brand.lightness + (100 - brand.lightness) * 0.65},
theme,
saturationFactor,
primary.rgb,
);
const onLight = pickOnColor(light.rgb);
const tint = brandTint(brand, primary.rgb, theme, saturationFactor);
return {
'--instance-brand-primary': hsl(hue, saturation, lightness),
'--instance-brand-secondary': hsl(hue, saturation * (6 / 7), lightness * 0.89),
'--instance-brand-primary-light': hsl(hue, saturation, lightness + (100 - lightness) * 0.65),
'--instance-brand-primary-fill': prefersDarkText ? 'hsl(0, 0%, 8%)' : 'hsl(0, 0%, 100%)',
'--instance-text-on-brand-primary': prefersDarkText ? 'hsl(0, 0%, 8%)' : 'hsl(0, 0%, 98%)',
'--instance-brand-primary': primary.css,
'--instance-brand-secondary': secondary.css,
'--instance-brand-primary-shade': shade.css,
'--instance-brand-primary-light': light.css,
'--instance-brand-primary-foreground': foreground.css,
'--instance-brand-primary-fill': onPrimary.fill.css,
'--instance-brand-primary-light-fill': onLight.fill.css,
'--instance-text-on-brand-primary': onPrimary.text.css,
'--instance-text-on-brand-primary-light': onLight.text.css,
'--instance-brand-tint': tint.rest.css,
'--instance-brand-tint-hover': tint.hover.css,
'--instance-brand-tint-border': foreground.css,
'--instance-text-on-brand-tint': tint.text.css,
};
}
@@ -106,8 +106,15 @@ export type ThemeVariableName =
| "--border-color-hover"
| "--brand-primary"
| "--brand-primary-fill"
| "--brand-primary-foreground"
| "--brand-primary-light"
| "--brand-primary-light-fill"
| "--brand-primary-pressed"
| "--brand-primary-shade"
| "--brand-secondary"
| "--brand-tint"
| "--brand-tint-border"
| "--brand-tint-hover"
| "--button-danger-active-fill"
| "--button-danger-fill"
| "--button-danger-outline-active-border"
@@ -421,6 +428,9 @@ export type ThemeVariableName =
| "--text-code"
| "--text-link"
| "--text-on-brand-primary"
| "--text-on-brand-primary-light"
| "--text-on-brand-tint"
| "--text-on-dark"
| "--text-primary"
| "--text-primary-muted"
| "--text-secondary"
@@ -570,8 +580,15 @@ export const THEME_VARIABLES: ReadonlyArray<ThemeVariableDefinition> = [
{name: "--border-color-hover", kind: "color", groupId: "borders", groupLabel: "Borders & focus", source: "color-system"},
{name: "--brand-primary", kind: "color", groupId: "brand", groupLabel: "Brand & accents", source: "color-system"},
{name: "--brand-primary-fill", kind: "color", groupId: "brand", groupLabel: "Brand & accents", source: "color-system"},
{name: "--brand-primary-foreground", kind: "color", groupId: "brand", groupLabel: "Brand & accents", source: "color-system"},
{name: "--brand-primary-light", kind: "color", groupId: "brand", groupLabel: "Brand & accents", source: "color-system"},
{name: "--brand-primary-light-fill", kind: "color", groupId: "brand", groupLabel: "Brand & accents", source: "color-system"},
{name: "--brand-primary-pressed", kind: "color", groupId: "brand", groupLabel: "Brand & accents", source: "color-system"},
{name: "--brand-primary-shade", kind: "color", groupId: "brand", groupLabel: "Brand & accents", source: "color-system"},
{name: "--brand-secondary", kind: "color", groupId: "brand", groupLabel: "Brand & accents", source: "color-system"},
{name: "--brand-tint", kind: "color", groupId: "brand", groupLabel: "Brand & accents", source: "color-system"},
{name: "--brand-tint-border", kind: "color", groupId: "brand", groupLabel: "Brand & accents", source: "color-system"},
{name: "--brand-tint-hover", kind: "color", groupId: "brand", groupLabel: "Brand & accents", source: "color-system"},
{name: "--button-danger-active-fill", kind: "color", groupId: "buttons", groupLabel: "Buttons", source: "color-system"},
{name: "--button-danger-fill", kind: "color", groupId: "buttons", groupLabel: "Buttons", source: "color-system"},
{name: "--button-danger-outline-active-border", kind: "color", groupId: "buttons", groupLabel: "Buttons", source: "color-system"},
@@ -885,6 +902,9 @@ export const THEME_VARIABLES: ReadonlyArray<ThemeVariableDefinition> = [
{name: "--text-code", kind: "color", groupId: "code", groupLabel: "Code & terminal", source: "color-system"},
{name: "--text-link", kind: "color", groupId: "text", groupLabel: "Text", source: "color-system"},
{name: "--text-on-brand-primary", kind: "color", groupId: "text", groupLabel: "Text", source: "color-system"},
{name: "--text-on-brand-primary-light", kind: "color", groupId: "text", groupLabel: "Text", source: "color-system"},
{name: "--text-on-brand-tint", kind: "color", groupId: "text", groupLabel: "Text", source: "color-system"},
{name: "--text-on-dark", kind: "color", groupId: "text", groupLabel: "Text", source: "color-system"},
{name: "--text-primary", kind: "color", groupId: "text", groupLabel: "Text", source: "color-system"},
{name: "--text-primary-muted", kind: "color", groupId: "text", groupLabel: "Text", source: "color-system"},
{name: "--text-secondary", kind: "color", groupId: "text", groupLabel: "Text", source: "color-system"},
@@ -1035,8 +1055,15 @@ export const THEME_VARIABLE_NAMES: ReadonlyArray<string> = [
"--border-color-hover",
"--brand-primary",
"--brand-primary-fill",
"--brand-primary-foreground",
"--brand-primary-light",
"--brand-primary-light-fill",
"--brand-primary-pressed",
"--brand-primary-shade",
"--brand-secondary",
"--brand-tint",
"--brand-tint-border",
"--brand-tint-hover",
"--button-danger-active-fill",
"--button-danger-fill",
"--button-danger-outline-active-border",
@@ -1350,6 +1377,9 @@ export const THEME_VARIABLE_NAMES: ReadonlyArray<string> = [
"--text-code",
"--text-link",
"--text-on-brand-primary",
"--text-on-brand-primary-light",
"--text-on-brand-tint",
"--text-on-dark",
"--text-primary",
"--text-primary-muted",
"--text-secondary",
@@ -1500,8 +1530,15 @@ export const THEME_STUDIO_DARK_DEFAULT_VARIABLE_VALUES: Readonly<Record<string,
"--border-color-hover": "hsla(258, calc(10% * var(--saturation-factor)), 50%, 0.3)",
"--brand-primary": "hsl(242, calc(70% * var(--saturation-factor)), 55%)",
"--brand-primary-fill": "hsl(0, 0%, 100%)",
"--brand-primary-foreground": "hsl(242, calc(70% * var(--saturation-factor)), 55%)",
"--brand-primary-light": "hsl(242, calc(100% * var(--saturation-factor)), 84%)",
"--brand-primary-light-fill": "hsl(0, 0%, 100%)",
"--brand-primary-pressed": "color-mix(in srgb, hsl(242, calc(70% * var(--saturation-factor)), 55%) 90%, hsl(0, 0%, 0%))",
"--brand-primary-shade": "hsl(0, 0%, 0%)",
"--brand-secondary": "hsl(242, calc(60% * var(--saturation-factor)), 49%)",
"--brand-tint": "color-mix(in srgb, var(--brand-primary) 36%, var(--background-secondary) 64%)",
"--brand-tint-border": "var(--brand-primary)",
"--brand-tint-hover": "color-mix(in srgb, var(--brand-primary) 48%, var(--background-secondary) 52%)",
"--button-danger-active-fill": "hsl(359, calc(65% * var(--saturation-factor)), 45%)",
"--button-danger-fill": "hsl(359, calc(70% * var(--saturation-factor)), 54%)",
"--button-danger-outline-active-border": "transparent",
@@ -1815,6 +1852,9 @@ export const THEME_STUDIO_DARK_DEFAULT_VARIABLE_VALUES: Readonly<Record<string,
"--text-code": "color-mix(in srgb, hsl(258, calc(10% * var(--saturation-factor)), 90.36%) 82%, hsl(340, calc(50% * var(--saturation-factor)), 90%) 18%)",
"--text-link": "hsl(198, calc(92% * var(--saturation-factor)), 70%)",
"--text-on-brand-primary": "hsl(0, 0%, 98%)",
"--text-on-brand-primary-light": "hsl(0, 0%, 98%)",
"--text-on-brand-tint": "var(--text-on-brand-primary)",
"--text-on-dark": "hsl(0, 0%, 98%)",
"--text-primary": "hsl(258, calc(10% * var(--saturation-factor)), 96%)",
"--text-primary-muted": "hsl(258, calc(10% * var(--saturation-factor)), 81.42%)",
"--text-secondary": "hsl(258, calc(10% * var(--saturation-factor)), 90.36%)",
@@ -1965,8 +2005,15 @@ export const THEME_STUDIO_LIGHT_DEFAULT_VARIABLE_VALUES: Readonly<Record<string,
"--border-color-hover": "hsla(220, calc(10% * var(--saturation-factor)), 40%, 0.25)",
"--brand-primary": "hsl(242, calc(70% * var(--saturation-factor)), 55%)",
"--brand-primary-fill": "hsl(0, 0%, 100%)",
"--brand-primary-foreground": "hsl(242, calc(70% * var(--saturation-factor)), 55%)",
"--brand-primary-light": "hsl(242, calc(100% * var(--saturation-factor)), 84%)",
"--brand-primary-light-fill": "hsl(0, 0%, 100%)",
"--brand-primary-pressed": "color-mix(in srgb, hsl(242, calc(70% * var(--saturation-factor)), 55%) 90%, hsl(0, 0%, 0%))",
"--brand-primary-shade": "hsl(0, 0%, 0%)",
"--brand-secondary": "hsl(242, calc(60% * var(--saturation-factor)), 49%)",
"--brand-tint": "color-mix(in srgb, var(--brand-primary) 12%, var(--background-primary) 88%)",
"--brand-tint-border": "color-mix(in srgb, var(--brand-primary) 45%, transparent 55%)",
"--brand-tint-hover": "color-mix(in srgb, var(--brand-primary) 18%, var(--background-primary) 82%)",
"--button-danger-active-fill": "hsl(359, calc(65% * var(--saturation-factor)), 45%)",
"--button-danger-fill": "hsl(359, calc(70% * var(--saturation-factor)), 54%)",
"--button-danger-outline-active-border": "transparent",
@@ -2280,6 +2327,9 @@ export const THEME_STUDIO_LIGHT_DEFAULT_VARIABLE_VALUES: Readonly<Record<string,
"--text-code": "color-mix(in srgb, hsl(220, calc(10% * var(--saturation-factor)), 33.78%) 72%, hsl(340, calc(50% * var(--saturation-factor)), 38%) 28%)",
"--text-link": "hsl(198, calc(92% * var(--saturation-factor)), 45%)",
"--text-on-brand-primary": "hsl(0, 0%, 98%)",
"--text-on-brand-primary-light": "hsl(0, 0%, 98%)",
"--text-on-brand-tint": "var(--brand-primary)",
"--text-on-dark": "hsl(0, 0%, 98%)",
"--text-primary": "hsl(220, calc(10% * var(--saturation-factor)), 15%)",
"--text-primary-muted": "hsl(220, calc(10% * var(--saturation-factor)), 42.2%)",
"--text-secondary": "hsl(220, calc(10% * var(--saturation-factor)), 33.78%)",
@@ -24,6 +24,7 @@
--studio-fg-muted: var(--text-secondary);
--studio-fg-subtle: var(--text-tertiary);
--studio-fg-on-accent: var(--text-on-brand-primary);
--studio-fg-on-danger: var(--text-on-dark);
--studio-accent: var(--brand-primary);
--studio-accent-hover: var(--brand-secondary);
@@ -31,7 +32,7 @@
--studio-focus: var(--focus-primary);
--studio-button-primary: var(--brand-primary);
--studio-button-primary-hover: color-mix(in srgb, var(--brand-primary) 88%, var(--text-primary) 12%);
--studio-button-primary-hover: var(--studio-accent-hover);
--studio-button-secondary: var(--button-secondary-fill);
--studio-button-secondary-hover: var(--button-secondary-active-fill);
@@ -95,7 +95,7 @@ function buildEditorTheme(baseTheme: ThemeStudioBaseTheme): Extension {
},
'.cm-tooltip-autocomplete > ul > li[aria-selected]': {
backgroundColor: 'var(--studio-accent, rgba(120, 160, 220, 0.3))',
color: 'var(--studio-fg-primary)',
color: 'var(--studio-fg-on-accent)',
},
'.cm-tooltip-autocomplete > ul > li': {
fontFamily: 'var(--studio-font-mono)',
@@ -80,7 +80,7 @@
.dangerSolid {
--bg: var(--studio-danger);
--bg-hover: var(--studio-danger-hover);
--fg: var(--studio-fg-on-accent);
--fg: var(--studio-fg-on-danger);
}
.leadingIcon {
@@ -47,7 +47,7 @@
}
:global(.theme-light) .count {
color: var(--brand-primary);
color: var(--brand-primary-foreground);
}
.preview {
@@ -295,7 +295,7 @@
.checkboxChecked {
background-color: var(--brand-primary);
border-color: var(--brand-primary);
border-color: var(--brand-primary-foreground);
}
.checkboxChecked::after {
@@ -305,7 +305,7 @@
top: 50%;
width: 0.25rem;
height: 0.5625rem;
border: solid white;
border: solid var(--brand-primary-fill);
border-width: 0 0.15625rem 0.15625rem 0;
transform: translate(-50%, -60%) rotate(45deg);
}
@@ -14,7 +14,7 @@
}
.radioButtonSelected {
border-color: var(--brand-primary);
border-color: var(--brand-primary-foreground);
background-color: var(--brand-primary);
}
@@ -27,7 +27,7 @@
height: 0.375rem;
width: 0.375rem;
border-radius: 50%;
background-color: white;
background-color: var(--brand-primary-fill);
}
.labelContent {
@@ -129,7 +129,7 @@
}
.button.primary:active:not(:disabled):not([data-disabled]) {
background-color: color-mix(in srgb, var(--brand-primary) 90%, #000);
background-color: var(--brand-primary-pressed);
}
.button.secondary {
@@ -213,7 +213,7 @@
.button.recording {
background-color: var(--accent-success, #16a34a);
border-color: color-mix(in srgb, var(--accent-success, #16a34a) 80%, white 20%);
color: var(--brand-primary-fill);
color: var(--button-primary-text);
animation: buttonRecordingPulse 1.1s infinite;
}
@@ -48,11 +48,11 @@
.checked {
background-color: var(--brand-primary);
border: 0.0625rem solid var(--brand-primary);
border: 0.0625rem solid var(--brand-primary-foreground);
}
.checkIcon {
color: white;
color: var(--brand-primary-fill);
}
.label {
@@ -80,12 +80,12 @@
.menuChecked {
background-color: var(--brand-primary);
border-color: var(--brand-primary);
border-color: var(--brand-primary-foreground);
}
.menuChecked:hover {
background-color: var(--brand-primary);
border-color: var(--brand-primary);
border-color: var(--brand-primary-foreground);
}
.checkboxIndicator {
@@ -167,7 +167,6 @@ export const Checkbox: React.FC<CheckboxProps> = observer(
<CheckIcon
size={checkIconSizeRem}
weight="bold"
color={'#ffffff'}
className={styles.checkIcon}
data-flx="ui.checkbox.checkbox.check-icon"
/>
@@ -224,7 +223,6 @@ export const Checkbox: React.FC<CheckboxProps> = observer(
<CheckIcon
size={checkIconSizeRem}
weight="bold"
color={'#ffffff'}
className={styles.checkIcon}
data-flx="ui.checkbox.checkbox.check-icon--2"
/>
@@ -46,7 +46,7 @@
.pageButtonActive {
background-color: var(--brand-primary);
color: white;
color: var(--brand-primary-fill);
}
.pageButtonActive:hover {
@@ -160,7 +160,7 @@
.barFill {
background: linear-gradient(
90deg,
var(--brand-primary, #5865f2),
var(--brand-primary-foreground, #5865f2),
color-mix(in srgb, var(--brand-primary-light, #7983f5) 84%, #fff 16%)
);
border-radius: inherit;
@@ -171,7 +171,7 @@
.vertical .barFill {
background: linear-gradient(
0deg,
var(--brand-primary, #5865f2),
var(--brand-primary-foreground, #5865f2),
color-mix(in srgb, var(--brand-primary-light, #7983f5) 84%, #fff 16%)
);
width: 100%;
@@ -180,8 +180,8 @@
:global(.theme-light) .barFill {
background: linear-gradient(
90deg,
var(--brand-primary, #5865f2),
color-mix(in srgb, var(--brand-primary, #5865f2) 72%, #111 28%)
var(--brand-primary-foreground, #5865f2),
color-mix(in srgb, var(--brand-primary-foreground, #5865f2) 72%, #111 28%)
);
box-shadow: none;
}
@@ -189,8 +189,8 @@
:global(.theme-light) .vertical .barFill {
background: linear-gradient(
0deg,
var(--brand-primary, #5865f2),
color-mix(in srgb, var(--brand-primary, #5865f2) 72%, #111 28%)
var(--brand-primary-foreground, #5865f2),
color-mix(in srgb, var(--brand-primary-foreground, #5865f2) 72%, #111 28%)
);
}
@@ -273,13 +273,13 @@
.rdpToday .rdpDayButton {
font-weight: 700;
text-decoration: underline;
text-decoration-color: var(--brand-primary);
text-decoration-color: var(--brand-primary-foreground);
text-underline-offset: 0.1875rem;
}
.rdpSelected .rdpDayButton {
background-color: var(--brand-primary);
color: #fff;
color: var(--brand-primary-fill);
font-weight: 600;
}
@@ -364,13 +364,17 @@
.itemSelected {
background-color: var(--brand-primary);
color: #fff;
color: var(--brand-primary-fill);
}
.itemSelected.itemHighlighted {
background-color: var(--brand-primary);
}
.itemSelected .itemText :where(*) {
color: inherit;
}
.itemRendered {
position: relative;
}
@@ -116,6 +116,7 @@
}
.switchThumb[data-state='checked'] {
background-color: var(--brand-primary-fill);
transform: translateX(1.375rem);
}
@@ -42,5 +42,5 @@
:global(.theme-light) {
--keybind-hint-default-background: hsl(0, 0%, 18%);
--keybind-hint-default-color: var(--text-on-brand-primary);
--keybind-hint-default-color: var(--text-on-dark);
}
@@ -84,14 +84,14 @@
}
.checkbox.checked {
border-color: var(--brand-primary);
border-color: var(--brand-primary-foreground);
background-color: var(--brand-primary);
}
.checkIcon {
height: 0.75rem;
width: 0.75rem;
color: white;
color: var(--brand-primary-fill);
}
.radioContainer {
@@ -115,14 +115,14 @@
}
.radio.radioSelected {
border-color: var(--brand-primary);
border-color: var(--brand-primary-foreground);
}
.radioInner {
height: 0.625rem;
width: 0.625rem;
border-radius: 50%;
background-color: var(--brand-primary);
background-color: var(--brand-primary-foreground);
}
.labelColumn {
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff. Show more