Skip to content

Commit 65890fb

Browse files
markcclaude
andcommitted
Refactor 04-Themes to App Shell pattern with dual sidebars
Major architectural simplification replacing 3 layout styles with a unified App Shell pattern featuring pinnable LHS/RHS sidebars. PHP Changes (04-Themes/public/index.php): - 209 lines / 8KB (was 287 lines / 13KB) - 27% smaller - Single render() method replaces Simple/TopNav/SideBar methods - DRY sidebar($side) generates both LHS and RHS sidebars - Removed themes array (no layout switching needed) - Kept colors array for color scheme selection CSS Changes (docs/base.css): - Replaced 200+ lines of old sidebar code with ~90 lines - New classes: .sidebar-left, .sidebar-right, .sidebar-header, .pin-toggle, .overlay - Transform-based sidebar animations (better performance) - Simplified responsive breakpoints to 768px and 1280px - Mobile (0-767px): Full width, sidebars flyover on demand - Tablet (768px+): 1rem side padding - Desktop (1280px+): 2rem padding, pin toggles enabled JS Changes (docs/base.js): - toggleSidebar(side) - handles left/right sidebar toggle - pinSidebar(side) - pin/unpin with localStorage persistence - closeSidebars() - close all non-pinned sidebars - initSidebars() - restore pinned state on load - Overlay click and Escape key close non-pinned sidebars Documentation (CLAUDE.md): - Updated breakpoints: Mobile (0-767px), Tablet (768px+), Desktop (1280px+) - Removed references to 4+ breakpoint system Benefits: - One UX pattern to learn vs 3 layout styles - Maximum content width (sidebars overlay, don't squeeze) - Power users can pin sidebars on desktop (1280px+) - Simpler codebase, easier to maintain - Framework-portable pattern (works in any stack) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 16d5e0f commit 65890fb

File tree

4 files changed

+544
-567
lines changed

4 files changed

+544
-567
lines changed

04-Themes/public/index.php

Lines changed: 108 additions & 187 deletions
Original file line numberDiff line numberDiff line change
@@ -1,112 +1,103 @@
11
<?php declare(strict_types=1);
22
// Copyright (C) 2015-2026 Mark Constable <mc@netserva.org> (MIT License)
3+
// 04-Themes: App Shell with dual sidebars (LHS navigation, RHS settings)
34

45
if (!class_exists('Ctx')) {
56
readonly class Ctx {
67
public array $in;
78
public function __construct(
89
public string $email = 'mc@netserva.org',
9-
array $in = ['o' => 'Home', 'm' => 'list', 't' => 'Simple', 'x' => ''],
10+
array $in = ['o' => 'Home', 'm' => 'list', 'x' => ''],
1011
public array $out = ['doc' => 'SPE::04', 'head' => '', 'main' => '', 'foot' => ''],
11-
public array $nav = [['🏠 Home', 'Home'], ['📖 About', 'About'], ['✉️ Contact', 'Contact']],
12-
public array $themes = [['🎨 Simple', 'Simple'], ['🎨 TopNav', 'TopNav'], ['🎨 SideBar', 'SideBar']]
12+
public array $nav = [
13+
['home', 'Home', 'Home'],
14+
['book-open','About', 'About'],
15+
['mail', 'Contact', 'Contact'],
16+
],
17+
public array $colors = [
18+
['circle', 'Stone', 'default'],
19+
['waves', 'Ocean', 'ocean'],
20+
['trees', 'Forest', 'forest'],
21+
['sunset', 'Sunset', 'sunset'],
22+
]
1323
) {
14-
$this->in = array_map(static fn($k, $v) => (isset($_GET[$k]) && is_string($_GET[$k]) ? $_GET[$k] : $v)
15-
|> trim(...)
16-
|> htmlspecialchars(...), array_keys($in), $in)
24+
$this->in = array_map(static fn($k, $v) => $_GET[$k] ?? $v
25+
|> trim(...) |> htmlspecialchars(...), array_keys($in), $in)
1726
|> (static fn($v) => array_combine(array_keys($in), $v));
1827
}
1928
}
2029

2130
readonly class Init {
2231
private array $out;
23-
2432
public function __construct(private Ctx $ctx) {
25-
[$o, $m, $t] = [$ctx->in['o'], $ctx->in['m'], $ctx->in['t']];
33+
[$o, $m] = [$ctx->in['o'], $ctx->in['m']];
2634
$model = "{$o}Model";
2735
$ary = class_exists($model) ? (new $model($ctx))->$m() : [];
2836
$view = "{$o}View";
2937
$main = class_exists($view) ? (new $view($ctx, $ary))->$m() : "<p>{$ary['main']}</p>";
3038
$this->out = [...$ctx->out, ...$ary, 'main' => $main];
3139
}
32-
3340
public function __toString(): string {
3441
return match ($this->ctx->in['x']) {
3542
'json' => (header('Content-Type: application/json') ? '' : '') . json_encode($this->out),
36-
default => (new Theme($this->ctx, $this->out))->{$this->ctx->in['t']}()
43+
default => (new Theme($this->ctx, $this->out))->render()
3744
};
3845
}
3946
}
4047

4148
abstract class Plugin {
4249
public function __construct(protected Ctx $ctx) {}
4350
public function create(): array { return ['head' => 'Create', 'main' => 'Not implemented']; }
44-
public function read(): array { return ['head' => 'Read', 'main' => 'Not implemented']; }
51+
public function read(): array { return ['head' => 'Read', 'main' => 'Not implemented']; }
4552
public function update(): array { return ['head' => 'Update', 'main' => 'Not implemented']; }
4653
public function delete(): array { return ['head' => 'Delete', 'main' => 'Not implemented']; }
47-
public function list(): array { return ['head' => 'List', 'main' => 'Not implemented']; }
54+
public function list(): array { return ['head' => 'List', 'main' => 'Not implemented']; }
4855
}
4956

5057
final class HomeModel extends Plugin {
5158
#[\Override] public function list(): array {
52-
return ['head' => 'Theme Layouts', 'main' => 'This chapter introduces <b>Model/View separation</b> and three switchable theme layouts.'];
59+
return ['head' => 'App Shell', 'main' => 'Dual sidebar interface with <b>LHS navigation</b> and <b>RHS settings</b>.'];
5360
}
5461
}
55-
5662
final class AboutModel extends Plugin {
5763
#[\Override] public function list(): array {
58-
return ['head' => 'About Page', 'main' => 'This chapter adds <b>Model/View separation</b> and switchable theme layouts.'];
64+
return ['head' => 'About', 'main' => 'This chapter introduces the <b>App Shell</b> pattern with pinnable sidebars.'];
5965
}
6066
}
61-
6267
final class ContactModel extends Plugin {
6368
#[\Override] public function list(): array {
64-
return ['head' => 'Contact Page', 'main' => 'Get in touch using the <b>email form</b> below.'];
69+
return ['head' => 'Contact', 'main' => 'Get in touch using the <b>email form</b> below.'];
6570
}
6671
}
6772

