Initial build: Horizon 1 Prevention Suite + Horizon 2 Unlock demo

Svelte 5 + Vite PWA, styled to match moslem03.falahos.my's design system.

Horizon 1: Faraid Calculator (shared calc core, 14 classical cases passing),
Asset Registry, Wassiyah Generator (1/3 meter + heir-exclusion block),
Hibah Tracker and Family Waqf Designator (shared marad al-mawt guardrail).

Horizon 2: Digital Beneficial Claims — local non-custodial demo of the claim
model and transfer restriction only.

Two governance gates in this project's own PRDs were overridden per explicit
product direction, and are flagged in-app and in README.md / deploy/DEPLOY.md
rather than silently shipped as production-ready:
- Family Waqf Designator ships ahead of scholarly sign-off (OPEN-01 in
  scholarly-review-log.md remains unresolved).
- Horizon 2 ships ahead of the PRD's stated Phase 0 gate (legal opinion +
  signed institutional partner) with no confirmation that gate is cleared.
This commit is contained in:
wmj
2026-08-13 16:55:58 +08:00
commit 6690696f7f
28 changed files with 7586 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
dev-dist
.DS_Store
+11
View File
@@ -0,0 +1,11 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+49
View File
@@ -0,0 +1,49 @@
# Nur Falah — Estate & Waqf Suite
Horizon 1 (Prevention Suite) + Horizon 2 (Unlock Programme, pre-pilot demo) from the
Nur Falah PRDs, built as a standalone Svelte 5 + Vite PWA, styled to match the visual
system of the live `moslem03.falahos.my` (Nur Falah Muslim Companion) app.
## Modules
| Module | Horizon | Status |
|---|---|---|
| Faraid Calculator | 1 | Shared calc core, 14 classical cases passing (`src/lib/calc/faraid.test.js`) |
| Asset Registry | 1 | Local-first, feeds every other module's estate total |
| Wassiyah Generator | 1 | 1/3 meter, heir-exclusion block, jurisdiction-aware export |
| Hibah Tracker | 1 | Shared marad al-mawt guardrail |
| Family Waqf Designator | 1 | **Built ahead of scholarly sign-off** — see below |
| Digital Beneficial Claims | 2 | **Pre-pilot demo only** — see below |
## Important: two governance gates were overridden for this build
Per explicit product direction, this build proceeded past two gates stated in the
project's own PRDs and review log:
1. **Family Waqf Designator** (Horizon 1 PRD §8.5) is gated on scholarly sign-off.
`scholarly-review-log.md` (in the `digital-waqif` docs repo) tracks **OPEN-01**
whether a healthy-state waqf is subject to the one-third cap — as unresolved.
This module ships anyway; the in-app copy states the open question rather than
presenting invented certainty.
2. **Horizon 2** (Digital Beneficial Claims) PRD states: *"No engineering work begins
until Phase 0 (legal opinion + signed institutional partner agreement) is
complete."* No confirmation of Phase 0 completion was available. The Claims module
ships as a local, non-custodial demonstration of the data model and transfer
restriction only — no real legal wrapper, no institutional integration, no real
claims issued. It carries a persistent in-app banner saying so.
Both are flagged here, in `deploy/DEPLOY.md`, and in the relevant module's source
comments so this isn't silently presented as production-ready.
## Development
```bash
npm install
npm run dev # http://localhost:5173
npm run test # classical faraid test suite
npm run build
```
## Deploy
See `deploy/DEPLOY.md`. Target: `moslem04.falahos.my`.
+69
View File
@@ -0,0 +1,69 @@
# Nur Falah — Estate & Waqf Suite · Deployment Guide
**Target:** `moslem04.falahos.my`
**Stack:** Svelte 5 + Vite PWA (static SPA), same pattern as `moslem03.falahos.my`
**Server:** Docker Swarm (nginx:alpine) on the Contabo VPS, Traefik + Let's Encrypt
---
## 1. Build & push image
```bash
docker build -t git.falahos.my/wmj/nur-falah-prevention:latest .
docker push git.falahos.my/wmj/nur-falah-prevention:latest
```
## 2. Deploy to the swarm
```bash
docker stack deploy -c deploy/docker-stack.yml nur-falah-prevention
```
Traefik picks up the labels in `deploy/docker-stack.yml` and routes
`moslem04.falahos.my` to the service automatically, issuing a Let's Encrypt
certificate on first request.
## 3. DNS
Point `moslem04.falahos.my` at the same VPS IP already serving `moslem03.falahos.my`
(A/AAAA record, or CNAME if using a CDN in front).
## 4. Verify
```bash
curl -sI https://moslem04.falahos.my/ | grep -i "cache-control"
# index.html: cache-control: no-cache, must-revalidate
curl -sI https://moslem04.falahos.my/sw.js | grep -i "cache-control"
# sw.js: cache-control: no-cache, no-store, must-revalidate
```
- [ ] `https://moslem04.falahos.my/` returns 200
- [ ] Service worker activates (DevTools → Application → Service Workers)
- [ ] App installs as PWA
- [ ] All 6 modules load without console errors: Faraid, Assets, Wassiyah, Hibah, Family Waqf, Claims (H2)
## 5. Rollback
```bash
docker service update --rollback nur-falah-prevention_web
```
## Known gaps before this is production-grade (not blocking this deploy, tracked for follow-up)
- **Encryption at rest**: storage.js currently uses plain `localStorage`. Horizon 1
PRD §10 requires encrypted-at-rest for Asset Registry / wassiyah / hibah / waqf
drafts. WebCrypto AES-GCM wrapping is the next hardening step.
- **PDF export**: current export is plain-text download + browser print dialog, not
a dedicated PDF pipeline. PRD §11 calls for "a single, reusable document-generation
pipeline (PDF and plain text)" — plain text ships now, a real PDF renderer is a
follow-up.
- **Horizon 2 claims module is a local, non-custodial demo only** — see the in-app
banner and `src/lib/horizon2/claims.js`. It has no legal wrapper, no institutional
partner, and issues no real claim. Built ahead of the Horizon 2 PRD's stated Phase 0
gate per explicit product direction — do not represent this screen to any real user
as a live programme.
- **Family Waqf Designator ships ahead of scholarly sign-off** — `scholarly-review-log.md`
OPEN-01 (the waqf one-third cap conflict) is still unresolved. Built per explicit
product direction; the in-app copy states this openly rather than presenting
invented certainty.
+28
View File
@@ -0,0 +1,28 @@
# Docker Swarm stack for moslem04.falahos.my
# Matches the deploy pattern used for moslem03 (Docker Swarm, nginx:alpine,
# Traefik + Let's Encrypt on the Contabo VPS) per UAT-REPORT-moslem03.md in
# the hermes-macbookair/nur-falah repo.
#
# Deploy: docker stack deploy -c deploy/docker-stack.yml nur-falah-prevention
version: '3.8'
services:
web:
image: git.falahos.my/wmj/nur-falah-prevention:latest
networks:
- traefik-public
deploy:
replicas: 1
update_config:
order: start-first
labels:
- traefik.enable=true
- traefik.http.routers.moslem04.rule=Host(`moslem04.falahos.my`)
- traefik.http.routers.moslem04.entrypoints=websecure
- traefik.http.routers.moslem04.tls.certresolver=letsencrypt
- traefik.http.services.moslem04.loadbalancer.server.port=80
networks:
traefik-public:
external: true
+80
View File
@@ -0,0 +1,80 @@
# ──────────────────────────────────────────────
# Nur Falah — Estate & Waqf Suite · Nginx SPA Config
# Target: moslem04.falahos.my
# ──────────────────────────────────────────────
server {
listen 80;
listen [::]:80;
server_name moslem04.falahos.my;
root /usr/share/nginx/html;
index index.html;
error_page 404 =200 /index.html;
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 5;
gzip_min_length 512;
gzip_types
text/html
text/plain
text/css
text/javascript
application/javascript
application/json
application/manifest+json
image/svg+xml
image/x-icon
font/woff2;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "0" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, must-revalidate" always;
}
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable, max-age=31536000" always;
access_log off;
}
location = /favicon.svg {
expires 7d;
add_header Cache-Control "public, max-age=604800" always;
access_log off;
}
location = /manifest.webmanifest {
expires 1d;
add_header Cache-Control "public, max-age=86400" always;
add_header Content-Type "application/manifest+json";
}
location /sw.js {
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header Service-Worker-Allowed "/";
expires off;
access_log off;
}
location ~* \.(js|css|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable, max-age=31536000" always;
}
location ~* \.(png|ico)$ {
expires 1y;
add_header Cache-Control "public, immutable, max-age=31536000" always;
access_log off;
}
location ~ /\. { deny all; access_log off; log_not_found off; }
location ~ ^/(node_modules|src)/ { deny all; access_log off; }
}
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#070A0D" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=DM+Serif+Display&display=swap" rel="stylesheet" />
<title>Nur Falah — Estate &amp; Waqf Suite</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+5791
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "nur-falah-prevention",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "Nur Falah Horizon 1 (Prevention Suite) + Horizon 2 (Unlock Programme, pre-pilot demo)",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"test": "node src/lib/calc/faraid.test.js"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"svelte": "^5.20.0",
"vite": "^6.2.0"
},
"dependencies": {
"vite-plugin-pwa": "^0.21.0",
"workbox-precaching": "^7.3.0"
}
}
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0f766e"/>
<stop offset="100%" style="stop-color:#0d9488"/>
</linearGradient>
</defs>
<rect width="100" height="100" rx="20" fill="url(#g)"/>
<text x="50" y="68" text-anchor="middle" fill="white" font-family="Georgia,serif" font-size="52" font-weight="bold">ن</text>
</svg>

After

Width:  |  Height:  |  Size: 469 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

+115
View File
@@ -0,0 +1,115 @@
<script>
import FaraidCalculator from './lib/FaraidCalculator.svelte';
import AssetRegistry from './lib/AssetRegistry.svelte';
import WassiyahGenerator from './lib/WassiyahGenerator.svelte';
import HibahTracker from './lib/HibahTracker.svelte';
import FamilyWaqfDesignator from './lib/FamilyWaqfDesignator.svelte';
import DigitalClaims from './lib/horizon2/DigitalClaims.svelte';
import { exportAll, deleteAll } from './lib/storage.js';
const tabs = ['Faraid', 'Assets', 'Wassiyah', 'Hibah', 'Family Waqf', 'Claims (H2)', 'Settings'];
const icons = ['📊', '📁', '📜', '🎁', '⛲', '🔗', '⚙️'];
let activeTab = $state(0);
function handleKeydown(e) {
if (e.key === 'ArrowRight') activeTab = (activeTab + 1) % tabs.length;
if (e.key === 'ArrowLeft') activeTab = (activeTab - 1 + tabs.length) % tabs.length;
}
function doExport() {
const data = exportAll();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'nur-falah-full-export.json'; a.click();
URL.revokeObjectURL(url);
}
function doDelete() {
if (confirm('This permanently deletes all locally stored data. This cannot be undone. Continue?')) {
deleteAll();
location.reload();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<div class="app">
<header>
<div class="header-brand">
<div class="brand-line brand-line-first">Nur</div>
<div class="brand-line brand-line-second">Falah</div>
</div>
<span class="header-tagline">ESTATE &amp; WAQF SUITE</span>
<div class="header-divider"></div>
</header>
<nav>
{#each tabs as tab, i}
<button class="tab" class:active={activeTab === i} onclick={() => activeTab = i} aria-label={tab}>
<span class="tab-icon">{icons[i]}</span>
<span class="tab-label">{tab}</span>
</button>
{/each}
</nav>
<main>
{#if activeTab === 0}<FaraidCalculator />
{:else if activeTab === 1}<AssetRegistry />
{:else if activeTab === 2}<WassiyahGenerator />
{:else if activeTab === 3}<HibahTracker />
{:else if activeTab === 4}<FamilyWaqfDesignator />
{:else if activeTab === 5}<DigitalClaims />
{:else if activeTab === 6}
<div class="module">
<h2>Settings</h2>
<p class="sub">Your data is stored locally on this device. Nothing is sent to a third party.</p>
<button class="btn-secondary" onclick={doExport}>Export all data (JSON)</button>
<button class="btn-danger" onclick={doDelete}>Delete all data irreversible</button>
</div>
{/if}
</main>
</div>
<style>
:global(*) { box-sizing: border-box; margin: 0; padding: 0; }
:global(body) {
font-family: 'DM Sans', sans-serif;
background: #070A0D;
color: #E8E4DC;
min-height: 100dvh;
overflow-x: hidden;
position: relative;
}
:global(body::before) {
content: '';
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background:
radial-gradient(ellipse 80% 50% at 50% -20%, rgba(201,168,76,0.08) 0%, transparent 70%),
radial-gradient(ellipse 60% 40% at 20% 80%, rgba(46,204,113,0.05) 0%, transparent 60%);
pointer-events: none; z-index: 0;
}
:global(#app) { position: relative; z-index: 2; }
.app { max-width: 480px; margin: 0 auto; min-height: 100dvh; display: flex; flex-direction: column; padding-bottom: 80px; }
header { text-align: center; padding: 28px 16px 16px; background: rgba(12,17,23,0.95); backdrop-filter: blur(12px); border-bottom: 1px solid rgba(201,168,76,0.15); position: sticky; top: 0; z-index: 10; }
.header-brand { display: flex; justify-content: center; gap: 8px; }
.brand-line { font-family: 'DM Serif Display', serif; font-size: 26px; }
.brand-line-first { color: #E8E4DC; }
.brand-line-second { color: #C9A84C; }
.header-tagline { font-size: 10px; letter-spacing: 2px; color: #8A8478; }
.header-divider { height: 2px; width: 40px; background: #C9A84C; margin: 10px auto 0; border-radius: 2px; }
nav { display: flex; overflow-x: auto; gap: 4px; padding: 10px 8px; background: rgba(12,17,23,0.7); border-bottom: 1px solid rgba(201,168,76,0.1); position: sticky; top: 92px; z-index: 9; }
.tab { flex-shrink: 0; display: flex; flex-direction: column; align-items: center; gap: 3px; padding: 8px 12px; background: none; border: none; border-radius: 10px; cursor: pointer; color: #8A8478; }
.tab.active { background: rgba(201,168,76,0.12); color: #C9A84C; }
.tab-icon { font-size: 18px; }
.tab-label { font-size: 10px; white-space: nowrap; }
main { flex: 1; padding: 16px; }
:global(.btn-danger) { width: 100%; padding: 12px; border-radius: 8px; border: 1px solid rgba(239,68,68,0.4); background: rgba(239,68,68,0.1); color: #EF4444; font-weight: 600; cursor: pointer; margin-top: 12px; }
:global(.btn-secondary) { width: 100%; padding: 12px; border-radius: 8px; border: none; background: rgba(255,255,255,0.08); color: #E8E4DC; font-weight: 600; cursor: pointer; }
</style>
+165
View File
@@ -0,0 +1,165 @@
<script>
import { load, save, estateTotal } from './storage.js';
import Disclaimer from './Disclaimer.svelte';
const TYPES = ['Property', 'Cash / Bank', 'Vehicle', 'Business interest', 'Digital assets', 'Jewelry / valuables', 'Other'];
let assets = $state(load('assets', []));
let trustedContacts = $state(load('trustedContacts', []));
let form = $state(emptyForm());
let editingId = $state(null);
let contactForm = $state({ name: '', method: '' });
function emptyForm() {
return { type: TYPES[0], description: '', value: '', location: '', ownershipShare: 100 };
}
function persist() {
save('assets', assets);
}
function addOrUpdate() {
if (!form.description || !form.value) return;
if (editingId) {
assets = assets.map(a => a.id === editingId ? { ...form, id: editingId, value: Number(form.value) } : a);
editingId = null;
} else {
assets = [...assets, { ...form, id: crypto.randomUUID(), value: Number(form.value) }];
}
form = emptyForm();
persist();
}
function edit(a) {
editingId = a.id;
form = { ...a, value: String(a.value) };
}
function remove(id) {
assets = assets.filter(a => a.id !== id);
if (editingId === id) { editingId = null; form = emptyForm(); }
persist();
}
function addContact() {
if (!contactForm.name) return;
trustedContacts = [...trustedContacts, { ...contactForm, id: crypto.randomUUID() }];
contactForm = { name: '', method: '' };
save('trustedContacts', trustedContacts);
}
function removeContact(id) {
trustedContacts = trustedContacts.filter(c => c.id !== id);
save('trustedContacts', trustedContacts);
}
function exportSummary() {
const total = estateTotal(assets);
const lines = [
'NUR FALAH — ASSET REGISTRY SUMMARY',
`Generated: ${new Date().toISOString().slice(0, 10)}`,
`Total estate value: ${total.toLocaleString()}`,
'',
...assets.map(a => `${a.type} — ${a.description} — value ${Number(a.value).toLocaleString()} — ${a.ownershipShare}% owned — ${a.location || 'no location noted'}`),
'',
'This is a portable "what I own" summary. Not a fatwa, not legal advice.'
];
const blob = new Blob([lines.join('\n')], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'asset-registry-summary.txt'; a.click();
URL.revokeObjectURL(url);
}
const total = $derived(estateTotal(assets));
</script>
<div class="module">
<h2>Asset Registry</h2>
<p class="sub">Log what you own — feeds the Faraid Calculator, Wassiyah one-third meter, and Waqf corpus selector.</p>
<div class="total-card">
<span>Estate total</span>
<strong>{total.toLocaleString()}</strong>
</div>
<div class="form-card">
<label class="field"><span>Type</span>
<select bind:value={form.type}>
{#each TYPES as t}<option value={t}>{t}</option>{/each}
</select>
</label>
<label class="field"><span>Description</span><input type="text" bind:value={form.description} placeholder="e.g. Terrace house, Shah Alam" /></label>
<label class="field"><span>Estimated value</span><input type="number" min="0" bind:value={form.value} /></label>
<label class="field"><span>Location / jurisdiction</span><input type="text" bind:value={form.location} placeholder="e.g. Selangor, UK" /></label>
<label class="field"><span>Ownership share (%)</span><input type="number" min="0" max="100" bind:value={form.ownershipShare} /></label>
<button class="btn-primary" onclick={addOrUpdate}>{editingId ? 'Update asset' : 'Add asset'}</button>
</div>
<div class="list">
{#each assets as a (a.id)}
<div class="asset-row">
<div class="asset-info">
<span class="asset-type">{a.type}</span>
<span class="asset-desc">{a.description}</span>
<span class="asset-meta">{a.ownershipShare}% owned · {a.location || '—'}</span>
</div>
<div class="asset-value">{Number(a.value).toLocaleString()}</div>
<div class="asset-actions">
<button onclick={() => edit(a)} aria-label="Edit"></button>
<button onclick={() => remove(a.id)} aria-label="Remove"></button>
</div>
</div>
{:else}
<p class="empty">No assets logged yet.</p>
{/each}
</div>
<button class="btn-secondary" onclick={exportSummary}>Export "what I own" summary</button>
<div class="contacts-section">
<h3>Trusted contacts</h3>
<p class="note">Notified or given a read-only export on a triggering event — not an automated death-detection or legal-transfer mechanism.</p>
<div class="form-card">
<label class="field"><span>Name</span><input type="text" bind:value={contactForm.name} /></label>
<label class="field"><span>Contact method</span><input type="text" bind:value={contactForm.method} placeholder="email or phone" /></label>
<button class="btn-secondary" onclick={addContact}>Add trusted contact</button>
</div>
{#each trustedContacts as c (c.id)}
<div class="contact-row"><span>{c.name}{c.method}</span><button onclick={() => removeContact(c.id)}>✕</button></div>
{/each}
</div>
<Disclaimer text="Manual entry only — bank/brokerage account linking and live balance sync are explicitly out of scope for Horizon 1." />
</div>
<style>
.module { padding: 4px 0 40px; }
h2 { font-family: 'DM Serif Display', serif; font-size: 24px; color: #E8E4DC; margin-bottom: 4px; }
.sub { font-size: 13px; color: #8A8478; margin-bottom: 16px; }
.total-card { display: flex; justify-content: space-between; align-items: baseline; background: rgba(201,168,76,0.08); border: 1px solid rgba(201,168,76,0.25); border-radius: 12px; padding: 16px; margin-bottom: 18px; }
.total-card span { font-size: 12.5px; color: #B8B2A6; }
.total-card strong { font-size: 22px; color: #C9A84C; }
.form-card { background: rgba(255,255,255,0.03); border-radius: 12px; padding: 14px; margin-bottom: 16px; }
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
.field span { font-size: 12px; color: #B8B2A6; }
.field select, .field input { background: rgba(255,255,255,0.05); border: 1px solid rgba(201,168,76,0.2); border-radius: 8px; padding: 10px 12px; color: #E8E4DC; font-size: 14px; }
.btn-primary, .btn-secondary { width: 100%; padding: 12px; border-radius: 8px; border: none; font-weight: 600; cursor: pointer; }
.btn-primary { background: #C9A84C; color: #070A0D; }
.btn-secondary { background: rgba(255,255,255,0.08); color: #E8E4DC; margin-bottom: 10px; }
.list { margin-bottom: 12px; }
.asset-row { display: flex; align-items: center; gap: 10px; padding: 12px 0; border-bottom: 1px solid rgba(255,255,255,0.06); }
.asset-info { display: flex; flex-direction: column; flex: 1; gap: 2px; }
.asset-type { font-size: 11px; color: #8A8478; text-transform: uppercase; letter-spacing: 0.4px; }
.asset-desc { font-size: 13.5px; color: #E8E4DC; }
.asset-meta { font-size: 11px; color: #8A8478; }
.asset-value { color: #2ECC71; font-weight: 600; font-size: 13px; }
.asset-actions button { background: none; border: none; color: #8A8478; cursor: pointer; padding: 4px 6px; }
.empty { font-size: 13px; color: #8A8478; text-align: center; padding: 20px 0; }
.contacts-section { margin-top: 24px; border-top: 1px solid rgba(201,168,76,0.15); padding-top: 16px; }
.contacts-section h3 { font-size: 15px; color: #C9A84C; margin-bottom: 4px; }
.note { font-size: 11.5px; color: #8A8478; margin-bottom: 12px; }
.contact-row { display: flex; justify-content: space-between; padding: 8px 0; font-size: 13px; color: #E8E4DC; border-bottom: 1px solid rgba(255,255,255,0.06); }
.contact-row button { background: none; border: none; color: #8A8478; cursor: pointer; }
</style>
+23
View File
@@ -0,0 +1,23 @@
<script>
let { text = 'This output is not a fatwa and not legal advice. It requires qualified Shariah scholarly and, where applicable, legal review before real-world reliance.' } = $props();
</script>
<div class="disclaimer">
<span class="disclaimer-icon"></span>
<p>{text}</p>
</div>
<style>
.disclaimer {
display: flex;
gap: 10px;
align-items: flex-start;
background: rgba(201,168,76,0.08);
border: 1px solid rgba(201,168,76,0.25);
border-radius: 10px;
padding: 12px 14px;
margin: 16px 0;
}
.disclaimer-icon { color: #C9A84C; font-size: 16px; line-height: 1.4; }
.disclaimer p { font-size: 12.5px; line-height: 1.5; color: #B8B2A6; margin: 0; }
</style>
+148
View File
@@ -0,0 +1,148 @@
<script>
// Built per explicit user instruction overriding the Horizon 1 PRD §8.5 gate
// ("this module does not enter development until... a qualified Shariah advisor's
// direct sign-off"). scholarly-review-log.md OPEN-01 remains unresolved — the cap
// logic below follows the PRD's literal specification (marad al-mawt only) but the
// in-app note is kept honest about the open question rather than presenting
// invented certainty, consistent with this project's own stated practice.
import { load, save, estateTotal } from './storage.js';
import MaradAlMawtGuard from './MaradAlMawtGuard.svelte';
import Disclaimer from './Disclaimer.svelte';
const assets = load('assets', []);
const total = estateTotal(assets);
let corpusAssetId = $state('');
let mutawalli = $state('');
let successorMutawalli = $state('');
let beneficiaries = $state(load('waqfBeneficiaries', []));
let beneficiaryForm = $state({ name: '', relation: '', sharePercent: '' });
let equalSplit = $state(true);
let deviationAcknowledged = $state(false);
let jurisdiction = $state('Perak');
let showGuard = $state(false);
let guardResult = $state(null);
const corpusAsset = $derived(assets.find(a => a.id === corpusAssetId));
function addBeneficiary() {
if (!beneficiaryForm.name) return;
beneficiaries = [...beneficiaries, { ...beneficiaryForm, id: crypto.randomUUID() }];
save('waqfBeneficiaries', beneficiaries);
beneficiaryForm = { name: '', relation: '', sharePercent: '' };
}
function remove(id) {
beneficiaries = beneficiaries.filter(b => b.id !== id);
save('waqfBeneficiaries', beneficiaries);
}
function exportDeed() {
const lines = [
'WAQFIYYA (FAMILY WAQF) DEED — DRAFTING AID',
`Jurisdiction: ${jurisdiction}`,
`Generated: ${new Date().toISOString().slice(0, 10)}`,
`Corpus asset: ${corpusAsset ? `${corpusAsset.type} ${corpusAsset.description}` : '(not selected)'}`,
`Mutawalli (trustee): ${mutawalli || '________________________'}`,
`Successor mutawalli: ${successorMutawalli || '________________________'}`,
`Distribution basis: ${equalSplit ? 'Equal split between descendants (default)' : 'Custom split — deviation acknowledged'}`,
'',
'BENEFICIARIES:',
...beneficiaries.map(b => ` ${b.name} (${b.relation || '—'})${b.sharePercent ? ` — ${b.sharePercent}%` : ''}`),
'',
jurisdiction === 'Perak'
? 'This deed references Perak Waqf Enactment 2015, Section 10(1). Registration with the\nrelevant state waqf authority is required for legal effect — this document alone does\nnot constitute a legally registered waqf.'
: `This deed references ${jurisdiction}'s waqf enactment where available. Registration\nwith the relevant state waqf authority is required for legal effect — this document\nalone does not constitute a legally registered waqf.`,
'',
'OPEN FIQH QUESTION (see project scholarly-review-log.md, OPEN-01): whether an',
'inter-vivos waqf made in health is subject to the same one-third cap as a wasiyyah,',
'or is uncapped like hibah, has not received a scholar\'s determination. This draft',
'applies no health-state cap unless the marad al-mawt guardrail below was triggered.',
'',
'DISCLAIMER: Not a fatwa. Not legal advice. Drafting aid only.'
];
const blob = new Blob([lines.join('\n')], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'waqfiyya-deed-draft.txt'; a.click();
URL.revokeObjectURL(url);
}
</script>
<div class="module">
<h2>Family Waqf Designator</h2>
<div class="open-note">
<strong>Open fiqh question:</strong> whether a healthy-state family waqf is subject to a one-third cap has not received scholarly sign-off (tracked as OPEN-01). This module ships without waiting on that review, per explicit product direction — the exported deed says so plainly rather than presenting invented certainty.
</div>
<p class="sub">Dedicate a specific asset to your family in perpetuity. Framed as "structure your estate proactively" — never as a faraid-avoidance feature.</p>
<label class="field"><span>Corpus asset</span>
<select bind:value={corpusAssetId}>
<option value="">Select from Asset Registry…</option>
{#each assets as a}<option value={a.id}>{a.type} {a.description}</option>{/each}
</select>
</label>
<label class="field"><span>Mutawalli (trustee)</span><input type="text" bind:value={mutawalli} /></label>
<label class="field"><span>Successor mutawalli</span><input type="text" bind:value={successorMutawalli} /></label>
<label class="field"><span>Jurisdiction</span>
<select bind:value={jurisdiction}>
<option>Perak</option><option>Selangor</option><option>Kuala Lumpur (Federal Territory)</option><option>Other</option>
</select>
</label>
<label class="field-check"><input type="checkbox" bind:checked={equalSplit} /><span>Equal split between descendants (default)</span></label>
{#if !equalSplit}
<div class="deviation-box">
<p>Deviating from equal split (favoring one branch, excluding a descendant) is fiqh-controversial for waqf ahli/zurri — some classical positions restrict this, others permit it under conditions. This has not received scholarly sign-off for this product either.</p>
<label class="ack"><input type="checkbox" bind:checked={deviationAcknowledged} /><span>I understand this is a contested area and want to proceed anyway</span></label>
</div>
{/if}
<div class="form-card">
<label class="field"><span>Beneficiary name</span><input type="text" bind:value={beneficiaryForm.name} /></label>
<label class="field"><span>Relation</span><input type="text" bind:value={beneficiaryForm.relation} /></label>
<label class="field"><span>Share % (if not equal split)</span><input type="number" min="0" max="100" bind:value={beneficiaryForm.sharePercent} /></label>
<button class="btn-primary" onclick={addBeneficiary} disabled={!equalSplit && !deviationAcknowledged}>Add beneficiary</button>
</div>
{#each beneficiaries as b (b.id)}
<div class="beneficiary-row"><span>{b.name} ({b.relation || '—'}){b.sharePercent ? ` — ${b.sharePercent}%` : ''}</span><button onclick={() => remove(b.id)}>✕</button></div>
{/each}
<div class="guard-section">
<h3>Marad al-mawt check</h3>
{#if !showGuard}
<button class="btn-secondary" onclick={() => showGuard = true}>Run health-state check</button>
{:else}
<MaradAlMawtGuard estateTotal={total} beneficiaryRelation={beneficiaries[0]?.relation || ''} onAnswered={r => guardResult = r} />
{/if}
</div>
<button class="btn-primary" disabled={!corpusAssetId || !mutawalli || (guardResult?.flagged && guardResult?.beneficiaryIsHeir)} onclick={exportDeed}>Export waqfiyya deed (drafting aid)</button>
<Disclaimer text="This app's output alone does not constitute a legally registered waqf. Registration with the relevant state waqf authority is required for legal effect." />
</div>
<style>
.module { padding: 4px 0 40px; }
h2 { font-family: 'DM Serif Display', serif; font-size: 24px; color: #E8E4DC; margin-bottom: 10px; }
.open-note { font-size: 11.5px; color: #C9A84C; background: rgba(201,168,76,0.08); border: 1px solid rgba(201,168,76,0.25); border-radius: 10px; padding: 10px 12px; margin-bottom: 12px; line-height: 1.5; }
.sub { font-size: 13px; color: #8A8478; margin-bottom: 16px; }
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
.field span { font-size: 12px; color: #B8B2A6; }
.field select, .field input { background: rgba(255,255,255,0.05); border: 1px solid rgba(201,168,76,0.2); border-radius: 8px; padding: 10px 12px; color: #E8E4DC; font-size: 14px; }
.field-check { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; font-size: 13.5px; color: #E8E4DC; }
.deviation-box, .guard-section { background: rgba(255,255,255,0.03); border-radius: 10px; padding: 12px; margin-bottom: 14px; }
.deviation-box p { font-size: 12px; color: #B8B2A6; line-height: 1.5; margin-bottom: 8px; }
.ack { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #E8E4DC; }
.form-card { background: rgba(255,255,255,0.03); border-radius: 12px; padding: 14px; margin-bottom: 16px; }
.btn-primary, .btn-secondary { width: 100%; padding: 12px; border-radius: 8px; border: none; font-weight: 600; cursor: pointer; }
.btn-primary { background: #C9A84C; color: #070A0D; }
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-secondary { background: rgba(255,255,255,0.08); color: #E8E4DC; }
.beneficiary-row { display: flex; justify-content: space-between; padding: 8px 0; font-size: 13px; color: #E8E4DC; border-bottom: 1px solid rgba(255,255,255,0.06); }
.beneficiary-row button { background: none; border: none; color: #8A8478; cursor: pointer; }
.guard-section h3 { font-size: 14px; color: #C9A84C; margin-bottom: 8px; }
</style>
+112
View File
@@ -0,0 +1,112 @@
<script>
import { calculateFaraid, applyEstateValue } from './calc/faraid.js';
import Disclaimer from './Disclaimer.svelte';
let deceasedGender = $state('male');
let estateValue = $state(0);
let spouseCount = $state(0);
let sons = $state(0);
let daughters = $state(0);
let father = $state(false);
let mother = $state(false);
let fullBrothers = $state(0);
let fullSisters = $state(0);
let paternalBrothers = $state(0);
let paternalSisters = $state(0);
let maternalSiblings = $state(0);
const hasDescendant = $derived(sons > 0 || daughters > 0);
const siblingsBlocked = $derived(father || sons > 0);
const result = $derived.by(() => {
const r = calculateFaraid({
deceasedGender, spouseCount, sons, daughters, father, mother,
fullBrothers, fullSisters, paternalBrothers, paternalSisters, maternalSiblings
});
return applyEstateValue(r, estateValue || 0);
});
</script>
<div class="module">
<h2>Faraid Calculator</h2>
<p class="sub">Answer simple questions about surviving family — no fiqh terminology needed.</p>
<label class="field">
<span>Deceased's gender</span>
<select bind:value={deceasedGender}>
<option value="male">Male</option>
<option value="female">Female</option>
</select>
</label>
<label class="field">
<span>Estate value (for computed amounts)</span>
<input type="number" min="0" bind:value={estateValue} placeholder="0" />
</label>
<label class="field">
<span>{deceasedGender === 'male' ? 'Number of surviving wives' : 'Surviving husband?'}</span>
{#if deceasedGender === 'male'}
<input type="number" min="0" max="4" bind:value={spouseCount} />
{:else}
<select bind:value={spouseCount}><option value={0}>No</option><option value={1}>Yes</option></select>
{/if}
</label>
<label class="field"><span>Sons</span><input type="number" min="0" bind:value={sons} /></label>
<label class="field"><span>Daughters</span><input type="number" min="0" bind:value={daughters} /></label>
<label class="field-check"><input type="checkbox" bind:checked={father} /><span>Father survives</span></label>
<label class="field-check"><input type="checkbox" bind:checked={mother} /><span>Mother survives</span></label>
{#if !hasDescendant}
<div class="siblings-note">Sibling questions only apply when there is no surviving father or son.</div>
{#if !siblingsBlocked}
<label class="field"><span>Full brothers</span><input type="number" min="0" bind:value={fullBrothers} /></label>
<label class="field"><span>Full sisters</span><input type="number" min="0" bind:value={fullSisters} /></label>
<label class="field"><span>Paternal (consanguine) brothers</span><input type="number" min="0" bind:value={paternalBrothers} /></label>
<label class="field"><span>Paternal (consanguine) sisters</span><input type="number" min="0" bind:value={paternalSisters} /></label>
<label class="field"><span>Maternal (uterine) siblings</span><input type="number" min="0" bind:value={maternalSiblings} /></label>
{/if}
{/if}
<div class="results">
<h3>Shares</h3>
{#each result.shares as s}
<div class="share-row">
<span class="share-heir">{s.heir}</span>
<span class="share-frac">{s.fraction}</span>
{#if estateValue > 0}<span class="share-amount">{s.amount.toLocaleString()}</span>{/if}
</div>
{/each}
{#if result.awlApplied}
<p class="note-box">Fixed shares exceeded the estate, so 'awl (proportional reduction) has been applied — every fixed share above is already reduced proportionally to fit the whole estate.</p>
{/if}
{#if result.raddApplied}
<p class="note-box">There was leftover residue with no residuary ('asabah) heir, so radd (return of surplus) has been applied — the shares above already include each eligible heir's proportional bonus.</p>
{/if}
{#if result.isUmariyyatayn}
<p class="note-box">Umariyyatayn / gharrawain ruling applied: the mother's share is one-third of the remainder after the spouse's share, not one-third of the whole estate.</p>
{/if}
</div>
<Disclaimer />
</div>
<style>
.module { padding: 4px 0 40px; }
h2 { font-family: 'DM Serif Display', serif; font-size: 24px; color: #E8E4DC; margin-bottom: 4px; }
.sub { font-size: 13px; color: #8A8478; margin-bottom: 20px; }
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
.field span { font-size: 12.5px; color: #B8B2A6; }
.field select, .field input { background: rgba(255,255,255,0.05); border: 1px solid rgba(201,168,76,0.2); border-radius: 8px; padding: 10px 12px; color: #E8E4DC; font-size: 15px; }
.field-check { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; font-size: 13.5px; color: #E8E4DC; }
.siblings-note { font-size: 11.5px; color: #8A8478; font-style: italic; margin: 8px 0; }
.results { margin-top: 24px; border-top: 1px solid rgba(201,168,76,0.15); padding-top: 16px; }
.results h3 { font-size: 15px; color: #C9A84C; margin-bottom: 10px; }
.share-row { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.06); font-size: 13.5px; }
.share-heir { color: #E8E4DC; flex: 1; }
.share-frac { color: #C9A84C; font-weight: 600; width: 60px; text-align: right; }
.share-amount { color: #2ECC71; width: 90px; text-align: right; }
.note-box { font-size: 12px; color: #B8B2A6; background: rgba(255,255,255,0.04); border-radius: 8px; padding: 10px 12px; margin-top: 10px; line-height: 1.5; }
</style>
+111
View File
@@ -0,0 +1,111 @@
<script>
import { load, save, estateTotal } from './storage.js';
import MaradAlMawtGuard from './MaradAlMawtGuard.svelte';
import Disclaimer from './Disclaimer.svelte';
const assets = load('assets', []);
const total = estateTotal(assets);
let gifts = $state(load('hibahGifts', []));
let form = $state({ recipient: '', relation: '', description: '', date: new Date().toISOString().slice(0, 10), statement: '' });
let guardResult = $state(null);
let showGuard = $state(false);
let pendingSave = $state(false);
function startAdd() {
showGuard = true;
guardResult = null;
}
function onGuardAnswered(res) {
guardResult = res;
}
function confirmSave() {
if (!form.recipient || !form.description) return;
if (guardResult?.flagged && guardResult?.beneficiaryIsHeir) return; // blocked
const gift = {
...form,
id: crypto.randomUUID(),
flagged: !!guardResult?.flagged,
cap: guardResult?.cap ?? null,
acknowledgmentLink: `nf-hibah-ack-${crypto.randomUUID().slice(0, 8)}`
};
gifts = [...gifts, gift];
save('hibahGifts', gifts);
form = { recipient: '', relation: '', description: '', date: new Date().toISOString().slice(0, 10), statement: '' };
showGuard = false;
guardResult = null;
}
function remove(id) {
gifts = gifts.filter(g => g.id !== id);
save('hibahGifts', gifts);
}
const lifetimeTotal = $derived(gifts.reduce((s, g) => s + 0, 0)); // value optional; kept simple per hibah = gift not always monetary
</script>
<div class="module">
<h2>Hibah Tracker</h2>
<p class="sub">Log a lifetime gift — completed by offer, acceptance, and possession.</p>
<div class="form-card">
<label class="field"><span>Recipient</span><input type="text" bind:value={form.recipient} /></label>
<label class="field"><span>Relation to you</span><input type="text" bind:value={form.relation} /></label>
<label class="field"><span>Asset / gift description</span><input type="text" bind:value={form.description} /></label>
<label class="field"><span>Date</span><input type="date" bind:value={form.date} /></label>
<label class="field"><span>Offer-and-acceptance statement</span><textarea bind:value={form.statement} rows="2"></textarea></label>
{#if !showGuard}
<button class="btn-primary" onclick={startAdd}>Log this hibah</button>
{:else}
<MaradAlMawtGuard estateTotal={total} beneficiaryRelation={form.relation} onAnswered={onGuardAnswered} />
{#if guardResult}
<button
class="btn-primary"
disabled={guardResult.flagged && guardResult.beneficiaryIsHeir}
onclick={confirmSave}
>Confirm and save</button>
{/if}
{/if}
</div>
<div class="list">
{#each gifts as g (g.id)}
<div class="gift-row" class:flagged={g.flagged}>
<div class="gift-info">
<strong>{g.recipient}</strong> <span class="muted">({g.relation || '—'})</span>
<p class="gift-desc">{g.description}</p>
<span class="muted">{g.date}</span>
{#if g.flagged}<span class="flag-badge">Flagged — terminal-illness context</span>{/if}
</div>
<button onclick={() => remove(g.id)}>✕</button>
</div>
{:else}
<p class="empty">No hibah entries logged yet.</p>
{/each}
</div>
<Disclaimer />
</div>
<style>
.module { padding: 4px 0 40px; }
h2 { font-family: 'DM Serif Display', serif; font-size: 24px; color: #E8E4DC; margin-bottom: 4px; }
.sub { font-size: 13px; color: #8A8478; margin-bottom: 16px; }
.form-card { background: rgba(255,255,255,0.03); border-radius: 12px; padding: 14px; margin-bottom: 16px; }
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
.field span { font-size: 12px; color: #B8B2A6; }
.field input, .field textarea { background: rgba(255,255,255,0.05); border: 1px solid rgba(201,168,76,0.2); border-radius: 8px; padding: 10px 12px; color: #E8E4DC; font-size: 14px; font-family: inherit; resize: vertical; }
.btn-primary { width: 100%; padding: 12px; border-radius: 8px; border: none; font-weight: 600; cursor: pointer; background: #C9A84C; color: #070A0D; }
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
.gift-row { display: flex; justify-content: space-between; align-items: flex-start; padding: 12px 0; border-bottom: 1px solid rgba(255,255,255,0.06); }
.gift-row.flagged { background: rgba(239,68,68,0.05); border-radius: 8px; padding: 12px; margin-bottom: 6px; border-bottom: none; }
.gift-info { font-size: 13px; color: #E8E4DC; }
.gift-desc { color: #B8B2A6; margin: 2px 0; }
.muted { color: #8A8478; font-size: 11.5px; }
.flag-badge { display: inline-block; margin-top: 4px; font-size: 10.5px; background: #EF4444; color: white; padding: 2px 8px; border-radius: 6px; }
.gift-row button { background: none; border: none; color: #8A8478; cursor: pointer; }
.empty { font-size: 13px; color: #8A8478; text-align: center; padding: 20px 0; }
</style>
+50
View File
@@ -0,0 +1,50 @@
<script>
// Single shared component per Horizon 1 PRD §9: "identical wording and identical
// one-third-cap calculation logic... so a future fiqh-review correction only ever
// needs to be made once." Consumed by Hibah Tracker and Family Waqf Designator.
import { oneThirdCap, isQuranicHeirRelation } from './calc/faraid.js';
let { estateTotal = 0, onAnswered = () => {}, beneficiaryRelation = '' } = $props();
let answered = $state(false);
let flagged = $state(false);
const cap = $derived(oneThirdCap(estateTotal));
const beneficiaryIsHeir = $derived(isQuranicHeirRelation(beneficiaryRelation));
function answer(isFlagged) {
answered = true;
flagged = isFlagged;
onAnswered({ flagged: isFlagged, cap, beneficiaryIsHeir });
}
</script>
<div class="guard">
{#if !answered}
<p class="guard-question">Are you currently living with a diagnosed terminal or critical illness (marad al-mawt)?</p>
<div class="guard-buttons">
<button class="btn-secondary" onclick={() => answer(false)}>No</button>
<button class="btn-warn" onclick={() => answer(true)}>Yes</button>
</div>
{:else if flagged}
<div class="guard-flagged">
<p><strong>Flagged — terminal-illness context.</strong> This entry is capped at one-third of your estate (<strong>{cap.toLocaleString()}</strong>) and cannot name an existing Quranic heir as beneficiary, exactly like a wasiyyah.</p>
{#if beneficiaryIsHeir}
<p class="guard-error">The beneficiary relation you entered ("{beneficiaryRelation}") appears to be an existing heir. This is blocked under the marad al-mawt guardrail — remove or change the beneficiary to proceed.</p>
{/if}
<p class="guard-note">This answer is logged for your own records and any future scholarly or legal review. It is asked fresh on every entry and is never cached.</p>
</div>
{/if}
</div>
<style>
.guard { background: rgba(239,68,68,0.06); border: 1px solid rgba(239,68,68,0.2); border-radius: 10px; padding: 14px; margin: 12px 0; }
.guard-question { font-size: 13.5px; color: #E8E4DC; margin: 0 0 10px; }
.guard-buttons { display: flex; gap: 10px; }
.btn-secondary, .btn-warn { flex: 1; padding: 10px; border-radius: 8px; border: none; font-weight: 600; cursor: pointer; }
.btn-secondary { background: rgba(255,255,255,0.08); color: #E8E4DC; }
.btn-warn { background: #EF4444; color: white; }
.guard-flagged p { font-size: 12.5px; color: #E8E4DC; line-height: 1.5; margin: 0 0 8px; }
.guard-error { color: #EF4444 !important; font-weight: 600; }
.guard-note { color: #8A8478 !important; font-style: italic; }
</style>
+144
View File
@@ -0,0 +1,144 @@
<script>
import { load, save, estateTotal } from './storage.js';
import { oneThirdCap, isQuranicHeirRelation } from './calc/faraid.js';
import Disclaimer from './Disclaimer.svelte';
const assets = load('assets', []);
const total = estateTotal(assets);
const cap = oneThirdCap(total);
let jurisdiction = $state('UK');
let bequests = $state(load('wassiyahBequests', []));
let form = $state({ recipient: '', relation: '', description: '', value: '' });
let witness1 = $state(load('witness1', ''));
let witness2 = $state(load('witness2', ''));
let overrideAcknowledged = $state(false);
const bequestTotal = $derived(bequests.reduce((s, b) => s + Number(b.value), 0));
const exceedsCap = $derived(bequestTotal > cap);
const blockedRecipient = $derived(isQuranicHeirRelation(form.relation));
function addBequest() {
if (!form.recipient || !form.value) return;
bequests = [...bequests, { ...form, id: crypto.randomUUID(), value: Number(form.value) }];
save('wassiyahBequests', bequests);
form = { recipient: '', relation: '', description: '', value: '' };
}
function remove(id) {
bequests = bequests.filter(b => b.id !== id);
save('wassiyahBequests', bequests);
}
function exportDraft() {
if (exceedsCap && !overrideAcknowledged) return;
const lines = [
'WASSIYAH (ISLAMIC WILL) — DRAFT',
`Jurisdiction: ${jurisdiction}`,
`Generated: ${new Date().toISOString().slice(0, 10)}`,
`Net estate (per Asset Registry): ${total.toLocaleString()}`,
`One-third discretionary limit: ${cap.toLocaleString()}`,
`Total bequeathed: ${bequestTotal.toLocaleString()}${exceedsCap ? ' [EXCEEDS ONE-THIRD — OVERRIDE ACKNOWLEDGED]' : ''}`,
'',
'BEQUESTS:',
...bequests.map(b => ` ${b.recipient} (${b.relation || 'non-heir'}) — ${b.description} — ${Number(b.value).toLocaleString()}`),
'',
'This bequest is limited to the discretionary one-third of the net estate and cannot',
'benefit a person who is already a fixed-share (Faraid) heir, except with the consent',
'of all other heirs after death.',
'',
jurisdiction === 'UK'
? 'UK NOTE: this draft references Wills Act 1837 execution requirements. A will only has\nlegal effect if it is also validly executed under UK law, independent of its faraid-\ncorrectness. Have this reviewed by a UK-qualified solicitor before relying on it.'
: 'MALAYSIA NOTE: Land Office or Shariah Court recognition is not guaranteed by an\napp-generated document alone. State-specific requirements vary across all fourteen states.',
'',
`Witness 1: ${witness1 || '________________________'}`,
`Witness 2: ${witness2 || '________________________'}`,
'',
'DISCLAIMER: Not a fatwa. Not legal advice. Requires qualified review before real-world reliance.'
];
save('witness1', witness1); save('witness2', witness2);
const blob = new Blob([lines.join('\n')], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'wassiyah-draft.txt'; a.click();
URL.revokeObjectURL(url);
window.print();
}
</script>
<div class="module">
<h2>Wassiyah Generator</h2>
<p class="sub">Discretionary bequest limited to one-third of your net estate, calculated against your Asset Registry.</p>
<div class="meter-card">
<div class="meter-row"><span>Net estate</span><strong>{total.toLocaleString()}</strong></div>
<div class="meter-row"><span>One-third limit</span><strong class="gold">{cap.toLocaleString()}</strong></div>
<div class="meter-bar"><div class="meter-fill" class:over={exceedsCap} style="width: {Math.min(100, (bequestTotal / (cap || 1)) * 100)}%"></div></div>
<div class="meter-row"><span>Bequeathed so far</span><strong class:danger={exceedsCap}>{bequestTotal.toLocaleString()}</strong></div>
</div>
<label class="field"><span>Jurisdiction</span>
<select bind:value={jurisdiction}><option value="UK">United Kingdom</option><option value="MY">Malaysia</option></select>
</label>
<div class="form-card">
<label class="field"><span>Recipient name</span><input type="text" bind:value={form.recipient} /></label>
<label class="field"><span>Relation to you</span><input type="text" bind:value={form.relation} placeholder="e.g. nephew, charity, friend not spouse/child/parent/sibling" /></label>
<label class="field"><span>Description</span><input type="text" bind:value={form.description} /></label>
<label class="field"><span>Value</span><input type="number" min="0" bind:value={form.value} /></label>
{#if blockedRecipient}
<p class="block-error">This relation ("{form.relation}") matches an existing Quranic heir. You cannot bequeath to an existing heir via wassiyah — the fixed Faraid shares already govern their portion. This entry is blocked.</p>
{:else}
<button class="btn-primary" onclick={addBequest}>Add bequest</button>
{/if}
</div>
{#each bequests as b (b.id)}
<div class="bequest-row">
<div><strong>{b.recipient}</strong> <span class="muted">({b.relation || 'non-heir'})</span><br/><span class="muted">{b.description}</span></div>
<div class="bequest-value">{Number(b.value).toLocaleString()}<button onclick={() => remove(b.id)}>✕</button></div>
</div>
{/each}
{#if exceedsCap}
<div class="override-box">
<p><strong>This exceeds the one-third limit.</strong> Under Faraid, a bequest above one-third of the net estate requires the consent of all heirs after your death, or it is void ab initio for the excess. Proceeding without that consent risks a materially wrong religious and, in some jurisdictions, legal claim reaching your family.</p>
<label class="ack"><input type="checkbox" bind:checked={overrideAcknowledged} /><span>I understand and want to proceed anyway</span></label>
</div>
{/if}
<label class="field"><span>Witness 1</span><input type="text" bind:value={witness1} /></label>
<label class="field"><span>Witness 2</span><input type="text" bind:value={witness2} /></label>
<button class="btn-primary" disabled={exceedsCap && !overrideAcknowledged} onclick={exportDraft}>Export draft (PDF + text)</button>
<Disclaimer />
</div>
<style>
.module { padding: 4px 0 40px; }
h2 { font-family: 'DM Serif Display', serif; font-size: 24px; color: #E8E4DC; margin-bottom: 4px; }
.sub { font-size: 13px; color: #8A8478; margin-bottom: 16px; }
.meter-card { background: rgba(255,255,255,0.03); border-radius: 12px; padding: 14px; margin-bottom: 18px; }
.meter-row { display: flex; justify-content: space-between; font-size: 13px; color: #B8B2A6; margin-bottom: 6px; }
.meter-row strong { color: #E8E4DC; }
.gold { color: #C9A84C !important; }
.danger { color: #EF4444 !important; }
.meter-bar { height: 8px; background: rgba(255,255,255,0.08); border-radius: 4px; overflow: hidden; margin: 8px 0; }
.meter-fill { height: 100%; background: #2ECC71; transition: width 0.3s; }
.meter-fill.over { background: #EF4444; }
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
.field span { font-size: 12px; color: #B8B2A6; }
.field select, .field input { background: rgba(255,255,255,0.05); border: 1px solid rgba(201,168,76,0.2); border-radius: 8px; padding: 10px 12px; color: #E8E4DC; font-size: 14px; }
.form-card { background: rgba(255,255,255,0.03); border-radius: 12px; padding: 14px; margin-bottom: 16px; }
.btn-primary { width: 100%; padding: 12px; border-radius: 8px; border: none; font-weight: 600; cursor: pointer; background: #C9A84C; color: #070A0D; }
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
.block-error { font-size: 12px; color: #EF4444; line-height: 1.5; }
.bequest-row { display: flex; justify-content: space-between; align-items: center; padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.06); font-size: 13px; color: #E8E4DC; }
.muted { color: #8A8478; font-size: 11.5px; }
.bequest-value { color: #2ECC71; font-weight: 600; display: flex; align-items: center; gap: 8px; }
.bequest-value button { background: none; border: none; color: #8A8478; cursor: pointer; }
.override-box { background: rgba(239,68,68,0.08); border: 1px solid rgba(239,68,68,0.25); border-radius: 10px; padding: 12px; margin: 16px 0; }
.override-box p { font-size: 12px; color: #E8E4DC; line-height: 1.5; margin-bottom: 10px; }
.ack { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: #E8E4DC; }
</style>
+230
View File
@@ -0,0 +1,230 @@
// Shared Faraid calculation core.
// Single engine consumed by: Faraid Calculator, Wassiyah Generator (1/3 meter),
// Hibah Tracker (marad al-mawt cap), Family Waqf Designator (marad al-mawt cap).
// Per Horizon 1 PRD §11: "one engine, four consumers, so the modules can never disagree."
//
// Scope note: models the majority-position fixed shares, 'awl, radd, and hijab
// blocking for spouse/children/parents/siblings (full, consanguine, uterine).
// Does not yet model grandparents, grandchildren, or extended 'asabah chains —
// tracked as a follow-up, not silently assumed correct for those cases.
function gcd(a, b) { return b === 0 ? a : gcd(b, a % b); }
class Fraction {
constructor(num, den) {
if (den === 0) throw new Error('Zero denominator');
if (den < 0) { num = -num; den = -den; }
const g = gcd(Math.abs(num), den) || 1;
this.num = num / g;
this.den = den / g;
}
add(o) { return new Fraction(this.num * o.den + o.num * this.den, this.den * o.den); }
sub(o) { return new Fraction(this.num * o.den - o.num * this.den, this.den * o.den); }
mul(o) { return new Fraction(this.num * o.num, this.den * o.den); }
div(o) { return new Fraction(this.num * o.den, this.den * o.num); }
toNumber() { return this.num / this.den; }
toString() { return this.den === 1 ? `${this.num}` : `${this.num}/${this.den}`; }
static zero() { return new Fraction(0, 1); }
}
/**
* @param {object} heirs
* spouseCount, deceasedGender ('male'|'female'),
* sons, daughters, father, mother (bool),
* fullBrothers, fullSisters, paternalBrothers, paternalSisters, maternalSiblings
*/
export function calculateFaraid(heirs) {
const {
deceasedGender = 'male',
spouseCount = 0,
sons = 0,
daughters = 0,
father = false,
mother = false,
fullBrothers = 0,
fullSisters = 0,
paternalBrothers = 0,
paternalSisters = 0,
maternalSiblings = 0
} = heirs;
const hasChildren = sons > 0 || daughters > 0;
const hasDescendants = hasChildren; // grandchildren not yet modelled
const hasFather = !!father;
// Hijab (blocking): full/consanguine siblings blocked by father or a son (not daughter alone).
// Uterine (maternal) siblings blocked by any child or father/grandfather.
const siblingsBlockedByDescendantOrFather = hasFather || sons > 0;
const effFullBrothers = siblingsBlockedByDescendantOrFather ? 0 : fullBrothers;
const effFullSisters = siblingsBlockedByDescendantOrFather ? 0 : fullSisters;
const effPaternalBrothers = (siblingsBlockedByDescendantOrFather || effFullBrothers > 0) ? 0 : paternalBrothers;
const effPaternalSisters = (siblingsBlockedByDescendantOrFather || effFullBrothers > 0 || (effFullSisters > 0 && sons === 0 && daughters === 0)) ? 0 : paternalSisters;
const effMaternalSiblings = (hasChildren || hasFather) ? 0 : maternalSiblings;
const shares = []; // { heir, count, fraction, note }
let fixedTotal = Fraction.zero();
// Spouse
if (spouseCount > 0) {
let f;
if (deceasedGender === 'male') {
f = hasDescendants ? new Fraction(1, 8) : new Fraction(1, 4); // wife/wives share this jointly
} else {
f = hasDescendants ? new Fraction(1, 4) : new Fraction(1, 2); // husband
}
shares.push({ heir: deceasedGender === 'male' ? 'Wife/Wives (combined)' : 'Husband', count: spouseCount, fraction: f });
fixedTotal = fixedTotal.add(f);
}
// Children: sons/daughters take residue by 'asabah (2:1) if sons present; if daughters only, fixed shares apply.
let childrenAreResiduary = sons > 0;
if (!childrenAreResiduary && daughters > 0) {
const f = daughters === 1 ? new Fraction(1, 2) : new Fraction(2, 3);
shares.push({ heir: daughters === 1 ? 'Daughter' : 'Daughters (combined)', count: daughters, fraction: f });
fixedTotal = fixedTotal.add(f);
}
// Father
if (hasFather) {
if (hasDescendants) {
const f = new Fraction(1, 6);
shares.push({ heir: 'Father', count: 1, fraction: f, note: sons === 0 ? 'plus residue if any remains' : undefined });
fixedTotal = fixedTotal.add(f);
}
// if no descendants, father is pure 'asabah — handled in residue step
}
// Mother — gharrawain/umariyyatayn: spouse + both parents, mother gets 1/3 of *remainder after spouse*, not 1/3 of estate.
const isUmariyyatayn = mother && hasFather && !hasChildren && (fullBrothers + fullSisters + paternalBrothers + paternalSisters + maternalSiblings === 0) && spouseCount > 0;
if (mother) {
let f;
const siblingCountForMotherBlock = effFullBrothers + effFullSisters + effPaternalBrothers + effPaternalSisters + effMaternalSiblings;
if (isUmariyyatayn) {
const remainderAfterSpouse = new Fraction(1, 1).sub(shares.find(s => s.heir.includes('Wife') || s.heir.includes('Husband')).fraction);
f = remainderAfterSpouse.mul(new Fraction(1, 3));
shares.push({ heir: 'Mother', count: 1, fraction: f, note: 'gharrawain/umariyyatayn ruling applied' });
} else if (hasDescendants || siblingCountForMotherBlock >= 2) {
f = new Fraction(1, 6);
shares.push({ heir: 'Mother', count: 1, fraction: f });
} else {
f = new Fraction(1, 3);
shares.push({ heir: 'Mother', count: 1, fraction: f });
}
fixedTotal = fixedTotal.add(f);
}
// Kalala siblings (no father, no descendants) — full siblings first, else consanguine, else uterine always independent
const noFatherNoDescendant = !hasFather && !hasDescendants;
if (effMaternalSiblings > 0) {
const f = effMaternalSiblings === 1 ? new Fraction(1, 6) : new Fraction(1, 3);
shares.push({ heir: 'Maternal (uterine) siblings', count: effMaternalSiblings, fraction: f, note: 'shared equally regardless of sex' });
fixedTotal = fixedTotal.add(f);
}
if (noFatherNoDescendant && (effFullBrothers > 0 || effFullSisters > 0)) {
if (effFullBrothers === 0 && effFullSisters > 0) {
const f = effFullSisters === 1 ? new Fraction(1, 2) : new Fraction(2, 3);
shares.push({ heir: effFullSisters === 1 ? 'Full sister' : 'Full sisters (combined)', count: effFullSisters, fraction: f });
fixedTotal = fixedTotal.add(f);
}
// else: full brothers present -> full siblings become 'asabah, handled in residue
} else if (noFatherNoDescendant && effPaternalBrothers === 0 && effPaternalSisters > 0) {
const f = effPaternalSisters === 1 ? new Fraction(1, 2) : new Fraction(2, 3);
shares.push({ heir: effPaternalSisters === 1 ? 'Paternal (consanguine) sister' : 'Paternal (consanguine) sisters (combined)', count: effPaternalSisters, fraction: f });
fixedTotal = fixedTotal.add(f);
}
// 'Awl: if fixed shares exceed the whole estate, reduce all fixed shares proportionally.
let awlApplied = false;
let awlFactor = new Fraction(1, 1);
if (fixedTotal.toNumber() > 1) {
awlApplied = true;
awlFactor = new Fraction(1, 1).div(fixedTotal);
for (const s of shares) s.fraction = s.fraction.mul(awlFactor);
fixedTotal = new Fraction(1, 1);
}
// Residue to 'asabah (sons+daughters 2:1, else father, else full/paternal brothers+sisters 2:1)
let residue = new Fraction(1, 1).sub(fixedTotal);
let raddApplied = false;
if (residue.toNumber() > 0) {
if (sons > 0) {
const units = sons * 2 + daughters;
const sonShare = residue.mul(new Fraction(2, units));
const daughterShare = residue.mul(new Fraction(1, units));
shares.push({ heir: 'Son(s)', count: sons, fraction: sonShare.mul(new Fraction(sons, 1)), note: '2:1 with daughters, per son total shown' });
if (daughters > 0) shares.push({ heir: 'Daughter(s) (residuary share)', count: daughters, fraction: daughterShare.mul(new Fraction(daughters, 1)) });
residue = Fraction.zero();
}
// Father as 'asabah bil-ghayr: with daughters only (no son), father's fixed 1/6 plus any leftover residue.
if (hasFather && hasDescendants && sons === 0 && residue.toNumber() > 0) {
const fatherShare = shares.find(s => s.heir === 'Father');
if (fatherShare) fatherShare.fraction = fatherShare.fraction.add(residue);
else shares.push({ heir: 'Father', count: 1, fraction: residue });
residue = Fraction.zero();
}
if (hasFather && !hasDescendants && residue.toNumber() > 0 && !shares.some(s => s.heir === 'Father')) {
shares.push({ heir: 'Father (residuary)', count: 1, fraction: residue });
residue = Fraction.zero();
} else if (noFatherNoDescendant && effFullBrothers > 0 && residue.toNumber() > 0) {
const units = effFullBrothers * 2 + effFullSisters;
shares.push({ heir: 'Full brother(s)/sister(s) (residuary, 2:1)', count: effFullBrothers + effFullSisters, fraction: residue });
residue = Fraction.zero();
} else if (noFatherNoDescendant && effPaternalBrothers > 0 && residue.toNumber() > 0) {
shares.push({ heir: 'Paternal brother(s)/sister(s) (residuary, 2:1)', count: effPaternalBrothers + effPaternalSisters, fraction: residue });
residue = Fraction.zero();
}
}
// Radd: leftover residue with no residuary heir returns to fixed-share heirs (excl. spouse) proportionally.
if (residue.toNumber() > 0 && shares.length > 0) {
raddApplied = true;
const raddEligible = shares.filter(s => !s.heir.includes('Wife') && !s.heir.includes('Husband'));
const eligibleTotal = raddEligible.reduce((acc, s) => acc.add(s.fraction), Fraction.zero());
if (eligibleTotal.toNumber() > 0) {
for (const s of raddEligible) {
const bonus = s.fraction.div(eligibleTotal).mul(residue);
s.fraction = s.fraction.add(bonus);
}
} else {
// no eligible heirs at all besides spouse: spouse takes remainder by radd exception (contested; flagged)
const spouseShare = shares.find(s => s.heir.includes('Wife') || s.heir.includes('Husband'));
if (spouseShare) { spouseShare.fraction = spouseShare.fraction.add(residue); spouseShare.note = (spouseShare.note || '') + ' [radd-to-spouse: minority position, flag for scholarly review]'; }
}
residue = Fraction.zero();
}
return {
shares: shares.map(s => ({ ...s, fraction: s.fraction.toString(), fractionValue: s.fraction.toNumber() })),
awlApplied,
raddApplied,
isUmariyyatayn
};
}
/** Apply computed shares against a currency estate value. */
export function applyEstateValue(result, estateValue) {
return {
...result,
shares: result.shares.map(s => ({ ...s, amount: Math.round(s.fractionValue * estateValue * 100) / 100 }))
};
}
/** One-third cap check used by Wassiyah, Hibah (marad al-mawt), and Family Waqf (marad al-mawt). */
export function oneThirdCap(estateValue) {
return Math.round((estateValue / 3) * 100) / 100;
}
export const QURANIC_HEIR_LABELS = [
'spouse', 'wife', 'husband', 'son', 'daughter', 'father', 'mother',
'full brother', 'full sister', 'paternal brother', 'paternal sister',
'maternal brother', 'maternal sister'
];
/** Blocking check for wassiyah/hibah/waqf beneficiary validation: is this relation an existing heir? */
export function isQuranicHeirRelation(relation) {
const r = (relation || '').toLowerCase();
return QURANIC_HEIR_LABELS.some(label => r.includes(label));
}
+109
View File
@@ -0,0 +1,109 @@
// Classical-case regression suite for the shared Faraid engine.
// Horizon 1 PRD §8.1 acceptance criteria: "minimum ten cases... runs in CI on every change."
// Run: node src/lib/calc/faraid.test.js
import { calculateFaraid } from './faraid.js';
let pass = 0, fail = 0;
function approx(a, b, eps = 1e-9) { return Math.abs(a - b) < eps; }
function check(name, heirs, expected) {
const result = calculateFaraid(heirs);
const got = {};
for (const s of result.shares) got[s.heir] = s.fractionValue;
let ok = true;
const details = [];
for (const [heir, frac] of Object.entries(expected)) {
const val = got[heir];
if (val === undefined || !approx(val, frac)) {
ok = false;
details.push(` ${heir}: expected ${frac}, got ${val}`);
}
}
if (result.flagExpected) {
if (result.awlApplied !== result.flagExpected.awl) { ok = false; details.push(` awlApplied expected ${result.flagExpected.awl}, got ${result.awlApplied}`); }
}
if (ok) { pass++; console.log(`PASS ${name}`); }
else { fail++; console.log(`FAIL ${name}`); details.forEach(d => console.log(d)); console.log(' full result:', JSON.stringify(result, null, 2)); }
}
// 1. Wife, one daughter, father, mother — textbook case (PRD §8.1 acceptance criterion:
// "wife 1/8, daughter 1/2, mother 1/6, father 1/6 plus residue" — residue here is 1/24,
// so father's total is 1/6 + 1/24 = 5/24).
check('Wife + 1 daughter + father + mother', {
spouseCount: 1, deceasedGender: 'male', daughters: 1, father: true, mother: true
}, { 'Wife/Wives (combined)': 1 / 8, 'Daughter': 1 / 2, 'Mother': 1 / 6, 'Father': 1 / 6 + 1 / 24 });
// 2. Umariyyatayn / gharrawain: spouse + both parents, no children/siblings
check('Umariyyatayn: husband + father + mother (deceased female)', {
spouseCount: 1, deceasedGender: 'female', father: true, mother: true
}, { 'Husband': 1 / 2, 'Mother': (1 - 0.5) / 3 });
// 3. 'Awl case: wife + 2 full sisters + mother (shares exceed 1)
check("'Awl: wife + 2 full sisters + mother", {
spouseCount: 1, deceasedGender: 'male', fullSisters: 2, mother: true
}, {});
{
const r = calculateFaraid({ spouseCount: 1, deceasedGender: 'male', fullSisters: 2, mother: true });
if (r.awlApplied) { pass++; console.log("PASS 'Awl flag triggers when shares exceed estate"); }
else { fail++; console.log("FAIL 'Awl flag did not trigger"); }
}
// 4. Hijab: son blocks all siblings entirely
check('Hijab: son blocks full brothers', {
sons: 1, fullBrothers: 2
}, { 'Son(s)': 1 });
// 5. Hijab: father blocks maternal siblings
check('Hijab: father blocks maternal siblings', {
father: true, maternalSiblings: 2
}, { 'Father (residuary)': 1 });
// 6. Sons and daughters residuary 2:1
check('2 sons + 1 daughter, no other heirs', {
sons: 2, daughters: 1
}, { 'Son(s)': 4 / 5, 'Daughter(s) (residuary share)': 1 / 5 });
// 7. Daughters only (2+) fixed 2/3
check('2 daughters only', {
daughters: 2
}, {});
{
const r = calculateFaraid({ daughters: 2 });
const d = r.shares.find(s => s.heir.includes('Daughters'));
if (d && approx(d.fractionValue, 2 / 3 + (1 / 3))) { pass++; console.log('PASS 2 daughters: fixed 2/3 + radd of residue (no other heirs)'); }
else { fail++; console.log('FAIL 2 daughters case', JSON.stringify(r)); }
}
// 8. Maternal (uterine) siblings only, kalala case, single sibling
check('Single maternal sibling, no parent/descendant', {
maternalSiblings: 1
}, {});
{
const r = calculateFaraid({ maternalSiblings: 1 });
const m = r.shares.find(s => s.heir.includes('Maternal'));
if (m && approx(m.fractionValue, 1)) { pass++; console.log('PASS single maternal sibling gets 1/6 + radd = whole estate (sole heir)'); }
else { fail++; console.log('FAIL single maternal sibling case', JSON.stringify(r)); }
}
// 9. Multiple maternal siblings, kalala case (1/3 shared)
{
const r = calculateFaraid({ maternalSiblings: 3 });
const m = r.shares.find(s => s.heir.includes('Maternal'));
if (m && approx(m.fractionValue, 1)) { pass++; console.log('PASS 3 maternal siblings: base 1/3 + radd = whole estate (sole heirs)'); }
else { fail++; console.log('FAIL 3 maternal siblings case', JSON.stringify(r)); }
}
// 10. Full siblings vs paternal (consanguine) blocking: full brother blocks paternal siblings entirely
check('Full brother blocks paternal siblings', {
fullBrothers: 1, paternalSisters: 2
}, { 'Full brother(s)/sister(s) (residuary, 2:1)': 1 });
// 11. Spouse + mother + father, no children, no siblings (not umariyyatayn since... wait it is) — distinct check with only mother+father, no spouse
check('Mother + father only, no spouse/children/siblings', {
mother: true, father: true
}, { 'Mother': 1 / 3, 'Father (residuary)': 2 / 3 });
console.log(`\n${pass} passed, ${fail} failed`);
if (fail > 0) process.exit(1);
+116
View File
@@ -0,0 +1,116 @@
<script>
// Built per explicit user instruction overriding the Horizon 2 PRD's Phase 0 gate.
// See claims.js header for the full note. This screen is clearly labeled pre-pilot
// throughout — the PRD's own non-goals (§3.2) forbid presenting this as a freely
// tradeable security or a live institutional integration, and it is neither.
import { listClaims, issueClaim, transferClaim, removeClaim } from './claims.js';
import Disclaimer from '../Disclaimer.svelte';
let claims = $state(listClaims());
let form = $state({ assetLabel: '', heirPoolId: '', holderName: '', heirShareFraction: '', faraidRulingRef: '' });
let transferTarget = $state({});
let error = $state('');
function issue() {
if (!form.assetLabel || !form.holderName || !form.heirPoolId) return;
const claim = issueClaim({ ...form });
claims = [...claims, claim];
form = { assetLabel: '', heirPoolId: '', holderName: '', heirShareFraction: '', faraidRulingRef: '' };
}
function doTransfer(claim, mode) {
error = '';
try {
const t = transferTarget[claim.id] || {};
transferClaim(claim.id, { toHolderName: t.name, toHeirPoolId: t.heirPoolId || claim.heirPoolId, mode });
claims = listClaims();
} catch (e) {
error = e.message;
}
}
function remove(id) {
removeClaim(id);
claims = listClaims();
}
</script>
<div class="module">
<div class="pilot-banner">
<strong>PRE-PILOT DEMONSTRATION — NOT A LIVE PROGRAMME.</strong>
Horizon 2 PRD Status: "No engineering work begins until Phase 0 (legal opinion + signed
institutional partner agreement) is complete." That confirmation has not been provided in
this session. This screen is a structural demonstration of the claim model and its transfer
restriction — it issues no real legal claim, moves no real asset, and is not backed by any
SPV/trust legal wrapper or institutional partner.
</div>
<h2>Digital Beneficial Claims</h2>
<p class="sub">Represents one heir's already-calculated, faraid-verified fractional interest in a specific illiquid asset. Transferable only within the same heir pool, or back to the wrapper entity — never onto an open market.</p>
<div class="form-card">
<label class="field"><span>Asset (named, illiquid — e.g. land parcel)</span><input type="text" bind:value={form.assetLabel} /></label>
<label class="field"><span>Heir pool ID (shared by all co-heirs of this asset)</span><input type="text" bind:value={form.heirPoolId} placeholder="e.g. HP-2026-001" /></label>
<label class="field"><span>Holder name</span><input type="text" bind:value={form.holderName} /></label>
<label class="field"><span>Heir share fraction (from faraid ruling)</span><input type="text" bind:value={form.heirShareFraction} placeholder="e.g. 1/8" /></label>
<label class="field"><span>Faraid ruling reference</span><input type="text" bind:value={form.faraidRulingRef} placeholder="Shariah Court ruling no. / equivalent" /></label>
<button class="btn-primary" onclick={issue}>Issue demo claim</button>
</div>
{#if error}<p class="error-box">{error}</p>{/if}
{#each claims as c (c.id)}
<div class="claim-card">
<div class="claim-head">
<strong>{c.assetLabel}</strong>
<span class="status status-{c.status}">{c.status}</span>
</div>
<div class="claim-meta">Holder: {c.holderName} · Share: {c.heirShareFraction || '—'} · Heir pool: {c.heirPoolId}</div>
<div class="claim-meta">Ruling ref: {c.faraidRulingRef || '—'}</div>
<div class="transfer-row">
<input type="text" placeholder="Recipient name (same heir pool)" value={transferTarget[c.id]?.name || ''} oninput={e => transferTarget[c.id] = { ...transferTarget[c.id], name: e.target.value }} />
<button class="btn-small" onclick={() => doTransfer(c, 'heir-pool-transfer')}>Transfer within pool</button>
<button class="btn-small" onclick={() => doTransfer(c, 'buy-back')}>Buy-back to wrapper</button>
</div>
<details class="history">
<summary>History</summary>
{#each c.history as h}<div class="history-row">{h.event}{h.at.slice(0,10)}{h.to ? ` → ${h.to}` : ''}</div>{/each}
</details>
<button class="remove-btn" onclick={() => remove(c.id)}>Remove demo claim</button>
</div>
{:else}
<p class="empty">No demo claims issued yet.</p>
{/each}
<Disclaimer text="Not a security offering. Not a cryptocurrency or speculative asset. Any real deployment requires a legal wrapper (SPV/trust) holding actual title and a named institutional partner — neither exists in this build." />
</div>
<style>
.module { padding: 4px 0 40px; }
.pilot-banner { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); border-radius: 10px; padding: 12px 14px; font-size: 11.5px; color: #E8E4DC; line-height: 1.5; margin-bottom: 18px; }
h2 { font-family: 'DM Serif Display', serif; font-size: 24px; color: #E8E4DC; margin-bottom: 4px; }
.sub { font-size: 13px; color: #8A8478; margin-bottom: 16px; }
.form-card { background: rgba(255,255,255,0.03); border-radius: 12px; padding: 14px; margin-bottom: 16px; }
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
.field span { font-size: 12px; color: #B8B2A6; }
.field input { background: rgba(255,255,255,0.05); border: 1px solid rgba(201,168,76,0.2); border-radius: 8px; padding: 10px 12px; color: #E8E4DC; font-size: 14px; width: 100%; }
.btn-primary { width: 100%; padding: 12px; border-radius: 8px; border: none; font-weight: 600; cursor: pointer; background: #C9A84C; color: #070A0D; }
.error-box { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); color: #EF4444; font-size: 12px; padding: 10px 12px; border-radius: 8px; margin-bottom: 12px; line-height: 1.5; }
.claim-card { background: rgba(255,255,255,0.03); border-radius: 12px; padding: 14px; margin-bottom: 12px; }
.claim-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
.claim-head strong { color: #E8E4DC; font-size: 14px; }
.status { font-size: 10px; text-transform: uppercase; padding: 3px 8px; border-radius: 6px; background: rgba(46,204,113,0.15); color: #2ECC71; }
.status-transferred { background: rgba(201,168,76,0.15); color: #C9A84C; }
.status-bought-back { background: rgba(255,255,255,0.1); color: #B8B2A6; }
.claim-meta { font-size: 11.5px; color: #8A8478; margin-bottom: 2px; }
.transfer-row { display: flex; flex-direction: column; gap: 8px; margin-top: 10px; }
.transfer-row input { background: rgba(255,255,255,0.05); border: 1px solid rgba(201,168,76,0.2); border-radius: 8px; padding: 8px 10px; color: #E8E4DC; font-size: 13px; }
.btn-small { background: rgba(255,255,255,0.08); color: #E8E4DC; border: none; border-radius: 8px; padding: 8px; font-size: 12px; cursor: pointer; }
.history { margin-top: 10px; font-size: 11px; color: #8A8478; }
.history-row { padding: 2px 0; }
.remove-btn { margin-top: 10px; background: none; border: none; color: #8A8478; font-size: 11px; cursor: pointer; }
.empty { font-size: 13px; color: #8A8478; text-align: center; padding: 20px 0; }
</style>
+66
View File
@@ -0,0 +1,66 @@
// Digital Beneficial Claims data model.
// Built per explicit user instruction overriding Horizon 2 PRD's stated gate:
// "No engineering work begins until Phase 0 (legal opinion + signed institutional
// partner agreement) is complete." No evidence Phase 0 is complete — this ships as
// a local, non-custodial demonstration model only, structurally enforcing the
// PRD §5.2 transfer restriction (no open market) even though the real legal
// wrapper (SPV/trust, Horizon 2 PRD §5.1) and institutional integration do not exist
// yet. Nothing here moves real assets or represents a real legal claim.
import { load, save } from '../storage.js';
const KEY = 'h2Claims';
/**
* @param {object} params { assetId, assetLabel, heirPoolId, holderName, heirShareFraction, faraidRulingRef }
*/
export function issueClaim(params) {
const claims = load(KEY, []);
const claim = {
id: crypto.randomUUID(),
status: 'issued', // issued | transferred | bought-back
issuedAt: new Date().toISOString(),
history: [{ event: 'issued', at: new Date().toISOString(), to: params.holderName }],
...params
};
claims.push(claim);
save(KEY, claims);
return claim;
}
export function listClaims() {
return load(KEY, []);
}
/**
* Transfer restriction per Horizon 2 PRD §5.2: a claim may only transfer to
* (a) another verified heir within the same heir pool for that asset, or
* (b) the legal wrapper entity itself (buy-back). No default path to an open market
* structurally enforced here, not left to UI copy alone.
*/
export function transferClaim(claimId, { toHolderName, toHeirPoolId, mode }) {
const claims = load(KEY, []);
const claim = claims.find(c => c.id === claimId);
if (!claim) throw new Error('Claim not found');
if (mode === 'buy-back') {
claim.status = 'bought-back';
claim.history.push({ event: 'buy-back', at: new Date().toISOString(), note: 'valuation set by independent valuation, not a market bid (demo: no real valuation engine wired)' });
} else if (mode === 'heir-pool-transfer') {
if (toHeirPoolId !== claim.heirPoolId) {
throw new Error('BLOCKED: recipient is not in the same faraid-determined heir pool for this asset. Per PRD §5.2 there is no default path to an open market.');
}
claim.holderName = toHolderName;
claim.status = 'transferred';
claim.history.push({ event: 'transfer', at: new Date().toISOString(), to: toHolderName });
} else {
throw new Error('Unsupported transfer mode — pre-approved licensed institutional buyer flow requires a separately reviewed compliance process not modelled here.');
}
save(KEY, claims);
return claim;
}
export function removeClaim(claimId) {
const claims = load(KEY, []).filter(c => c.id !== claimId);
save(KEY, claims);
}
+35
View File
@@ -0,0 +1,35 @@
// String-dictionary i18n, per Horizon 1 PRD §11: "preserve the existing
// internationalization pattern... a product requirement, not implementation detail."
import { writable } from 'svelte/store';
export const lang = writable(load());
function load() {
try { return localStorage.getItem('nf.lang') || 'en'; } catch { return 'en'; }
}
export function setLang(l) {
lang.set(l);
try { localStorage.setItem('nf.lang', l); } catch {}
}
const dict = {
en: {
appName: 'Nur Falah', tagline: 'ESTATE & WAQF SUITE',
nav_faraid: 'Faraid', nav_assets: 'Assets', nav_wassiyah: 'Wassiyah',
nav_hibah: 'Hibah', nav_waqf: 'Family Waqf', nav_claims: 'Claims',
disclaimer_short: 'Not a fatwa. Not legal advice. Requires qualified review before real-world reliance.'
},
ms: {
appName: 'Nur Falah', tagline: 'RANGKAIAN HARTA & WAKAF',
nav_faraid: 'Faraid', nav_assets: 'Aset', nav_wassiyah: 'Wasiat',
nav_hibah: 'Hibah', nav_waqf: 'Wakaf Keluarga', nav_claims: 'Tuntutan',
disclaimer_short: 'Bukan fatwa. Bukan nasihat undang-undang. Memerlukan semakan bertauliah sebelum digunakan.'
}
};
export function t(key) {
let current = 'en';
lang.subscribe(v => current = v)();
return (dict[current] && dict[current][key]) || dict.en[key] || key;
}
+48
View File
@@ -0,0 +1,48 @@
// Local-first storage helper. Horizon 1 PRD §10: encrypted at rest, local-first,
// opt-in sync only, full export + irreversible delete one-tap from settings.
// Scope note: this ships plain localStorage for the working build; wrapping every
// read/write in WebCrypto AES-GCM (device-key-derived) is the remaining hardening
// step before this is production-safe for the "High/Critical" data rows in §10 —
// tracked here rather than silently assumed done.
const PREFIX = 'nf.';
export function load(key, fallback) {
try {
const raw = localStorage.getItem(PREFIX + key);
return raw ? JSON.parse(raw) : fallback;
} catch {
return fallback;
}
}
export function save(key, value) {
localStorage.setItem(PREFIX + key, JSON.stringify(value));
}
export function remove(key) {
localStorage.removeItem(PREFIX + key);
}
export function exportAll() {
const out = {};
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k && k.startsWith(PREFIX)) out[k.slice(PREFIX.length)] = JSON.parse(localStorage.getItem(k));
}
return out;
}
/** Irreversible, immediate — per §10, no confirmation-delay pattern beyond the button itself. */
export function deleteAll() {
const keys = [];
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k && k.startsWith(PREFIX)) keys.push(k);
}
keys.forEach(k => localStorage.removeItem(k));
}
export function estateTotal(assets) {
return (assets || []).reduce((sum, a) => sum + (Number(a.value) || 0) * (Number(a.ownershipShare ?? 100) / 100), 0);
}
+6
View File
@@ -0,0 +1,6 @@
import { mount } from 'svelte';
import App from './App.svelte';
const app = mount(App, { target: document.getElementById('app') });
export default app;
+28
View File
@@ -0,0 +1,28 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
svelte(),
VitePWA({
registerType: 'autoUpdate',
manifest: {
name: 'Nur Falah — Estate & Waqf Suite',
short_name: 'Nur Falah',
description: 'Faraid calculator, wassiyah, asset registry, hibah, family waqf, and digital claims',
theme_color: '#070A0D',
background_color: '#070A0D',
display: 'standalone',
orientation: 'portrait-primary',
icons: [
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png' }
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,svg,png,woff2,ico}']
}
})
]
});