Files
webmail/lib/theme-flat-fields.ts
T

425 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 heavy weight, not a tinted row. Hanken
Grotesk's 600 sits too close to the 500 of read senders to tell apart, so
unread sender and subject go to 700 and read senders drop to 400. */
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"] [data-unread="true"] .text-foreground:is(.font-semibold, .font-bold) {
font-weight: 700;
}
body[data-theme-skin="builtin-flat-fields"] [data-tour="email-list"] [data-unread="false"] .font-medium:is([class~="text-foreground/80"], .text-muted-foreground) {
font-weight: 400;
}
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;
}
`;