6873
class View {
6974
public function __construct(protected Ctx $ctx, protected array $ary) {}
70-
7175
public function list(): string {
72-
return <<<HTML
73-
<div class="card">
74-
<h2>{$this->ary['head']}</h2>
75-
<p>{$this->ary['main']}</p>
76-
</div>
77-
HTML;
76+
return "<div class=\"card\"><h2>{$this->ary['head']}</h2><p>{$this->ary['main']}</p></div>";
7877
}
7978
}
8079

8180
final class HomeView extends View {
8281
#[\Override] public function list(): string {
8382
return <<<HTML
84-
<div class="card">
85-
<h2>{$this->ary['head']}</h2>
86-
<p>{$this->ary['main']}</p>
87-
88-
<h3 class="mt-4">🎨 Simple</h3>
89-
<p>A clean, centered layout with the navigation in a card below the header. Best for simple sites with minimal navigation. The header, nav card, content, and footer stack vertically in a single column container.</p>
90-
91-
<h3 class="mt-4">🎨 TopNav</h3>
92-
<p>A fixed navigation bar at the top of the viewport with centered navigation links and a theme dropdown. The content area has extra top margin to account for the fixed navbar. Ideal for sites that need persistent navigation while scrolling.</p>
93-
94-
<h3 class="mt-4">🎨 SideBar</h3>
95-
<p>A two-column layout with a collapsible sidebar. Click the « toggle at the bottom to collapse to icons-only mode with hover tooltips (state persists via localStorage). Groups can be collapsed by clicking their titles. On mobile, the ☰ button toggles sidebar visibility. Best for admin dashboards and applications with many navigation items.</p>
96-
97-
<h3 class="mt-4">What's New in This Chapter?</h3>
98-
<ul class="mt-2" style="list-style:disc;padding-left:1.5rem">
99-
<li><b>Model/View Separation</b> — Models return data arrays, Views render HTML from that data</li>
100-
<li><b>Theme Class</b> — A single Theme class with methods for each layout (Simple, TopNav, SideBar)</li>
101-
<li><b>URL Parameter</b> — Use <code>?t=</code> to switch themes: <a href="?t=Simple">Simple</a>, <a href="?t=TopNav">TopNav</a>, <a href="?t=SideBar">SideBar</a></li>
102-
<li><b>Dropdown Component</b> — Reusable dropdown menu for theme selection</li>
103-
</ul>
104-
</div>
105-
<div class="flex justify-center mt-4">
106-
<button class="btn-hover btn-success" onclick="showToast('Success!', 'success')">Success</button>
107-
<button class="btn-hover btn-danger" onclick="showToast('Error!', 'danger')">Danger</button>
108-
</div>
109-
HTML;
83+
<div class="card">
84+
<h2>{$this->ary['head']}</h2>
85+
<p>{$this->ary['main']}</p>
86+
<h3>Features</h3>
87+
<ul style="list-style:disc;padding-left:1.5rem;margin-top:0.5rem">
88+
<li><b>LHS Sidebar</b> - Navigation (pages, posts)</li>
89+
<li><b>RHS Sidebar</b> - Settings (theme, colors)</li>
90+
<li><b>Flyover</b> - Sidebars overlay content by default</li>
91+
<li><b>Pin</b> - Lock sidebars open on desktop (1280px+)</li>
92+
</ul>
93+
<h3>Breakpoints</h3>
94+
<ul style="list-style:disc;padding-left:1.5rem;margin-top:0.5rem">
95+
<li><b>Mobile</b> (0-767px) - Full width content</li>
96+
<li><b>Tablet</b> (768-1279px) - 1rem side padding</li>
97+
<li><b>Desktop</b> (1280px+) - 2rem padding, pin enabled</li>
98+
</ul>
99+
</div>
100+
HTML;
110101
}
111102
}
112103

