Appearance
Styling ​
Overview ​
Collage Admin styles live in app/assets/scss/ and follow the 7-1 pattern with Dart Sass modules (@use / @forward, no @import). nuxt.config.ts loads a single global entry, css: ['~/assets/scss/main.scss'], and adds two Sass load paths (vite.css.preprocessorOptions.scss.loadPaths):
| Load path | Lets a partial write |
|---|---|
./app/assets/scss | @use 'abstracts' as *; from any depth |
./node_modules/vuetify/lib/styles | @use 'vuetify/settings' as v; (breakpoint map) |
Almost all styling is global: all 24 .vue files with a <style> block use scoped, and the rest of the UI is styled by class names defined in the partials below.
File Structure ​
app/assets/scss/
├── main.scss # Entry point: @use abstracts, then every partial in order
├── abstracts/ # No CSS output; forwarded together by _index.scss
│ ├── _index.scss # @forward variables, mixins, functions, media
│ ├── _variables.scss # Color, type, gap, radius, modal tokens
│ ├── _mixins.scss # flex/transition/truncate/scrollbar mixins
│ ├── _functions.scss # px-to-rem()
│ └── _media.scss # media($key) over Vuetify's breakpoint map
├── base/ # _reset, _typography
├── layout/ # _layout, _header, _sidebar, _breadcrumbs
├── components/ # 34 partials (buttons, forms, dialogs, tables, skeleton, toggle, sso-settings, ...)
└── pages/ # _login, _collage-detail, _assets-detail, _settings, _error, _portalsmain.scss imports partials in a fixed order: base, layout, components (ending with sso-settings then mobile-responsive), pages. Later partials win on equal specificity, so components/_mobile-responsive.scss and the page partials can override component rules. A new partial must be added to main.scss; nothing is globbed. Every partial starts with @use '../abstracts' as *;.
The :root CSS custom property emission (@include emit-css-tokens, $cl-palette, --cl-*) is commented out in both main.scss and _variables.scss, so no --cl-* variables exist at runtime.
Design Tokens (_variables.scss) ​
Tokens are plain SCSS variables in abstracts/_variables.scss. Many shades are kept commented out in the file; only the uncommented ones below can be used.
Colors ​
| Family | Available tokens | Notes |
|---|---|---|
| Blue (brand) | $blue-color-700 #3A47BB, -500 #6473FF, -400, -350, -300, -200, -100, -50 | -500 is Vuetify primary |
| Gray (text, surfaces) | $gray-color #3D3D3D, -900 #525252, -800, -600 #8F8F8F, -500, -400, -300, -200 #E0E0E0, -150, -100, -50 #F6F6F6 | $gray-color is body text and Vuetify on-surface |
| Red | $red-color-700, -500 #FF5572, -200, -100 | -500 is Vuetify error |
| Green | $green-color #00A13B, -700, -400, -300, -100 | $green-color is Vuetify success |
| Teal | $teal-color #075850, -900, -600 #40E0D0, -100 | -600 is Vuetify secondary |
| Orange | $orange-color-700, -500 #FF7F50, -300, -200, -100, -50 | -500 is Vuetify warning |
| Lavender | $lavender-color-900 #383859, -200, -100 #E9E9EF | |
| Cream | $cream-color-300, -200 | |
| Warning | $warning-color #977100, -300, -100 | |
| White / black | $white-color, $white-color-800/-750/-300 (rgba), $black-color, $black-color-50 | |
| Other | $slate-color #0F172A (legacy search UI), $border-color (= $gray-color-200), $box-shadow-10/15/20/25/40/60 (black at that % alpha) |
$avatar-char-colors is a map a to z used by components/_avatars.scss to give .char_a ... .char_z initials a stable background color.
Typography ​
The font is Inter, loaded from Google Fonts by a <link> in nuxt.config.ts app.head, with $font-family: 'Inter', sans-serif.
| Group | Tokens |
|---|---|
| Sizes | $font-size-10, -12, -14, -16, -18, -22, -28, -34 (px) |
| Weights | $font-weight-regular 400, -medium 500, -semibold 600, -bold 700, -extrabold 800, -black 900 |
| Gaps | $gap-xs 4, $gap-sm 8, $gap-md 12, $gap-lg 16, $gap-xl 24 (px) |
| Radius | $border-radius-xs 4, -sm 8, -md 12, -lg 16, -xl 20 (px) |
| Icon/button size | $icon-size-small 32, -medium 36, -large 40 (px) |
| Modal width | $modal-sm 480, $modal-md 600, $modal-lg 800, $modal-xl 1120 (px) |
base/_typography.scss sets all headings to $font-weight-medium, line-height 1.2, no margin; h1 is 34 px semibold, stepping down to 28 px at md-and-down and 22 px at sm-and-down.
Vuetify 3 Theme ​
The theme is set in app/plugins/vuetify.ts (enforce: 'pre'), not in SCSS. Hex values are repeated there, so a token change in _variables.scss does not change Vuetify colors.
| Vuetify color | Value | Matching token |
|---|---|---|
primary, info | #6473FF | $blue-color-500 |
primary-darken-1 | #3A47BB | $blue-color-700 |
secondary | #40E0D0 | $teal-color-600 |
secondary-darken-1 | #2DBEAF | commented-out $teal-color-700 |
error | #FF5572 | $red-color-500 |
warning | #FF7F50 | $orange-color-500 |
success | #00A13B | $green-color |
background, surface | #FFFFFF | $white-color |
on-surface | #3D3D3D | $gray-color |
Other options: defaultTheme: 'light', defaults.global.ripple: false, icons.defaultSet: 'mdi' (@mdi/font CSS imported by the plugin), and ssr: { clientWidth: 1280, clientHeight: 800 }. The SSR size exists because plain ssr: true makes Vuetify assume a 0 px viewport on the server, which rendered v-navigation-drawer in its 76 px mobile form until hydration; mobile clients now reflow once on mount instead.
Component Styling ​
Component look comes from the global partials, keyed by class names used in templates. <style scoped> blocks in SFCs are for genuinely local tweaks.
Breakpoints. Use the media($key) mixin, which reads Vuetify's $display-breakpoints and raises a Sass @error for an unknown key, so SCSS and useDisplay() share thresholds (xs 0, sm 600, md 960, lg 1280, xl 1920, xxl 2560).
scss
.sso-providers {
@include media('md-and-down') { /* < 1280px */ }
}Valid keys: xs, sm, md, lg, xl, xxl, sm-and-up, md-and-up, lg-and-up, xl-and-up, sm-and-down, md-and-down, lg-and-down, xl-and-down.
Toggle buttons (components/_toggle.scss). Styles v-btn-toggle as a segmented control with a sliding highlight. The template must include a <div class="bg-color" /> after the buttons; it is moved with transform: translateX(...) based on which nth-child button is --active/--selected.
| Variant | Button width (desktop / md-and-down) | Highlight |
|---|---|---|
| default | 140 / 110 px | $blue-color-500 |
v-btn--size-small | 48 px | 48 px |
v-btn--size-large | 160 / 130 px | 160 / 130 px |
.bg-teal, .bg-white on .bg-color | - | teal or white highlight |
.gray-toggle on the toggle | - | gray track, dark active text, no uppercase |
Only the first two buttons are positioned, so the control supports two options. Example from workspace-settings/sso/setup.vue: <v-btn-toggle class="gray-toggle mb-3"> ... <div class="bg-color bg-white" />.
Skeletons (components/_skeleton.scss). Ported from Admin-Frontend custom.css; restyles v-skeleton-loader bones and adds named variants: .icon-skeleton, .global-search-skeleton, .btn-skeleton, .btn-link-skeleton, .form-label-skeleton, .form-sublabel-skeleton, .form-input-skeleton, .form-textarea-skeleton, .form-image-skeleton, .form-logo-skeleton, .form-favicon-skeleton, .logo-skeleton, .chips-group-skeleton, .tags-skeleton, .divider-skeleton, .text-skeleton, .assets-version-history-skeleton, among others.
SSO settings (components/_sso-settings.scss). Scoped under .sso-settings, the root class of workspace-settings/sso/index.vue and sso/setup.vue.
| Block | Purpose and rules |
|---|---|
.sso-status + .sso-status-dot | Status pill with a 7 px dot in currentcolor. Green by default; .is-sync-never, .is-sync-queued, .is-sync-running gray; .is-sync-failed red ($red-color-700); .is-off gray on light background |
.sso-stepper, .sso-step | Setup steps; .is-current, .is-done, .is-pass states, .sso-step-mark and .sso-step-label, :focus-visible ring |
.sso-providers, .sso-provider | Provider picker tiles; selected state is driven by [aria-checked='true'], not a class |
.sso-section, .sso-drop | Metadata section and file drop zone (.is-over while dragging) |
.sso-card, .sso-live-dot | Status cards; the live dot pulses with @keyframes sso-live-pulse, disabled under prefers-reduced-motion: reduce |
.sso-card-feedback.is-error | Inline error under a card |
.sso-registered | Collapsible block, open state from [aria-expanded='true'] |
.sso-guide | Fixed right drawer (SsoGuideDrawer.vue), 440 px wide, z-index: 2000, hidden with translateX(102%), shown with .is-open |
Asset preview dialog (components/_dialogs.scss, pages/_assets-detail.scss). AssetPreviewDialog passes body-class="asset-preview-dialog" to AppDialog; .v-card-text.asset-preview-dialog is excluded from the default card-text padding rule and, like .tag-dialog, gets padding: 0 and overflow-y: hidden; inside, it is a flex column.
| Block | Purpose and rules |
|---|---|
.asset-preview-counter | "n of m" in the header; 12 px, $gray-color-800, tabular-nums so the width does not jump |
.swipeable / .non-swipeable | Set on the stage wrapper. .swipeable adds touch-action: pan-y, disables text selection and image dragging, and shows a grab cursor on .image-preview; PDF, doc, text and HTML previews get .non-swipeable |
.asset-preview-stage | Relative, clipped container for the stage-slide-next / stage-slide-prev transitions (0.4 s cubic-bezier(0.25, 0.8, 0.25, 1), leaving slide absolutely positioned, translateX(+/-100%)) |
.asset-preview-thumbs, .asset-preview-thumb-track | Centred strip, 24 px top margin, cursor: grabbing while .dragging; track gap $gap-md (12 px), $gap-sm (8 px) at xs. thumb-slide-* transitions animate slot moves |
.asset-preview-thumb-slot | 76 x 56 px; 62 x 42 px at sm-and-down; 42 x 28 px at xs |
.asset-preview-thumb | 0.6 opacity, 2 px transparent border, $lavender-color-100 background, grab cursor, and a $white-color-300 wash (.v-img::before). .active: opacity 1, scale(1.1), $blue-color-500 border, no wash. .placeholder: borderless skeleton. svgIcon / svgImage / gifImage thumbnails use object-fit: contain with 8 px padding |
.asset-preview-nav.prev / .next | Round white arrows outside the card at -66px, -18px at md-and-down, -16px at sm-and-down; nudge 2 px outward on hover |
.assets-detail-wrapper.transparent-images | PNG, SVG and GIF previews: $gray-color-200 background, $border-radius-sm, 16 px padding. .image-preview also gets filter: drop-shadow(0 0 1px $gray-color-300) |
Login tiles (pages/_login.scss). .social-tile-sso svg (and .social-tile-passkey svg) are 18 px. A tile row with .tiles-4 fits four tiles per row (calc((100% - 24px) / 4), 12 px text) and drops to two per row at xs.
Utility Classes ​
There is no utility-class partial. Shared helpers are mixins in abstracts/_mixins.scss and a function in _functions.scss:
| Helper | Output |
|---|---|
@include flex-center | flex, items and content centered |
@include flex-between | flex, centered items, space-between |
@include transition-ease($duration: 0.4s) | transition: all $duration ease !important (with -webkit-) |
@include truncate | single-line ellipsis |
@include line-clamp($lines: 2) | multi-line clamp |
@include custom-scrollbar | 4 px scrollbar, thumb visible only on hover/active/focus-within; Firefox thin scrollbar |
@include custom-scrollbar-show | same, thumb always visible |
@include skeleton-base | $gray-color-100 background + skeleton-pulse animation |
px-to-rem($px) | $px / 16 rem |
skeleton-base is not included anywhere and no @keyframes skeleton-pulse is defined, so using it today gives a static gray block.
Animations ​
Keyframes are defined next to the component that uses them; there is no shared animations file.
| Keyframes | Defined in | Used for |
|---|---|---|
btn_dot | components/_buttons.scss | button loading dots (scale 0 to 1) |
loader | components/_loaders.scss | loaders |
floatUpDown, slideDown, slideUp | components/_upload.scss | upload panel |
sso-live-pulse | components/_sso-settings.scss | SSO live status dot |
Most hover and state transitions use the transition-ease mixin (0.4 s) or $transition-all-ease.
Rules ​
- Use tokens from
abstracts/_variables.scssand check the token is not commented out; Vuetify colors are separate hex values inplugins/vuetify.ts. - Start each partial with
@use '../abstracts' as *;and register it inmain.scssin the right layer. - Use
@include media('<key>')for breakpoints, not hand-written@mediawidths. - Spacing uses the
$gap-*scale (4, 8, 12, 16, 24 px); radius uses$border-radius-*(4 to 20 px). !importantis common in the partials (414 uses) to beat Vuetify specificity, andtransition-easeitself emits it.- Drive interactive states from ARIA attributes where the component already sets them (
aria-checked,aria-expanded), as the SSO styles do.
Related Documentation ​
- Configuration — SCSS preprocessor setup
- Plugins — Vuetify plugin configuration