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
- No CSS framework, no utility classes. Every class names the thing it
styles, not how it looks.
.friend-card-photo, not.w-16.rounded. - No Svelte scoped styles. Not one component has a
<style>block, so Svelte never appends a.svelte-1a2b3chash to a class. What you see in the DOM inspector is what you write in your selector, permanently. - 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. - User CSS goes last. The theme store re-appends
#user-stylesheetto the end of<head>whenever it changes, so an equal-specificity tie always resolves in your favour. This is why you never need!important. - 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-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: darkon:rootso 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: noneto 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:
@importis removed (it would pull in an unbounded external stylesheet).url()is allowed only forhttps:anddata:image/(no tracking pixels).position: fixedbecomesstatic(no viewport-covering overlays).- Every selector is prefixed with
.profile-page, including rules nested inside@media/@supports. Writingbodyorhtmltargets the profile page itself, which is the useful interpretation. @keyframesblocks 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.