Frontend
Styling
On this page
Introduction
A Marmeon app styles its pages with stylesheets and classes. The client entry imports the framework's stylesheet and your own, and the build turns them into stylesheets that the document links:
import { createMarmeonApp, progressBar } from '@marmeon/react';
import '@marmeon/react/styles.css';
import '../styles/app.css';
import { routes } from 'virtual:marmeon/routes';
import { title } from './head.ts';
import { messages } from './i18n.ts';
import { resolvePage } from './pages.ts';
void createMarmeonApp({ resolve: resolvePage, title, routes, messages, progress: progressBar({ delay: 250 }) });Pages and layouts name classes, never a style attribute. That keeps the app ready for a strict Content-Security-Policy, which
blocks every style attribute in the markup. This page covers where stylesheets go, why inline styles are out and what to do
instead, and the classes of the framework's own components.
Your stylesheets
A stylesheet imported in bootstrap/client.tsx becomes part of the build: marmeon build writes it into a stylesheet that the
built index.html links, from your own origin. While you develop, Vite adds it as a <style> element once the entry runs.
A stylesheet can also be linked from index.html directly, which Vite processes the same way in the build:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Notes</title>
<link rel="stylesheet" href="/styles/app.css" />
<!--app-head-->
</head>
<body>
<div id="app"><!--app-html--></div>
</body>
</html>A linked stylesheet applies before the entry runs, also in development. A module can bring its own stylesheet next to its views:
marmeon make:auth writes modules/auth/auth.css and imports it in bootstrap/client.tsx.
No style attributes
A Content-Security-Policy can limit where styles come from. With style-src 'self', the browser applies stylesheets of your origin
and ignores every style attribute in the server's markup. A nonce does not help, because it covers <style> and <link>
elements, never attributes. React does not add the attribute later either: when it hydrates, it takes the server's markup as it is.
So a style prop on a server-rendered element is lost under such a policy.
Write classes instead, and use data attributes for state:
<p className="note-status" data-pinned={note.pinned || undefined}>{note.title}</p>.note-status[data-pinned] { font-weight: 600; }A value that only the page knows, such as a width in percent, goes into a CSS variable that you set through the CSSOM after the element has mounted. The policy does not govern the CSSOM:
import { useLayoutEffect, useRef } from 'react';
export function Progress({ done }: { done: number }) {
const bar = useRef<HTMLDivElement>(null);
useLayoutEffect(() => bar.current?.style.setProperty('--done', `${done}%`), [done]);
return <div ref={bar} className="progress" />;
}.progress { width: var(--done, 0%); height: 4px; background: currentColor; }The framework follows the same rule. Its components render no style attribute. LayerDialog takes a style
prop and applies it through the CSSOM after it has mounted. ConfirmDialog takes one too and renders in the browser only, where
React sets it through the CSSOM. The progress bar sets its properties through the CSSOM as well.
A strict policy for styles
contentSecurityPolicy() in bootstrap/app.ts limits scripts and leaves styles alone by default. When your pages carry no style
attributes, limit styles too:
import { join } from 'node:path';
import { defineApplication } from '@marmeon/core';
import { contentSecurityPolicy, NONCE } from '@marmeon/http';
import system from '#modules/system';
import { AppConfig } from '../config/app.ts';
import { SecurityHeaders } from '../middleware/SecurityHeaders.ts';
export default defineApplication({
root: join(import.meta.dirname, '..'),
config: [AppConfig],
modules: [system],
middleware: {
global: [SecurityHeaders, contentSecurityPolicy({ styleSrc: ["'self'", NONCE] })],
},
});'self' allows the built stylesheets, and NONCE allows the <style> elements that Vite adds in development and that React adds
for streamed parts of a page. styleSrc: ["'self'"] alone blocks nothing of the framework in a build. The
Content-Security-Policy page covers the other directives.
The framework's look
@marmeon/react/styles.css styles the framework's components:
| Component | Classes |
|---|---|
The form around a link that sends, <Link> to a POST, PUT, PATCH or DELETE route | marmeon-link |
The dialog frame, LayerDialog | marmeon-layer, marmeon-layer--modal, marmeon-layer--slideover, marmeon-layer__content, marmeon-layer__close |
The confirm dialog, ConfirmDialog | marmeon-confirm, marmeon-confirm__title, marmeon-confirm__field, marmeon-confirm__actions |
| A failed visit | marmeon-failure--network for the notice when the server cannot be reached, marmeon-failure--server for the dialog with the server's error page, marmeon-failure__frame, marmeon-failure__close |
| The built-in error page | marmeon-error, marmeon-error__status, marmeon-error__title, marmeon-error__exception |
Every rule sits in :where(), so it has no specificity. A class of your own, passed as className, overrides it, and so does a
rule on the components' data attributes: [data-marmeon-layer], [data-variant], [data-marmeon-confirm], [data-kind],
[data-destructive] and [data-marmeon-failure]. The failure notices take their colours from variables, which you set on :root:
:root {
--marmeon-failure-background: #1f2937;
--marmeon-failure-color: #f9fafb;
--marmeon-failure-dialog-background: Canvas;
--marmeon-failure-dialog-color: CanvasText;
}The progress bar reads --marmeon-progress-color, --marmeon-progress-height and --marmeon-progress-z-index. Without the import
of styles.css, the components are unstyled, and their look is yours.
Styling state
The framework marks state with attributes, so the stylesheet can follow it:
| Attribute | Where | When |
|---|---|---|
aria-current="page", data-current | A <Link> | It leads to the page on screen. |
data-pending | A <Link>, a <Form> | Its visit or its request is under way. |
data-dirty | A <Form> | A field differs from where the form started. |
aria-busy="true" | <html> | A visit is under way. |
data-island, data-status | The element around an <Island> | Always: the island's route, and its status, such as loading or ready. |
data-marmeon-layer, data-variant, data-depth | A LayerDialog | Its key, modal or slideover, and its depth. |
data-marmeon-confirm, data-kind, data-destructive | A ConfirmDialog | Always; confirm, or leave for the guard's question; the request is destructive. |
a[data-current] { font-weight: 600; }
html[aria-busy='true'] main { opacity: 0.6; transition: opacity 0.2s 0.25s; }
[data-island][data-status='loading'] { min-height: 4rem; }Animations between pages live in the stylesheet as well. The navigation page shows them.