mirror of
https://github.com/discourse/discourse.git
synced 2026-08-06 13:08:40 +08:00
## What Adds repair-oriented guidance to the existing `discourse-writing-html-css` skill. This extends the skill from authoring new CSS into safely modifying existing CSS, covering: - selector scoping instead of specificity escalation - deleting stale CSS/imports before adding overrides - consolidating device-specific CSS into `common/` with viewport mixins - overflow containment patterns (`min-width: 0`, `minmax(0, 1fr)`, `@include ellipsis`, etc.) - iOS/container scroll guidance - FormKit/select-kit token/API repairs - foundation-wide audit and verification checklist Also adds `references/css-repair.md` as the deeper companion reference. ## Why Recent CSS repair work shows the same pattern repeatedly: delete stale CSS, scope what remains, use shared tokens/FormKit APIs, and fix layout by correcting containment rather than layering more overrides. This documents that workflow directly in the skill. ## Testing ```text bin/lint .skills/discourse-writing-html-css/SKILL.md .skills/discourse-writing-html-css/references/css-repair.md No files to lint, exiting. [bin/lint] All lints passed ``` --------- Co-authored-by: chapoi <101828855+chapoi@users.noreply.github.com>
476 lines
27 KiB
Markdown
Vendored
476 lines
27 KiB
Markdown
Vendored
---
|
|
name: discourse-writing-html-css
|
|
description: Write and repair HTML/CSS/SCSS for Discourse core, plugins, themes, and theme components. Use when authoring or modifying templates (.gjs/.hbs), stylesheets (.scss), component markup, class names, responsive layout, FormKit/select-kit styling, or CSS regressions. Covers Discourse's BEM-with-standalone-modifiers naming, the CSS custom-property color palette (theming + dark mode), template/HTML conventions, CSS repair patterns, and where stylesheets live.
|
|
---
|
|
|
|
# Writing HTML & CSS for Discourse
|
|
|
|
Discourse styles must survive conditions the author never sees: a **theme** restyling the
|
|
component, **light/dark color schemes**, any **viewport** width, and **screen-reader** users
|
|
navigating the markup. A component is correct only when it holds up across all of them.
|
|
|
|
Two CSS rules are the most load-bearing — get them right by reflex:
|
|
|
|
1. **Name classes with BEM** so themes can target and override cleanly.
|
|
2. **Never hardcode color** — pull from the CSS custom-property palette so themes and dark
|
|
mode work for free.
|
|
|
|
These rules operationalize Discourse's documented frontend philosophy — **mobile-first,
|
|
progressive enhancement (works without hover or JS), a themeable base layer, and a shared design
|
|
system over bespoke styling.** The two source-of-truth docs are
|
|
[`25-css-guidelines-bem.md`](../../docs/developer-guides/docs/03-code-internals/25-css-guidelines-bem.md)
|
|
(naming) and
|
|
[`27-designing-for-devices.md`](../../docs/developer-guides/docs/03-code-internals/27-designing-for-devices.md)
|
|
(responsive / device adaptation). The canonical real-world example is the chat loading skeleton —
|
|
[`plugins/chat/assets/javascripts/discourse/components/chat-skeleton.gjs`](../../plugins/chat/assets/javascripts/discourse/components/chat-skeleton.gjs)
|
|
and its `.scss`.
|
|
|
|
**Deeper detail lives in companion files — read the relevant one before working in that area:**
|
|
|
|
- [references/color-and-theming.md](references/color-and-theming.md) — full palette, semantic
|
|
tokens, `--d-*` design vars.
|
|
- [references/layout-and-responsive.md](references/layout-and-responsive.md) — intrinsic layout
|
|
and the `lib/viewport` breakpoint API.
|
|
- [references/css-authoring.md](references/css-authoring.md) — native-CSS-vs-SASS swaps, local
|
|
custom properties (incl. theme interaction), shared mixins, file organization, and buttons.
|
|
- [references/css-repair.md](references/css-repair.md) — repairing existing CSS: stale selector
|
|
deletion, selector scoping, overflow fixes, FormKit/token migration, mobile/desktop cleanup,
|
|
and regression verification.
|
|
- [references/accessibility.md](references/accessibility.md) — screen-reader-only text, live-region
|
|
announcements, contrast & forced-colors detail (the short a11y rules stay inline below).
|
|
|
|
## BEM naming (block / element / modifier)
|
|
|
|
Discourse uses a **modified BEM**: standard `block__element`, but modifiers are **standalone
|
|
classes**, not `block__element--modifier` suffixes.
|
|
|
|
| Part | Syntax | Example |
|
|
| --- | --- | --- |
|
|
| Block | `.block` | `.chat-skeleton`, `.d-button` |
|
|
| Element | `.block__element` | `.chat-skeleton__message`, `.header__item` |
|
|
| Modifier | `.--modifier` (standalone) | `.--cancel`, `.--animation`, `.--error` |
|
|
| State | `.is-foo` / `.has-foo` | `.is-open`, `.has-errors` |
|
|
|
|
- **One block per reusable component.** A distinct block-level class per Ember component, then
|
|
hang elements and modifiers off it. Blocks may nest inside blocks.
|
|
- **An element** is a part with no meaning outside its block. Elements do **not** chain
|
|
(`block__el1__el2` is wrong — use `block__el2`); the skeleton uses flat `__message`,
|
|
`__message-avatar`, `__message-text`.
|
|
- **A modifier** is a standalone `.--modifier` for appearance variants (not the verbose
|
|
`block__element--modifier`) — they're often reused, and it keeps the DOM readable.
|
|
- **State prefixes** `is-`/`has-` mark a condition driven by JS or interaction (`is-open`,
|
|
`has-errors`), as opposed to a design variant (`--cancel`).
|
|
- **Prefer adding a class over the CSS `:has()` selector.** If a component already knows its own
|
|
state, express it with a class (`is-open`, a `--modifier`) rather than `:has()`, which can be
|
|
costly (re-evaluated on DOM mutations; broad/nested selectors are worst). Reserve `:has()` for
|
|
when you can't add a class — e.g. styling a parent off cooked/third-party markup — and scope it
|
|
tightly.
|
|
|
|
### Dash convention
|
|
|
|
Use **two dashes**: `.--modifier`. This is the documented standard and dominates the codebase.
|
|
Legacy **single-dash** modifiers exist (`.-animation` in the chat-skeleton predates the
|
|
convention) — don't copy them in new code, and don't mass-rename existing ones unless that's
|
|
the task.
|
|
|
|
### Name by meaning, not appearance
|
|
|
|
Class names describe **what a thing is**, never **how it looks** — a presentational name
|
|
becomes a lie the moment a theme, redesign, or responsive reflow changes the appearance, and
|
|
you can't rename it without hunting down every override. Avoid:
|
|
|
|
- **Position** — `block-right` → `block__sidebar`, `block__actions`.
|
|
- **Color** — `warning-red` / `text-blue` → `block--warning`, `block__link`.
|
|
- **Size** — `box-300px`, `text-large` → `block__panel`, `--prominent`.
|
|
|
|
Same for modifiers: `.--danger` / `.--compact` (intent), not `.--red` / `.--narrow`
|
|
(appearance).
|
|
|
|
### Don't build class names from user input
|
|
|
|
Never interpolate a user-controlled value (group/category/tag name, username, custom field)
|
|
directly into a class — they collide with generic utility/state classes (a group named "hidden"
|
|
emits `class="hidden"` and silently inherits its rules, often `display: none`) and make
|
|
unpredictable selectors. Carry the value in a **data attribute** and target it with an attribute
|
|
selector:
|
|
|
|
```hbs
|
|
{{! BAD — a group named "hidden" becomes class="hidden" }}
|
|
<span class="group-badge {{@group.name}}">…</span>
|
|
|
|
{{! GOOD — namespaced in an attribute, can't collide }}
|
|
<span class="group-badge" data-group-name={{@group.name}}>…</span>
|
|
```
|
|
|
|
```scss
|
|
.group-badge[data-group-name="staff"] { color: var(--tertiary); }
|
|
```
|
|
|
|
If a class is genuinely required (an existing theme hook), **prefix it** (`group-#{name}`,
|
|
`category-#{slug}`) and prefer slugs over free-text. These values still need normal escaping
|
|
for safety — see the XSS note under HTML conventions.
|
|
|
|
### Nesting & modifier application
|
|
|
|
Nest elements under the block with SCSS `&`. A modifier can apply **directly** on an element
|
|
(`&.--modifier`) or **indirectly** from an ancestor (`.--modifier &`) — the latter keeps the
|
|
DOM clean when many children react to one condition (e.g. one `--error` on the block):
|
|
|
|
```scss
|
|
.composer {
|
|
&__input {
|
|
&.--disabled { … } // <input class="composer__input --disabled">
|
|
.--error & { border-color: var(--danger); } // <div class="composer --error"> … </div>
|
|
}
|
|
}
|
|
```
|
|
|
|
## Color & theming — never hardcode
|
|
|
|
**Do not write hex, `rgb()`, or named colors for UI surfaces, text, or borders.** Use the CSS
|
|
custom-property palette so the result adapts to every theme and color scheme:
|
|
|
|
```scss
|
|
// BAD — breaks theming and dark mode
|
|
.notice { color: #222; background: #fff; border: 1px solid #ddd; }
|
|
|
|
// GOOD — adapts to every theme and color scheme
|
|
.notice { color: var(--primary); background: var(--secondary); border: 1px solid var(--primary-low); }
|
|
```
|
|
|
|
**Do not author a separate dark-mode block.** The palette already inverts; if something looks
|
|
wrong in dark mode you picked the wrong palette variable, not the wrong color.
|
|
|
|
**Prefer the semantic `--token-color-*` tokens for standard UI** (text, surfaces, borders,
|
|
icons); reach into the raw palette for bespoke components a token doesn't cover. Most-used
|
|
palette vars: `--primary` (text/foreground, with `-low`…`-high` and `-100`…`-900` steps),
|
|
`--secondary` (background), `--tertiary` (accent/links), `--danger`/`--success`, and
|
|
`rgba(var(--x-rgb), …)` for translucency. Full palette, tokens, and `--d-*` design vars:
|
|
[references/color-and-theming.md](references/color-and-theming.md).
|
|
|
|
**Don't rely on color alone, and mind contrast.** Never signal state or meaning by color by
|
|
itself (a red border for an error, a green dot for "online") — pair it with an icon, text, or
|
|
shape so it's perceivable to colorblind users and in forced-colors mode. Stick to the palette's
|
|
intended foreground/background pairings (text in `--primary` on a `--secondary` surface, etc.),
|
|
which are contrast-tuned per scheme; don't invent low-contrast combinations like `--primary-low`
|
|
text on `--secondary`. WCAG AA targets and forced-colors/WHCM notes:
|
|
[references/accessibility.md](references/accessibility.md).
|
|
|
|
## Style with restraint
|
|
|
|
Discourse is a highly themeable platform: core and plugin styles are a **base that theme
|
|
authors build on**, and anything you over-style is something they then have to override or
|
|
undo. Aim for the minimum that makes a component clear and functional, and leave the aesthetics
|
|
to themes.
|
|
|
|
- **Style for structure and function, not decoration.** Layout, spacing, sizing, and states
|
|
(hover/focus/disabled) — yes. Decorative flourishes that aren't core to the component's
|
|
meaning (drop shadows, gradients, custom borders, bespoke typography) are opinions a theme may
|
|
not share — leave them out.
|
|
- **When a visual choice isn't load-bearing, it probably belongs in a theme, not core.** A
|
|
plainer component a theme can dress up beats a heavily-styled one a theme must strip down. When
|
|
in doubt, do less.
|
|
- It's the *why* behind several rules here — palette/tokens over fixed values, low specificity,
|
|
override hooks (`...attributes`, local `--custom-properties`) — so themes can adjust without
|
|
fighting your CSS.
|
|
|
|
## Browser support
|
|
|
|
Discourse targets the **latest stable releases** of Edge, Chrome, Firefox, and Safari
|
|
(including iOS 16.4+) — no IE, no legacy polyfills. Use modern CSS freely; the practical floor
|
|
is the oldest still-"latest-stable" Safari, so for a very new feature confirm Safari support
|
|
(Baseline "widely available" is a safe bar).
|
|
|
|
## Native CSS first
|
|
|
|
Discourse is gradually moving toward native CSS — when a native feature does the job, prefer it
|
|
over a compile-time SASS construct (`var(--…)` over `$variables`, `clamp()` over `sass:math`,
|
|
`light-dark()` over SCSS color functions, `var(--font-up-2)` over the `$font-up-2` alias).
|
|
**But keep the established helpers** — `z("header")`, the `lib/viewport` mixins, `&` nesting.
|
|
Full swap list + rule-of-thumb: [references/css-authoring.md](references/css-authoring.md).
|
|
|
|
## CSS best practices
|
|
|
|
- **Keep specificity low.** Target by **one class**, not deep descendant chains
|
|
(`.card__title`, not `.card .body h2`). Don't style by ID or over-qualify (`div.card` →
|
|
`.card`). **Avoid `!important`** — it usually signals a specificity fight you can solve by
|
|
simplifying the selector. When it's genuinely necessary (overriding inline styles or a
|
|
third-party rule), always add a comment saying why.
|
|
|
|
- **Units & flexible sizing.** Prefer `em`/`rem` over `px` so the UI scales with the user's
|
|
adjustable base font size (`px` is fine for hairline borders). Avoid fixed heights/magic
|
|
dimensions — let content size the box (translated strings and long usernames run longer than
|
|
English); prefer `min-`/`max-` over hard `height`/`width`. Use **`gap`** for flex/grid spacing,
|
|
not per-child margins. On user-generated text (titles, usernames, URLs), add
|
|
`overflow-wrap: anywhere` so a long unbroken string can't force horizontal scroll.
|
|
|
|
- **Local custom properties.** Hoist a value to a component-scoped `--property` when it's reused
|
|
or feeds a `calc()` (the name documents the math better than a magic number). Don't promote
|
|
every value reflexively. Full pattern + theme interaction:
|
|
[references/css-authoring.md](references/css-authoring.md).
|
|
|
|
- **Right-to-left: use logical properties.** Write `margin-inline`, `padding-inline`,
|
|
`inset-inline-start`/`-end`, `border-start-*`, `text-align: start`/`end` — not `left`/`right`
|
|
or `margin-left`. New code defaults to these and avoids a separate `_rtl.scss`. Legacy code
|
|
uses physical props + `_rtl.scss`; don't mass-convert, but don't add new physical-direction
|
|
rules either.
|
|
|
|
- **Motion & focus (a11y).** Gate non-essential animation behind
|
|
`@media (prefers-reduced-motion: no-preference)` (the chat-skeleton shimmer does this). Animate
|
|
**cheap properties** — `transform` and `opacity` are GPU-composited; animating layout
|
|
properties (`width`, `height`, `top`/`left`, `margin`) triggers reflow and causes jank. Never
|
|
`outline: none` without a replacement — use **`:focus-visible`** so keyboard users get a clear
|
|
ring while it stays hidden for mouse clicks.
|
|
|
|
- **Reuse the shared mixins** (`common/foundation/mixins.scss`): `ellipsis` / `line-clamp($n)`
|
|
for truncation, `d-animation` (bakes in reduced-motion), `unselectable`. Details and the
|
|
legacy ones to skip: [references/css-authoring.md](references/css-authoring.md).
|
|
|
|
## Repairing existing CSS
|
|
|
|
When modifying existing Discourse CSS, prefer **removing or narrowing** over adding another
|
|
override. Most CSS regressions come from stale selectors, broad shared rules, old mobile/desktop
|
|
splits, or component architecture changing underneath a stylesheet.
|
|
|
|
Before writing new CSS, check where the selector is used and whether it is still rendered:
|
|
|
|
```sh
|
|
rg "<class-or-selector>" app/assets/stylesheets plugins themes
|
|
git log --oneline --since='2026-01-01' -- '*.scss' '*.css' --grep='fix|scope|selector|overflow|mobile|formkit|token|foundation|remove'
|
|
git show --stat --patch <suspect-commit> -- '*.scss' '*.css'
|
|
```
|
|
|
|
### Preferred repair moves
|
|
|
|
- **Scope broad selectors down.** Do not fix leakage by adding `!important` or deeper descendant
|
|
chains. If `.name`, `.num`, `.btn`, `.d-icon`, `.select-kit`, `td`, or `th` leaks, target the
|
|
real component/state: `.selected-name .name`, `.topic-list-data.num`,
|
|
`.sidebar-filter__clear`.
|
|
|
|
- **Delete stale CSS and imports.** If a component/class was removed or replaced, remove its
|
|
stylesheet/imports rather than keeping compatibility ghosts. Check with `rg` before assuming a
|
|
selector still matters.
|
|
|
|
- **Move device-specific rules into `common/` with viewport mixins.** New and repaired styles
|
|
should live in one responsive stylesheet using `@include viewport.from(...)` /
|
|
`@include viewport.until(...)`, not split `desktop/` and `mobile/` copies.
|
|
|
|
- **Fix overflow with containment primitives.** Try `min-width: 0`, `minmax(0, 1fr)`,
|
|
`max-width: 100%`, `max-height: 100%`, `overflow: hidden`, `flex-wrap: wrap`,
|
|
`table-layout: fixed`, and `@include ellipsis` before adding magic widths.
|
|
|
|
- **Put scroll on the owning container, not `html`/`body`.** Especially on iOS, body scrolling
|
|
fixes usually create flicker or broken fixed layouts. Identify the route/modal/panel that
|
|
should scroll and give that container the height/overflow.
|
|
|
|
- **Use FormKit/select-kit APIs and tokens instead of global internal overrides.** Prefer
|
|
FormKit field/container modifiers and `--form-kit-*` variables. Avoid broad rules like
|
|
`.form-kit__container-content { width: 100%; }` outside FormKit itself.
|
|
|
|
- **Avoid global DOM inference.** Be suspicious of `body:has(...)`, `html { overflow-y: scroll; }`,
|
|
`li:last-child` for dynamic lists, and component-only variables placed in `:root`. If the app
|
|
knows the state, render a class/state/modifier.
|
|
|
|
- **Audit shared foundation changes.** Changes to `.btn`, `.select-kit`, `.d-icon`,
|
|
`.topic-list-data`, category/tag badges, inputs, or foundation variables affect plugins and
|
|
themes. Check chat, reactions, solved, topic voting, Data Explorer, admin, Horizon, mobile,
|
|
and RTL where relevant.
|
|
|
|
### Red flags
|
|
|
|
Stop and re-check if your patch adds:
|
|
|
|
```scss
|
|
!important
|
|
body:has(...)
|
|
html { overflow-y: scroll; }
|
|
:root { --one-component-var: ... }
|
|
width: 340px;
|
|
min-width: 300px;
|
|
left: ...; right: ...; // without RTL thought
|
|
li:last-child
|
|
.name { ... }
|
|
.num { ... }
|
|
.btn { ... }
|
|
```
|
|
|
|
These are not banned, but they are radioactive enough to need a clear reason.
|
|
|
|
### Verification for CSS repair PRs
|
|
|
|
Check the affected surface in:
|
|
|
|
- desktop and mobile viewports
|
|
- light and dark palettes
|
|
- Horizon if header/sidebar/foundation/theme variables are touched
|
|
- RTL if physical positioning, icons, scroll fades, or nav is touched
|
|
- iOS Safari / iOS-like behavior for scroll/chat/composer fixes
|
|
- FormKit/select-kit contexts when forms or choosers are touched
|
|
- plugin surfaces sharing common foundation classes
|
|
- stale imports after deleting CSS
|
|
|
|
For visual UX changes, include before/after screenshots. Deep-dive repair patterns and examples:
|
|
[references/css-repair.md](references/css-repair.md).
|
|
|
|
## HTML / template conventions
|
|
|
|
Discourse templates are **`.gjs`** (Glimmer components with inline `<template>`) or `.hbs`.
|
|
|
|
- **Escape by default.** Use `{{value}}` (escaped). Never `{{{value}}}` / triple-curlies or raw
|
|
`innerHTML` for user-derived content — that's an XSS hole. Trusted HTML must be explicitly
|
|
marked (`trustHTML` / `htmlSafe`) and only for content you control.
|
|
- **Icons** come from the `dIcon` helper, never inline SVG or `<i class="fa">`:
|
|
|
|
```gjs
|
|
import dIcon from "discourse/ui-kit/helpers/d-icon";
|
|
// …in <template>: {{dIcon "chevron-left"}}
|
|
```
|
|
|
|
Use a **real icon name** — icons render from Discourse's registered SVG sprite (a subset of
|
|
Font Awesome), not arbitrary names. Don't guess; if a plugin needs an icon outside the subset,
|
|
register it (`register_svg_icon` in `plugin.rb`).
|
|
|
|
- **Icon-only controls need an accessible label.** An icon conveys nothing to a screen reader,
|
|
so a control with only an icon must carry a label: on `<DButton>` use `@title` (an i18n key —
|
|
also a tooltip) or `@ariaLabel`, or `@translatedTitle` for pre-translated text; on raw markup,
|
|
a translated `aria-label`. A button with visible text doesn't need this. (`dIcon` renders the
|
|
glyph `aria-hidden` by default — the accessible name belongs on the control, not the icon.)
|
|
- **Screen-reader-only text uses `.sr-only`, not `display: none`.** For text that should exist
|
|
for assistive tech but not show on screen (a label for an icon-only region, a skip target), use
|
|
the `.sr-only` helper — `display: none`/`visibility: hidden` remove it from the accessibility
|
|
tree. See [references/accessibility.md](references/accessibility.md).
|
|
- **Announce dynamic content via the `a11y` service — never a hand-rolled `aria-live`.** Content
|
|
that appears without a page navigation (async results, a toast, inline validation) needs
|
|
`this.a11y.announce(message, "polite" | "assertive")` to be read out. Live regions only work
|
|
when **persistent in the DOM before the change** — which is exactly why you route through the
|
|
service rather than adding an `aria-live` element alongside the new content. Details and the
|
|
why: [references/accessibility.md](references/accessibility.md).
|
|
- **All display strings are translatable.** Pull copy through `i18n(...)`; never hardcode
|
|
user-facing English. Use placeholders for interpolation — never concatenate translated
|
|
fragments. Write strings in **"Sentence case"**.
|
|
- **Semantic, accessible markup.** Reach for the element that describes the content before a
|
|
generic `<div>`/`<span>`:
|
|
- **Landmarks & sectioning** — `<nav>`, `<header>`/`<footer>`, `<main>`, `<aside>`,
|
|
`<section>`/`<article>` expose landmarks and an outline screen-reader users navigate by; a
|
|
wall of `<div>`s gives them nothing to jump between. `<ul>`/`<ol>` + `<li>` for lists,
|
|
`<table>` only for tabular data.
|
|
- **Interactive & form** — real `<button>` for actions (not a clickable `<div>`), `<a>` for
|
|
navigation, `<label>` tied to its input, `<fieldset>`/`<legend>` for groups.
|
|
- Add `alt`/`aria-*` only to fill gaps native semantics can't — don't paper over a wrong
|
|
element with ARIA. And don't add `<section>`/`<nav>` purely as styling hooks where they
|
|
carry no role; a `<div>` is honest there.
|
|
- Prefer existing `<DButton>` and other shared components — they get semantics and a11y right.
|
|
- **Buttons: `<button>` for actions, `<a>` for navigation — then one standalone variant.** Choose
|
|
the element by behavior (anything that changes the URL is a link), not looks. A button-looking
|
|
control needs `.btn` **plus exactly one** mutually-exclusive variant (`btn-default`,
|
|
`btn-primary`, `btn-danger`, `btn-flat`/`btn-transparent`); `<DButton>` adds `.btn` for you, so
|
|
pass the variant via `@class`. **Only controls that look *and* function like a standard button
|
|
get these classes** — a `<button>` inside a dropdown, menu, tab, or list row is styled by its
|
|
own component and must not get a `.btn-*` variant. Full guidance:
|
|
[references/css-authoring.md](references/css-authoring.md).
|
|
- **Use FormKit for forms — don't roll your own.** Build forms with the `<Form>` component
|
|
(`import Form from "discourse/components/form"`), which yields field/row/submit pieces
|
|
(`<form.Field>`, `<form.Row>`, `<form.Submit>`) and handles layout, validation, state, and the
|
|
label/error/a11y wiring for you. Don't hand-assemble a raw `<form>` with manual `<input>`s and
|
|
bespoke validation. See
|
|
[`docs/developer-guides/docs/03-code-internals/21-form-kit.md`](../../docs/developer-guides/docs/03-code-internals/21-form-kit.md)
|
|
(`frontend/discourse/app/form-kit`).
|
|
- **Splat `...attributes` on the component's root element** so a caller can pass a class,
|
|
`data-*`, `aria-*`, or a `--modifier` through. Without it the component is a closed box. The
|
|
root is also where the BEM block class lives: `<div class="user-card" ...attributes>`.
|
|
- **Use `dConcatClass` for conditional/computed classes** instead of hand-built strings or
|
|
stacked inline `{{if}}`s (`import dConcatClass from "discourse/ui-kit/helpers/d-concat-class"`).
|
|
It drops falsy values cleanly:
|
|
|
|
```gjs
|
|
<div class={{dConcatClass "card" (if @selected "is-selected") (if @compact "--compact")}}>
|
|
```
|
|
|
|
- **Know `<PluginOutlet>`, but don't add outlets speculatively.** Outlets are named seams where
|
|
plugins/themes inject content (400+ across the app); you'll work inside them often. Each one is
|
|
a **public API surface and maintenance commitment** — once it exists, extensions depend on its
|
|
name and `@outletArgs`, so it can't be moved freely. Add one only for a concrete need; pass
|
|
data via `lazyHash` (not `hash`) and name it by location (`above-…`, `below-…`). See
|
|
[`13-plugin-outlet-connectors.md`](../../docs/developer-guides/docs/03-code-internals/13-plugin-outlet-connectors.md).
|
|
- **Heading levels follow the document outline, not type size.** Never pick a level for its
|
|
default font size — if the right heading looks wrong-sized, style it in CSS
|
|
(`font-size: var(--font-up-1)`). An `<h1>` styled smaller is fine; an `<h3>` chosen because
|
|
you wanted smaller text is not.
|
|
- **Avoid "div-itis" — if you can't name what a wrapper does, drop it.** The test for every
|
|
wrapping element: state its layout or semantic job in a few words (its own `max-width`, a
|
|
positioning context, a scroll area, a flex/grid container, a real semantic region). If you
|
|
can't, delete it and let the child stand on its own. A lone `<button>` wrapped in a `<div>`, or
|
|
two or three nested `<div>`s that just pass content straight through, are the usual offenders —
|
|
the markup carries weight it doesn't earn. Pick the right element too (`<span>` inline, `<div>`
|
|
for a block/structural container, a semantic element where one fits). Beyond clutter, a stray
|
|
wrapper between a flex/grid parent and its children **breaks layout** — the items stop being
|
|
direct children, so `gap`/`flex`/`grid-template` no longer reach them. No style, role, or layout
|
|
reason → delete it.
|
|
- **Components clean up after themselves — don't render empty containers.** If a container's
|
|
contents are conditional, put the container inside the condition so it isn't emitted when
|
|
empty — an empty-but-present element still counts as a flex/grid item and `gap` slot, leaving
|
|
a phantom gap:
|
|
|
|
```hbs
|
|
{{! GOOD — nothing emitted when there's nothing to show }}
|
|
{{#if @actions}}
|
|
<div class="card__actions">
|
|
{{#each @actions as |action|}}<DButton @action={{action}} />{{/each}}
|
|
</div>
|
|
{{/if}}
|
|
```
|
|
|
|
Likewise, **don't put `padding`/`margin`/`gap` on a container that can render empty** — that
|
|
reserves space with no content. And **don't lean on `:empty`** to hide it: Ember leaves
|
|
whitespace/comment nodes (`<!---->`) that make `:empty` fail to match, so it silently won't
|
|
apply. The template conditional is the only reliable guard.
|
|
- **No empty backing class** for a template-only component unless explicitly requested.
|
|
- Don't add JSDoc to new code; if editing code that already has it, keep it accurate.
|
|
|
|
## Where stylesheets live
|
|
|
|
Core stylesheets are under `app/assets/stylesheets/`. Place a partial by target, then register
|
|
it in the matching `_index.scss` / parent `@import` (partials are underscore-prefixed and
|
|
**not** auto-globbed).
|
|
|
|
| Path | Applies to |
|
|
| --- | --- |
|
|
| `common/base/` | **Where new styles go** — one responsive stylesheet for all viewports |
|
|
| `common/components/` | Reusable component styles |
|
|
| `desktop/` | **Legacy desktop-only** — don't add new styles here |
|
|
| `mobile/` | **Legacy mobile-only** — don't add new styles here |
|
|
| `*_rtl.scss` | Legacy RTL overrides — new code uses logical properties instead |
|
|
|
|
- **Write one responsive stylesheet, not desktop + mobile copies.** Discourse designs
|
|
**mobile-first** and enhances upward (see the philosophy doc,
|
|
[`27-designing-for-devices.md`](../../docs/developer-guides/docs/03-code-internals/27-designing-for-devices.md)):
|
|
new styles live in `common/` and adapt with breakpoints. **Prefer intrinsic layout** (e.g.
|
|
`grid-template-columns: repeat(auto-fill, minmax(14em, 1fr))`) and reach for a breakpoint only
|
|
to *restructure*; use the `lib/viewport` mixins (`viewport.from`/`until`/`between`). The legacy
|
|
device split — the `desktop/`/`mobile/` dirs, the `.mobile-view`/`.desktop-view` classes, and
|
|
`site.mobileView` in JS — is **deprecated**; don't use it. Details, breakpoints, and the
|
|
`capabilities` service: [references/layout-and-responsive.md](references/layout-and-responsive.md).
|
|
- **Design to work without hover.** Touch users can't hover, so hover is an *enhancement*, not a
|
|
requirement — nothing essential should be hover-only. When you do add hover styling, scope it
|
|
to `html.discourse-no-touch` (see the layout reference).
|
|
- `common/foundation/variables.scss` and `mixins.scss` are injected everywhere — that's where
|
|
layout-width vars and `z()` come from. (Font sizes/line-heights are native custom properties —
|
|
`var(--font-up-2)`, `var(--line-height-medium)`.)
|
|
|
|
### Plugins & themes
|
|
|
|
- **Plugin** styles live in `plugins/<name>/assets/stylesheets/` and are registered in
|
|
`plugin.rb`: `register_asset "stylesheets/common/my-feature.scss"` (optionally `, :desktop` /
|
|
`, :admin`).
|
|
- **Themes/components** ship `common/`/`desktop/`/`mobile/` SCSS compiled with the palette
|
|
injected — the same `var(--…)` and `$…` variables are available, so color, BEM, and native-CSS
|
|
rules apply identically. The same responsive-first rule holds: put new styles in `common/`.
|
|
|
|
## Before committing
|
|
|
|
Lint every changed file (CSS via stylelint, templates via the JS toolchain):
|
|
|
|
```sh
|
|
bin/lint --fix path/to/file.scss path/to/file.gjs
|
|
bin/lint --fix --recent # all recently changed files
|
|
```
|