- name
- webscheduler-frontend
- description
- WebScheduler frontend contract — Vite entry points, SPA lifecycle and navigation, dark mode (FOUC prevention + Tailwind class strategy), shared fetch layer, profile surface, avatar/initials system, design system (`xs-card`, `xs-btn`, `xs-actions-container`), `layouts/app` layout rules, and appointments toolbar layout. Use whenever you're working in `resources/js`, `resources/scss`, `app/Views/`, the SPA layer, Vite config, theme toggles, view layouts, avatars, or any styling/layout decision. Triggers on phrases like "view", "layout", "SPA", "Vite", "dark mode", "theme", "Tailwind", "SCSS", "avatar", "initials", "xs-card", "xs-btn", "appointments view", "toolbar", "scripts section", "DOMContentLoaded", "spa:navigated", or any JS/CSS/template change.
# WebScheduler — Frontend Contract
## 1. Canonical Entry Points (Vite)
Defined in `vite.config.js` `rollupOptions.input`:
| Key | File |
| --- | --- |
| `main` | `resources/js/app.js` |
| `style` | `resources/scss/app-consolidated.scss` |
| `spa` | `resources/js/spa.js` |
| `dark-mode` | `resources/js/dark-mode.js` |
| `theme-bootstrap` | `resources/js/theme-bootstrap.js` |
| `app-layout-init` | `resources/js/layout/app-layout-init.js` |
| `public-booking` | `resources/js/public-booking.js` |
| `public-booking-bootstrap` | `resources/js/public-booking-bootstrap.js` |
| `unified-sidebar` | `resources/js/unified-sidebar.js` |
| `charts` | `resources/js/charts.js` |
| `setup` | `resources/js/setup.js` |
`resources/js/material-web.js` was removed — the `@material/web` bundle was unused (zero `<md-*>` elements in production views).
**Non-entry utility modules** (import only, not Vite entry points):
- `resources/js/currency.js` — currency formatting, loads settings from `/api/v1/settings/localization`
- `resources/js/utils/avatar.js` — avatar initials helper (see §6 below)
## 2. Lifecycle Contract
Core lifecycle for authenticated surfaces:
1. `app.js` loads
2. `spa.js` intercepts links/forms
3. `initializeComponents()` runs on load and after `spa:navigated`
4. View code uses `xsRegisterViewInit(fn)`
5. Initializers **must be idempotent** via dataset guards
Body-level overlays or modals created by JS (outside `#spa-content`) must explicitly clean themselves up on `spa:leaving`. Hiding them is not sufficient when the DOM node survives SPA swaps; teardown must remove stale nodes, listeners, and pending timers so the next initializer run can recreate fresh state.
**Do not add bare `DOMContentLoaded`-only logic for app surfaces that can SPA-navigate.**
## 3. SPA Navigation Contract
`spa.js` behavior:
- Swaps `#spa-content`
- Emits `spa:navigated` after successful swap
- Supports `data-no-spa` and `no-spa` opt-outs
- Supports `forceReload` semantics for same-URL post-mutation refresh
If a controller action redirects back to the current page, the JSON response **must** include a `redirect` key so SPA navigation can force-reload (see `api-contract` skill).
## 4. Dark Mode Contract (Owner Section)
**Tailwind configuration:** `darkMode: 'class'` in `tailwind.config.js`. All Tailwind `dark:` utilities require `.dark` on `<html>`.
**FOUC prevention — inline blocking script:** The script is **literally inlined** in `<head>` — NOT a `<script src>` or `type="module"`. All three layouts (`app.php`, `public.php`, `auth.php`) and the standalone `booking.php` carry the same one-liner. It runs synchronously during HTML parsing, before any CSS file loads:
```html
<script {csp-script-nonce}>!function(){var t=localStorage.getItem('xs-theme')||(window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);document.documentElement.classList.toggle('dark',t==='dark');document.documentElement.style.colorScheme=t;if(t==='dark')document.documentElement.style.backgroundColor='#111827';document.documentElement.classList.add('xs-no-transition')}();</script>
```
> **CSP nonce required:** Every inline `<script>` in CI4 layouts must carry `{csp-script-nonce}`. CI4 replaces this placeholder with `nonce="…"` when `app.CSPEnabled = true`. Omitting it causes a CSP violation and the script is silently blocked in production.
What it does (in order):
1. Reads `xs-theme` from `localStorage`; falls back to `prefers-color-scheme`
2. Sets `data-theme` attribute + toggles `.dark` class on `<html>`
3. Sets `style.colorScheme = t` — system UI (scrollbars, native inputs) renders in the right scheme
4. For dark mode: sets `style.backgroundColor = '#111827'` on `<html>` — covers viewport before CSS loads
5. Adds `.xs-no-transition` class — suppresses all `transition-colors` when the CSS file first applies
**`xs-no-transition` CSS rule** lives in `resources/scss/base/_reset.scss`:
```scss
.xs-no-transition, .xs-no-transition * { transition: none !important; }
```
This prevents the 200ms animated fade that would otherwise occur when `dark:bg-gray-900` first applies to `<body>`.
**FOUC cleanup:** The guards must be removed after the first paint to restore normal transitions for user-triggered theme switches.
- **Layouts that load `app-layout-init.js`:** cleanup runs in the double `requestAnimationFrame` inside `onDomReady()`.
- **Standalone pages (e.g. `booking.php`):** cleanup is a bare inline script at end of `<body>`:
```html
<script {csp-script-nonce}>requestAnimationFrame(function(){requestAnimationFrame(function(){document.documentElement.classList.remove('xs-no-transition');document.documentElement.style.backgroundColor='';document.documentElement.style.colorScheme=''})});</script>
```
**Dark background values by page:**
- Authenticated app (`layouts/app.php`, `auth.php`, `public.php`): `#111827` (Tailwind `gray-900` = `dark:bg-gray-900` on `<body>`)
- Public booking SPA (`booking.php`): `#0f172a` (Tailwind `slate-900` = `dark:bg-slate-900` on `<body>`)
**`<html>` tag:** must NOT carry `transition-colors duration-200` — it has no `background-color` rule. `transition-colors` lives on `<body>` only, for user-triggered theme switches.
**`theme-bootstrap` Vite entry:** still exists in `vite.config.js` and builds `resources/js/theme-bootstrap.js`, but **no layout loads it via `<script src>`**. The content is inlined directly. Do not re-introduce the module src load — it would defer execution and break FOUC prevention.
**Runtime management:** `DarkModeManager` in `resources/js/dark-mode.js` handles the full lifecycle:
- On every `applyTheme()` call: sets `data-theme` attribute, `.dark` class, AND `style.colorScheme`
- Persists to `localStorage` key `xs-theme`
- Dispatches `xs:theme-changed` custom event for downstream components
- Re-wires `[data-theme-toggle]` buttons after each `spa:navigated` event
**SCSS:** Dark overrides use `.dark { ... }` selector in `resources/scss/abstracts/_custom-properties.scss`, **not** `[data-theme="dark"]`.
**Summary:** `data-theme` attribute is always kept in sync (for external consumers), but `.dark` class is the canonical trigger for Tailwind utilities and SCSS dark overrides. See `ui-ux` skill for dark mode styling rules (color tokens, xs-no-transition usage).
## 5. Styling Contract
- Tailwind + consolidated SCSS only
- **No inline `style` attributes** in app-facing templates — caught by pre-merge grep check
## 6. Avatar System Contract (Owner Section)
All avatar rendering — profile images and initials fallback — has a **single source of truth** on both PHP and JS sides.
### 6.1 PHP Helpers (`app/Helpers/app_helper.php`)
Four canonical helpers, loaded via `helper('app')`:
| Helper | Purpose |
| --- | --- |
| `avatar_initials(string $name, string $default = 'U'): string` | Derives 1-2 letter initials from a display name. Strips titles (Dr., Prof., Mr., Mrs., Ms., Rev.) and suffixes (MD, PhD, DDS, DO, RN, NP, PA, DVM, Jr., Sr., II, III, IV). Multi-word → first + last initial. Single-word → first 2 chars. Empty → `$default`. |
| `avatar_display_name(array $user, string $fallback = 'User'): string` | Prefers `$user['name']`; falls back to `first_name` + `last_name` concatenation. |
| `avatar_profile_image_url(array $user): ?string` | Resolves profile image URL. Paths starting with `assets/` map to `FCPATH`; paths starting with `uploads/` or `writable/` map to `WRITEPATH`. Returns `null` if no usable path. |
| `avatar_data(array $user, string $defaultInitial = 'U'): array` | Returns `['imageUrl' => ?string, 'initials' => string, 'displayName' => string]`. Use this in views for image-first rendering with initials fallback. |
`ProfilePageService::buildProfileImageUrl()` and `buildProfileInitials()` delegate to these helpers.
### 6.2 JS Utility (`resources/js/utils/avatar.js`)
Single ESM module — import directly:
```js
import { getAvatarInitials, getDisplayName } from '../../utils/avatar.js';
```
| Export | Signature | Behaviour |
| --- | --- | --- |
| `getDisplayName(entity, fallback)` | `(object, string) → string` | Prefers `entity.name`; falls back to `first_name` + `last_name`. |
| `getAvatarInitials(name, options)` | `(string, { defaultInitial? }) → string` | Same normalization rules as PHP: strip titles/suffixes, multi-word → first+last initial, single-word → first 2 chars. `options.defaultInitial` defaults to `'U'`. |
For inline `<script>` blocks in PHP views (which cannot use ESM imports), use the globals exposed by `resources/js/app.js`:
```js
window.xsGetAvatarInitials(name, defaultInitial)
window.xsGetDisplayName(entity, fallback)
```
### 6.3 Canonical Default Initials by Context
| Context | Default |
| --- | --- |
| User / staff / header | `'U'` |
| Customer | `'C'` |
| Staff assignment widget | `'S'` |
| Provider assignment widget | `'P'` |
| Scheduler provider chip | `'?'` |
### 6.4 Covered Surfaces
- `app/Views/layouts/app.php` — header user avatar (image-first via `avatar_data()`)
- `app/Views/user-management/index.php` — PHP rows + JS `userRow()` via `window.xsGetAvatarInitials`
- `app/Views/customer-management/index.php` — `avatar_data()` with `defaultInitial: 'C'`
- `app/Views/customer-management/history.php` — large customer header avatar
- `app/Views/appointments/form.php` — customer avatar placeholder (`'C'`)
- `app/Views/user-management/components/provider-staff.php` — server PHP + JS `renderStaff()` widget
- `app/Views/user-management/components/staff-providers.php` — server PHP + JS `renderProviders()` widget
- `resources/js/modules/scheduler/appointment-colors.js` — `getProviderInitials()` delegates to `getAvatarInitials`
- `resources/js/modules/customer-management/customer-search.js`
- `resources/js/modules/appointments/appointments-form.js`
### 6.5 Do Not Duplicate
**Do not reimplement initials logic in any view, controller, or JS module.** Always call the shared helper. A one-letter initial in a completed surface is a regression against this contract.
## 7. View Layout & Design System Contract
> **Design system component tokens (`xs-card`, `xs-btn`, `xs-actions-container`) are owned by the `ui-ux` skill.** This section covers layout-level contracts only.
### 7.1 Canonical Layout
All authenticated views **must** extend `layouts/app`. The legacy `layouts/dashboard` layout is **deprecated**; do not use it for new views or when migrating existing views.
| Layout | Status | Sections |
| --- | --- | --- |
| `layouts/app` | **Active standard** | `sidebar`, `header_title`, `header_subtitle`, `header_primary_action`, `header_controls`, `content`, `scripts`, `modals` |
| `layouts/dashboard` | **Deprecated** | `dashboard_stats`, `dashboard_actions`, `dashboard_filters`, `dashboard_content_top`, `dashboard_content` |
To suppress the header's built-in "New Appointment" CTA for a page that provides its own CTA in `content`:
```php
<?= $this->section('header_primary_action') ?>hidden<?= $this->endSection() ?>
```
### 7.2 Design System Component Classes
| Token | Element | Notes |
| --- | --- | --- |
| `xs-card` | Card container | Replaces `card card-spacious` |
| `xs-card-header` | Card header bar | Use with `xs-card-header-content` (left) + `xs-card-actions` (right) |
| `xs-card-header-content` | Left slot of card header | Contains title + subtitle |
| `xs-card-title` | Title inside `xs-card-header-content` | |
| `xs-card-subtitle` | Subtitle inside `xs-card-header-content` | |
| `xs-card-actions` | Right slot of card header | Icon buttons, secondary controls |
| `xs-card-body` | Card content area | Add `p-0` when the card contains a full-bleed table |
GitHub에서 보기