Files
plspace/src/styles/README.md
T
2026-07-29 09:16:38 +09:00

6.4 KiB

The styling contract

These stylesheets are the public API of plspace's appearance. They are written so that a user's own CSS can override any of it without fighting the cascade.

Rules the app follows

  1. No CSS framework, no utility classes. Every class names the thing it styles, not how it looks. .friend-card-photo, not .w-16.rounded.
  2. No Svelte scoped styles. Not one component has a <style> block, so Svelte never appends a .svelte-1a2b3c hash to a class. What you see in the DOM inspector is what you write in your selector, permanently.
  3. Single-class selectors. Almost nothing here is more specific than one class. No IDs, no !important, no long descendant chains. A plain .blog-entry { … } in your CSS ties on specificity and wins on order.
  4. User CSS goes last. The theme store re-appends #user-stylesheet to the end of <head> whenever it changes, so an equal-specificity tie always resolves in your favour. This is why you never need !important.
  5. Everything visual is a custom property. No rule hard-codes a colour, font or size. Retinting the entire app is a matter of overriding tokens.

Layer 1: tokens

tokens.css declares every colour, font, border and metric on :root. This is the intended entry point — it changes the whole app coherently, including parts you haven't looked at.

:root {
  --ms-chrome-bg: #2d0b3a;
  --ms-module-header-bg: #4a1a5c;
  --ms-link: #ff77cc;
  --ms-font-family: 'Comic Sans MS', Verdana, sans-serif;
}

The five presets in src/lib/themes.ts are written entirely at this layer — read them as worked examples.

Token groups: typography (--ms-font-*), page (--ms-page-*, --ms-canvas-*), links (--ms-link*), chrome (--ms-chrome-*, --ms-nav-*), modules (--ms-module-*, --ms-band-*), tables (--ms-table-*), forms (--ms-input-*, --ms-button-*), avatars (--ms-avatar-*), layout (--ms-page-width*, --ms-column-*).

Layer 2: classes

When tokens aren't enough, target classes directly.

Area Classes
Page skeleton .page, .page-title, .page-subtitle, .layout--split, .layout--dashboard, .layout--single, .layout-column--left, .layout-column--main, .layout-column--right
Chrome .site-header, .site-logo, .site-nav, .site-nav-link, .site-footer
Boxes .module, .module-header, .module-body, .module--band, .module--plain, .section-heading
Profile .profile-page, .profile-photo, .profile-headline, .profile-vitals, .profile-mood, .profile-badge
Tables .data-table, .data-table-label, .data-table-value, .interests-table, .details-table
Friends .friend-grid, .friend-card, .friend-card-name, .friend-card-photo, .person-row
Entries .blog-list, .blog-entry, .blog-entry-header, .blog-entry-body, .blog-entry-actions, .blog-action, .entry-teaser
Comments .comment-list, .comment, .comment-author, .comment-body
Media .attachment, .attachment-media, .attachment-caption, .preview-card, .poll
Mail .mail-folders, .mail-table, .mail-row, .mail-summary
Forms .button, .field, .field-label, .link-button, .tab-bar, .tab

Layer 3: state attributes

Rather than inventing a modifier class per combination, elements carry data attributes describing what they are. Style by state with attribute selectors:

.blog-entry[data-mine='true']            { background: #fffbe6; }
.blog-entry[data-visibility='private']   { background: #fff0f0; }
.blog-entry[data-boosted='true']         { opacity: 0.85; }
.blog-entry[data-sensitive='true']       { border-left: 3px solid red; }
.comment[data-depth='2']                 { font-size: 11px; }
.mail-row[data-kind='follow_request']    { background: #ffffcc; }
.attachment[data-type='video']           { border-color: purple; }
.blog-action[aria-pressed='true']        { font-weight: 700; }
.site-nav-link[aria-current='page']      { background: orange; }

Available attributes: data-account (on entries, cards, rows), data-status-id, data-visibility, data-boosted, data-reply, data-sensitive, data-mine, data-compact, data-depth, data-kind, data-type, data-verified, data-timeline, data-view, data-folder, data-row, data-badge, data-private, data-session.

There is no dark mode

Deliberately. A dark theme is a set of token overrides and nothing more, so it lives at layer 1 like any other theme — see the Midnight and Terminal presets in src/lib/themes.ts. Shipping a hardcoded toggle would have meant one dark theme nobody could edit, next to a styling system built for exactly this.

Two things a dark theme must remember:

  • Set color-scheme: dark on :root so native form controls and scrollbars follow. Viewer CSS is unscoped, so this works.
  • Recolour --ms-chrome-bg. The logo sits on the chrome band and is knocked out to white by --ms-logo-filter, so any dark band works as-is. If you theme the band to a light colour, set --ms-logo-filter: none to get the mark's own navy back.

Publishing a layout on your profile

Put CSS in a profile field named css (or style, layout, stylesheet) on your server. Anyone viewing your profile in plspace gets it. Because it's an ordinary profile field it federates normally — other clients just show it as text.

Published CSS is scoped to .profile-page and filtered before it is applied:

  • @import is removed (it would pull in an unbounded external stylesheet).
  • url() is allowed only for https: and data:image/ (no tracking pixels).
  • position: fixed becomes static (no viewport-covering overlays).
  • Every selector is prefixed with .profile-page, including rules nested inside @media / @supports. Writing body or html targets the profile page itself, which is the useful interpretation.
  • @keyframes blocks pass through untouched, so animations still work.

The net effect: you can do anything to your own page, and nothing to anyone else's or to the surrounding app. Viewers can switch the whole feature off in Settings.

Adding styles to the app itself

Add the rule to the file that owns that area — chrome.css, layout.css, module.css, profile.css, blog.css, forms.css — and register any new colour or metric as a token in tokens.css first. Do not introduce a <style> block in a component; it would create a hashed class the contract above promises doesn't exist.