Version: 1.0 Last Updated: 2026-03-16 Status: Authoritative -- all BloomWatch UI development must conform to this document.
- Design Philosophy
- Design Tokens Reference
- Component Usage Guidelines
- Layout System
- Interaction & Motion Design
- Accessibility Standards
- CSS Architecture Rules
- Angular Component Patterns
- Common UI Patterns
- Anti-Patterns
BloomWatch draws from two overlapping aesthetic traditions:
Kawaii -- Rounded shapes, soft pastels, playful micro-interactions, and a sense of warmth. Everything should feel approachable and gentle. The rounded corners on our components (radius-lg through radius-2xl), the pastel color palette (pink-50 through pink-200 surface tints), and the Quicksand/Nunito font pairing all serve this goal.
Y2K -- Gel button effects, glossy shine overlays, gradient borders, sparkle animations, and saturated accent colors. This is visible in our gel-shine ::before pseudo-elements on buttons, our --bloom-gradient-gel-* tokens, and the animated sparkle borders on highlighted cards.
What this identity means in practice:
- Components have generous border-radius values (minimum
--bloom-radius-lg/ 16px for interactive elements). - Surfaces use soft shadows with colored tints rather than harsh black shadows.
- Primary interactions carry a "gel" quality -- glossy, dimensional, tactile.
- Animations use spring/bounce easing (
--bloom-ease-bounce,--bloom-ease-spring) to feel organic rather than mechanical. - Color choices skew toward pink, lilac, and soft blue. These are our emotional anchors.
What this identity does NOT mean:
- It does not mean cluttered. Every decorative element must serve a purpose -- guiding attention, communicating state, or providing feedback.
- It does not mean childish. The aesthetic is playful and inviting, not juvenile. Avoid excessive emoji, cartoon illustrations as primary UI elements, or baby-talk copy.
- It does not mean sacrificing usability. Every kawaii flourish exists alongside -- never instead of -- clear information hierarchy, readable text, and accessible interactions.
| Goal | Manifestation |
|---|---|
| Playful | Bounce/spring easing on hover, gel button shine, sparkle borders, float animations |
| Warm | Pink-tinted surfaces (--bloom-surface-tinted, --bloom-surface-body), pastel gradients, rounded everything |
| Inviting | Low-contrast soft shadows, generous spacing, friendly rounded fonts (Quicksand headings, Nunito body) |
| Trustworthy | Consistent component behavior, clear status colors, predictable layout patterns |
| Focused | Clean information hierarchy, restrained use of color, whitespace as a first-class design tool |
Every UI decision in BloomWatch must pass this test: does the aesthetic choice improve or impede the user's ability to accomplish their task?
- A sparkle border on a highlighted WatchSpace card draws attention to the most relevant item. This improves task flow. Keep it.
- A bouncing animation on every list item would distract from reading. This impedes task flow. Remove it.
- A gel button with hover-lift gives tactile feedback that an element is interactive. This improves clarity. Keep it.
- A rainbow gradient on body text would reduce readability. This impedes comprehension. Never do it.
When in doubt, choose clarity over flair.
All visual values in BloomWatch are defined as CSS custom properties in _tokens.scss. Using raw values (hex colors, pixel sizes, millisecond durations) directly in component styles is prohibited. Every value must reference a token.
The system provides six color ramps, each with shades from 50 (lightest) to 900 (darkest):
| Palette | Token prefix | Role |
|---|---|---|
| Pink | --bloom-pink-* |
Primary brand color. Hot pink. Used for primary actions, links, brand identity, focus rings. |
| Blue | --bloom-blue-* |
Secondary brand color. Electric blue. Used for secondary actions, informational states. |
| Lilac | --bloom-lilac-* |
Accent color. Purple. Used for accent actions, decorative elements, gradient blending. |
| Lime | --bloom-lime-* |
Success/positive accent. Used for "watching," "completed," positive states. |
| Peach | --bloom-peach-* |
Warm accent. Used sparingly for warm highlights, sunset gradients. |
| Yellow | --bloom-yellow-* |
Caution/highlight accent. Used for warnings, "on hold" states. |
| Neutral | --bloom-neutral-* |
Gray ramp with a subtle lilac undertone (not pure gray). Shades 0 (white) through 950 (near-black). |
Primary actions (submit, confirm, main CTA): --bloom-pink-* shades, or --bloom-gradient-gel-pink.
Secondary actions (alternative actions, less emphasis): --bloom-blue-* shades, or --bloom-gradient-gel-blue.
Accent/tertiary actions (special features, optional actions): --bloom-lilac-* shades, or --bloom-gradient-gel-lilac.
Destructive actions (delete, remove, cancel): Use the danger button variant. Never use pink for destructive actions -- pink is a positive color in this system.
Neutral/passive actions (dismiss, close, back): ghost button variant, neutral palette.
Always prefer semantic tokens over raw palette tokens when the purpose is semantic:
// CORRECT -- semantic intent is clear
color: var(--bloom-primary);
background-color: var(--bloom-surface-raised);
border-color: var(--bloom-border-focus);
// WRONG -- raw palette, hides intent
color: var(--bloom-pink-500);
background-color: var(--bloom-neutral-50);
border-color: var(--bloom-pink-400);When you need a specific shade for a visual effect (e.g., a gradient stop, a tinted background), using the palette token directly is acceptable. But for meaning-bearing uses (error, surface, border, text), use the semantic alias.
Semantic tokens available:
| Category | Tokens |
|---|---|
| Brand | --bloom-primary, --bloom-primary-light, --bloom-primary-dark |
| Brand | --bloom-secondary, --bloom-secondary-light, --bloom-secondary-dark |
| Brand | --bloom-accent, --bloom-accent-light, --bloom-accent-dark |
| Status | --bloom-success, --bloom-success-light, --bloom-success-dark, --bloom-success-bg |
| Status | --bloom-warning, --bloom-warning-light, --bloom-warning-dark, --bloom-warning-bg |
| Status | --bloom-error, --bloom-error-light, --bloom-error-dark, --bloom-error-bg |
| Status | --bloom-info, --bloom-info-light, --bloom-info-dark, --bloom-info-bg |
| Surfaces | --bloom-surface-base, --bloom-surface-raised, --bloom-surface-overlay, --bloom-surface-sunken, --bloom-surface-tinted, --bloom-surface-body |
| Text | --bloom-text-primary, --bloom-text-secondary, --bloom-text-tertiary, --bloom-text-disabled, --bloom-text-inverse, --bloom-text-link, --bloom-text-link-hover |
| Borders | --bloom-border-default, --bloom-border-strong, --bloom-border-focus, --bloom-border-error |
Use the pre-defined gradient tokens. Do not invent new gradients without adding them as tokens first.
| Token | Use Case |
|---|---|
--bloom-gradient-primary |
Pink gradient (135deg), CTA backgrounds |
--bloom-gradient-secondary |
Blue gradient (135deg), secondary CTA backgrounds |
--bloom-gradient-accent |
Lilac gradient (135deg), accent highlights |
--bloom-gradient-kawaii |
Pink-to-lilac-to-blue (135deg), brand watermark, decorative elements, gradient text |
--bloom-gradient-sunset |
Pink-to-peach (135deg), warm decorative elements |
--bloom-gradient-ocean |
Lilac-to-blue (135deg), cool decorative elements |
--bloom-gradient-surface |
Vertical pink-to-lilac-to-blue, used on body background |
--bloom-gradient-gel-pink |
Vertical gel button gradient (pink), used in .bloom-btn--primary |
--bloom-gradient-gel-blue |
Vertical gel button gradient (blue), used in .bloom-btn--secondary |
--bloom-gradient-gel-lilac |
Vertical gel button gradient (lilac), used in .bloom-btn--accent |
| Token | Font | Usage |
|---|---|---|
--bloom-font-family-display |
Quicksand (fallback: Nunito, system-ui) | Headings, navigation labels, button labels, badge labels, input labels. Anything that is a title or a control label. |
--bloom-font-family-body |
Nunito (fallback: system-ui) | Body text, paragraphs, descriptions, form field values, list items. Anything that is running text. |
--bloom-font-family-mono |
JetBrains Mono (fallback: Fira Code, ui-monospace) | Code blocks, technical identifiers, debug output. |
Rule: Never mix display and body fonts within a single text element. Headings get display font. Body text gets body font. There are no exceptions.
The scale follows an approximate 1.25 ratio. Use these tokens exclusively:
| Token | Size | Usage guidance |
|---|---|---|
--bloom-text-xs |
0.75rem (12px) | Hint text, error messages, fine print, timestamps |
--bloom-text-sm |
0.875rem (14px) | Secondary labels, input labels (sm/md), navigation links, badge text, small button text |
--bloom-text-base |
1rem (16px) | Body text, input field values (md), medium button text |
--bloom-text-md |
1.125rem (18px) | Emphasized body text, large input values, large button text |
--bloom-text-lg |
1.25rem (20px) | H4, subtitle text, brand name in nav |
--bloom-text-xl |
1.5rem (24px) | H3, section subheadings |
--bloom-text-2xl |
1.875rem (30px) | H2 (mobile), prominent section headings |
--bloom-text-3xl |
2.25rem (36px) | H1 (mobile), H2 (desktop) |
--bloom-text-4xl |
3rem (48px) | H1 (desktop), hero headings |
| Token | Weight | Usage |
|---|---|---|
--bloom-font-light |
300 | Decorative large text only. Never for body text. |
--bloom-font-regular |
400 | Default body text weight |
--bloom-font-medium |
500 | Slightly emphasized body text, error messages |
--bloom-font-semibold |
600 | Input labels, navigation links, badge text, button text, <strong> |
--bloom-font-bold |
700 | Headings (all levels), avatar initials |
--bloom-font-extrabold |
800 | Brand logotype only (BloomWatch in the nav bar) |
| Token | Value | Usage |
|---|---|---|
--bloom-leading-none |
1 | Single-line elements only (badges, buttons, icons) |
--bloom-leading-tight |
1.25 | Headings |
--bloom-leading-snug |
1.375 | Multi-line headings, tight UI text |
--bloom-leading-normal |
1.5 | Default body text |
--bloom-leading-relaxed |
1.625 | Paragraph text (set in base styles for <p>) |
--bloom-leading-loose |
2 | Large-spaced text blocks, rarely used |
BloomWatch uses a 4px base grid. All spacing values are multiples or simple fractions of this base.
| Token | Value | Common uses |
|---|---|---|
--bloom-space-0 |
0 | Reset |
--bloom-space-px |
1px | Hairline borders |
--bloom-space-0-5 |
2px | Badge padding (sm), required asterisk spacing |
--bloom-space-1 |
4px | Icon gaps within buttons/badges, tight inline spacing |
--bloom-space-1-5 |
6px | Input label-to-field gap, button padding (sm vertical), badge dot size spacing |
--bloom-space-2 |
8px | Button icon gap, input prefix/suffix gap, nav brand icon gap, button padding (md vertical) |
--bloom-space-2-5 |
10px | Input field padding (md vertical) |
--bloom-space-3 |
12px | Button padding (lg vertical), nav link padding, card header-to-body gap, footer border-top gap |
--bloom-space-4 |
16px | Default page content padding (mobile), paragraph spacing, container inline padding (mobile) |
--bloom-space-5 |
20px | Card internal padding, button padding (md horizontal), input padding (lg horizontal) |
--bloom-space-6 |
24px | Shell content padding (mobile), container inline padding (tablet), section spacing |
--bloom-space-8 |
32px | Shell content padding (desktop), button padding (lg horizontal), major section gaps |
--bloom-space-10 |
40px | Shell content side padding (desktop lg), large gaps |
--bloom-space-12 |
48px | Large vertical rhythm between major sections |
--bloom-space-16 |
64px | Extra-large spacing, page-level vertical rhythm |
--bloom-space-20 |
80px | Hero sections, splash screens |
--bloom-space-24 |
96px | Maximum vertical spacing |
Rule: Use the spacing token closest to your needs. If you find yourself wanting a value that doesn't exist (e.g., 13px), round to the nearest token (12px = --bloom-space-3). Do not create ad-hoc values.
All interactive and container elements in BloomWatch use rounded corners. The scale:
| Token | Value | Usage |
|---|---|---|
--bloom-radius-none |
0 | Explicitly square elements only (extremely rare) |
--bloom-radius-sm |
6px | Inline code blocks, focus outlines on links, small accent shapes |
--bloom-radius-md |
10px | Skip link corners |
--bloom-radius-lg |
16px | Small buttons (sm), small input wrappers (sm), mobile nav links, nav toggle button |
--bloom-radius-xl |
20px | Medium buttons (md), medium inputs (md), nav links (desktop) |
--bloom-radius-2xl |
24px | Large buttons (lg), large inputs (lg), cards (both variants) |
--bloom-radius-3xl |
32px | Extra-large containers, hero sections |
--bloom-radius-pill |
9999px | Badges, scrollbar tracks/thumbs, decorative pills, divider lines |
--bloom-radius-circle |
50% | Avatars, status dots, spinner dots |
Rule: Border radius for a component must scale with its size. Small components get --bloom-radius-lg. Medium components get --bloom-radius-xl. Large components get --bloom-radius-2xl. This is already established in button and input size variants -- follow the same pattern for all new components.
Shadows in BloomWatch use our neutral-900 color as a base (rgb(33 26 51 / ...)) to maintain the lilac-undertone neutral palette. They are soft and diffused, never harsh.
| Token | Usage |
|---|---|
--bloom-shadow-xs |
Subtle depth: ghost button hover, input focus companion |
--bloom-shadow-sm |
Default card elevation, button resting state, avatar frame |
--bloom-shadow-md |
Hovered buttons, active cards, highlighted card resting state |
--bloom-shadow-lg |
Hovered cards, mobile nav dropdown, elevated overlays |
--bloom-shadow-xl |
Hovered highlighted cards, modals, top-level overlays |
Colored glow shadows (for special emphasis only):
| Token | Usage |
|---|---|
--bloom-shadow-glow-pink |
Primary CTA emphasis, active WatchSpace indicator |
--bloom-shadow-glow-blue |
Secondary emphasis, informational highlights |
--bloom-shadow-glow-lilac |
Accent emphasis, premium feature indicators |
--bloom-shadow-glow-lime |
Success/positive emphasis |
Inner glow (--bloom-shadow-inner-glow): Used exclusively for gel-effect components. Do not apply to flat surfaces.
Rule: A resting component should use --bloom-shadow-sm or --bloom-shadow-md. Hovered state escalates by one level. Active/pressed state de-escalates. Do not jump more than two levels in a single interaction.
| Token | Duration | Usage |
|---|---|---|
--bloom-duration-instant |
75ms | Immediate feedback: opacity changes, color swaps |
--bloom-duration-fast |
150ms | Button transitions, input border changes, focus states |
--bloom-duration-normal |
250ms | Card hover transitions, nav link transitions, fade-in animations |
--bloom-duration-slow |
400ms | Slide-in animations, page content entrance, staggered reveals |
--bloom-duration-slower |
600ms | Complex orchestrated animations (used sparingly) |
Easing functions:
| Token | Curve | Usage |
|---|---|---|
--bloom-ease-default |
cubic-bezier(0.4, 0, 0.2, 1) |
General purpose. Use for most transitions. |
--bloom-ease-in |
cubic-bezier(0.4, 0, 1, 1) |
Elements leaving the screen. |
--bloom-ease-out |
cubic-bezier(0, 0, 0.2, 1) |
Elements entering the screen. Fade-in, slide-in. |
--bloom-ease-in-out |
cubic-bezier(0.4, 0, 0.2, 1) |
Symmetric transitions. |
--bloom-ease-bounce |
cubic-bezier(0.34, 1.56, 0.64, 1) |
Kawaii button hover-lift, playful micro-interactions. Overshoots slightly. |
--bloom-ease-spring |
cubic-bezier(0.22, 1.4, 0.36, 1) |
Card hover, nav brand icon rotation. More pronounced overshoot than bounce. |
Composite shorthand tokens (duration + easing):
| Token | Usage |
|---|---|
--bloom-transition-fast |
Button state changes, input focus rings |
--bloom-transition-normal |
Card hover, general component transitions |
--bloom-transition-slow |
Page-level animations, complex reveals |
--bloom-transition-bounce |
Button vertical lift on hover |
--bloom-transition-spring |
Card lift on hover, playful element transitions |
Never use raw z-index numbers. Always use the token scale:
| Token | Value | Usage |
|---|---|---|
--bloom-z-behind |
-1 | Background decorative elements, sparkle border pseudo-elements |
--bloom-z-base |
0 | Default stacking |
--bloom-z-raised |
10 | Cards, raised surfaces |
--bloom-z-dropdown |
100 | Dropdown menus, autocomplete lists |
--bloom-z-sticky |
200 | Sticky navigation bar (used by shell-nav) |
--bloom-z-overlay |
300 | Overlay backgrounds, drawer backdrops |
--bloom-z-modal |
400 | Modal dialogs |
--bloom-z-popover |
500 | Popovers, floating elements |
--bloom-z-toast |
600 | Toast notifications |
--bloom-z-tooltip |
700 | Tooltips, skip-link |
Selector: bloom-button
Import: BloomButtonComponent from @shared/ui
| Input | Type | Default | Description |
|---|---|---|---|
variant |
'primary' | 'secondary' | 'accent' | 'ghost' | 'danger' |
'primary' |
Visual style |
size |
'sm' | 'md' | 'lg' |
'md' |
Size scale |
type |
'button' | 'submit' | 'reset' |
'button' |
HTML button type |
disabled |
boolean |
false |
Disables interaction |
loading |
boolean |
false |
Shows spinner, disables interaction |
fullWidth |
boolean |
false |
Stretches to container width |
| Output | Type | Description |
|---|---|---|
clicked |
MouseEvent |
Emitted on click (suppressed when disabled/loading) |
Content projection slots:
- Default: Button label text
[bloomButtonIconLeft]: Icon before label[bloomButtonIconRight]: Icon after label
| Variant | Use When | Example |
|---|---|---|
primary |
The single most important action on screen. One per visible section maximum. | "Create WatchSpace", "Save Changes", "Log In" |
secondary |
Important but not the primary action. Can appear alongside primary. | "View Profile", "Sync AniList", "Search" |
accent |
Special, optional, or exploratory actions. Used to add visual variety. | "Explore", "Try Premium", feature-specific actions |
ghost |
Low-emphasis actions, navigation-like actions, cancel/dismiss. | "View More", "Cancel", "Back", "Invite", toolbar actions |
danger |
Destructive, irreversible, or high-consequence actions. | "Delete WatchSpace", "Remove Member", "Disconnect Account" |
Never:
- Use
primaryfor destructive actions. Usedanger. - Use
dangerfor non-destructive actions. The red gel gradient must always mean "this will remove or destroy something." - Place two
primarybuttons adjacent to each other. If two actions are equally important, make onesecondary. - Use
ghostfor the main CTA. It is too low-emphasis.
| Size | When to Use |
|---|---|
sm |
Inside cards, inline with text, table rows, secondary actions in tight spaces. Min-height 2rem. |
md |
Default. Forms, standalone CTAs, dialog actions. Min-height 2.5rem. |
lg |
Hero CTAs, full-width mobile actions, prominent standalone actions. Min-height 3rem. |
Icons are projected into named slots. Always pair icons with text labels. Icon-only buttons are not yet supported by this component. When an icon-only button is needed, it should be built as a separate component.
<!-- Icon on the left -->
<bloom-button variant="primary">
<span bloomButtonIconLeft>ICON</span>
Like
</bloom-button>
<!-- Icon on the right -->
<bloom-button variant="secondary">
Add
<span bloomButtonIconRight>ICON</span>
</bloom-button>Set [loading]="true" to show a three-dot bounce spinner and hide the label/icons. The button retains its dimensions. The aria-busy="true" attribute is set automatically.
<bloom-button [loading]="isSubmitting()" (clicked)="onSubmit()">
Save Changes
</bloom-button>- The component sets
aria-disabledandaria-busyautomatically. - The inner
<button>element receives thetypeattribute -- always settype="submit"for form submission buttons. - Focus is indicated by a 2px
--bloom-pink-400outline with 2px offset. - Click events are suppressed when disabled or loading -- no additional guard logic needed in the parent.
Selector: bloom-card
Import: BloomCardComponent from @shared/ui
| Input | Type | Default | Description |
|---|---|---|---|
variant |
'default' | 'highlighted' |
'default' |
Visual style |
hoverable |
boolean |
true |
Enables hover-lift effect |
ariaLabel |
string | undefined |
undefined |
Accessible label for the card article element |
Content projection slots:
[bloomCardHeader]: Card header area (receives decorative kawaii gradient accent line)- Default: Card body content
[bloomCardFooter]: Card footer area (receives top border separator when non-empty)
| Variant | Use When |
|---|---|
default |
Standard content containers. Dashboard widgets, list items, settings panels, search results. The majority of cards. |
highlighted |
A single card that needs emphasis. The user's primary WatchSpace, a featured anime, a premium feature, an active watch party. Use sparingly -- if everything is highlighted, nothing is. |
- For full-page content sections. Cards are discrete, bounded containers. Use page-level layout with
--bloom-surface-basebackgrounds instead. - For single-line items. Use a simple flex row or list item, not a card with padding overhead.
- For navigation elements. Cards are content containers, not links. If a card appears clickable, it should contain a button or link inside, not be wrapped in an
<a>tag.
The header slot receives a decorative kawaii gradient line (--bloom-gradient-kawaii) at the top of the card when populated. This is automatic. Do not add additional decorative borders to card headers.
The footer slot gains a 1px solid --bloom-border-default top border and auto margin-top when populated. This pushes the footer to the bottom of a flex-column layout. Do not manually add borders to card footers.
Empty slots (header, footer) are hidden via :empty { display: none }. This means if you project nothing, there is no visual artifact.
<!-- Full card with all slots -->
<bloom-card>
<h3 bloomCardHeader>WatchSpace Name</h3>
<p>Description text goes here in the default body slot.</p>
<div bloomCardFooter>
<bloom-button variant="primary" size="sm">Open</bloom-button>
<bloom-button variant="ghost" size="sm">Invite</bloom-button>
</div>
</bloom-card>
<!-- Minimal card (body only) -->
<bloom-card [hoverable]="false">
<p>Simple content without header or footer.</p>
</bloom-card>- The root element is an
<article>. SetariaLabelwhen the card's purpose is not obvious from its heading content. - If the card is not interactive, set
[hoverable]="false"to avoid implying interactivity. - The sparkle-border on
highlightedvariant isaria-hidden="true".
Selector: bloom-input
Import: BloomInputComponent from @shared/ui
| Input | Type | Default | Description |
|---|---|---|---|
label |
string |
'' |
Visible label text |
placeholder |
string |
'' |
Placeholder text |
type |
string |
'text' |
HTML input type |
size |
'sm' | 'md' | 'lg' |
'md' |
Size scale |
disabled |
boolean |
false |
Disables the input |
readonly |
boolean |
false |
Makes the input read-only |
required |
boolean |
false |
Marks as required (shows pink asterisk) |
error |
string |
'' |
Error message (triggers error state) |
hint |
string |
'' |
Hint text (shown when no error) |
inputId |
string |
auto-generated | HTML id for the input element |
autocomplete |
string |
'off' |
HTML autocomplete attribute |
| Output | Type | Description |
|---|---|---|
valueChange |
string |
Emitted on every input event |
Content projection slots:
[bloomInputPrefix]: Icon/element before the input field[bloomInputSuffix]: Icon/element after the input field
ControlValueAccessor: This component implements ControlValueAccessor. It works with both template-driven and reactive forms.
Use bloom-input for all single-line text inputs in the application. This includes text, email, password, search, url, tel, and number types.
Do not use it for:
- Multi-line text (build a
bloom-textareacomponent following the same patterns). - Select/dropdown inputs (build a
bloom-selectcomponent). - Checkboxes, radio buttons, toggles (these need their own components).
| Size | When to Use |
|---|---|
sm |
Inline search bars, filters, compact forms, within cards. Min-height 2rem. |
md |
Default. Login/register forms, settings forms, standard data entry. Min-height 2.75rem. |
lg |
Prominent search bars, hero-level input, onboarding forms. Min-height 3.25rem. |
- When
erroris non-empty, the input enters error state: red border, red background tint, label turns red, error message appears with a shake animation, andaria-invalid="true"is set. - When
hintis provided and there is no error, hint text appears below the field. - Error and hint are linked to the input via
aria-describedby. - Error messages use
role="alert"for screen reader announcement.
<!-- With validation -->
<bloom-input
label="Email"
type="email"
placeholder="you@example.com"
[required]="true"
[error]="emailError()"
hint="We'll never share your email"
autocomplete="email"
/>
<!-- With prefix icon -->
<bloom-input label="Search" placeholder="Search anime..." size="sm">
<span bloomInputPrefix>SEARCH_ICON</span>
</bloom-input>- Labels are automatically linked to inputs via the
for/idrelationship. - Required fields show a visual asterisk (hidden from screen readers with
aria-hidden="true") and set the HTMLrequiredattribute. - Error messages are announced via
role="alert". - The
aria-describedbyattribute chains to either the hint or error paragraph ID. - Focus produces a pink ring shadow (
0 0 0 3px rgb(255 45 138 / 0.12)).
Selector: bloom-badge
Import: BloomBadgeComponent from @shared/ui
| Input | Type | Default | Description |
|---|---|---|---|
color |
'pink' | 'blue' | 'green' | 'lilac' | 'yellow' | 'neutral' |
'pink' |
Color variant |
size |
'sm' | 'md' |
'md' |
Size scale |
dot |
boolean |
false |
Shows animated status dot |
ariaLabel |
string | undefined |
undefined |
Accessible label |
Content projection: Default slot for badge label text.
Badges carry meaning through color. Be consistent:
| Color | Semantic Meaning in BloomWatch |
|---|---|
green |
Active/positive states: "Watching", "Online", "Completed", "Synced" |
pink |
Primary/brand states: "Completed" (alternative), genre tags (Romance), featured |
blue |
Informational: "Plan to Watch", genre tags (Action), sync status |
lilac |
Accent/special: genre tags (Fantasy), premium features |
yellow |
Caution/hold: "On Hold", "Paused", awaiting action |
neutral |
Inactive/dismissed: "Dropped", "Offline", archived |
The dot input adds a small animated pulsing circle before the label. Use it exclusively for live status indicators -- states that can change in real time (online/offline, watching/idle, sync status). Do not use dots on static labels like genre tags.
- For buttons. Badges are display-only; they do not have click handlers.
- For large blocks of text. Badges are for short labels (1-3 words).
- For counts/numbers alone. Use a counter element or number display, not a badge.
Selector: bloom-avatar
Import: BloomAvatarComponent from @shared/ui
| Input | Type | Default | Description |
|---|---|---|---|
src |
string |
'' |
Image URL |
alt |
string |
'' |
Image alt text |
name |
string |
'' |
User name (used for initials fallback and default alt) |
size |
'xs' | 'sm' | 'md' | 'lg' |
'md' |
Size scale |
status |
'online' | 'offline' | 'watching' | 'none' |
'none' |
Status indicator |
ariaLabel |
string | undefined |
undefined |
Accessible label |
| Size | Dimensions | When to Use |
|---|---|---|
xs |
1.5rem (24px) | Inside avatar stacks, inline mentions, compact lists |
sm |
2rem (32px) | Navigation user display, avatar stacks, list items |
md |
2.75rem (44px) | Card headers, comment authors, member lists |
lg |
4rem (64px) | Profile displays, user detail views, settings pages |
When no src is provided, the component renders initials on a --bloom-gradient-kawaii background. The initials are derived from the name input:
- Two-word names: First letter of first word + first letter of last word (e.g., "Naruto Uzumaki" renders "NU").
- Single-word names: First two characters (e.g., "Sakura" renders "SA").
- No name: Renders "?".
Always provide name even when src is available, so the initials fallback works if the image fails to load.
| Status | Visual | Use When |
|---|---|---|
online |
Green dot with green glow | User is active in the app |
offline |
Gray dot (neutral-400) | User is not active |
watching |
Pink dot with animated pulse glow | User is currently in a watch session |
none |
No indicator | Status is irrelevant or unknown |
Selector: bloom-avatar-stack
Import: BloomAvatarStackComponent from @shared/ui
Wraps multiple bloom-avatar components in an overlapping row. Each avatar after the first has -0.5rem left margin. The decorative outer ring is removed in stack context to reduce visual noise.
<bloom-avatar-stack ariaLabel="Watch party members">
<bloom-avatar size="sm" name="User One" status="online" />
<bloom-avatar size="sm" name="User Two" status="watching" />
<bloom-avatar size="sm" name="User Three" status="offline" />
</bloom-avatar-stack>Rules:
- Use
smorxsavatars in stacks.mdandlgare too large for overlapping presentation. - Keep stacks to 5 members maximum visually. For larger groups, show 4 avatars plus a "+N" overflow indicator (to be built as a new component).
- Always provide
ariaLabelon the stack describing the group.
When building a new shared UI component, follow these conventions established by the existing library:
File structure:
src/app/shared/ui/<component-name>/
bloom-<component-name>.ts # Component class
bloom-<component-name>.scss # Scoped styles
Naming:
- Selector:
bloom-<name>(e.g.,bloom-toggle,bloom-dialog) - Class:
Bloom<Name>Component(e.g.,BloomToggleComponent) - CSS root class:
.bloom-<name>(e.g.,.bloom-toggle) - CSS child classes:
.bloom-<name>__<element>(BEM, e.g.,.bloom-toggle__track) - CSS modifier classes:
.bloom-<name>--<modifier>(BEM, e.g.,.bloom-toggle--checked)
Required characteristics:
standalone: true-- all BloomWatch components are standalone.- Use
input()signal function, never@Input()decorator. - Use
output()signal function, never@Output()decorator. - Use
computed()for derived state (e.g., CSS class maps). - Set
:host { display: inline-block; }or:host { display: block; }in SCSS (inline-block for inline elements like buttons/badges, block for container elements like cards/inputs). - Include
@media (prefers-reduced-motion: reduce)overrides for any animations. - Empty slot content must be hidden with
&:empty { display: none; }. - Export the component and its types from
src/app/shared/ui/index.ts.
BloomWatch uses two layout wrappers, selected by route configuration:
When to use: All authenticated routes -- Dashboard, WatchSpaces, Settings, Showcase. Any page that needs the navigation bar.
Structure:
- Sticky top navigation bar with frosted-glass background (
backdrop-filter: blur(16px) saturate(1.8)) - Navigation inner constrained to
--bloom-container-xl(1200px) - Main content area (
<main class="shell-content">) constrained to--bloom-container-xlwith responsive padding - Content entrance animation:
bloom-fade-in-upwith slow duration
Content padding by breakpoint:
| Breakpoint | Padding |
|---|---|
| Mobile (< 768px) | --bloom-space-6 (24px) all sides |
| Tablet (>= 768px) | --bloom-space-8 (32px) all sides |
| Desktop (>= 1024px) | --bloom-space-8 vertical, --bloom-space-10 horizontal |
When to use: Unauthenticated routes -- Login, Register. Any full-screen experience without the navigation chrome (onboarding flows, error pages).
Structure: Pure <router-outlet /> with no wrapping elements. The rendered component is fully responsible for its own layout.
Pages rendered within MinimalLayout should center their content and use --bloom-surface-body (or the gradient background inherited from body) as their backdrop.
Pages rendered inside ShellLayout receive the shell-content wrapper automatically. Your page component should NOT add its own max-width container unless it needs a narrower constraint.
// CORRECT -- page-specific structure
:host {
display: block;
}
.page-header {
margin-bottom: var(--bloom-space-8);
}
.page-content {
// Content flows naturally within the shell-content constraint
}
// WRONG -- do not duplicate the container
:host {
max-width: var(--bloom-container-xl);
margin: 0 auto;
padding: var(--bloom-space-8); // This padding is already on shell-content
}For pages that should be narrower (e.g., settings forms, profile editing), use the bloom-container-narrow utility class (42rem / 672px max-width):
<div class="bloom-container-narrow">
<h1>Settings</h1>
<!-- form content -->
</div>Login and register pages should fill the viewport and center their content:
:host {
display: flex;
align-items: center;
justify-content: center;
min-height: 100dvh;
padding: var(--bloom-space-4);
}Use utility classes for common grid layouts:
Responsive auto-fill grids (for card lists, search results):
<!-- Cards: min 280px each, auto-fill remaining space -->
<div class="bloom-grid-auto-md bloom-gap-6">
<bloom-card>...</bloom-card>
<bloom-card>...</bloom-card>
<bloom-card>...</bloom-card>
</div>| Class | Min column width | Best for |
|---|---|---|
bloom-grid-auto-sm |
200px | Badge collections, small items, tag groups |
bloom-grid-auto-md |
280px | Cards, search results, WatchSpace lists |
bloom-grid-auto-lg |
360px | Large cards, detail panels, wide content |
Fixed column grids:
<!-- 2-column on all screen sizes -->
<div class="bloom-grid bloom-grid-cols-2 bloom-gap-6">
<div>Left</div>
<div>Right</div>
</div>For responsive behavior, combine fixed grids with media-query overrides in component SCSS rather than relying on utility classes.
Horizontal row with centered items (common for toolbars, action bars):
<div class="bloom-row bloom-gap-3">
<bloom-button variant="primary" size="sm">Save</bloom-button>
<bloom-button variant="ghost" size="sm">Cancel</bloom-button>
</div>Vertical stack (common for form layouts, content sections):
<div class="bloom-stack bloom-gap-4">
<bloom-input label="Name" />
<bloom-input label="Email" />
<bloom-button type="submit">Submit</bloom-button>
</div>Space-between row (common for header + action pairs):
<div class="bloom-flex bloom-justify-between bloom-items-center">
<h2>Watch Spaces</h2>
<bloom-button variant="primary" size="sm">Create New</bloom-button>
</div>BloomWatch is mobile-first. Base styles target the smallest viewport. Enhancements are added at larger breakpoints.
Breakpoints (SCSS variables for use in @media queries):
| Variable | Value | Name | Target |
|---|---|---|---|
$bloom-bp-xs |
375px | Extra small | Large phones |
$bloom-bp-sm |
640px | Small | Small tablets, landscape phones |
$bloom-bp-md |
768px | Medium | Tablets |
$bloom-bp-lg |
1024px | Large | Small desktops, landscape tablets |
$bloom-bp-xl |
1280px | Extra large | Desktops |
$bloom-bp-2xl |
1536px | 2XL | Large desktops |
Breakpoint mixins (preferred over raw @media queries):
@use 'app/shared/styles/tokens' as *;
.my-component {
// Mobile-first base styles
padding: var(--bloom-space-4);
@include bp-md {
padding: var(--bloom-space-6);
}
@include bp-lg {
padding: var(--bloom-space-8);
}
}Available mixins: bp-xs, bp-sm, bp-md, bp-lg, bp-xl, bp-2xl, bp-mobile-only, bp-tablet-only.
Rule: Write all base styles for mobile first. Only use min-width breakpoints to add enhancements. The bp-mobile-only and bp-tablet-only mixins exist for hiding/showing elements at specific ranges but should be used sparingly.
The Shell Layout provides a sticky top navigation bar with:
- Brand link (BloomWatch logotype with gradient text and flower icon)
- Horizontal nav links on desktop
- Hamburger menu + slide-down dropdown on mobile (< 768px)
- Active link styling via
routerLinkActive="shell-nav__link--active"
Navigation links are rendered as <a> elements with routerLink. Each link has:
- A decorative icon span (hidden from assistive tech via
aria-hidden="true") - ARIA
role="menuitem"within arole="menubar"list - Click handler that closes the mobile menu (
closeMobile())
When adding new navigation items, follow this pattern:
<li role="none">
<a
routerLink="/new-route"
routerLinkActive="shell-nav__link--active"
class="shell-nav__link"
role="menuitem"
(click)="closeMobile()"
>
<span class="shell-nav__link-icon" aria-hidden="true">ICON</span>
Link Label
</a>
</li>Keep navigation items to 5-7 maximum. The current set (Dashboard, Watch Spaces, Settings, Showcase) is within budget. Showcase should be removed or hidden behind a dev flag before production.
-
Purpose over decoration. Every animation must serve one of: guiding attention, providing feedback, communicating state change, or orienting the user in space. If you cannot name which of these an animation serves, remove it.
-
Fast feedback, slow reveals. Interactive feedback (button press, focus ring, hover state) uses
--bloom-duration-fast(150ms). Content entrance (page load, section reveal) uses--bloom-duration-slow(400ms). This creates a responsive feel for interactions while giving content transitions enough time to be perceived. -
Spring and bounce are intentional. The
--bloom-ease-bounceand--bloom-ease-springcurves give BloomWatch its kawaii character. Use them for hover-lift effects and playful micro-interactions. Do not use them for content transitions or page navigation -- those should use--bloom-ease-out. -
Stagger for hierarchy. When revealing a list of items, use
bloom-stagger-childrento animate them sequentially with 60ms delay per item. This creates visual flow and reduces cognitive load. Maximum 12 staggered children.
All interactive elements must have all three states defined:
Hover (:hover:not(:disabled)):
- Buttons:
translateY(-2px)lift + shadow escalation one level - Cards (hoverable):
translateY(-4px)lift + shadow escalation two levels + border color change to--bloom-pink-200 - Nav links: Background tint (
--bloom-pink-50), border reveal (--bloom-pink-100),translateY(-1px)micro-lift - Avatar stack items:
translateY(-2px)with z-index elevation
Focus (:focus-visible):
- Universal: 2px solid
--bloom-border-focus(--bloom-pink-400) outline, 2px offset - Inputs: Replace outline with pink ring shadow (
0 0 0 3px rgb(255 45 138 / 0.12)) plus border color change - Never use
:focusfor visual styles (it fires on click). Always use:focus-visible.
Active (:active:not(:disabled)):
- Buttons:
translateY(1px)press-down + shadow de-escalation + gel-shine opacity reduction - Cards:
translateY(-1px)reduced lift + shadow de-escalation one level - Nav links:
translateY(0)return to baseline
Content within shell-content enters with bloom-fade-in-up (opacity 0 -> 1, translateY 8px -> 0) over --bloom-duration-slow. This is applied once on the shell-content container, not on individual page components.
Individual pages should NOT add their own entrance animation on the :host element, as this would compound with the shell-content animation. If a page needs staggered section reveals, apply animations to internal sections, not the host.
Button loading: Use the built-in [loading]="true" input. The three-dot bounce animation (bloom-loading-dot keyframes) plays within the button's existing dimensions.
Content loading (skeleton screens): Use bloom-animate-shimmer on placeholder elements. The shimmer moves a white-to-transparent gradient left-to-right on a 2-second loop.
<!-- Skeleton card while loading -->
<bloom-card [hoverable]="false">
<div class="skeleton-line skeleton-line--title bloom-animate-shimmer"></div>
<div class="skeleton-line bloom-animate-shimmer"></div>
<div class="skeleton-line bloom-animate-shimmer"></div>
</bloom-card>Spinner loading (for standalone spinners): Use bloom-animate-spin for a standard 0.8s rotation, or bloom-animate-spin-slow for a gentle 3s rotation.
Pulse loading (for indicating live state): Use bloom-animate-pulse (opacity pulse) or bloom-animate-pulse-scale (scale pulse).
Appropriate micro-interactions (use these):
bloom-hover-lift: Cards, clickable surfaces. Lift + shadow escalation.bloom-hover-grow: Thumbnails, icons on hover. Subtle 3% scale increase.bloom-hover-glow-pink: CTAs that need extra emphasis on hover.bloom-animate-wobble: Success confirmation on an element (single play, not looping).bloom-animate-jelly: Button press confirmation (single play).bloom-animate-bounce-in: Element appearing for the first time (modal opening, toast entering).
Inappropriate micro-interactions (avoid these):
bloom-animate-bounceon content that should be static. Continuous bounce is for decorative elements only.bloom-animate-floaton UI controls. Floating elements are hard to click.- Multiple glow animations on the same screen. Pick one element to glow.
Every animation and transition in BloomWatch has a prefers-reduced-motion: reduce override. The global override in _animations.scss sets all animation durations to 0.01ms and iteration counts to 1, and removes hover transforms.
When adding new animations, always include a reduced-motion block:
.my-animation {
animation: my-keyframes 0.5s ease;
transition: transform var(--bloom-transition-normal);
}
@media (prefers-reduced-motion: reduce) {
.my-animation {
animation: none;
transition: none;
}
}The @include reduced-motion { ... } mixin from _tokens.scss is available for convenience.
WCAG 2.1 Level AA is the minimum standard. All UI in BloomWatch must meet this bar. This is non-negotiable.
- Focus visibility:
:focus-visibleproduces a 2px solid--bloom-pink-400outline with 2px offset on all interactive elements. This is set globally in_base.scss. Component-level focus styles may enhance but must not remove this indicator. - Focus within inputs: Input components use a shadow ring instead of an outline (because the outline would conflict with the rounded border). This is acceptable as long as the visual change is perceivable at 3:1 contrast.
- Tab order: Follow the natural DOM order. Do not use
tabindexvalues greater than 0. Usetabindex="0"only on elements that are not natively focusable but need to be (custom widgets). Usetabindex="-1"to make elements programmatically focusable without adding them to the tab order. - Focus trapping: Modals (when built) must trap focus within their boundaries. Use Angular CDK's
FocusTrapor equivalent. - Skip link: A
.skip-linkclass is defined in_base.scss. The main layout should include a skip link as the first focusable element inbody.
- Text on surfaces:
--bloom-text-primary(--bloom-neutral-900: #211a33) on--bloom-surface-base(--bloom-neutral-0: #ffffff) provides a contrast ratio exceeding 15:1. This is the standard text/surface pairing. - Secondary text:
--bloom-text-secondary(--bloom-neutral-600: #6b6085) on white provides approximately 5.5:1 contrast. Acceptable for AA normal text. - Tertiary text:
--bloom-text-tertiary(--bloom-neutral-500: #8e82a6) on white provides approximately 3.5:1 contrast. This is ONLY acceptable for large text (18px+) or non-critical helper text. Do not use tertiary text for information the user needs. - Disabled text:
--bloom-text-disabled(--bloom-neutral-400) does not need to meet contrast requirements per WCAG, but ensure the disabled state is conveyed through more than just color (opacity reduction, cursor change). - Button text: All gel-button variants use white text on saturated gradient backgrounds. Verify contrast against the darkest stop of the gradient (the lower portion). The current button designs meet this requirement.
- Badge text: Each badge color variant uses a 700-shade text on a 100-shade background with a 200-shade border. These pairings meet AA contrast requirements.
- Decorative elements: Flower icons, sparkle borders, gel-shine overlays, and nav link icons all use
aria-hidden="true". Do not expose decorative visuals to the accessibility tree. - Status indicators: Avatar status dots use
role="status"andaria-labelwith the status name. Badge dots arearia-hidden="true"because the badge label text conveys the status. - Required fields: The visual asterisk (
*) isaria-hidden="true". The HTMLrequiredattribute on the input communicates the requirement to screen readers. - Loading states: Buttons set
aria-busy="true"when loading. Page-level loading should usearia-live="polite"regions. - Screen-reader-only content: Use the
.sr-onlyclass for text that should be announced but not visible. The.sr-only-focusablevariant becomes visible when focused (for skip links).
- All interactive elements are keyboard-accessible via native HTML semantics (
<button>,<a>,<input>). - The navigation bar uses
role="menubar"withrole="menuitem"children. Navigation via arrow keys within the menu is not yet implemented -- this should be added when the nav becomes more complex. - The mobile menu toggle sets
aria-expandedandaria-controlscorrectly. - Cards are not keyboard-focusable by default. The interactive elements within cards (buttons, links) are the focus targets.
- Do not use ARIA for things HTML already handles. A
<button>does not needrole="button". An<a href="...">does not needrole="link". - Use
aria-labelfor elements where the visual content is insufficient (icon-only controls, avatar stacks, badges that need additional context). - Use
aria-describedbyto link inputs to their hints and error messages (already implemented inbloom-input). - Use
aria-invalid="true"on inputs with validation errors (already implemented inbloom-input). - Use
aria-disabledin addition to the HTMLdisabledattribute on custom components that wrap native elements.
Global styles live in src/app/shared/styles/ and are loaded via src/styles.scss:
| File | Purpose | Load order |
|---|---|---|
_tokens.scss |
CSS custom properties, SCSS variables, breakpoint mixins, utility mixins | 1st |
_base.scss |
CSS reset, element defaults, scrollbar styles, focus styles, accessibility utilities | 2nd |
_animations.scss |
Keyframe definitions, animation utility classes, hover interaction classes, stagger utilities, reduced-motion overrides | 3rd |
_utilities.scss |
Layout utilities, spacing utilities, typography utilities, color utilities, decorative utilities, responsive visibility | 4th |
Component styles are co-located with their components and scoped via Angular's view encapsulation:
src/app/shared/ui/<name>/bloom-<name>.scssfor shared UI componentssrc/app/features/<feature>/<component>.scssfor feature-specific componentssrc/app/core/layout/<layout>/<layout>.scssfor layout components
All component CSS uses BEM (Block Element Modifier):
.bloom-<block> -- Block
.bloom-<block>__<element> -- Element
.bloom-<block>--<modifier> -- Modifier
.bloom-<block>__<element>--<modifier> -- Element modifier
Examples from the existing codebase:
.bloom-btn // Block
.bloom-btn__label // Element
.bloom-btn__icon // Element
.bloom-btn__icon--left // Element modifier
.bloom-btn__spinner-dot // Element (nested for clarity, not nested BEM)
.bloom-btn--primary // Block modifier
.bloom-btn--sm // Block modifier
.bloom-btn--loading // State modifier
.bloom-btn--full-width // State modifierRules:
- Never nest BEM more than one level:
.bloom-btn__spinner-dotis fine..bloom-btn__spinner__dot__inneris not. - Modifiers on the block, not elements, when the modifier changes the entire block:
.bloom-btn--primary(affects the whole button) vs.bloom-btn__icon--left(affects only the icon position). - State modifiers use the same BEM pattern:
--loading,--disabled,--focused,--error,--active.
Use utility classes for:
- Quick layout within page templates (flex, grid, gap, padding, margin)
- One-off spacing or alignment adjustments
- Text color/size changes in content areas
- Decorative additions (gradient text, glass effect, glow borders)
<!-- Utility classes for page layout -->
<div class="bloom-flex bloom-justify-between bloom-items-center bloom-mb-6">
<h2>My Watch Spaces</h2>
<bloom-button variant="primary" size="sm">Create</bloom-button>
</div>Use component SCSS for:
- All shared UI component internal styling
- Complex, reusable visual patterns
- Anything that needs pseudo-elements (::before, ::after)
- Hover/focus/active state chains
- Responsive behavior specific to a component
Never use utility classes inside shared UI component templates. Shared components must be self-contained with their own SCSS. Utility classes are for consumption in feature templates and page layouts.
- Global styles in
_base.scssuse low specificity (element selectors, single-class selectors). - Component styles use single-class BEM selectors. No nesting beyond what BEM requires.
- Never use
!importantin component styles. The only!importantdeclarations in the system are in responsive visibility utilities (bloom-hide-mobile, etc.), where they are necessary to override unknown display values. - Never use ID selectors in CSS.
- Never use inline styles in templates (the showcase page uses inline styles for demonstration only -- do not replicate this in production code).
To use SCSS mixins and variables (breakpoints, reduced-motion, bloom-focus-ring, gel-shine, truncate) in a component's SCSS file:
@use 'app/shared/styles/tokens' as *;
.my-component {
padding: var(--bloom-space-4);
@include bp-md {
padding: var(--bloom-space-6);
}
&:focus-visible {
@include bloom-focus-ring;
}
@include reduced-motion {
transition: none;
}
}CSS custom properties (var(--bloom-*)) do not require any import -- they are globally available.
Angular's default ViewEncapsulation.Emulated is used (the default for standalone components). This means:
- Each component's styles are scoped with attribute selectors.
- Styles do not leak out of the component.
:hostis used to style the component's host element.::ng-deepis banned. If you need to style a child component, use CSS custom properties or content projection.
Every component in BloomWatch is standalone. The standalone: true flag is required. There are no NgModules for component declaration.
@Component({
selector: 'bloom-example',
standalone: true,
imports: [NgClass], // Import only what's used
styleUrl: './bloom-example.scss',
template: `...`,
})
export class BloomExampleComponent { }BloomWatch uses Angular's signal-based API. The decorator-based API (@Input(), @Output()) is prohibited.
// CORRECT
readonly variant = input<BloomButtonVariant>('primary');
readonly disabled = input<boolean>(false);
readonly clicked = output<MouseEvent>();
// WRONG -- do not use decorators
@Input() variant: BloomButtonVariant = 'primary';
@Output() clicked = new EventEmitter<MouseEvent>();Rules:
- All inputs are
readonlyand declared withinput<Type>(defaultValue). - All outputs are
readonlyand declared withoutput<Type>(). - Derived state uses
computed(), not getters. - Mutable internal state uses
signal().
// Derived state
readonly buttonClasses = computed(() => ({
'bloom-btn': true,
[`bloom-btn--${this.variant()}`]: true,
}));
// Internal mutable state
readonly isFocused = signal(false);BloomWatch uses named ng-content slots with attribute selectors for multi-slot projection:
<!-- Component template -->
<header class="bloom-card__header">
<ng-content select="[bloomCardHeader]" />
</header>
<div class="bloom-card__body">
<ng-content /> <!-- default slot -->
</div>
<footer class="bloom-card__footer">
<ng-content select="[bloomCardFooter]" />
</footer><!-- Consumer template -->
<bloom-card>
<h3 bloomCardHeader>Title</h3>
<p>Body content goes in the default slot.</p>
<div bloomCardFooter>Footer content</div>
</bloom-card>Naming convention: Slot selectors use camelCase attribute names prefixed with the component name: bloomCardHeader, bloomCardFooter, bloomButtonIconLeft, bloomInputPrefix, etc.
Empty slot handling: Always add &:empty { display: none; } to slot wrapper elements so that unused slots don't create empty space.
All form-field components must implement ControlValueAccessor to integrate with Angular's forms API. The bloom-input component demonstrates the canonical pattern:
@Component({
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => BloomInputComponent),
multi: true,
},
],
})
export class BloomInputComponent implements ControlValueAccessor {
readonly value = signal('');
private onChange: (value: string) => void = () => {};
private onTouched: () => void = () => {};
writeValue(val: string): void {
this.value.set(val ?? '');
}
registerOnChange(fn: (value: string) => void): void {
this.onChange = fn;
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
onInput(event: Event): void {
const target = event.target as HTMLInputElement;
this.value.set(target.value);
this.onChange(target.value);
this.valueChange.emit(target.value);
}
onBlur(): void {
this.isFocused.set(false);
this.onTouched();
}
}All new form components (textarea, select, toggle, checkbox) must follow this pattern.
Shared UI components (reusable across features):
src/app/shared/ui/<name>/
bloom-<name>.ts # Component + types
bloom-<name>.scss # Styles
Inline templates are used when the template is reasonably short (as seen in all current components). If a template exceeds approximately 50 lines, extract it to a .html file and use templateUrl.
Feature components (specific to one feature):
src/app/features/<feature>/
<component>.ts # Component
<component>.html # Template (if external)
<component>.scss # Styles (if needed)
<feature>.routes.ts # Feature routes
All shared UI components must be exported from src/app/shared/ui/index.ts. When adding a new component:
- Create the component files.
- Add the export to
index.ts:
// NewComponent
export { BloomNewComponent } from './new-component/bloom-new-component';
export type { BloomNewComponentVariant } from './new-component/bloom-new-component';- Feature components import from the barrel:
import { BloomNewComponent } from '../../shared/ui';
Routes use lazy-loaded standalone components and child route files:
// Feature routes file (e.g., watch-spaces.routes.ts)
import { Routes } from '@angular/router';
export const watchSpacesRoutes: Routes = [
{ path: '', loadComponent: () => import('./watch-space-list').then(m => m.WatchSpaceList) },
{ path: ':id', loadComponent: () => import('./watch-space-detail').then(m => m.WatchSpaceDetail) },
];All feature routes are lazy-loaded via loadChildren in the top-level app.routes.ts. Individual components within a feature are lazy-loaded via loadComponent.
Rendered within MinimalLayout. Centered in the viewport. Narrow width (max 24rem-28rem).
<div class="auth-page">
<bloom-card>
<div bloomCardHeader class="auth-header">
<h1>Log In</h1>
<p>Welcome back to BloomWatch</p>
</div>
<form class="bloom-stack bloom-gap-4">
<bloom-input
label="Email"
type="email"
placeholder="you@example.com"
[required]="true"
autocomplete="email"
/>
<bloom-input
label="Password"
type="password"
placeholder="Your password"
[required]="true"
autocomplete="current-password"
/>
<bloom-button type="submit" [fullWidth]="true" [loading]="isSubmitting()">
Log In
</bloom-button>
</form>
<div bloomCardFooter class="bloom-text-center">
<p class="bloom-text-sm bloom-text-secondary">
Don't have an account? <a routerLink="/register">Sign up</a>
</p>
</div>
</bloom-card>
</div>Key rules for auth forms:
- Always set
autocompleteattributes correctly (email,current-password,new-password,username). - Use
fullWidthon the submit button. - Show loading state on the button during submission.
- Use
type="submit"on the submit button and handle the form's(ngSubmit)event.
Rendered within ShellLayout. Use bloom-container-narrow for constraint. Stack inputs vertically with bloom-gap-4.
Use sm or md sized inputs inline with other controls:
<div class="bloom-row bloom-gap-3 bloom-flex-wrap">
<bloom-input placeholder="Search anime..." size="sm" class="bloom-flex-1">
<span bloomInputPrefix>SEARCH_ICON</span>
</bloom-input>
<bloom-badge color="pink">Romance</bloom-badge>
<bloom-badge color="blue">Action</bloom-badge>
</div>List page (e.g., WatchSpace list):
- Page title + "Create" button in a space-between header row
bloom-grid-auto-mdgrid for card items- Each card navigates to the detail page via a button or routerLink inside the card footer
Detail page (e.g., WatchSpace detail):
- Back link or breadcrumb at the top
- Title + metadata header section
- Content sections below, separated by
--bloom-space-8vertical rhythm
When a list or section has no content, show a centered message within a card:
<bloom-card [hoverable]="false">
<div class="bloom-center bloom-stack bloom-gap-4 bloom-py-8 bloom-text-center">
<span class="bloom-text-3xl" aria-hidden="true">DECORATIVE_ICON</span>
<h3>No Watch Spaces Yet</h3>
<p class="bloom-text-secondary">Create your first Watch Space to start tracking anime with friends.</p>
<bloom-button variant="primary">Create Watch Space</bloom-button>
</div>
</bloom-card>Rules for empty states:
- Always include a clear heading explaining what is empty.
- Always include a description of what the user should do.
- Always include a CTA button to resolve the empty state.
- Use a decorative icon (aria-hidden) for visual warmth but never let it be the sole communicator.
Handled by bloom-input's error input. The field shows red border, red background tint, shake animation on the error message, and aria-invalid + role="alert".
For API errors, failed loads, or unrecoverable states:
<bloom-card [hoverable]="false">
<div class="bloom-center bloom-stack bloom-gap-4 bloom-py-8 bloom-text-center">
<span class="bloom-text-3xl" aria-hidden="true">ERROR_ICON</span>
<h3 class="bloom-text-error">Something Went Wrong</h3>
<p class="bloom-text-secondary">We couldn't load your Watch Spaces. Please try again.</p>
<bloom-button variant="secondary" (clicked)="retry()">Try Again</bloom-button>
</div>
</bloom-card>Rules for error states:
- Use
--bloom-error-darkfor error heading text. - Provide a retry action when possible.
- Do not blame the user. Use neutral, friendly language.
- Log the technical error to the console/monitoring; show a human-readable message to the user.
For non-blocking errors (e.g., sync failure while the page still shows stale data):
<div class="bloom-flex bloom-gap-3 bloom-p-4 bloom-rounded-lg"
style="background: var(--bloom-error-bg); border: 1px solid var(--bloom-error-light);"
role="alert">
<span class="bloom-text-error">ERROR_ICON</span>
<p class="bloom-text-sm">AniList sync failed. Your data may be outdated.
<a href="#" (click)="retrySync(); $event.preventDefault()">Retry</a>
</p>
</div>Full page loading (first load of a feature): Use skeleton cards in the same grid layout as the eventual content. This prevents layout shift.
Section loading (loading a sub-section):
Use bloom-animate-pulse on a placeholder, or bloom-animate-shimmer on skeleton lines.
Action loading (submitting a form, triggering a sync):
Use [loading]="true" on the triggering button. Do not show a full-page spinner for button actions.
When built, toasts should:
- Use
z-index: var(--bloom-z-toast)(600). - Enter with
bloom-bounce-inanimation. - Auto-dismiss after 5 seconds with a progress bar.
- Use status colors:
--bloom-success-bgfor success,--bloom-error-bgfor error,--bloom-info-bgfor info. - Include a dismiss button.
- Be announced via
aria-live="polite". - Stack vertically from the top-right corner on desktop, bottom-center on mobile.
When built, modals should:
- Use
z-index: var(--bloom-z-modal)(400) with a--bloom-z-overlay(300) backdrop. - The backdrop should use
bloom-glass(frosted) styling. - The modal card should use
bloom-cardwithbloom-animate-bounce-inentrance. - Focus must be trapped within the modal.
- Escape key must close the modal.
aria-modal="true"androle="dialog"must be set.- The modal should be rendered at the body level (or via Angular CDK overlay) to avoid stacking context issues.
Destructive actions must always require confirmation. The confirmation dialog should:
- Clearly state what will be destroyed.
- Name the specific item (e.g., "Delete Watch Space 'Anime Night'?").
- Use a
dangerbutton for the destructive action. - Use a
ghostbutton for cancellation. - Default focus should be on the cancel button, not the destructive button.
NEVER hardcode color values.
// WRONG
color: #ff2d8a;
background: #fff0f6;
border: 1px solid #e8e3ef;
// CORRECT
color: var(--bloom-primary);
background: var(--bloom-surface-tinted);
border: 1px solid var(--bloom-border-default);NEVER hardcode spacing values.
// WRONG
padding: 16px;
margin-bottom: 24px;
gap: 8px;
// CORRECT
padding: var(--bloom-space-4);
margin-bottom: var(--bloom-space-6);
gap: var(--bloom-space-2);NEVER hardcode font sizes, weights, or families.
// WRONG
font-size: 14px;
font-weight: 600;
font-family: 'Quicksand', sans-serif;
// CORRECT
font-size: var(--bloom-text-sm);
font-weight: var(--bloom-font-semibold);
font-family: var(--bloom-font-family-display);NEVER hardcode border-radius values.
// WRONG
border-radius: 16px;
// CORRECT
border-radius: var(--bloom-radius-lg);NEVER hardcode z-index values.
// WRONG
z-index: 9999;
// CORRECT
z-index: var(--bloom-z-modal);NEVER use ::ng-deep. It breaks encapsulation and creates unmaintainable specificity chains. If a child component needs customization, use CSS custom properties or content projection.
NEVER wrap a <bloom-card> in an <a> tag. Cards are not links. Put interactive elements inside the card's projected content.
NEVER put a primary button next to another primary button. Use primary + ghost or primary + secondary.
NEVER use a danger button for non-destructive actions. Red means "this will remove or destroy something."
NEVER use bloom-animate-float or bloom-animate-bounce on interactive controls. Moving elements are harder to click and violate WCAG 2.1 Success Criterion 2.3.3.
NEVER place inline styles in production templates. The showcase page uses inline styles for demonstration brevity. Production code must use component SCSS or utility classes.
NEVER add max-width or container padding to pages rendered within ShellLayout. The shell-content wrapper already provides these constraints.
NEVER use position: fixed for elements that should be sticky. The nav bar is sticky, not fixed. Fixed positioning removes elements from the document flow and causes content overlap issues.
NEVER create new breakpoint values. Use the defined $bloom-bp-* variables. If a component doesn't need all breakpoints, skip the irrelevant ones rather than inventing intermediate values.
NEVER remove focus outlines without providing an alternative. The global :focus { outline: none; } in base styles is paired with :focus-visible styles. If you remove these, keyboard users cannot navigate.
NEVER use color alone to convey information. The badge dot, for example, has both color AND animation to indicate status. Error states use both red color AND shake animation AND error text.
NEVER use aria-hidden="true" on elements that contain interactive children. This removes both the element and its children from the accessibility tree.
NEVER auto-play audio or video. This violates WCAG 1.4.2.
NEVER use tabindex values greater than 0. This creates unpredictable tab order.
NEVER animate width, height, top, left, or margin. These trigger layout recalculation. Use transform and opacity only for animations. All existing BloomWatch animations follow this rule.
NEVER use backdrop-filter on large or frequently-repainting elements. It is acceptable on the nav bar (small, static) and small overlays. Do not apply it to scrolling content areas.
NEVER add box-shadow transitions to elements within a scrolling list. Shadow transitions during scroll cause jank. Apply shadow transitions only on hover, which pauses scrolling implicitly.
NEVER forget the anyComponentStyle budget. Angular's build config enforces an 8kB warning / 12kB error limit per component stylesheet. Keep component styles focused and lean. Extract shared patterns to global utilities rather than duplicating them.
Rainbow text on body copy. Gradient text (bloom-gradient-text) is for headings and decorative display text only. Running gradient text over a paragraph destroys readability.
Sparkle borders on everything. The animated sparkle border (bloom-card--highlighted, bloom-sparkle-border) should appear on 1-2 elements per page at most. When everything sparkles, nothing stands out.
Excessive bounce animations. One or two bouncing elements per page is charming. Five is a carnival. Zero is appropriate for data-heavy views.
Tiny text in kawaii fonts. Quicksand at very small sizes (below 12px) becomes hard to read due to its rounded letterforms. Never use the display font below --bloom-text-xs (12px). For very small text, use the body font (Nunito) instead.
Pastel-on-pastel text. Pink text on a pink-tinted background, lilac text on a lilac card -- these combinations fail contrast requirements and are hard to read even for sighted users. Always check the contrast between your text color and its immediate background.
| What | Where |
|---|---|
| Design tokens | src/app/shared/styles/_tokens.scss |
| Base reset styles | src/app/shared/styles/_base.scss |
| Animation system | src/app/shared/styles/_animations.scss |
| Utility classes | src/app/shared/styles/_utilities.scss |
| Global entry point | src/styles.scss |
| Shared UI components | src/app/shared/ui/ |
| UI barrel export | src/app/shared/ui/index.ts |
| Shell layout | src/app/core/layout/shell-layout/ |
| Minimal layout | src/app/core/layout/minimal-layout/ |
| Feature modules | src/app/features/<feature>/ |
| Application routes | src/app/app.routes.ts |
All tokens begin with --bloom-. The second segment indicates the category:
| Prefix | Category | Example |
|---|---|---|
--bloom-pink-* |
Pink palette | --bloom-pink-500 |
--bloom-blue-* |
Blue palette | --bloom-blue-300 |
--bloom-lilac-* |
Lilac palette | --bloom-lilac-400 |
--bloom-lime-* |
Lime palette | --bloom-lime-200 |
--bloom-peach-* |
Peach palette | --bloom-peach-500 |
--bloom-yellow-* |
Yellow palette | --bloom-yellow-100 |
--bloom-neutral-* |
Neutral palette | --bloom-neutral-600 |
--bloom-surface-* |
Surface colors | --bloom-surface-raised |
--bloom-text-* (color) |
Text colors | --bloom-text-secondary |
--bloom-text-* (size) |
Font sizes | --bloom-text-lg |
--bloom-font-* |
Font properties | --bloom-font-family-display |
--bloom-leading-* |
Line heights | --bloom-leading-normal |
--bloom-tracking-* |
Letter spacing | --bloom-tracking-wide |
--bloom-space-* |
Spacing | --bloom-space-4 |
--bloom-radius-* |
Border radius | --bloom-radius-xl |
--bloom-shadow-* |
Box shadows | --bloom-shadow-md |
--bloom-duration-* |
Animation duration | --bloom-duration-fast |
--bloom-ease-* |
Easing functions | --bloom-ease-bounce |
--bloom-transition-* |
Combined transitions | --bloom-transition-normal |
--bloom-z-* |
Z-index | --bloom-z-modal |
--bloom-gradient-* |
Gradients | --bloom-gradient-kawaii |
--bloom-border-* |
Border colors | --bloom-border-focus |
--bloom-container-* |
Container widths | --bloom-container-lg |
--bloom-bp-* |
Breakpoints (ref only) | --bloom-bp-md |
--bloom-nav-* |
Navigation dimensions | --bloom-nav-height |
--bloom-sidebar-* |
Sidebar dimensions | --bloom-sidebar-width |
import {
BloomButtonComponent,
BloomCardComponent,
BloomInputComponent,
BloomBadgeComponent,
BloomAvatarComponent,
BloomAvatarStackComponent,
} from '../../shared/ui'; // Adjust path depth as neededThis document is the law of the land for BloomWatch UI development. When it conflicts with a developer's personal preference, this document wins. When it conflicts with a third-party tutorial, this document wins. When it is silent on a topic, the existing codebase patterns are the next authority, followed by WCAG 2.1 AA guidelines, followed by Angular best practices.