0.1.0GitHub
FrontendStyling

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:

bootstrap/client.tsx
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:

index.html
<!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>
styles/app.css
.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:

modules/notes/views/Progress.tsx
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" />;
}
styles/app.css
.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:

bootstrap/app.ts
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:

ComponentClasses
The form around a link that sends, <Link> to a POST, PUT, PATCH or DELETE routemarmeon-link
The dialog frame, LayerDialogmarmeon-layer, marmeon-layer--modal, marmeon-layer--slideover, marmeon-layer__content, marmeon-layer__close
The confirm dialog, ConfirmDialogmarmeon-confirm, marmeon-confirm__title, marmeon-confirm__field, marmeon-confirm__actions
A failed visitmarmeon-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 pagemarmeon-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:

styles/app.css
: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:

AttributeWhereWhen
aria-current="page", data-currentA <Link>It leads to the page on screen.
data-pendingA <Link>, a <Form>Its visit or its request is under way.
data-dirtyA <Form>A field differs from where the form started.
aria-busy="true"<html>A visit is under way.
data-island, data-statusThe element around an <Island>Always: the island's route, and its status, such as loading or ready.
data-marmeon-layer, data-variant, data-depthA LayerDialogIts key, modal or slideover, and its depth.
data-marmeon-confirm, data-kind, data-destructiveA ConfirmDialogAlways; confirm, or leave for the guard's question; the request is destructive.
styles/app.css
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.