Skip to content

Commit f56b582

Browse files
add: preact/ experiment area
Single subdir for SPA experiments — starts with 01-hello (zero-build Preact render of a urn:solid:Person island, Pivot-inspired layout), will progressively push toward React-quality output until something breaks. Stages graduate to real tooling only when CDN-ESM stops being enough. Skipped from the apps catalog (RESERVED in build.js + validate.js) since these are sketches, not shipped apps.
1 parent a3d769d commit f56b582

4 files changed

Lines changed: 280 additions & 2 deletions

File tree

preact/01-hello/index.html

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1">
6+
<title>Profile — solid-preact 01</title>
7+
<style>
8+
* { margin: 0; box-sizing: border-box; }
9+
body {
10+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
11+
background: #f5f5f9; color: #222; min-height: 100vh;
12+
}
13+
.page {
14+
max-width: 1120px; margin: 0 auto;
15+
padding: 40px 24px 80px;
16+
display: grid;
17+
grid-template-columns: minmax(0, 1fr) 320px;
18+
gap: 24px;
19+
align-items: start;
20+
}
21+
@media (max-width: 820px) { .page { grid-template-columns: 1fr; } }
22+
23+
.card {
24+
background: #fff;
25+
border: 1px solid #e8e8ee;
26+
border-radius: 12px;
27+
padding: 28px;
28+
box-shadow: 0 1px 2px rgba(0,0,0,.03);
29+
}
30+
.card + .card { margin-top: 18px; }
31+
32+
.hero { display: flex; gap: 24px; align-items: flex-start; }
33+
.photo {
34+
width: 132px; height: 132px;
35+
border-radius: 16px; object-fit: cover;
36+
background: #eef;
37+
flex: 0 0 auto;
38+
}
39+
.photo-fallback {
40+
width: 132px; height: 132px; border-radius: 16px;
41+
background: linear-gradient(135deg, #6366f1, #8b5cf6);
42+
color: #fff; font: 600 56px/132px Georgia, serif;
43+
text-align: center; flex: 0 0 auto;
44+
}
45+
.hero-meta { flex: 1; min-width: 0; }
46+
.name {
47+
font: 700 28px/1.2 'Inter', sans-serif;
48+
color: #1a1a1a; margin: 0; letter-spacing: -0.3px;
49+
display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap;
50+
}
51+
.pronoun { font: 400 15px/1 #6366f1; color: #6366f1; }
52+
.facts {
53+
margin-top: 18px;
54+
display: grid; grid-template-columns: 1fr 1fr; gap: 10px 24px;
55+
font-size: 14px; color: #444;
56+
}
57+
@media (max-width: 560px) { .facts { grid-template-columns: 1fr; } }
58+
.fact { display: flex; align-items: center; gap: 8px; }
59+
.fact .ic { opacity: 0.55; }
60+
.fact a { color: inherit; text-decoration: none; }
61+
.fact a:hover { color: #6366f1; }
62+
63+
.section-title {
64+
font: 700 18px/1.2 'Inter', sans-serif;
65+
color: #1a1a1a; margin-bottom: 14px;
66+
}
67+
.prose { font: 400 15px/1.65 Georgia, serif; color: #333; }
68+
69+
.side-title {
70+
font: 700 16px/1 'Inter', sans-serif;
71+
color: #1a1a1a; margin-bottom: 14px;
72+
}
73+
.pills { display: flex; gap: 6px; flex-wrap: wrap; }
74+
.pill {
75+
padding: 4px 11px; border-radius: 999px;
76+
background: #eef0fe; color: #4338ca;
77+
font-size: 13px; font-weight: 500;
78+
}
79+
.list-plain { list-style: none; display: flex; flex-direction: column; gap: 6px; font-size: 14px; color: #333; }
80+
81+
.contact-row {
82+
display: flex; gap: 12px; align-items: flex-start;
83+
padding: 10px 0; border-top: 1px solid #eef;
84+
}
85+
.contact-row:first-child { border-top: 0; padding-top: 0; }
86+
.contact-ic {
87+
width: 32px; height: 32px; border-radius: 8px;
88+
background: #f5f5fb; display: flex; align-items: center; justify-content: center;
89+
flex: 0 0 auto;
90+
}
91+
.contact-body { flex: 1; font-size: 14px; color: #333; word-break: break-word; }
92+
.contact-label { font-size: 12px; color: #888; margin-top: 2px; }
93+
94+
.footer-tag {
95+
margin-top: 32px; text-align: center;
96+
font: 500 11px/1.4 ui-monospace, SFMono-Regular, monospace;
97+
color: #888; letter-spacing: 0.05em;
98+
}
99+
.footer-tag code { padding: 2px 7px; border-radius: 4px; background: #eef0fe; color: #4338ca; }
100+
</style>
101+
</head>
102+
<body>
103+
104+
<!-- Enriched sample data. Fields beyond the current urn:solid set (pronouns,
105+
bio, skills, languages, address, resume) are experiment-stage — we'll
106+
formalize what earns its keep before merging upstream. -->
107+
<script type="application/ld+json">
108+
{
109+
"@context": { "@vocab": "urn:solid:" },
110+
"@id": "#this",
111+
"@type": "Person",
112+
"name": "Sir Tim Berners-Lee",
113+
"nick": "timbl",
114+
"pronouns": "he/him",
115+
"email": "timbl@w3.org",
116+
"phone": "+1 617 000 0000",
117+
"homepage": "https://www.w3.org/People/Berners-Lee/",
118+
"img": "https://www.w3.org/People/Berners-Lee/Press/stock-photos/tim-berners-lee-inrupt-2024.jpg",
119+
"address": "Cambridge, MA, United States",
120+
"bio": "Inventor of the World Wide Web. Director of the World Wide Web Consortium. Co-founder of Inrupt and the Solid project — returning agency over data to the people it describes.",
121+
"skills": ["Web Architecture", "RDF", "Linked Data", "HTTP", "Standards"],
122+
"languages": ["English", "French"]
123+
}
124+
</script>
125+
126+
<div id="app"></div>
127+
128+
<script type="module">
129+
import { h, render } from 'https://esm.sh/preact@10'
130+
import htm from 'https://esm.sh/htm@3'
131+
132+
const html = htm.bind(h)
133+
134+
const niceLink = (u) => (u || '').replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '')
135+
const initial = (s) => (s || '?').trim().charAt(0).toUpperCase()
136+
137+
function Hero({ data }) {
138+
const { name, pronouns, address, email, phone, homepage, img } = data
139+
return html`
140+
<div class="hero">
141+
${img
142+
? html`<img class="photo" src=${img} alt=${name} onerror=${(e) => { const f = document.createElement('div'); f.className = 'photo-fallback'; f.textContent = initial(name); e.target.replaceWith(f) }} />`
143+
: html`<div class="photo-fallback">${initial(name)}</div>`}
144+
<div class="hero-meta">
145+
<h1 class="name">
146+
<span>${name}</span>
147+
${pronouns && html`<span class="pronoun">(${pronouns})</span>`}
148+
</h1>
149+
<div class="facts">
150+
${address && html`<div class="fact"><span class="ic">\u{1F4CD}</span><span>${address}</span></div>`}
151+
${phone && html`<div class="fact"><span class="ic">\u{260E}\uFE0F</span><a href="tel:${phone.replace(/\s+/g,'')}">${phone}</a></div>`}
152+
${email && html`<div class="fact"><span class="ic">\u{2709}\uFE0F</span><a href="mailto:${email}">${email}</a></div>`}
153+
${homepage&& html`<div class="fact"><span class="ic">\u{1F310}</span><a href=${homepage} target="_blank" rel="noopener">${niceLink(homepage)}</a></div>`}
154+
</div>
155+
</div>
156+
</div>
157+
`
158+
}
159+
160+
function Bio({ bio }) {
161+
if (!bio) return null
162+
return html`
163+
<div class="card">
164+
<h2 class="section-title">Bio</h2>
165+
<p class="prose">${bio}</p>
166+
</div>
167+
`
168+
}
169+
170+
function Skills({ skills }) {
171+
if (!skills?.length) return null
172+
return html`
173+
<div class="card">
174+
<h3 class="side-title">Skills</h3>
175+
<div class="pills">
176+
${skills.map(s => html`<span class="pill">${s}</span>`)}
177+
</div>
178+
</div>
179+
`
180+
}
181+
182+
function Languages({ languages }) {
183+
if (!languages?.length) return null
184+
return html`
185+
<div class="card">
186+
<h3 class="side-title">Languages</h3>
187+
<ul class="list-plain">
188+
${languages.map(l => html`<li>${l}</li>`)}
189+
</ul>
190+
</div>
191+
`
192+
}
193+
194+
function Contacts({ data }) {
195+
const rows = []
196+
if (data.phone) rows.push({ icon: '\u{260E}\uFE0F', value: data.phone, label: 'Mobile', href: `tel:${data.phone.replace(/\s+/g,'')}` })
197+
if (data.email) rows.push({ icon: '\u{2709}\uFE0F', value: data.email, label: 'Personal', href: `mailto:${data.email}` })
198+
if (data.address) rows.push({ icon: '\u{1F4CD}', value: data.address, label: 'Address' })
199+
if (!rows.length) return null
200+
return html`
201+
<div class="card">
202+
<h3 class="side-title">More Contacts</h3>
203+
<div>
204+
${rows.map(r => html`
205+
<div class="contact-row">
206+
<div class="contact-ic">${r.icon}</div>
207+
<div class="contact-body">
208+
${r.href ? html`<a href=${r.href}>${r.value}</a>` : r.value}
209+
<div class="contact-label">${r.label}</div>
210+
</div>
211+
</div>
212+
`)}
213+
</div>
214+
</div>
215+
`
216+
}
217+
218+
function Profile({ data }) {
219+
return html`
220+
<div class="page">
221+
<div>
222+
<div class="card">
223+
<${Hero} data=${data} />
224+
</div>
225+
<${Bio} bio=${data.bio} />
226+
</div>
227+
<div>
228+
<${Skills} skills=${data.skills} />
229+
<${Languages} languages=${data.languages} />
230+
<${Contacts} data=${data} />
231+
</div>
232+
</div>
233+
<div class="footer-tag">rendered by <code>preact@10</code> via <code>esm.sh</code> \u2014 zero build</div>
234+
`
235+
}
236+
237+
const dataEl = document.querySelector('script[type="application/ld+json"]')
238+
const data = JSON.parse(dataEl.textContent)
239+
render(html`<${Profile} data=${data} />`, document.getElementById('app'))
240+
</script>
241+
242+
</body>
243+
</html>

preact/README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# solid-apps / preact
2+
3+
Experiment area inside [solid-apps](../). Starts with the simplest possible Preact render of a `urn:solid:` JSON-LD data island, then progressively pushes the limits until it either matches React-quality SPA output or we find where it breaks.
4+
5+
Lives here (not its own repo) because gh-pages and the data conventions are already set up — and because experimenting with apps is what this repo is for.
6+
7+
Each stage is a single self-contained HTML file (zero build). A stage graduates to real tooling only when CDN-ESM stops being enough — if it ever does.
8+
9+
## Stages
10+
11+
| Stage | Focus |
12+
|-------|-------|
13+
| 01-hello | Minimal Preact render: mount a `<Profile>` component, read JSON-LD from a `<script type="application/ld+json">` island, display it. Inspired by the Pivot profile layout at sharon.pivot-test.solidproject.org. Static; no state, no edits. |
14+
| 02-inline-edit | `useState` / signals; click a field to edit; debounced PUT on blur. |
15+
| 03-routing | Hash routes, multi-page SPA shell. |
16+
| 04-fetch | Load external WebIDs. |
17+
| 05+ | TBD — whatever the previous stages surface as the next limit. |
18+
19+
## Findings
20+
21+
_(populated as we discover them)_
22+
23+
## Running
24+
25+
Live: <https://solid-apps.github.io/preact/01-hello/>
26+
27+
```bash
28+
# Or locally — nothing to install:
29+
python3 -m http.server 8000
30+
# → http://localhost:8000/preact/01-hello/
31+
```
32+
33+
## License
34+
35+
AGPL-3.0 for code, CC BY 4.0 for data — matching the rest of the Solid stack.

scripts/build.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), ".."
77

88
const RESERVED = new Set([
99
"schema", "scripts", "node_modules", ".github", ".git", ".claude",
10-
"assets", "vendor", "spec", "demo", "template"
10+
"assets", "vendor", "spec", "demo", "template", "preact"
1111
]);
1212

1313
const isAppDir = (name) => {

scripts/validate.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ const ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), ".."
99

1010
const RESERVED = new Set([
1111
"schema", "scripts", "node_modules", ".github", ".git", ".claude",
12-
"assets", "vendor", "spec", "demo", "template"
12+
"assets", "vendor", "spec", "demo", "template", "preact"
1313
]);
1414

1515
const isAppDir = (name) => {

0 commit comments

Comments
 (0)