@@ -115,173 +106,103 @@ final class AboutView extends View {}
115106
final class ContactView extends View {
116107
#[\Override] public function list(): string {
117108
return <<<HTML
118-
<div class="card">
119-
<h2>{$this->ary['head']}</h2>
120-
<p>{$this->ary['main']}</p>
121-
<form class="mt-2" onsubmit="return handleContact(this)">
122-
<div class="form-group"><label for="subject">Subject</label><input type="text" id="subject" name="subject" required></div>
123-
<div class="form-group"><label for="message">Message</label><textarea id="message" name="message" rows="4" required></textarea></div>
124-
<div class="text-right"><button type="submit" class="btn">Send Message</button></div>
125-
</form>
126-
</div>
127-
<script>
128-
function handleContact(form) {
129-
location.href = 'mailto:{$this->ctx->email}?subject=' + encodeURIComponent(form.subject.value) + '&body=' + encodeURIComponent(form.message.value);
130-
showToast('Opening email client...', 'success');
131-
return false;
132-
}
133-
</script>
134-
HTML;
109+
<div class="card">
110+
<h2>{$this->ary['head']}</h2>
111+
<p>{$this->ary['main']}</p>
112+
<form class="mt-2" onsubmit="return handleContact(this)">
113+
<div class="form-group"><label for="subject">Subject</label><input type="text" id="subject" name="subject" required></div>
114+
<div class="form-group"><label for="message">Message</label><textarea id="message" name="message" rows="4" required></textarea></div>
115+
<div class="text-right"><button type="submit" class="btn">Send Message</button></div>
116+
</form>
117+
</div>
118+
<script>
119+
function handleContact(form) {
120+
location.href = 'mailto:{$this->ctx->email}?subject=' + encodeURIComponent(form.subject.value) + '&body=' + encodeURIComponent(form.message.value);
121+
showToast('Opening email client...', 'success');
122+
return false;
123+
}
124+
</script>
125+
HTML;
135126
}
136127
}
137128

