Commit fdeedfd2 authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(portal): build the shell's stylesheet, and give it the brand it already had

portal.css is built with `source(none)`, so a template not named in an
@source line contributes nothing. layouts/portal.blade.php was not named.
Every utility the shell alone used — h-8 w-8 on the logo, max-w-2xl on the
column, pb-28 above the tab bar — was absent from the bundle, so a tenant
logo rendered at its natural size, the page went wider than the phone, and
the first tab sat off-screen. The layouts are scanned now, and a test asserts
they stay scanned.

The rail made it worse: .rail padded itself 1rem and pulled back -1rem, the
bleed trick for a rail inside a padded column. Its one caller sits in an
unpadded header and supplies its own padding, so the negative margins had
nothing to cancel and made the element 2rem wider than the viewport.

Colour. OC Sport themed their website navy and gold and never opened the
branding screen, so the portal, the PWA theme colour and the admin were all
still on our shipped blue. Branding colours still sitting on the default are
now inherited from the academy's own website palette; picking any colour
settles it. Editing the site bumps the brand cache, or the rest of the
product keeps yesterday's palette indefinitely.

That exposed what shade 600 was doing as a link colour: anchored on the
tenant's own colour, it lands within 0.02 of a dark navy, so links rendered
as body text. ColorRamp now derives an interactive colour placed at a
lightness that reads as a colour and clears 4.5:1 on the surface it is drawn
on — one for light, one for dark — and an ink() for the semantic palette,
which is chosen to be seen as a fill: amber is about 2:1 as 10px text on
white, and that is what every error message and status chip was using.
Filled buttons get a computed foreground instead of a hardcoded #fff.

