mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
415 lines
16 KiB
TypeScript
415 lines
16 KiB
TypeScript
// "Flat fields" - the bulwarkmail.org design system carried into the app.
|
|
//
|
|
// Neutral grounds, square panes, one regular-weight sans, and raspberry only
|
|
// as a small mark: the primary button, the unread square, the 3px edge on the
|
|
// active folder and the selected message, the focus ring and links. Errors,
|
|
// warnings and success have their own colours so nothing destructive can be
|
|
// mistaken for the brand.
|
|
//
|
|
// Contrast (WCAG 2.1) is checked for every foreground/background pair; the
|
|
// tightest is white on raspberry at 4.65:1, so button labels on primary stay
|
|
// at the base size and weight 500. Raspberry is never used as text: text
|
|
// links and `text-primary` take --color-ring, which reaches 4.5:1 on the grey
|
|
// surface in both variants.
|
|
|
|
export const flatFieldsCSS = `
|
|
:root {
|
|
--color-background: #ffffff;
|
|
--color-foreground: #18181b;
|
|
--color-card: #ffffff;
|
|
--color-card-foreground: #18181b;
|
|
--color-popover: #ffffff;
|
|
--color-popover-foreground: #18181b;
|
|
--color-secondary: #f4f4f5;
|
|
--color-secondary-foreground: #18181b;
|
|
--color-muted: #f4f4f5;
|
|
--color-muted-foreground: #56565d;
|
|
--color-accent: #f4f4f5;
|
|
--color-accent-foreground: #18181b;
|
|
--color-sidebar: #ffffff;
|
|
--color-sidebar-foreground: #18181b;
|
|
--color-sidebar-border: #dddde1;
|
|
--color-sidebar-accent: #f4f4f5;
|
|
--color-sidebar-accent-foreground: #18181b;
|
|
--color-selection: #f4f4f5;
|
|
--color-selection-foreground: #18181b;
|
|
--color-border: #dddde1;
|
|
--color-input: #7f7f87;
|
|
--color-ring: #c01f46;
|
|
--color-primary: #db2d54;
|
|
--color-primary-foreground: #ffffff;
|
|
--color-unread: #db2d54;
|
|
--color-destructive: #b42318;
|
|
--color-destructive-foreground: #ffffff;
|
|
--color-success: #17784a;
|
|
--color-success-foreground: #ffffff;
|
|
--color-warning: #8a5d00;
|
|
--color-warning-foreground: #ffffff;
|
|
--color-info: #1f5f9e;
|
|
--color-info-foreground: #ffffff;
|
|
--color-chart-1: #1f5f9e;
|
|
--color-chart-2: #17784a;
|
|
--color-chart-3: #8a5d00;
|
|
--color-chart-4: #db2d54;
|
|
--color-chart-5: #6b5bb5;
|
|
|
|
/* Square system: 0 on surfaces, 2px on things you press or type into. */
|
|
--radius-xs: 0px;
|
|
--radius-sm: 0px;
|
|
--radius-md: 2px;
|
|
--radius-lg: 2px;
|
|
--radius-xl: 2px;
|
|
--radius-2xl: 2px;
|
|
--radius-3xl: 2px;
|
|
--radius-4xl: 2px;
|
|
|
|
/* No frosted glass. */
|
|
--blur-xs: 0px;
|
|
--blur-sm: 0px;
|
|
--blur-md: 0px;
|
|
--blur-lg: 0px;
|
|
--blur-xl: 0px;
|
|
--blur-2xl: 0px;
|
|
--blur-3xl: 0px;
|
|
}
|
|
|
|
.dark {
|
|
--color-background: #131315;
|
|
--color-foreground: #f2f2f3;
|
|
--color-card: #1f1f22;
|
|
--color-card-foreground: #f2f2f3;
|
|
--color-popover: #1f1f22;
|
|
--color-popover-foreground: #f2f2f3;
|
|
--color-secondary: #1f1f22;
|
|
--color-secondary-foreground: #f2f2f3;
|
|
--color-muted: #1f1f22;
|
|
--color-muted-foreground: #ababb2;
|
|
/* A step above popover and muted so hover and selection stay visible
|
|
inside menus and on the quiet surface. */
|
|
--color-accent: #2a2a2e;
|
|
--color-accent-foreground: #f2f2f3;
|
|
--color-sidebar: #131315;
|
|
--color-sidebar-foreground: #f2f2f3;
|
|
--color-sidebar-border: #36363b;
|
|
--color-sidebar-accent: #2a2a2e;
|
|
--color-sidebar-accent-foreground: #f2f2f3;
|
|
--color-selection: #2a2a2e;
|
|
--color-selection-foreground: #f2f2f3;
|
|
--color-border: #36363b;
|
|
--color-input: #85858d;
|
|
--color-ring: #ff91a8;
|
|
--color-primary: #db2d54;
|
|
--color-primary-foreground: #ffffff;
|
|
--color-unread: #f0486f;
|
|
--color-destructive: #ff9b8f;
|
|
--color-destructive-foreground: #131315;
|
|
--color-success: #4cc38a;
|
|
--color-success-foreground: #131315;
|
|
--color-warning: #e2b341;
|
|
--color-warning-foreground: #131315;
|
|
--color-info: #7fb2e8;
|
|
--color-info-foreground: #131315;
|
|
--color-chart-1: #7fb2e8;
|
|
--color-chart-2: #4cc38a;
|
|
--color-chart-3: #e2b341;
|
|
--color-chart-4: #f0486f;
|
|
--color-chart-5: #a99bf0;
|
|
}
|
|
`;
|
|
|
|
// Component-level rules the tokens cannot express. Stable hooks only:
|
|
// [data-tour], [role], data-* state attributes, and Tailwind utilities.
|
|
// Nothing here moves, hides or restructures a component.
|
|
export const flatFieldsSkin = `
|
|
/* ── Type: Hanken Grotesk (self-hosted by the root layout as --font-hanken,
|
|
defined on <body>), then a system stack for scripts it does not cover. ── */
|
|
body[data-theme-skin="builtin-flat-fields"] {
|
|
font-family: var(--font-hanken, "Hanken Grotesk"), ui-sans-serif, system-ui,
|
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
|
|
Arial, "Noto Sans", "Noto Sans Thai", "Leelawadee UI", sans-serif;
|
|
}
|
|
|
|
body[data-theme-skin="builtin-flat-fields"] .font-bold {
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* Uppercase letter-spaced labels read as sentence-style small text. */
|
|
body[data-theme-skin="builtin-flat-fields"] .uppercase:not(.font-mono) {
|
|
text-transform: none;
|
|
letter-spacing: 0;
|
|
}
|
|
|
|
/* ── Flat: no shadows, glows or frosted glass. Clearing --tw-shadow keeps
|
|
Tailwind's ring utilities working. ── */
|
|
body[data-theme-skin="builtin-flat-fields"] [class*="shadow"] {
|
|
--tw-shadow: 0 0 #0000;
|
|
}
|
|
|
|
body[data-theme-skin="builtin-flat-fields"] [class*="backdrop-blur"] {
|
|
-webkit-backdrop-filter: none;
|
|
backdrop-filter: none;
|
|
}
|
|
|
|
/* Grounds are flat; the sign-in page and empty states use soft washes. The
|
|
image viewer's dark scrim keeps its controls legible and stays. */
|
|
body[data-theme-skin="builtin-flat-fields"] [class*="bg-gradient-to-"]:not([class*="from-black"]) {
|
|
background-image: none;
|
|
}
|
|
|
|
/* ── Square. \`rounded\` and \`rounded-full\` compile to literals, not radius
|
|
tokens. Pills, badges, toggles, counters, the compose button, swatches and
|
|
status dots become 2px shapes; spinners stay round. ── */
|
|
body[data-theme-skin="builtin-flat-fields"] .rounded,
|
|
body[data-theme-skin="builtin-flat-fields"] .rounded-full:not(.animate-spin) {
|
|
border-radius: var(--radius-md);
|
|
}
|
|
|
|
/* Calendar event chips (a colour edge on the left, \`rounded-r\`) are tiles. */
|
|
body[data-theme-skin="builtin-flat-fields"] :is(.rounded-r, .rounded-t) {
|
|
border-radius: 0;
|
|
}
|
|
|
|
/* Avatars are images, and images are square. Initials without a photo sit on
|
|
the quiet surface instead of a random saturated hue (which also fails
|
|
contrast with white initials for yellows and greens). The hue is an inline
|
|
style, hence the one !important. */
|
|
body[data-theme-skin="builtin-flat-fields"] .rounded-full.overflow-hidden.text-white[title] {
|
|
border-radius: 0;
|
|
}
|
|
|
|
body[data-theme-skin="builtin-flat-fields"] .rounded-full.overflow-hidden.text-white[title]:not(:has(img)) {
|
|
background-color: var(--color-muted) !important;
|
|
color: var(--color-muted-foreground);
|
|
font-weight: 500;
|
|
box-shadow: inset 0 0 0 1px var(--color-border);
|
|
}
|
|
|
|
/* Icons take the text colour; the brand is reserved for marks. */
|
|
/* Folder role icons take the text colour; tag icons (filled with the tag's
|
|
own colour) are user data and keep it. */
|
|
body[data-theme-skin="builtin-flat-fields"] [data-testid="folder-row"] svg:not([fill="currentColor"]) {
|
|
color: var(--color-muted-foreground);
|
|
}
|
|
|
|
body[data-theme-skin="builtin-flat-fields"] [data-testid="folder-row"][data-selected="true"] svg:not([fill="currentColor"]) {
|
|
color: var(--color-foreground);
|
|
}
|
|
|
|
/* Brand tints (bg-primary/5 to /20) are areas of pink: use the neutral
|
|
selection grey. Strong fills (/40 and up) are progress and marks. */
|
|
body[data-theme-skin="builtin-flat-fields"] :is([class~="bg-primary/5"], [class~="bg-primary/10"], [class~="bg-primary/15"], [class~="bg-primary/20"], [class~="dark:bg-primary/10"]),
|
|
body[data-theme-skin="builtin-flat-fields"] :is([class~="hover:bg-primary/5"], [class~="hover:bg-primary/10"], [class~="hover:bg-primary/15"], [class~="hover:bg-primary/20"], [class~="hover:bg-primary/30"]):hover {
|
|
background-color: var(--color-accent);
|
|
}
|
|
|
|
/* Checked and dragged rows: grey, without the pink ring. */
|
|
body[data-theme-skin="builtin-flat-fields"] :is([class~="ring-primary/20"], [class~="ring-primary/30"]) {
|
|
--tw-ring-shadow: 0 0 #0000;
|
|
}
|
|
|
|
/* Layers: dialogs, menus and toasts are square, separated by a 1px border. */
|
|
body[data-theme-skin="builtin-flat-fields"] [role="dialog"],
|
|
body[data-theme-skin="builtin-flat-fields"] [role="alertdialog"],
|
|
body[data-theme-skin="builtin-flat-fields"] [role="menu"],
|
|
body[data-theme-skin="builtin-flat-fields"] [role="listbox"],
|
|
body[data-theme-skin="builtin-flat-fields"] .toast-item {
|
|
border-radius: 0;
|
|
}
|
|
|
|
body[data-theme-skin="builtin-flat-fields"] [role="alertdialog"],
|
|
body[data-theme-skin="builtin-flat-fields"] [role="menu"],
|
|
body[data-theme-skin="builtin-flat-fields"] .toast-item {
|
|
border: 1px solid var(--color-input);
|
|
}
|
|
|
|
body[data-theme-skin="builtin-flat-fields"] .toast-item {
|
|
background-color: var(--color-popover);
|
|
}
|
|
|
|
/* ── Panes sit on the page ground, split by 1px rules, so grey only ever
|
|
means "selected" or "under the pointer". ── */
|
|
body[data-theme-skin="builtin-flat-fields"] .w-14.bg-secondary,
|
|
body[data-theme-skin="builtin-flat-fields"] .border-e.bg-secondary,
|
|
body[data-theme-skin="builtin-flat-fields"] .flex-col.h-full.bg-secondary {
|
|
background-color: var(--color-background);
|
|
}
|
|
|
|
/* ── Selection is grey plus a 3px brand edge: the theme widens the folder
|
|
row's start border and colours it, the rows that aren't selected keep a
|
|
transparent one so the names don't shift. ── */
|
|
body[data-theme-skin="builtin-flat-fields"] [data-tour="sidebar"] [data-testid="folder-row"] {
|
|
border-inline-start-color: transparent;
|
|
}
|
|
|
|
body[data-theme-skin="builtin-flat-fields"] [data-tour="sidebar"] [data-selected="true"] {
|
|
border-inline-start-width: 3px;
|
|
border-inline-start-color: var(--color-primary);
|
|
font-weight: 500;
|
|
}
|
|
|
|
/* Side panes (settings, contacts, files, calendar) mark their active item
|
|
only with bg-accent; give it the same edge. */
|
|
body[data-theme-skin="builtin-flat-fields"] :is(.border-e.bg-secondary, .flex-col.h-full.bg-secondary) button.bg-accent {
|
|
box-shadow: inset 3px 0 0 var(--color-primary);
|
|
}
|
|
|
|
body[data-theme-skin="builtin-flat-fields"] [data-tour="contacts-list"] .bg-selection {
|
|
box-shadow: inset 3px 0 0 var(--color-primary);
|
|
}
|
|
|
|
body[data-theme-skin="builtin-flat-fields"] [data-tour^="nav-"][aria-current="page"] {
|
|
background-color: var(--color-accent);
|
|
color: var(--color-accent-foreground);
|
|
box-shadow: inset 3px 0 0 var(--color-primary);
|
|
}
|
|
|
|
/* The calendar's "now" line is drawn with the destructive colour; it marks
|
|
time, not danger, so it takes the brand. */
|
|
body[data-theme-skin="builtin-flat-fields"] .pointer-events-none > .flex.items-center > .bg-destructive:is(.h-px, .w-2) {
|
|
background-color: var(--color-primary);
|
|
}
|
|
|
|
/* Unread counters are brand marks, not alarm red. */
|
|
body[data-theme-skin="builtin-flat-fields"] [data-tour^="nav-"] .bg-red-500 {
|
|
background-color: var(--color-primary);
|
|
color: var(--color-primary-foreground);
|
|
}
|
|
|
|
body[data-theme-skin="builtin-flat-fields"] [data-tour="email-list"] [aria-current="true"]::before {
|
|
content: "";
|
|
position: absolute;
|
|
inset-block: 0;
|
|
inset-inline-start: 0;
|
|
width: 3px;
|
|
background-color: var(--color-primary);
|
|
z-index: 1;
|
|
pointer-events: none;
|
|
}
|
|
|
|
/* The reading pane is one white ground; no grey band below short messages. */
|
|
body[data-theme-skin="builtin-flat-fields"] [data-tour="email-viewer"] .overflow-auto.bg-muted\\/30 {
|
|
background-color: var(--color-background);
|
|
}
|
|
|
|
/* Toggles: off is an outlined control with a grey thumb, on is a brand
|
|
track with a white thumb. */
|
|
body[data-theme-skin="builtin-flat-fields"] [role="switch"][aria-checked="false"] {
|
|
background-color: var(--color-background);
|
|
box-shadow: inset 0 0 0 1px var(--color-input);
|
|
}
|
|
|
|
body[data-theme-skin="builtin-flat-fields"] [role="switch"][aria-checked="false"] > span {
|
|
background-color: var(--color-input);
|
|
}
|
|
|
|
/* Things you type into or press carry the control border (3:1 on every
|
|
ground), also the fields that ask for border-border. This rule is not in
|
|
a layer, so it outranks the border utilities (the app's own
|
|
\`* { border-color }\` default sits in @layer base and doesn't). Borderless
|
|
fields are unaffected because they have no border width. */
|
|
body[data-theme-skin="builtin-flat-fields"] :is(input:not([type="checkbox"]):not([type="radio"]):not([type="range"]), select, textarea, .border-input) {
|
|
border-color: var(--color-input);
|
|
}
|
|
|
|
/* ...except a field that holds an error: it keeps the error colour its
|
|
page gives it (aria-invalid, border-destructive or a red border). */
|
|
body[data-theme-skin="builtin-flat-fields"] :is(input:not([type="checkbox"]):not([type="radio"]):not([type="range"]), select, textarea, .border-input):is([aria-invalid="true"], .border-destructive, [class*="border-red-"]) {
|
|
border-color: var(--color-destructive);
|
|
}
|
|
|
|
body[data-theme-skin="builtin-flat-fields"] :is(input[type="checkbox"], input[type="radio"]) {
|
|
accent-color: var(--color-primary);
|
|
}
|
|
|
|
/* Unread is a small square and a 600 weight, not a tinted row. */
|
|
body[data-theme-skin="builtin-flat-fields"] [data-tour="email-list"] .bg-accent\\/30:not(:hover) {
|
|
background-color: var(--color-background);
|
|
}
|
|
|
|
body[data-theme-skin="builtin-flat-fields"] [data-tour="email-list"] svg.fill-unread {
|
|
color: transparent;
|
|
fill: transparent;
|
|
background-color: var(--color-unread);
|
|
width: 7px;
|
|
height: 7px;
|
|
}
|
|
|
|
/* The square sits clear of the 3px selection edge. */
|
|
body[data-theme-skin="builtin-flat-fields"] [data-tour="email-list"] div.absolute:has(> svg.fill-unread) {
|
|
inset-inline-start: 6px;
|
|
}
|
|
|
|
/* In the split layout the app anchors the mark at "padding + half an
|
|
avatar", but the avatar is centred in the row, so the mark lined up with
|
|
nothing. Put it on the sender line it marks (text-sm in a 20-24px row).
|
|
The other layouts already centre it on their single line. The offset is
|
|
an inline style, hence !important. */
|
|
body[data-theme-skin="builtin-flat-fields"] [data-tour="email-list"] div.absolute[style*="+ 1.25rem"]:has(> svg.fill-unread) {
|
|
top: calc(var(--density-item-py) + 0.75rem) !important;
|
|
}
|
|
|
|
/* ── Raspberry is too light for text on grey: text uses the link colour. ── */
|
|
body[data-theme-skin="builtin-flat-fields"] .text-primary {
|
|
color: var(--color-ring);
|
|
}
|
|
|
|
body[data-theme-skin="builtin-flat-fields"] .email-content a,
|
|
body[data-theme-skin="builtin-flat-fields"] .email-content-text a {
|
|
color: var(--color-ring);
|
|
text-decoration: underline;
|
|
text-decoration-thickness: 1px;
|
|
text-underline-offset: 3px;
|
|
}
|
|
|
|
/* ── Primary hover goes darker, never lighter or pinker. ── */
|
|
body[data-theme-skin="builtin-flat-fields"] .bg-primary:is(button, a):hover,
|
|
body[data-theme-skin="builtin-flat-fields"] [data-tour="compose-button"]:hover {
|
|
background-color: color-mix(in srgb, var(--color-primary) 84%, #000);
|
|
}
|
|
|
|
/* Disabled is grey, not a faded pink. */
|
|
body[data-theme-skin="builtin-flat-fields"] button.bg-primary:disabled {
|
|
background-color: var(--color-muted);
|
|
color: var(--color-muted-foreground);
|
|
opacity: 1;
|
|
}
|
|
|
|
/* A chosen option in a segmented control is filled with the text colour, as
|
|
on the website's edition switch: a choice, not a call to action. */
|
|
body[data-theme-skin="builtin-flat-fields"] button[aria-pressed="true"].bg-primary,
|
|
body[data-theme-skin="builtin-flat-fields"] [role="radio"][aria-checked="true"].bg-primary,
|
|
body[data-theme-skin="builtin-flat-fields"] .flex.border.overflow-hidden > button.bg-primary {
|
|
background-color: var(--color-foreground);
|
|
color: var(--color-background);
|
|
}
|
|
|
|
/* The thread count stays a quiet label even when the thread has unread mail;
|
|
the unread square already says so. */
|
|
body[data-theme-skin="builtin-flat-fields"] [data-tour="email-list"] span.bg-primary.text-primary-foreground {
|
|
background-color: var(--color-muted);
|
|
color: var(--color-foreground);
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* ── Icons follow the website's rule: 1.5 stroke, square caps, mitred joins. ── */
|
|
body[data-theme-skin="builtin-flat-fields"] svg.tabler-icon {
|
|
stroke-width: 1.5;
|
|
stroke-linecap: square;
|
|
stroke-linejoin: miter;
|
|
}
|
|
|
|
/* ── Focus: one ring, 2px solid, 2px off the element. ── */
|
|
body[data-theme-skin="builtin-flat-fields"] :focus-visible {
|
|
outline: 2px solid var(--color-ring);
|
|
outline-offset: 2px;
|
|
--tw-ring-shadow: 0 0 #0000;
|
|
--tw-ring-offset-shadow: 0 0 #0000;
|
|
}
|
|
|
|
/* Text fields keep focus for as long as someone types, so the ring hugs the
|
|
field instead of floating a box around it. */
|
|
body[data-theme-skin="builtin-flat-fields"] :is(input, textarea, select, [contenteditable="true"]):focus-visible {
|
|
outline-offset: 0;
|
|
}
|
|
`;
|