138129
final class Theme {
139130
public function __construct(private Ctx $ctx, private array $out) {}
140131

141-
private function nav(): string {
142-
['o' => $o, 't' => $t] = $this->ctx->in;
143-
return $this->ctx->nav
144-
|> (static fn($n) => array_map(static fn($p) => sprintf(
145-
'<a href="?o=%s&t=%s"%s>%s</a>',
146-
$p[1], $t, $o === $p[1] ? ' class="active"' : '', $p[0]
147-
), $n))
148-
|> (static fn($a) => implode(' ', $a));
132+
public function render(): string {
133+
$body = $this->topnav() . $this->sidebar('left') . $this->sidebar('right') . $this->main();
134+
return $this->html($body);
135+
}
136+
137+
private function links(array $items, string $param, string $current): string {
138+
$o = $this->ctx->in['o'];
139+
return implode('', array_map(fn($p) => sprintf(
140+
'<a href="?o=%s"%s data-icon="%s"><i data-lucide="%s"></i> %s</a>',
141+
$param === 'o' ? $p[2] : $o, $current === $p[2] ? ' class="active"' : '', $p[0], $p[0], $p[1]
142+
), $items));
149143
}
150144

151-
private function dropdown(): string {
152-
['o' => $o, 't' => $t] = $this->ctx->in;
153-
$links = $this->ctx->themes
154-
|> (static fn($n) => array_map(static fn($p) => sprintf(
155-
'<a href="?o=%s&t=%s"%s>%s</a>',
156-
$o, $p[1], $t === $p[1] ? ' class="active"' : '', $p[0]
157-
), $n))
158-
|> (static fn($a) => implode('', $a));
159-
return "<div class=\"dropdown\"><span class=\"dropdown-toggle\">📐 Layout</span><div class=\"dropdown-menu\">{$links}</div></div>";
145+
private function colorLinks(): string {
146+
return implode('', array_map(fn($p) => sprintf(
147+
'<a href="#" data-scheme="%s" data-icon="%s"><i data-lucide="%s"></i> %s</a>',
148+
$p[2], $p[0], $p[0], $p[1]
149+
), $this->ctx->colors));
160150
}
161151

162-
private function colors(): string {
152+
private function topnav(): string {
163153
return <<<HTML
164-
<div class="dropdown"><span class="dropdown-toggle">🌈 Colors</span><div class="dropdown-menu">
165-
<a href="#" data-scheme="default">🪨 Stone</a>
166-
<a href="#" data-scheme="ocean">🌊 Ocean</a>
167-
<a href="#" data-scheme="forest">🌲 Forest</a>
168-
<a href="#" data-scheme="sunset">🌅 Sunset</a>
169-
</div></div>
154+
<nav class="topnav">
155+
<button class="menu-toggle" data-sidebar="left"><i data-lucide="menu"></i></button>
156+
<h1><a class="brand" href="/"><span>{$this->out['doc']}</span></a></h1>
157+
<button class="menu-toggle" data-sidebar="right"><i data-lucide="settings"></i></button>
158+
</nav>
170159
HTML;
171160
}
172161

173-
private function html(string $theme, string $body): string {
162+
private function sidebar(string $side): string {
163+
$nav = $side === 'left'
164+
? $this->links($this->ctx->nav, 'o', $this->ctx->in['o'])
165+
: '<a href="#" onclick="Base.toggleTheme();return false" data-icon="moon"><i data-lucide="moon"></i> Toggle Theme</a>'
166+
. '<div class="sidebar-divider"></div>' . $this->colorLinks();
167+
$title = $side === 'left' ? 'Navigation' : 'Settings';
168+
$icon = $side === 'left' ? 'compass' : 'sliders-horizontal';
169+
return <<<HTML
170+
<aside class="sidebar sidebar-{$side}">
171+
<div class="sidebar-header">
172+
<span><i data-lucide="{$icon}"></i> {$title}</span>
173+
<button class="pin-toggle" data-sidebar="{$side}" title="Pin sidebar"><i data-lucide="pin"></i></button>
174+
</div>
175+
<nav>{$nav}</nav>
176+
</aside>
177+
HTML;
178+
}
179+
180+
private function main(): string {
181+
return "<main>{$this->out['main']}</main>";
182+
}
183+
184+
private function html(string $body): string {
185+
$doc = $this->out['doc'];
174186
return <<<HTML
175187
<!DOCTYPE html>
176188
<html lang="en">
177189
<head>
178190
<meta charset="utf-8">
179191
<meta name="viewport" content="width=device-width, initial-scale=1">
180-
<title>{$this->out['doc']} [{$theme}]</title>
192+
<title>{$doc}</title>
181193
<link rel="stylesheet" href="/base.css">
182194
<link rel="stylesheet" href="/site.css">
183-
<script>(function(){const t=localStorage.getItem("base-theme"),s=localStorage.getItem("base-scheme"),c=t||(matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light");document.documentElement.className=c+(s&&s!=="default"?" scheme-"+s:"")})();</script>
195+
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
196+
<script>(function(){var t=localStorage.getItem('theme'),c=localStorage.getItem('scheme'),h=document.documentElement;h.className=(t||(matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light'))+(c&&c!=='default'?' scheme-'+c:'');})()</script>
184197
</head>
185198
<body>
186199
{$body}
200+
<div class="overlay"></div>
187201
<script src="/base.js"></script>
188202
</body>
189203
</html>
190204
HTML;
191205
}
192-
193-
public function Simple(): string {
194-
$nav = $this->nav();
195-
$dd = $this->dropdown();
196-
$colors = $this->colors();
197-
$body = <<<HTML
198-
<div class="container">
199-
<header class="mt-4"><h1><a class="brand" href="/">‹ <span>Themes PHP Example</span></a></h1></header>
200-
<nav class="card flex">
201-
{$nav} {$dd} {$colors}
202-
<span class="ml-auto"><button class="theme-toggle" id="theme-icon">🌙</button></span>
203-
</nav>
204-
<main class="mt-4 mb-4">{$this->out['main']}</main>
205-
<footer class="text-center"><small>© 2015-2026 Mark Constable (MIT License)</small></footer>
206-
</div>
207-
HTML;
208-
return $this->html('Simple', $body);
209-
}
210-
211-
public function TopNav(): string {
212-
$nav = $this->nav();
213-
$dd = $this->dropdown();
214-
$colors = $this->colors();
215-
$body = <<<HTML
216-
<nav class="topnav">
217-
<h1><a class="brand" href="/">‹ <span>Themes PHP Example</span></a></h1>
218-
<div class="topnav-links">{$nav} {$dd} {$colors}</div>
219-
<button class="theme-toggle" id="theme-icon">🌙</button>
220-
<button class="menu-toggle">☰</button>
221-
</nav>
222-
<div class="container">
223-
<main class="mt-4 mb-4">{$this->out['main']}</main>
224-
<footer class="text-center"><small>© 2015-2026 Mark Constable (MIT License)</small></footer>
225-
</div>
226-
HTML;
227-
return $this->html('TopNav', $body);
228-
}
229-
230-
public function SideBar(): string {
231-
['o' => $o, 't' => $t] = $this->ctx->in;
232-
$n1 = $this->ctx->nav
233-
|> (static fn($n) => array_map(static function($p) use ($o, $t) {
234-
[$icon, $label] = explode(' ', $p[0], 2);
235-
return sprintf(
236-
'<a href="?o=%s&t=%s"%s title="%s" data-icon="%s">%s</a>',
237-
$p[1], $t, $o === $p[1] ? ' class="active"' : '', $label, $icon, $p[0]
238-
);
239-
}, $n))
240-
|> (static fn($a) => implode('', $a));
241-
$n2 = $this->ctx->themes
242-
|> (static fn($n) => array_map(static function($p) use ($o, $t) {
243-
[$icon, $label] = explode(' ', $p[0], 2);
244-
return sprintf(
245-
'<a href="?o=%s&t=%s"%s title="%s" data-icon="%s">%s</a>',
246-
$o, $p[1], $t === $p[1] ? ' class="active"' : '', $label, $icon, $p[0]
247-
);
248-
}, $n))
249-
|> (static fn($a) => implode('', $a));
250-
$body = <<<HTML
251-
<nav class="topnav">
252-
<button class="menu-toggle">☰</button>
253-
<h1><a class="brand" href="/">‹ <span>Themes PHP Example</span></a></h1>
254-
<button class="theme-toggle" id="theme-icon">🌙</button>
255-
</nav>
256-
<div class="sidebar-layout">
257-
<aside class="sidebar">
258-
<div class="sidebar-group">
259-
<div class="sidebar-group-title" data-icon="📄">Pages</div>
260-
<nav>{$n1}</nav>
261-
</div>
262-
<div class="sidebar-group">
263-
<div class="sidebar-group-title" data-icon="📐">Layout</div>
264-
<nav>{$n2}</nav>
265-
</div>
266-
<div class="sidebar-group">
267-
<div class="sidebar-group-title" data-icon="🌈">Colors</div>
268-
<nav>
269-
<a href="#" data-scheme="default" title="Stone" data-icon="🪨">🪨 Stone</a>
270-
<a href="#" data-scheme="ocean" title="Ocean" data-icon="🌊">🌊 Ocean</a>
271-
<a href="#" data-scheme="forest" title="Forest" data-icon="🌲">🌲 Forest</a>
272-
<a href="#" data-scheme="sunset" title="Sunset" data-icon="🌅">🌅 Sunset</a>
273-
</nav>
274-
</div>
275-
<button class="sidebar-toggle" aria-label="Toggle sidebar"></button>
276-
</aside>
277-
<div class="sidebar-main">
278-
<main class="mt-4 mb-4">{$this->out['main']}</main>
279-
<footer class="text-center"><small>© 2015-2026 Mark Constable (MIT License)</small></footer>
280-
</div>
281-
</div>
282-
HTML;
283-
return $this->html('SideBar', $body);
284-
}
285206
}
286207
}
287208

0 commit comments

Comments
 (0)