Hapio Calendar

Content Security Policy

The calendar sets geometry through inline style attributes. Two ways to run it under a strict style-src, both verified in a test.

The hooks place every event, lane and label with CSS custom properties in inline style attributes (--event-top: 37.5%, --calendar-columns: …), so that positions need no measuring and no per-instance CSS. A Content-Security-Policy whose style-src has no 'unsafe-inline' blocks style attributes in server-rendered markup, and hydration does not apply them again: the grid collapses. The calendar's own code inserts no <style> elements and no inline scripts, so style attributes are the part of a policy it concerns, apart from one image in its theme (below).

There are two ways to run it under a strict policy. Both are checked on every change by an end-to-end test against pages served with these policies (a strict page and one with style attributes), each an editable calendar: no violation is reported, every event sits at its time, and an event's "More actions" button shows its icon and opens its menu.

Option 1: allow style attributes

Keep style-src strict for stylesheets and <style> elements, and allow inline style attributes only:

style-src 'self'; style-src-attr 'unsafe-inline'

Server rendering works unchanged. Style attributes cannot run script or load anything that img-src and font-src do not allow, which is why many strict policies allow them; with this option your style-src stays nonce- or hash-free.

Option 2: render the calendar in the browser

Without any inline-style allowance, render the calendar on the client only. React then sets every style through the CSSOM (element.style.setProperty), which style-src does not govern:

components/strict-csp-calendar.tsx
'use client'

import type { CalendarEventInput } from '@hapio/calendar-react'
import { useSyncExternalStore, type ReactNode } from 'react'

import { HapioCalendar } from '@/components/hapio-calendar/hapio-calendar'

function subscribe(): () => void {
  return () => undefined
}

/** `children` in the browser only: the server render and hydration show `fallback`. */
function ClientOnly({ children, fallback }: { children: ReactNode; fallback: ReactNode }) {
  const client = useSyncExternalStore(
    subscribe,
    () => true,
    () => false,
  )
  return client ? children : fallback
}

export function StrictCspCalendar({ events }: { events: readonly CalendarEventInput[] }) {
  return (
    <ClientOnly fallback={<div className="h-[36rem] rounded-xl border" />}>
      <HapioCalendar events={events} timeZone="Europe/Stockholm" className="h-[36rem]" />
    </ClientOnly>
  )
}

The server renders the fallback (give it the calendar's height), and the calendar appears after hydration. next/dynamic with ssr: false in a client component does the same.

What does not work

  • ssrEvents={false} alone. It leaves the event layers out of the server render, but the grids' own layout uses style attributes too, all of them in server-rendered markup:

    ElementInline styles
    the time grid's scrolleroverflow, scrollbar-gutter, --calendar-columns, --calendar-slots
    column headers--calendar-span
    the all-day row, week rows--calendar-levels, position
    slot labels--calendar-slot, --calendar-slot-top
    lanesposition
    the now indicator--calendar-now-top
    background eventstheir geometry and color

    Most of these values depend on the data (the number of days, slots and rows), so they cannot be classes.

  • Nonces and hashes apply to <style> and <script> elements, not to style attributes; 'unsafe-hashes' would need a hash per attribute value, which changes with every event.

The theme's icon

calendar-theme draws the "More actions" button's "⋯" with a CSS mask whose image is a data: SVG, and img-src governs CSS images. Keep data: in img-src (img-src 'self' data:, as the test pages do), or the buttons show no icon.

Your framework's part

The rest of a policy is your app's, and none of it changes with the calendar:

  • Next.js inlines its own scripts, so a static page needs script-src 'unsafe-inline', or a nonce from a proxy with dynamic rendering.
  • Your shadcn components are yours. The calendar uses your Popover, DropdownMenu and Dialog; Radix's modal dialog (the "Change time…" form and the scope dialog) inserts a <style> element to lock the page's scrolling while it is open, which needs a nonce (Radix reads it through the get-nonce package) or a style-src that allows it.
  • Theme switchers such as next-themes insert a <style> element while they switch themes; allow it by its hash, as the test pages do.