Screens: the outstanding balance was a third of a three-across statistics
row, wrapping onto two lines, and repeated verbatim in the action list above
it. It is one fact and it is the fact members open the app for, so it is the
headline, figure large and currency small, and the row is two tiles. Icon
path data was retyped in three places and the home screen's copies were
truncated mid-curve — a member saw a tick where a wallet should be — so
there is one icon component. The status chip five screens built by hand is
one component too.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 6b1c1297
......@@ -93,6 +93,26 @@ public function cssVariables(): array
'--brand-success' => $this->success,
'--brand-warning' => $this->warning,
'--brand-danger' => $this->danger,
/*
Text drawn on top of a semantic colour, black or white, whichever
actually reads. A filled amber button with white text is about
2:1 — the amber is the same in every tenant, so this was wrong in
every tenant, and hardcoding #fff at each call site is how it stayed
wrong. Same computation as --brand-fg, applied to these three.
*/
'--brand-success-fg' => $this->readableOn($this->success),
'--brand-warning-fg' => $this->readableOn($this->warning),
'--brand-danger-fg' => $this->readableOn($this->danger),
// …and the other direction: the same colour used *as* text, on a
// light surface and on a dark one. See ColorRamp::ink().
'--brand-success-ink' => ColorRamp::ink($this->success),
'--brand-warning-ink' => ColorRamp::ink($this->warning),
'--brand-danger-ink' => ColorRamp::ink($this->danger),
'--brand-success-ink-dark' => ColorRamp::ink($this->success, ColorRamp::DARK_SURFACE),
'--brand-warning-ink-dark' => ColorRamp::ink($this->warning, ColorRamp::DARK_SURFACE),
'--brand-danger-ink-dark' => ColorRamp::ink($this->danger, ColorRamp::DARK_SURFACE),
'--brand-font-size' => $this->fontSizeBase . 'px',
'--brand-font-ar' => "'{$this->fontAr}'",
'--brand-font-en' => "'{$this->fontEn}'",
......@@ -106,11 +126,23 @@ public function cssVariables(): array
}
$vars["{$prefix}-fg"] = $ramp->foreground;
// The colour text is set in when it has to read as interactive on
// a light surface. Not a shade of the ramp — see ColorRamp.
$vars["{$prefix}-link"] = $ramp->interactive;
$vars["{$prefix}-link-dark"] = $ramp->interactiveOnDark;
}
return $vars;
}
private function readableOn(string $hex): string
{
[$r, $g, $b] = ColorRamp::hexToRgb($hex);
return ColorRamp::readableForeground($r, $g, $b);
}
public function cssVariableBlock(): string
{
$lines = [];
......
......@@ -28,6 +28,8 @@ public function __construct(
public string $base,
public array $shades,
public string $foreground,
public string $interactive,
public string $interactiveOnDark,
) {}
/**
......@@ -81,9 +83,133 @@ public static function fromHex(string $hex): self
base: self::normalizeHex($hex),
shades: $shades,
foreground: self::readableForeground($r, $g, $b),
interactive: self::interactive($l, $c, $h),
interactiveOnDark: self::interactiveOnDark($l, $c, $h),
);
}
/** The darkest surface the product paints, used as the dark-mode reference. */
public const DARK_SURFACE = '#1a1d24';
/**
* The colour a link, an active tab or a chosen segment is drawn in, on a
* light surface.
*
* Shade 600 was doing this job, and it cannot: the ramp is anchored on the
* tenant's own colour, so for a navy at L 0.20 every shade from 500 up sits
* within 0.02 of it and the "link" renders as black text. A member cannot
* tell it apart from a heading. At the other end a yellow tenant's 600 is
* L 0.78 — about 2:1 on white, illegible.
*
* So this is not a shade. It is the brand's hue and chroma placed at a
* lightness that is unmistakably a colour and still passes 4.5:1 against
* the surface it is read on, walked down in small steps until it does.
* A brand that is already in that band comes back unchanged.
*/
private static function interactive(float $l, float $c, float $h): string
{
// The floor is 0.42, not something closer to the body text: --portal-ink
// sits at L 0.22, and a "link" at L 0.36 is a barely lighter shade of
// the paragraph around it. A member has to be able to see that it is a
// different colour, not merely measure that it is.
$lightness = max(0.42, min(0.52, $l));
for ($i = 0; $i < 12; $i++) {
[$r, $g, $b] = self::oklchToRgb($lightness, self::linkChroma($c), $h);
$hex = sprintf('#%02x%02x%02x', $r, $g, $b);
if (self::contrastRatio($hex, '#ffffff') >= 4.5) {
return $hex;
}
$lightness -= 0.02;
}
// Twelve steps down from 0.52 is L 0.28, which is dark enough on white
// for any hue. Reaching here means the conversion is wrong, not the
// colour, and a legible near-black beats an illegible brand colour.
return '#1f2937';
}
/**
* The same colour for a dark surface, walked the other way.
*
* A dark-mode portal painting links in the light-mode value gives a navy
* tenant navy-on-near-black. The reference surface is the darkest card the
* product paints, so a value that passes here passes on every lighter one.
*/
private static function interactiveOnDark(float $l, float $c, float $h): string
{
$lightness = max(0.68, min(0.86, $l));
for ($i = 0; $i < 12; $i++) {
[$r, $g, $b] = self::oklchToRgb($lightness, self::linkChroma($c), $h);
$hex = sprintf('#%02x%02x%02x', $r, $g, $b);
if (self::contrastRatio($hex, self::DARK_SURFACE) >= 4.5) {
return $hex;
}
$lightness += 0.02;
}
return '#e5e7eb';
}
/**
* Chroma is held up at the pale end — dropping a yellow to L 0.45 with its
* own chroma reads as brown, and the point of the colour is that a member
* recognises it as the academy's. A brand that is genuinely achromatic
* (a black or white primary) stays grey rather than being handed a hue it
* never had.
*/
private static function linkChroma(float $c): float
{
return $c < 0.02 ? $c : max($c, 0.06);
}
/**
* A colour made legible *as text* on a given surface, keeping its hue.
*
* The semantic palette is chosen to be seen as a fill: amber #f59e0b is
* about 2:1 on white and red #ef4444 about 3.7:1, so every error message,
* overdue figure and status chip in the product was drawn below AA while
* the colour itself was doing its job perfectly on a filled button. This
* darkens (or, on a dark surface, lightens) until it clears 4.5:1 and stops
* the moment it does — a colour that already passes is returned untouched.
*/
public static function ink(string $hex, string $surface = '#ffffff'): string
{
if (self::contrastRatio($hex, $surface) >= 4.5) {
return self::normalizeHex($hex);
}
[$r, $g, $b] = self::hexToRgb($hex);
[$l, $c, $h] = self::rgbToOklch($r, $g, $b);
[$sr, $sg, $sb] = self::hexToRgb($surface);
$towardsDark = self::relativeLuminance($sr, $sg, $sb) > 0.18;
$step = $towardsDark ? -0.03 : 0.03;
for ($i = 0; $i < 22; $i++) {
$l += $step;
if ($l <= 0.0 || $l >= 1.0) {
break;
}
[$r, $g, $b] = self::oklchToRgb($l, $c, $h);
$candidate = sprintf('#%02x%02x%02x', $r, $g, $b);
if (self::contrastRatio($candidate, $surface) >= 4.5) {
return $candidate;
}
}
return $towardsDark ? '#1f2937' : '#e5e7eb';
}
/**
* WCAG relative luminance, then whichever of black or white gives the
* higher contrast ratio against it. Both candidates are checked rather
......@@ -194,4 +320,36 @@ private static function oklch(float $l, float $c, float $h): string
{
return sprintf('oklch(%.4f %.4f %.2f)', max(0, min(1, $l)), max(0, $c), $h);
}
/**
* OKLCH → OKLab → LMS → linear sRGB → sRGB, the exact inverse of
* rgbToOklch(). Out-of-gamut channels are clamped, which is what a browser
* does with an oklch() it cannot display.
*
* @return array{int, int, int}
*/
public static function oklchToRgb(float $l, float $c, float $h): array
{
$a = $c * cos(deg2rad($h));
$b = $c * sin(deg2rad($h));
$l_ = $l + 0.3963377774 * $a + 0.2158037573 * $b;
$m_ = $l - 0.1055613458 * $a - 0.0638541728 * $b;
$s_ = $l - 0.0894841775 * $a - 1.2914855480 * $b;
$lms = [$l_ ** 3, $m_ ** 3, $s_ ** 3];
$linear = [
4.0767416621 * $lms[0] - 3.3077115913 * $lms[1] + 0.2309699292 * $lms[2],
-1.2684380046 * $lms[0] + 2.6097574011 * $lms[1] - 0.3413193965 * $lms[2],
-0.0041960863 * $lms[0] - 0.7034186147 * $lms[1] + 1.7076147010 * $lms[2],
];
return array_map(function (float $v): int {
$v = max(0.0, min(1.0, $v));
$encoded = $v <= 0.0031308 ? 12.92 * $v : 1.055 * $v ** (1 / 2.4) - 0.055;
return (int) round(max(0.0, min(1.0, $encoded)) * 255);
}, $linear);
}
}
......@@ -6,6 +6,7 @@
use App\Domain\Shared\Branding\ColorRamp;
use App\Domain\Shared\Models\Academy;
use App\Domain\Shared\Models\SystemSetting;
use App\Domain\Website\Models\WebsiteSetting;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
......@@ -98,13 +99,48 @@ public function bump(int $academyId): void
Academy::whereKey($academyId)->increment('branding_version');
}
/**
* Brand colours the public website may speak for when branding has not.
*
* Left is the branding key, right is the website_settings column it
* inherits from. Only identity colours are here: success/warning/danger
* mean the same thing in every tenant and are not a matter of taste, and
* the sidebar chrome is an admin-surface decision the website knows
* nothing about.
*/
private const WEBSITE_INHERITED = [
'primary_color' => 'primary_color',
'secondary_color' => 'secondary_color',
'accent_color' => 'accent_color',
'theme_color' => 'primary_color',
'sidebar_active' => 'primary_color',
];
private function build(Academy $academy, int $version): BrandProfile
{
$settings = $this->loadBrandingGroup($academy->id);
$website = $this->websitePalette($academy->id);
$colors = [];
foreach (self::COLOR_DEFAULTS as $key => $default) {
$colors[$key] = $this->hex($settings["branding.{$key}"] ?? null, $default);
/*
A tenant that themed its public site and never opened the branding
screen was getting our shipped blue everywhere else — the member
portal, the PWA's theme colour, the admin's active state — while
its own site was navy and gold. The branding form pre-fills these
defaults and saves them verbatim, so "still at the default" is
indistinguishable from "never chosen", and treating it as never
chosen is the reading that produces one brand instead of two.
Choosing the default colour deliberately, on a tenant whose website
says otherwise, is the one case this gets wrong; picking any other
colour settles it immediately and permanently.
*/
if ($colors[$key] === $default && isset(self::WEBSITE_INHERITED[$key])) {
$colors[$key] = $website[self::WEBSITE_INHERITED[$key]] ?? $default;
}
}
$ramps = [
......@@ -163,6 +199,43 @@ private function build(Academy $academy, int $version): BrandProfile
);
}
/**
* The public website's palette, or an empty array if it has none.
*
* Wrapped because this runs on the first branded render of a brand-new
* tenant, which may be a container that has not finished migrating: a
* missing table is a reason to fall back to the defaults, not to 500 on
* every page.
*
* @return array<string, string>
*/
private function websitePalette(int $academyId): array
{
try {
$website = WebsiteSetting::withoutGlobalScope('academy')
->where('academy_id', $academyId)
->first(['primary_color', 'secondary_color', 'accent_color']);
} catch (\Throwable) {
return [];
}
if (! $website) {
return [];
}
$palette = [];
foreach (['primary_color', 'secondary_color', 'accent_color'] as $column) {
$hex = $this->hex($website->{$column} ?? null, '');
if ($hex !== '') {
$palette[$column] = $hex;
}
}
return $palette;
}
/** One query for the whole group, replacing one query per field. */
private function loadBrandingGroup(int $academyId): array
{
......
......@@ -3,6 +3,7 @@
namespace App\Domain\Website\Services;
use App\Domain\Shared\Models\Academy;
use App\Domain\Shared\Services\BrandingService;
use App\Domain\Website\Models\WebsiteSetting;
class WebsiteSettingService
......@@ -27,6 +28,15 @@ public function update(Academy $academy, array $data): WebsiteSetting
app(WebsiteCacheService::class)->invalidate($academy->id);
// A tenant sitting on the shipped brand colours inherits the website's
// palette (see BrandingService::WEBSITE_INHERITED), so recolouring the
// site recolours the portal and the admin too — and the brand profile
// is cached until its version changes, which would otherwise leave the
// rest of the product on yesterday's palette indefinitely.
if ($settings->wasChanged(['primary_color', 'secondary_color', 'accent_color'])) {
app(BrandingService::class)->bump($academy->id);
}
return $settings->fresh();
}
......
......@@ -10,3 +10,22 @@ function format_money(int|float|null $piasters, string $currency = 'ج.م'): str
return number_format($piasters / 100, 2) . ' ' . $currency;
}
}
if (!function_exists('money_parts')) {
/**
* The same conversion, split so a template can typeset the two halves apart.
*
* A headline amount wants the figure large and the currency small beside it;
* concatenated into one string the only way to do that is to divide by 100
* in a view, and the division belongs here and nowhere else.
*
* @return array{amount: string, currency: string}
*/
function money_parts(int|float|null $piasters, string $currency = 'ج.م'): array
{
return [
'amount' => number_format(($piasters ?? 0) / 100, 2),
'currency' => $currency,
];
}
}
......@@ -15,12 +15,12 @@
use Livewire\Component;
/**
* Today, the next session, and one action list.
* The balance, the next session, and one action list.
*
* The action list is the point of the screen: مستحقات, مستند منتهي, قسط
* مستحق. A member opens this app to find out whether anything needs doing,
* and everything that does is on one surface rather than spread across five
* tabs waiting to be discovered.
* What is owed is the headline; قسط مستحق and مستند منتهي follow it. A member
* opens this app to find out whether anything needs doing, and everything that
* does is on one surface rather than spread across five tabs waiting to be
* discovered.
*/
#[Layout('layouts.portal')]
#[Title('الرئيسية')]
......@@ -44,18 +44,29 @@ public function render()
? $active->activeEnrollments()->pluck('training_group_id')->all()
: [];
// Money is family-scoped: a guardian asking what is owed is asking
// about the household, not about whichever child is selected. One
// query, read twice — the headline and the tone of it are the same
// fact, and asking the database the same question twice invited them
// to disagree.
$openInvoices = Invoice::whereIn('billable_id', $familyIds)
->where('billable_type', Participant::class)
->whereIn('status', ['sent', 'partially_paid', 'overdue'])
->get(['id', 'status', 'due_amount']);
return view('livewire.portal.portal-home', [
'greeting' => now()->hour < 12 ? __('صباح الخير') : __('مساء الخير'),
'activeChild' => $active,
'nextSession' => $this->nextSession($groupIds),
'attendanceRate' => $this->attendanceRateThisMonth($activeId),
'streak' => $this->currentStreak($activeId),
// Money is family-scoped: a guardian asking what is owed is asking
// about the household, not about whichever child is selected.
'outstanding' => (int) Invoice::whereIn('billable_id', $familyIds)
->where('billable_type', Participant::class)
->whereIn('status', ['sent', 'partially_paid', 'overdue'])
->sum('due_amount'),
'outstanding' => (int) $openInvoices->sum('due_amount'),
'outstandingCount' => $openInvoices->count(),
'duesOverdue' => $openInvoices->contains(
fn (Invoice $invoice) => ($invoice->status instanceof \BackedEnum
? $invoice->status->value
: (string) $invoice->status) === 'overdue'
),
'actions' => $this->actionList($activeId, $familyIds),
]);
}
......@@ -137,24 +148,10 @@ private function actionList(int $activeId, array $familyIds): array
{
$actions = [];
$due = (int) Invoice::whereIn('billable_id', $familyIds)
->where('billable_type', Participant::class)
->whereIn('status', ['sent', 'partially_paid', 'overdue'])
->sum('due_amount');
if ($due > 0) {
$overdue = Invoice::whereIn('billable_id', $familyIds)
->where('billable_type', Participant::class)
->where('status', 'overdue')
->exists();
$actions[] = [
'tone' => $overdue ? 'danger' : 'warning',
'label' => $overdue ? __('مستحقات متأخرة') : __('مستحقات'),
'detail' => format_money($due),
'route' => route('portal.payments'),
];
}
// The outstanding balance is not in this list any more: it is the
// headline of the screen. It was appearing twice — once as an action
// and once as a statistic — with the same number in both, which reads
// as two problems rather than one.
$nextInstallment = Installment::whereHas('paymentPlan.invoice', function ($q) use ($familyIds) {
$q->whereIn('billable_id', $familyIds)->where('billable_type', Participant::class);
......
......@@ -16,6 +16,14 @@ admin screen and builds with app.css.
*/
@import 'tailwindcss' source(none);
/*
The layouts come first and are not optional: every utility that appears only in
the shell — the header, the tab bar, the centred column — lives there and
nowhere else. Leaving them out builds a stylesheet that styles the content and
forgets the frame, which is exactly how a 32px logo renders at its natural size.
*/
@source '../views/layouts/portal.blade.php';
@source '../views/layouts/portal-public.blade.php';
@source '../views/portal/**/*.blade.php';
@source '../views/livewire/portal/**/*.blade.php';
@source '../views/components/portal/**/*.blade.php';
......@@ -112,6 +120,20 @@ per tenant, per request.
.pt-safe { padding-top: env(safe-area-inset-top, 0px); }
.pb-safe { padding-bottom: env(safe-area-inset-bottom, 0px); }
/*
The brand mark is sized in CSS, not in utilities. A tenant uploads whatever
it has — a square crest, a wide wordmark, a 2000px PNG — and the shell has
to survive all three. Height is fixed, width follows the aspect ratio, and
the cap stops a wide wordmark from eating the header.
*/
.portal-logo {
height: 2rem;
width: auto;
max-width: 7.5rem;
object-fit: contain;
object-position: center;
}
.portal-card {
border-radius: var(--radius-card);
background: var(--portal-surface);
......@@ -141,14 +163,24 @@ per tenant, per request.
justify-content: center;
}
/*
A horizontal scroller, and only that.
It used to also pad itself by 1rem and pull itself back out by -1rem — the
bleed trick for a rail sitting inside a padded column. Its one caller is the
profile switcher, which sits in the header with no padding to cancel and
supplies its own, so the negative margins had nothing to subtract from and
made the element 2rem wider than the viewport. That is what was scrolling
every portal screen sideways and cutting the first tab off the tab bar.
Spacing belongs to the element, not to the scroll behaviour.
*/
.rail {
display: flex;
gap: 0.75rem;
overflow-x: auto;
scroll-snap-type: x mandatory;
scrollbar-width: none;
padding-inline: 1rem;
margin-inline: -1rem;
}
.rail::-webkit-scrollbar { display: none; }
......
......@@ -5,7 +5,7 @@
<header class="flex items-center justify-between gap-3 px-4 pt-4">
<h2 class="text-sm font-bold">{{ $title }}</h2>
@if($action)
<a href="{{ $action }}" wire:navigate class="text-xs font-semibold" style="color: var(--brand-600);">
<a href="{{ $action }}" wire:navigate class="text-xs font-semibold" style="color: var(--portal-link);">
{{ $actionLabel ?? __('عرض الكل') }}
</a>
@endif
......
@props(['tone' => 'neutral'])
@php
/*
A status pill: the tone tinted behind, the tone's legible ink in front.
Five screens each built this by hand — the same rounded-full, the same 14%
color-mix, and the same mistake of setting the text to the fill colour.
#f59e0b as 10px text on white is about 2:1; as a wash behind it, it is
exactly right. Two different jobs, two different values.
*/
[$fill, $ink] = match ($tone) {
'success' => ['var(--brand-success)', 'var(--portal-success-ink)'],
'warning' => ['var(--brand-warning)', 'var(--portal-warning-ink)'],
'danger' => ['var(--brand-danger)', 'var(--portal-danger-ink)'],
'brand' => ['var(--brand-500)', 'var(--portal-link)'],
default => ['var(--portal-muted)', 'var(--portal-muted)'],
};
@endphp
<span {{ $attributes->merge(['class' => 'shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold']) }}
style="background: color-mix(in oklab, {{ $fill }} 14%, var(--portal-surface)); color: {{ $ink }};">
{{ $slot }}
</span>
@props(['name', 'width' => '1.6'])
@php
/*
One definition per icon, for the whole portal.
Before this, the tab bar, the header and the home screen each carried their
own copy of the same path data, and the copies drifted: the home screen's
were truncated mid-curve, so a member saw a stray tick where a wallet should
have been. Path data is not something to retype.
Heroicons v2, 24px outline.
*/
$paths = [
'home' => 'M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75',
'training' => 'M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5',
'payments' => 'M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9v3',
'academy' => 'M12 21v-8.25M15.75 21v-8.25M8.25 21v-8.25M3 9l9-6 9 6m-1.5 12V10.332A48.36 48.36 0 0012 9.75c-2.551 0-5.056.2-7.5.582V21M3 21h18M12 6.75h.008v.008H12V6.75z',
'account' => 'M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z',
'pass' => 'M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5zM13.5 14.25h2.25v2.25H13.5zM18 18.75h2.25V21H18zM13.5 18.75h2.25V21H13.5zM18 14.25h2.25v2.25H18z',
'bell' => 'M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0',
'chevron' => 'M8.25 4.5l7.5 7.5-7.5 7.5',
'clock' => 'M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z',
'pin' => 'M15 10.5a3 3 0 11-6 0 3 3 0 016 0z M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z',
'whistle' => 'M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z',
];
$d = $paths[$name] ?? null;
@endphp
@if($d)
<svg {{ $attributes->merge(['class' => 'h-5 w-5']) }} fill="none" stroke="currentColor"
stroke-width="{{ $width }}" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="{{ $d }}"/>
</svg>
@endif
@props(['tone' => 'neutral'])
{{-- The same pairing as x-portal.chip, in paragraph form: a rejection reason,
a cancellation, a note from the office. --}}
@php
[$fill, $ink] = match ($tone) {
'success' => ['var(--brand-success)', 'var(--portal-success-ink)'],
'warning' => ['var(--brand-warning)', 'var(--portal-warning-ink)'],
'danger' => ['var(--brand-danger)', 'var(--portal-danger-ink)'],
default => ['var(--portal-muted)', 'var(--portal-ink)'],
};
@endphp
<p {{ $attributes->merge(['class' => 'rounded-lg px-2 py-1 text-[11px] leading-relaxed']) }}
style="background: color-mix(in oklab, {{ $fill }} 12%, var(--portal-surface)); color: {{ $ink }};">
{{ $slot }}
</p>
......@@ -2,10 +2,10 @@
@php
$color = match ($tone) {
'danger' => 'var(--brand-danger)',
'warning' => 'var(--brand-warning)',
'success' => 'var(--brand-success)',
'brand' => 'var(--brand-600)',
'danger' => 'var(--portal-danger-ink)',
'warning' => 'var(--portal-warning-ink)',
'success' => 'var(--portal-success-ink)',
'brand' => 'var(--portal-link)',
default => 'var(--portal-ink)',
};
@endphp
......
......@@ -9,7 +9,7 @@
aria-selected="{{ $active === $key ? 'true' : 'false' }}"
class="flex-1 rounded-lg px-2 py-2 text-xs font-semibold transition-colors"
style="{{ $active === $key
? 'background: var(--portal-surface); color: var(--brand-700); box-shadow: 0 1px 2px rgba(0,0,0,.06);'
? 'background: var(--portal-surface); color: var(--portal-link); box-shadow: 0 1px 2px rgba(0,0,0,.06);'
: 'color: var(--portal-muted);' }}">
{{ $label }}
</button>
......
......@@ -27,6 +27,10 @@
--portal-border: oklch(0.92 0.004 265);
--portal-ink: oklch(0.22 0.02 265);
--portal-muted: oklch(0.55 0.015 265);
--portal-link: var(--brand-link);
--portal-success-ink: var(--brand-success-ink);
--portal-warning-ink: var(--brand-warning-ink);
--portal-danger-ink: var(--brand-danger-ink);
}
html, body { background: var(--portal-bg); color: var(--portal-ink); }
[x-cloak] { display: none !important; }
......@@ -36,7 +40,7 @@
<div class="mx-auto flex min-h-dvh max-w-lg flex-col px-4 py-8">
<header class="mb-6 text-center">
@if($brand->logoUrl)
<img src="{{ $brand->logoUrl }}" alt="{{ $brand->academyName }}" class="mx-auto h-14 object-contain">
<img src="{{ $brand->logoUrl }}" alt="{{ $brand->academyName }}" class="portal-logo mx-auto !h-16 !max-w-[13rem]">
@endif
<h1 class="mt-3 text-lg font-extrabold">{{ $brand->academyName }}</h1>
</header>
......@@ -53,7 +57,7 @@
</main>
<footer class="mt-6 text-center">
<a href="{{ route('login') }}" class="text-xs font-semibold" style="color: var(--brand-600);">
<a href="{{ route('login') }}" class="text-xs font-semibold" style="color: var(--portal-link);">
{{ __('لديك حساب بالفعل؟ سجّل الدخول') }}
</a>
</footer>
......
......@@ -7,7 +7,6 @@
$dir = in_array($locale, ['ar', 'he', 'fa', 'ur'], true) ? 'rtl' : 'ltr';
$portal = app(\App\Domain\Shared\Context\PortalContext::class);
$profiles = $portal->switchableProfiles();
$activeProfile = $portal->activeParticipant();
@endphp
<!DOCTYPE html>
<html dir="{{ $dir }}" lang="{{ $locale }}" class="h-full"
......@@ -49,6 +48,18 @@
--portal-border: oklch(0.92 0.004 265);
--portal-ink: oklch(0.22 0.02 265);
--portal-muted: oklch(0.55 0.015 265);
/* Interactive text — links, the active tab, a figure that is also
a destination. Not a shade of the ramp: a navy tenant's 600 is
indistinguishable from body text and a yellow tenant's is
illegible. See ColorRamp::interactive(). */
--portal-link: var(--brand-link);
/* The semantic palette as text. The raw --brand-danger/-warning are
fills; used as small text on a light card they sit below AA. */
--portal-success-ink: var(--brand-success-ink);
--portal-warning-ink: var(--brand-warning-ink);
--portal-danger-ink: var(--brand-danger-ink);
}
[data-theme='dark'] {
......@@ -57,6 +68,10 @@
--portal-border: oklch(0.31 0.016 265);
--portal-ink: oklch(0.96 0.004 265);
--portal-muted: oklch(0.70 0.012 265);
--portal-link: var(--brand-link-dark);
--portal-success-ink: var(--brand-success-ink-dark);
--portal-warning-ink: var(--brand-warning-ink-dark);
--portal-danger-ink: var(--brand-danger-ink-dark);
}
html, body {
......@@ -84,7 +99,9 @@
style="background: var(--portal-surface); border-color: var(--portal-border);">
<div class="flex items-center gap-2 px-4 py-2.5">
@if($brand->logoUrl)
<img src="{{ $brand->logoUrl }}" alt="" class="h-8 w-8 rounded-lg object-contain">
{{-- Sized by .portal-logo, not by utilities: the frame must hold
whatever a tenant uploaded, square crest or wide wordmark. --}}
<img src="{{ $brand->logoUrl }}" alt="{{ $brand->academyName }}" class="portal-logo shrink-0">
@else
<span class="grid h-8 w-8 place-items-center rounded-lg text-sm font-bold"
style="background: var(--brand-500); color: var(--brand-fg);">
......@@ -92,13 +109,11 @@
</span>
@endif
{{-- The screen's name, once. The active member used to be repeated
here under it — the profile switcher below already names them,
and every screen's own heading names them again. --}}
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-bold leading-tight">{{ $title ?? $brand->academyName }}</p>
@if($activeProfile)
<p class="truncate text-xs leading-tight" style="color: var(--portal-muted);">
{{ $activeProfile->person?->name_ar ?? $activeProfile->person?->name }}
</p>
@endif
</div>
@if($portal->speaksForAnyone())
......@@ -109,9 +124,7 @@
class="tap-target rounded-xl"
style="color: var(--portal-muted);"
aria-label="{{ __('بطاقة الدخول') }}">
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.6" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5zM13.5 14.25h2.25v2.25H13.5zM18 18.75h2.25V21H18zM13.5 18.75h2.25V21H13.5zM18 14.25h2.25v2.25H18z"/>
</svg>
<x-portal.icon name="pass" class="h-6 w-6" />
</a>
@endif
......@@ -119,12 +132,10 @@ class="tap-target rounded-xl"
class="tap-target relative rounded-xl"
style="color: var(--portal-muted);"
aria-label="{{ __('الإشعارات') }}">
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.6" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0"/>
</svg>
<x-portal.icon name="bell" class="h-6 w-6" />
@if(($unreadNotifications ?? 0) > 0)
<span class="absolute end-1.5 top-1.5 grid h-4 min-w-4 place-items-center rounded-full px-1 text-[10px] font-bold text-white"
style="background: var(--brand-danger);">
<span class="absolute end-1.5 top-1.5 grid h-4 min-w-4 place-items-center rounded-full px-1 text-[10px] font-bold"
style="background: var(--brand-danger); color: var(--brand-danger-fg);">
{{ $unreadNotifications > 9 ? '9+' : $unreadNotifications }}
</span>
@endif
......@@ -164,23 +175,21 @@ class="tap-target relative rounded-xl"
aria-label="{{ __('التنقل الرئيسي') }}">
<ul class="flex items-stretch justify-around gap-1 px-2 pt-1.5">
@foreach([
['portal.home', 'الرئيسية', 'M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75'],
['portal.training', 'التدريب', 'M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5'],
['portal.payments', 'المدفوعات', 'M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9v3'],
['portal.academy', 'الأكاديمية', 'M12 21v-8.25M15.75 21v-8.25M8.25 21v-8.25M3 9l9-6 9 6m-1.5 12V10.332A48.36 48.36 0 0012 9.75c-2.551 0-5.056.2-7.5.582V21M3 21h18M12 6.75h.008v.008H12V6.75z'],
['portal.account', 'حسابي', 'M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z'],
] as [$route, $label, $path])
['portal.home', 'الرئيسية', 'home'],
['portal.training', 'التدريب', 'training'],
['portal.payments', 'المدفوعات', 'payments'],
['portal.academy', 'الأكاديمية', 'academy'],
['portal.account', 'حسابي', 'account'],
] as [$route, $label, $icon])
@php $isActive = request()->routeIs($route . '*'); @endphp
<li class="flex-1">
<a href="{{ route($route) }}" wire:navigate
@class(['flex flex-col items-center gap-1 rounded-xl px-1 py-1.5 transition-colors'])
style="color: {{ $isActive ? 'var(--brand-600)' : 'var(--portal-muted)' }};"
style="color: {{ $isActive ? 'var(--portal-link)' : 'var(--portal-muted)' }};"
@if($isActive) aria-current="page" @endif>
<span class="tap-target !min-h-9">
<svg class="h-6 w-6" fill="none" stroke="currentColor"
stroke-width="{{ $isActive ? '2.1' : '1.6' }}" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="{{ $path }}"/>
</svg>
<x-portal.icon :name="$icon" class="h-6 w-6"
:width="$isActive ? '2.1' : '1.6'" />
</span>
<span class="text-[10px] {{ $isActive ? 'font-bold' : 'font-medium' }}">{{ __($label) }}</span>
</a>
......
......@@ -33,10 +33,7 @@
{{-- Answerable only since event_registrations gained a
participant_id: it linked a person, so "which of my
children is registered" had no answer. --}}
<span class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold"
style="background: color-mix(in oklab, var(--brand-success) 14%, var(--portal-surface)); color: var(--brand-success);">
{{ __('مسجَّل') }}
</span>
<x-portal.chip tone="success">{{ __('مسجَّل') }}</x-portal.chip>
@endif
</div>
<p class="num mt-0.5 text-[11px]" style="color: var(--portal-muted);">
......
......@@ -55,7 +55,7 @@
@php
$expired = $document->expires_at?->isPast();
$soon = ! $expired && $document->expires_at && $document->expires_at->lte(now()->addDays(30));
$tone = $expired ? 'var(--brand-danger)' : ($soon ? 'var(--brand-warning)' : 'var(--brand-success)');
$tone = $expired ? 'danger' : ($soon ? 'warning' : 'success');
@endphp
<article class="portal-card px-4 py-3">
<div class="flex items-start justify-between gap-2">
......@@ -65,13 +65,10 @@
{{ $document->documentable?->person?->name_ar }}
</p>
</div>
<span class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold"
style="background: color-mix(in oklab, {{ $tone }} 14%, var(--portal-surface)); color: {{ $tone }};">
{{ $document->status->label() }}
</span>
<x-portal.chip :tone="$tone">{{ $document->status->label() }}</x-portal.chip>
</div>
@if($document->expires_at)
<p class="num mt-1 text-[11px]" style="color: {{ $tone }};">
<p class="num mt-1 text-[11px]" style="color: var(--portal-{{ $tone }}-ink);">
{{ $expired ? __('انتهت في') : __('تنتهي في') }} {{ $document->expires_at->translatedFormat('j M Y') }}
</p>
@endif
......@@ -93,18 +90,15 @@ class="block rounded-xl px-4 py-3 text-center text-sm font-bold"
@forelse($requests as $request)
@php
$tone = match ($request->status) {
'approved' => 'var(--brand-success)',
'rejected' => 'var(--brand-danger)',
default => 'var(--brand-warning)',
'approved' => 'success',
'rejected' => 'danger',
default => 'warning',
};
@endphp
<article class="portal-card px-4 py-3">
<div class="flex items-start justify-between gap-2">
<p class="min-w-0 truncate text-sm font-bold">{{ $request->type }}</p>
<span class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold"
style="background: color-mix(in oklab, {{ $tone }} 14%, var(--portal-surface)); color: {{ $tone }};">
{{ $request->status }}
</span>
<x-portal.chip :tone="$tone">{{ $request->status }}</x-portal.chip>
</div>
@if($request->participant)
<p class="mt-0.5 truncate text-[11px]" style="color: var(--portal-muted);">
......@@ -115,10 +109,7 @@ class="block rounded-xl px-4 py-3 text-center text-sm font-bold"
<p class="mt-1 text-xs leading-relaxed" style="color: var(--portal-muted);">{{ $request->reason }}</p>
@endif
@if($request->admin_notes)
<p class="mt-2 rounded-lg px-2 py-1 text-[11px]"
style="background: color-mix(in oklab, {{ $tone }} 10%, var(--portal-surface)); color: {{ $tone }};">
{{ $request->admin_notes }}
</p>
<x-portal.note :tone="$tone" class="mt-2">{{ $request->admin_notes }}</x-portal.note>
@endif
<p class="mt-1 text-[11px]" style="color: var(--portal-muted);">{{ $request->created_at?->diffForHumans() }}</p>
</article>
......@@ -160,7 +151,7 @@ class="flex items-center justify-between py-3 text-sm font-semibold">
<li>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" class="w-full py-3 text-start text-sm font-semibold" style="color: var(--brand-danger);">
<button type="submit" class="w-full py-3 text-start text-sm font-semibold" style="color: var(--portal-danger-ink);">
{{ __('تسجيل الخروج') }}
</button>
</form>
......
......@@ -26,7 +26,7 @@ class="mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
<input id="expires" type="date" dir="ltr" wire:model="expiresAt"
class="num mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
style="border-color: var(--portal-border); background: var(--portal-surface);">
@error('expiresAt') <p role="alert" class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror
@error('expiresAt') <p role="alert" class="mt-1 text-[11px]" style="color: var(--portal-danger-ink);">{{ $message }}</p> @enderror
</div>
<div>
......@@ -36,7 +36,7 @@ class="mt-1 block w-full text-xs">
<div wire:loading wire:target="file" class="mt-1 text-[11px]" style="color: var(--portal-muted);">
{{ __('جارٍ الرفع...') }}
</div>
@error('file') <p role="alert" class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror
@error('file') <p role="alert" class="mt-1 text-[11px]" style="color: var(--portal-danger-ink);">{{ $message }}</p> @enderror
</div>
<button type="submit" wire:loading.attr="disabled" wire:target="upload,file"
......@@ -51,7 +51,7 @@ class="w-full rounded-xl px-4 py-3 text-sm font-bold disabled:opacity-60"
@php
$expired = $document->expires_at?->isPast();
$soon = ! $expired && $document->expires_at && $document->expires_at->lte(now()->addDays(30));
$tone = $expired ? 'var(--brand-danger)' : ($soon ? 'var(--brand-warning)' : 'var(--brand-success)');
$tone = $expired ? 'danger' : ($soon ? 'warning' : 'success');
@endphp
<article class="portal-card px-4 py-3">
<div class="flex items-start justify-between gap-2">
......@@ -61,21 +61,15 @@ class="w-full rounded-xl px-4 py-3 text-sm font-bold disabled:opacity-60"
{{ $document->documentable?->person?->name_ar }}
</p>
</div>
<span class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold"
style="background: color-mix(in oklab, {{ $tone }} 14%, var(--portal-surface)); color: {{ $tone }};">
{{ $document->status->label() }}
</span>
<x-portal.chip :tone="$tone">{{ $document->status->label() }}</x-portal.chip>
</div>
@if($document->expires_at)
<p class="num mt-1 text-[11px]" style="color: {{ $tone }};">
<p class="num mt-1 text-[11px]" style="color: var(--portal-{{ $tone }}-ink);">
{{ $expired ? __('انتهت في') : __('تنتهي في') }} {{ $document->expires_at->translatedFormat('j M Y') }}
</p>
@endif
@if($document->rejection_reason)
<p class="mt-2 rounded-lg px-2 py-1 text-[11px]"
style="background: color-mix(in oklab, var(--brand-danger) 10%, var(--portal-surface)); color: var(--brand-danger);">
{{ $document->rejection_reason }}
</p>
<x-portal.note tone="danger" class="mt-2">{{ $document->rejection_reason }}</x-portal.note>
@endif
</article>
@empty
......
......@@ -2,8 +2,8 @@
<x-portal.tabs :tabs="['installments' => __('الأقساط'), 'offers' => __('العروض')]" :active="$tab" />
@error('pay') <div role="alert" class="portal-card px-4 py-3 text-xs" style="color: var(--brand-danger);">{{ $message }}</div> @enderror
@error('offer') <div role="alert" class="portal-card px-4 py-3 text-xs" style="color: var(--brand-danger);">{{ $message }}</div> @enderror
@error('pay') <div role="alert" class="portal-card px-4 py-3 text-xs" style="color: var(--portal-danger-ink);">{{ $message }}</div> @enderror
@error('offer') <div role="alert" class="portal-card px-4 py-3 text-xs" style="color: var(--portal-danger-ink);">{{ $message }}</div> @enderror
@if($tab === 'installments')
@forelse($installments as $installment)
......@@ -19,7 +19,7 @@
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="text-sm font-bold">{{ __('قسط') }} #{{ $installment->sequence }}</p>
<p class="num text-[11px]" style="color: {{ $overdue ? 'var(--brand-danger)' : 'var(--portal-muted)' }};">
<p class="num text-[11px]" style="color: {{ $overdue ? 'var(--portal-danger-ink)' : 'var(--portal-muted)' }};">
{{ $installment->due_date?->translatedFormat('j M Y') }}
@if($overdue) · {{ __('متأخر') }} @endif
</p>
......@@ -44,7 +44,7 @@ class="mt-3 w-full rounded-xl px-4 py-2.5 text-xs font-bold"
@elseif($invoice)
<a href="{{ route('portal.invoice.transfer', $invoice->uuid) }}" wire:navigate
class="mt-3 block rounded-xl border px-4 py-2.5 text-center text-xs font-bold"
style="border-color: var(--portal-border); color: var(--brand-600);">
style="border-color: var(--portal-border); color: var(--portal-link);">
{{ __('تسجيل تحويل') }}
</a>
@endif
......@@ -67,7 +67,7 @@ class="mt-3 block rounded-xl border px-4 py-2.5 text-center text-xs font-bold"
</p>
</div>
<a href="{{ route('portal.invoice', $renewal->uuid) }}" wire:navigate
class="num shrink-0 text-sm font-bold" style="color: var(--brand-600);">
class="num shrink-0 text-sm font-bold" style="color: var(--portal-link);">
{{ format_money((int) $renewal->due_amount) }}
</a>
</li>
......@@ -85,7 +85,7 @@ class="num shrink-0 text-sm font-bold" style="color: var(--brand-600);">
— {{ $offer->participant?->person?->name_ar }}
</p>
@if($offer->expires_at)
<p class="num mt-1 text-[11px]" style="color: var(--brand-warning);">
<p class="num mt-1 text-[11px]" style="color: var(--portal-warning-ink);">
{{ __('ينتهي العرض') }} {{ $offer->expires_at->diffForHumans() }}
</p>
@endif
......
......@@ -5,7 +5,7 @@
{{ $invoice->issue_date?->translatedFormat('j M Y') }} · {{ $invoice->status->label() }}
</p>
<p class="num mt-3 text-3xl font-extrabold leading-none"
style="color: {{ $invoice->due_amount > 0 ? 'var(--brand-danger)' : 'var(--brand-success)' }};">
style="color: {{ $invoice->due_amount > 0 ? 'var(--portal-danger-ink)' : 'var(--portal-success-ink)' }};">
{{ format_money((int) $invoice->due_amount) }}
</p>
<p class="num mt-1 text-[11px]" style="color: var(--portal-muted);">
......
......@@ -3,7 +3,7 @@
<div class="portal-card px-4 py-4">
<p class="num text-sm font-bold">{{ $invoice->number }}</p>
<p class="mt-2 text-xs" style="color: var(--portal-muted);">{{ __('المطلوب سداده') }}</p>
<p class="num text-3xl font-extrabold leading-none" style="color: var(--brand-danger);">
<p class="num text-3xl font-extrabold leading-none" style="color: var(--portal-danger-ink);">
{{ format_money((int) $invoice->due_amount) }}
</p>
</div>
......@@ -28,7 +28,7 @@
</x-portal.card>
@else
<x-portal.card>
<p class="text-xs leading-relaxed" style="color: var(--brand-warning);">
<p class="text-xs leading-relaxed" style="color: var(--portal-warning-ink);">
{{ __('لم تُضِف الأكاديمية بيانات إنستاباي لهذا الفرع بعد. تواصل مع الإدارة قبل التحويل.') }}
</p>
</x-portal.card>
......@@ -44,7 +44,7 @@
<input id="amount" type="text" inputmode="decimal" dir="ltr" wire:model="amount"
class="num mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
style="border-color: var(--portal-border); background: var(--portal-surface);">
@error('amount') <p role="alert" class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror
@error('amount') <p role="alert" class="mt-1 text-[11px]" style="color: var(--portal-danger-ink);">{{ $message }}</p> @enderror
</div>
<div>
......@@ -55,7 +55,7 @@ class="num mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
<p class="mt-1 text-[11px]" style="color: var(--portal-muted);">
{{ __('الرقم الذي يظهر في رسالة التأكيد — به تُطابق الإدارة التحويل مع كشف الحساب') }}
</p>
@error('senderReference') <p role="alert" class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror
@error('senderReference') <p role="alert" class="mt-1 text-[11px]" style="color: var(--portal-danger-ink);">{{ $message }}</p> @enderror
</div>
<div class="grid grid-cols-2 gap-3">
......@@ -70,7 +70,7 @@ class="num mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
<input id="date" type="date" dir="ltr" wire:model="transferredAt"
class="num mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
style="border-color: var(--portal-border); background: var(--portal-surface);">
@error('transferredAt') <p role="alert" class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror
@error('transferredAt') <p role="alert" class="mt-1 text-[11px]" style="color: var(--portal-danger-ink);">{{ $message }}</p> @enderror
</div>
</div>
......@@ -81,7 +81,7 @@ class="mt-1 block w-full text-xs">
<div wire:loading wire:target="proof" class="mt-1 text-[11px]" style="color: var(--portal-muted);">
{{ __('جارٍ رفع الملف...') }}
</div>
@error('proof') <p role="alert" class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror
@error('proof') <p role="alert" class="mt-1 text-[11px]" style="color: var(--portal-danger-ink);">{{ $message }}</p> @enderror
</div>
<button type="submit" wire:loading.attr="disabled" wire:target="submit,proof"
......
......@@ -5,9 +5,14 @@
{{-- Family-scoped on purpose: a household has one balance even when it
has three players, which is why the profile switcher does not
narrow this figure. --}}
<p class="num mt-1 text-3xl font-extrabold leading-none"
style="color: {{ $totalDue > 0 ? 'var(--brand-danger)' : 'var(--brand-success)' }};">
{{ format_money($totalDue) }}
{{-- Figure large, currency small beside it — the same typesetting the
home screen's headline uses, so the two agree on what a balance
looks like. --}}
@php $total = money_parts($totalDue); @endphp
<p class="num mt-1.5 flex items-baseline justify-center gap-1.5 whitespace-nowrap"
style="color: {{ $totalDue > 0 ? 'var(--portal-danger-ink)' : 'var(--portal-success-ink)' }};">
<span class="text-3xl font-extrabold leading-none tracking-tight">{{ $total['amount'] }}</span>
<span class="text-sm font-bold" style="opacity: .75;">{{ $total['currency'] }}</span>
</p>
<p class="mt-1 text-[11px]" style="color: var(--portal-muted);">{{ __('لكل الأعضاء المرتبطين بحسابك') }}</p>
</div>
......@@ -26,7 +31,7 @@
<p class="text-sm font-semibold">
{{ __('قسط') }} #{{ $installment->sequence }}
</p>
<p class="num text-xs" style="color: {{ $overdue ? 'var(--brand-danger)' : 'var(--portal-muted)' }};">
<p class="num text-xs" style="color: {{ $overdue ? 'var(--portal-danger-ink)' : 'var(--portal-muted)' }};">
{{ $installment->due_date?->translatedFormat('j M Y') }}
@if($overdue) · {{ __('متأخر') }} @endif
</p>
......@@ -58,7 +63,7 @@ class="portal-card flex items-center gap-3 px-4 py-3">
@endif
</p>
</div>
<span class="num shrink-0 text-sm font-extrabold" style="color: var(--brand-danger);">
<span class="num shrink-0 text-sm font-extrabold" style="color: var(--portal-danger-ink);">
{{ format_money((int) $invoice->due_amount) }}
</span>
</a>
......@@ -79,7 +84,7 @@ class="portal-card flex items-center gap-3 px-4 py-3">
@foreach($payments as $payment)
<li class="portal-card flex items-center gap-3 px-4 py-3">
<span class="grid h-9 w-9 shrink-0 place-items-center rounded-xl"
style="background: color-mix(in oklab, var(--brand-success) 14%, var(--portal-surface)); color: var(--brand-success);">
style="background: color-mix(in oklab, var(--brand-success) 14%, var(--portal-surface)); color: var(--portal-success-ink);">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/>
</svg>
......@@ -114,7 +119,7 @@ class="portal-card flex items-center gap-3 px-4 py-3">
</p>
<p class="text-xs" style="color: var(--portal-muted);">{{ __('الرصيد المتاح') }}</p>
</div>
<span class="num shrink-0 text-lg font-extrabold" style="color: var(--brand-600);">
<span class="num shrink-0 text-lg font-extrabold" style="color: var(--portal-link);">
{{ format_money((int) $wallet->balance) }}
</span>
</div>
......
......@@ -62,7 +62,7 @@ class="mt-4 w-full rounded-xl px-4 py-2.5 text-sm font-bold"
</p>
</div>
<button type="button" wire:click="forgetDevice({{ $device->id }})"
class="shrink-0 text-xs font-semibold" style="color: var(--brand-danger);">
class="shrink-0 text-xs font-semibold" style="color: var(--portal-danger-ink);">
{{ __('إلغاء') }}
</button>
</div>
......
......@@ -32,7 +32,7 @@ class="peer sr-only">
@endforeach
</div>
@error('consents') <p role="alert" class="mt-3 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror
@error('consents') <p role="alert" class="mt-3 text-[11px]" style="color: var(--portal-danger-ink);">{{ $message }}</p> @enderror
<button type="button" wire:click="saveConsents"
class="mt-4 w-full rounded-xl px-4 py-2.5 text-sm font-bold"
......@@ -47,7 +47,7 @@ class="mt-4 w-full rounded-xl px-4 py-2.5 text-sm font-bold"
</p>
<button type="button" wire:click="export"
class="mt-3 w-full rounded-xl border px-4 py-2.5 text-sm font-bold"
style="border-color: var(--portal-border); color: var(--brand-600);">
style="border-color: var(--portal-border); color: var(--portal-link);">
{{ __('تنزيل بياناتي') }}
</button>
</x-portal.card>
......@@ -55,7 +55,7 @@ class="mt-3 w-full rounded-xl border px-4 py-2.5 text-sm font-bold"
<x-portal.card :title="__('حذف الحساب')">
@if($deletion)
<div class="rounded-xl px-3 py-2.5 text-xs"
style="background: color-mix(in oklab, var(--brand-warning) 12%, var(--portal-surface)); color: var(--brand-warning);">
style="background: color-mix(in oklab, var(--brand-warning) 12%, var(--portal-surface)); color: var(--portal-warning-ink);">
<p class="font-bold">{{ $deletion->statusLabel() }}</p>
@if($deletion->status === 'pending')
<p class="num mt-1">
......@@ -77,7 +77,7 @@ class="mt-3 w-full rounded-xl border px-4 py-2.5 text-sm font-bold"
</p>
@if(count($blockers) > 0)
<div class="mt-3 rounded-xl px-3 py-2.5 text-xs"
style="background: color-mix(in oklab, var(--brand-warning) 12%, var(--portal-surface)); color: var(--brand-warning);">
style="background: color-mix(in oklab, var(--brand-warning) 12%, var(--portal-surface)); color: var(--portal-warning-ink);">
<p class="font-bold">{{ __('لا يمكن الحذف حالياً:') }}</p>
@foreach($blockers as $blocker)
<p class="mt-1">• {{ $blocker }}</p>
......@@ -86,7 +86,7 @@ class="mt-3 w-full rounded-xl border px-4 py-2.5 text-sm font-bold"
@endif
<button type="button" wire:click="$set('confirmingDeletion', true)"
class="mt-3 w-full rounded-xl border px-4 py-2.5 text-sm font-bold"
style="border-color: color-mix(in oklab, var(--brand-danger) 40%, var(--portal-border)); color: var(--brand-danger);">
style="border-color: color-mix(in oklab, var(--brand-danger) 40%, var(--portal-border)); color: var(--portal-danger-ink);">
{{ __('طلب حذف الحساب') }}
</button>
@else
......@@ -104,12 +104,12 @@ class="mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
<input id="delpass" type="password" wire:model="deletionPassword" autocomplete="current-password"
class="mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
style="border-color: var(--portal-border); background: var(--portal-surface);">
@error('deletionPassword') <p role="alert" class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror
@error('deletionPassword') <p role="alert" class="mt-1 text-[11px]" style="color: var(--portal-danger-ink);">{{ $message }}</p> @enderror
</div>
<div class="flex gap-2">
<button type="button" wire:click="requestDeletion"
class="flex-1 rounded-xl px-4 py-2.5 text-sm font-bold text-white"
style="background: var(--brand-danger);">
class="flex-1 rounded-xl px-4 py-2.5 text-sm font-bold"
style="background: var(--brand-danger); color: var(--brand-danger-fg);">
{{ __('تأكيد الطلب') }}
</button>
<button type="button" wire:click="$set('confirmingDeletion', false)"
......
......@@ -30,7 +30,7 @@ class="mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
</option>
@endforeach
</select>
@error('sessionId') <p role="alert" class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror
@error('sessionId') <p role="alert" class="mt-1 text-[11px]" style="color: var(--portal-danger-ink);">{{ $message }}</p> @enderror
<p class="mt-1 text-[11px]" style="color: var(--portal-muted);">
{{ __('تُقبل الأعذار حتى ٧ أيام بعد الحصة.') }}
</p>
......@@ -56,7 +56,7 @@ class="mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
<textarea id="reason" rows="3" wire:model="reason"
class="mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
style="border-color: var(--portal-border); background: var(--portal-surface);"></textarea>
@error('reason') <p role="alert" class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror
@error('reason') <p role="alert" class="mt-1 text-[11px]" style="color: var(--portal-danger-ink);">{{ $message }}</p> @enderror
</div>
<div>
......@@ -71,7 +71,7 @@ class="mt-1 block w-full text-xs">
<div wire:loading wire:target="attachment" class="mt-1 text-[11px]" style="color: var(--portal-muted);">
{{ __('جارٍ الرفع...') }}
</div>
@error('attachment') <p role="alert" class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror
@error('attachment') <p role="alert" class="mt-1 text-[11px]" style="color: var(--portal-danger-ink);">{{ $message }}</p> @enderror
</div>
<button type="submit" wire:loading.attr="disabled" wire:target="submit,attachment"
......@@ -87,7 +87,7 @@ class="w-full rounded-xl px-4 py-3 text-sm font-bold disabled:opacity-60"
<p class="text-xs" style="color: var(--portal-muted);">
{{ __('لديك') }} {{ $open }} {{ __('طلب قيد المراجعة.') }}
<a href="{{ route('portal.account', ['tab' => 'requests']) }}" wire:navigate
class="font-semibold" style="color: var(--brand-600);">{{ __('عرض طلباتي') }}</a>
class="font-semibold" style="color: var(--portal-link);">{{ __('عرض طلباتي') }}</a>
</p>
</x-portal.card>
@endif
......
......@@ -8,7 +8,7 @@
<div class="h-1 rounded-full"
style="background: {{ $step >= $n ? 'var(--brand-500)' : 'var(--portal-border)' }};"></div>
<p class="mt-1 text-center text-[10px]"
style="color: {{ $step >= $n ? 'var(--brand-600)' : 'var(--portal-muted)' }};">{{ __($label) }}</p>
style="color: {{ $step >= $n ? 'var(--portal-link)' : 'var(--portal-muted)' }};">{{ __($label) }}</p>
</li>
@endforeach
</ol>
......@@ -28,7 +28,7 @@
@if($inputDir) dir="{{ $inputDir }}" @endif
class="mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
style="border-color: var(--portal-border); background: var(--portal-surface);">
@error($field) <p role="alert" class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror
@error($field) <p role="alert" class="mt-1 text-[11px]" style="color: var(--portal-danger-ink);">{{ $message }}</p> @enderror
</div>
@endforeach
......@@ -46,7 +46,7 @@ class="mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
style="border-color: var(--portal-border); background: var(--portal-surface);">
</div>
</div>
@error('password') <p role="alert" class="text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror
@error('password') <p role="alert" class="text-[11px]" style="color: var(--portal-danger-ink);">{{ $message }}</p> @enderror
<button type="submit" wire:loading.attr="disabled"
class="w-full rounded-xl px-4 py-3 text-sm font-bold disabled:opacity-60"
......@@ -69,7 +69,7 @@ class="w-full rounded-xl px-4 py-3 text-sm font-bold disabled:opacity-60"
autocomplete="one-time-code"
class="num mt-1 w-full rounded-xl border px-3 py-2.5 text-center text-lg tracking-[0.4em]"
style="border-color: var(--portal-border); background: var(--portal-surface);">
@error('code') <p role="alert" class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror
@error('code') <p role="alert" class="mt-1 text-[11px]" style="color: var(--portal-danger-ink);">{{ $message }}</p> @enderror
</div>
<button type="submit" class="w-full rounded-xl px-4 py-3 text-sm font-bold"
style="background: var(--brand-500); color: var(--brand-fg);">
......@@ -86,7 +86,7 @@ class="num mt-1 w-full rounded-xl border px-3 py-2.5 text-center text-lg trackin
<input id="childName" type="text" wire:model="childName"
class="mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
style="border-color: var(--portal-border); background: var(--portal-surface);">
@error('childName') <p role="alert" class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror
@error('childName') <p role="alert" class="mt-1 text-[11px]" style="color: var(--portal-danger-ink);">{{ $message }}</p> @enderror
</div>
<div class="grid grid-cols-2 gap-3">
......@@ -95,7 +95,7 @@ class="mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
<input id="dob" type="date" dir="ltr" wire:model="childDateOfBirth"
class="num mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
style="border-color: var(--portal-border); background: var(--portal-surface);">
@error('childDateOfBirth') <p role="alert" class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror
@error('childDateOfBirth') <p role="alert" class="mt-1 text-[11px]" style="color: var(--portal-danger-ink);">{{ $message }}</p> @enderror
</div>
<div>
<label for="gender" class="block text-xs font-semibold">{{ __('النوع') }}</label>
......@@ -106,7 +106,7 @@ class="mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
<option value="male">{{ __('ذكر') }}</option>
<option value="female">{{ __('أنثى') }}</option>
</select>
@error('childGender') <p role="alert" class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror
@error('childGender') <p role="alert" class="mt-1 text-[11px]" style="color: var(--portal-danger-ink);">{{ $message }}</p> @enderror
</div>
</div>
......@@ -142,7 +142,7 @@ class="mt-1 h-4 w-4 shrink-0 rounded" style="accent-color: var(--brand-500);">
<span class="text-sm font-semibold">
{{ $type->label() }}
@if($type->isRequired())
<span class="text-[10px] font-normal" style="color: var(--brand-danger);">— {{ __('مطلوب') }}</span>
<span class="text-[10px] font-normal" style="color: var(--portal-danger-ink);">— {{ __('مطلوب') }}</span>
@endif
</span>
<span class="mt-0.5 block text-[11px] leading-relaxed" style="color: var(--portal-muted);">
......@@ -152,8 +152,8 @@ class="mt-1 h-4 w-4 shrink-0 rounded" style="accent-color: var(--brand-500);">
</label>
@endforeach
@error('consents') <p role="alert" class="text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror
@error('phone') <p role="alert" class="text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror
@error('consents') <p role="alert" class="text-[11px]" style="color: var(--portal-danger-ink);">{{ $message }}</p> @enderror
@error('phone') <p role="alert" class="text-[11px]" style="color: var(--portal-danger-ink);">{{ $message }}</p> @enderror
<p class="rounded-xl px-3 py-2 text-[11px] leading-relaxed"
style="background: color-mix(in oklab, var(--brand-500) 8%, var(--portal-surface)); color: var(--portal-muted);">
......
......@@ -18,20 +18,26 @@
$cancelled = ($session->status instanceof \BackedEnum ? $session->status->value : $session->status) === 'cancelled';
[$tone, $statusLabel] = match (true) {
$cancelled => ['var(--brand-warning)', __('ملغاة')],
$statusValue === 'present' => ['var(--brand-success)', __('حاضر')],
$statusValue === 'late' => ['var(--brand-warning)', __('متأخر')],
$statusValue === 'excused' => ['var(--portal-muted)', __('معذور')],
in_array($statusValue, ['absent', 'no_show'], true) => ['var(--brand-danger)', __('غائب')],
$cancelled => ['warning', __('ملغاة')],
$statusValue === 'present' => ['success', __('حاضر')],
$statusValue === 'late' => ['warning', __('متأخر')],
$statusValue === 'excused' => ['neutral', __('معذور')],
in_array($statusValue, ['absent', 'no_show'], true) => ['danger', __('غائب')],
default => [null, null],
};
@endphp
<li class="portal-card px-4 py-3">
<div class="flex items-start gap-3">
<div class="shrink-0 text-center">
{{-- A fixed column, not an intrinsic one: "سبتمبر" is
three times the width of "مايو", and without a
width the whole row shifts from one month to the
next down a single list. --}}
<div class="w-12 shrink-0 text-center">
<p class="num text-lg font-extrabold leading-none">{{ $session->session_date?->format('j') }}</p>
<p class="text-[10px]" style="color: var(--portal-muted);">{{ $session->session_date?->translatedFormat('M') }}</p>
<p class="mt-0.5 text-[10px] leading-tight" style="color: var(--portal-muted);">
{{ $session->session_date?->translatedFormat('M') }}
</p>
</div>
<div class="min-w-0 flex-1">
......@@ -40,10 +46,7 @@
{{ $session->topic ?: ($session->group?->name_ar ?? __('حصة تدريب')) }}
</p>
@if($statusLabel)
<span class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold"
style="background: color-mix(in oklab, {{ $tone }} 14%, var(--portal-surface)); color: {{ $tone }};">
{{ $statusLabel }}
</span>
<x-portal.chip :tone="$tone">{{ $statusLabel }}</x-portal.chip>
@endif
</div>
......@@ -58,7 +61,7 @@
@if($session->trainer)
<p class="mt-1 text-xs" style="color: var(--portal-muted);">
{{ __('المدرب') }}:
<span class="font-medium" style="color: var(--brand-600);">
<span class="font-medium" style="color: var(--portal-link);">
{{ $session->trainer->person?->name_ar ?? $session->trainer->person?->name }}
</span>
@if($session->substitute_reason)
......@@ -70,10 +73,9 @@
@endif
@if($cancelled)
<p class="mt-1 rounded-lg px-2 py-1 text-[11px]"
style="background: color-mix(in oklab, var(--brand-warning) 12%, var(--portal-surface)); color: var(--brand-warning);">
<x-portal.note tone="warning" class="mt-1">
{{ $session->cancelled_reason ?: __('أُلغيت هذه الحصة') }}
</p>
</x-portal.note>
@endif
@if($session->objectives)
......@@ -110,7 +112,7 @@ class="mt-2 inline-block rounded-lg px-3 py-1.5 text-[11px] font-bold"
{{ $evaluation->evaluation_date?->translatedFormat('j M Y') }}
</p>
</div>
<span class="num shrink-0 text-lg font-extrabold" style="color: var(--brand-600);">
<span class="num shrink-0 text-lg font-extrabold" style="color: var(--portal-link);">
{{ $evaluation->overall_score }}
</span>
</li>
......
......@@ -18,7 +18,7 @@
: 'background: var(--brand-100); color: var(--brand-700);' }}">
{{ mb_substr($name, 0, 1) }}
</span>
<span class="max-w-28 truncate">{{ $name }}</span>
<span class="max-w-24 truncate">{{ $name }}</span>
</button>
@endforeach
</div>
<?php
namespace Tests\Feature;
use App\Domain\Shared\Branding\ColorRamp;
use Tests\TestCase;
/**
* The portal is rendered in whatever colour a tenant picked, so its legibility
* cannot be checked by looking at one screen — it has to hold for the whole
* range of colours a tenant can pick.
*
* These are the brands that used to break it: a navy so dark that every shade
* from 500 up collapsed onto the text colour, a yellow so light that the same
* shades disappeared into the page, and the semantic amber, which is perfect as
* a fill and about 2:1 as text.
*/
class PortalBrandContrastTest extends TestCase
{
private const BRANDS = [
'oc sport navy' => '#0c1236',
'gold' => '#fbcf0a',
'shipped blue' => '#2563eb',
'green' => '#10b981',
'red' => '#ef4444',
'amber' => '#f59e0b',
'near black' => '#000000',
'near white' => '#ffffff',
];
public function test_the_interactive_colour_is_legible_on_both_surfaces(): void
{
foreach (self::BRANDS as $name => $hex) {
$ramp = ColorRamp::fromHex($hex);
$this->assertGreaterThanOrEqual(
4.5,
ColorRamp::contrastRatio($ramp->interactive, '#ffffff'),
"{$name}: link colour {$ramp->interactive} is not readable on a light card",
);
$this->assertGreaterThanOrEqual(
4.5,
ColorRamp::contrastRatio($ramp->interactiveOnDark, ColorRamp::DARK_SURFACE),
"{$name}: dark-mode link colour {$ramp->interactiveOnDark} is not readable on a dark card",
);
}
}
/**
* A link that passes contrast by being black is not a link. The point of
* the colour is that a member can tell it apart from the paragraph it sits
* in, which is what shade 600 could not do for a dark brand.
*/
public function test_the_interactive_colour_is_distinguishable_from_body_text(): void
{
// --portal-ink, oklch(0.22 0.02 265).
$bodyInk = '#161b24';
foreach (['oc sport navy' => '#0c1236', 'shipped blue' => '#2563eb'] as $name => $hex) {
$ramp = ColorRamp::fromHex($hex);
$this->assertGreaterThan(
1.6,
ColorRamp::contrastRatio($ramp->interactive, $bodyInk),
"{$name}: link colour {$ramp->interactive} is indistinguishable from body text",
);
}
}
public function test_semantic_colours_are_legible_as_text_and_keep_their_fills(): void
{
foreach (['#10b981', '#f59e0b', '#ef4444'] as $hex) {
$this->assertGreaterThanOrEqual(
4.5,
ColorRamp::contrastRatio(ColorRamp::ink($hex), '#ffffff'),
"{$hex} as text on a light card",
);
$this->assertGreaterThanOrEqual(
4.5,
ColorRamp::contrastRatio(ColorRamp::ink($hex, ColorRamp::DARK_SURFACE), ColorRamp::DARK_SURFACE),
"{$hex} as text on a dark card",
);
// …and the same colour filled, with whatever foreground it was
// given. White on amber was the failure this pins.
[$r, $g, $b] = ColorRamp::hexToRgb($hex);
$this->assertGreaterThanOrEqual(
4.5,
ColorRamp::contrastRatio(ColorRamp::readableForeground($r, $g, $b), $hex),
"text drawn on a filled {$hex}",
);
}
}
/**
* portal.css is built with `source(none)`, so a template that is not named
* in an @source line contributes no utilities at all. The layouts were not
* named: the header, the tab bar and the centred column silently lost every
* class they alone used, which is how a 32px logo rendered at 500px and
* pushed every screen sideways.
*/
public function test_the_portal_stylesheet_scans_its_own_layouts(): void
{
$css = file_get_contents(resource_path('css/portal.css'));
$this->assertStringContainsString('source(none)', $css, 'the premise of this test changed');
foreach (['layouts/portal.blade.php', 'layouts/portal-public.blade.php'] as $layout) {
$this->assertStringContainsString(
"@source '../views/{$layout}'",
$css,
"{$layout} is rendered by the portal but is not scanned for utilities",
);
}
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment