Sfoglia il codice sorgente

feat: complete form system based on style system

AI 2 giorni fa
commit
2d1048126e
10 ha cambiato i file con 1563 aggiunte e 0 eliminazioni
  1. 121 0
      admin.php
  2. 140 0
      answer.php
  3. 445 0
      assets/css/style.css
  4. 20 0
      assets/js/answer.js
  5. 124 0
      assets/js/create.js
  6. 158 0
      create.php
  7. 1 0
      data/.htaccess
  8. 373 0
      docs/style_system.md
  9. 35 0
      index.php
  10. 146 0
      submit.php

+ 121 - 0
admin.php

@@ -0,0 +1,121 @@
+<?php
+// admin.php - View submitted responses
+
+$form_id = $_GET['id'] ?? '';
+$token = $_GET['token'] ?? '';
+
+// Basic checks
+$form_file = __DIR__ . '/data/forms/' . preg_replace('/[^a-zA-Z0-9_-]/', '', $form_id) . '.json';
+if (empty($form_id) || empty($token) || !file_exists($form_file)) {
+    die('<div style="font-family:sans-serif; text-align:center; padding:50px;"><h2>Access Denied</h2></div>');
+}
+
+$form_data = json_decode(file_get_contents($form_file), true);
+
+if ($token !== $form_data['admin_token']) {
+    die('<div style="font-family:sans-serif; text-align:center; padding:50px;"><h2>Invalid Token</h2></div>');
+}
+
+$questions_map = [];
+foreach ($form_data['questions'] as $q) {
+    // Keep it short for the table headers
+    $questions_map[$q['id']] = mb_strimwidth($q['label'], 0, 30, "...");
+}
+
+// Read answers
+$answers_dir = __DIR__ . '/data/answers';
+$submissions = [];
+if (is_dir($answers_dir)) {
+    $files = glob("{$answers_dir}/{$form_id}_*.json");
+    foreach ($files as $f) {
+        $data = json_decode(file_get_contents($f), true);
+        if ($data) {
+            $submissions[] = $data;
+        }
+    }
+}
+
+// Sort by submitted_at descending
+usort($submissions, function($a, $b) {
+    return strtotime($b['submitted_at']) - strtotime($a['submitted_at']);
+});
+
+$total_responses = count($submissions);
+?>
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Admin - <?= htmlspecialchars($form_data['title']) ?></title>
+    <link rel="stylesheet" href="assets/css/style.css">
+</head>
+<body class="admin-page">
+    <header class="site-header">
+        <div class="container header-inner">
+            <div class="brand">
+                <span class="brand-title">Responses: <?= htmlspecialchars($form_data['title']) ?></span>
+            </div>
+            <nav class="site-nav">
+                <a href="index.php" class="btn btn-secondary">Admin Home</a>
+            </nav>
+        </div>
+    </header>
+
+    <main class="container">
+        
+        <div class="admin-stats mt-2 mb-3" style="display:flex; gap:1rem; flex-wrap:wrap;">
+            <div class="panel stat-card" style="flex:1; min-width:200px; text-align:center; margin-bottom:0;">
+                <div style="font-size:0.9rem; color:var(--brand-muted);">Total Responses</div>
+                <div class="stat-value" style="font-size:2rem; font-weight:700; color:var(--brand-accent);"><?= $total_responses ?></div>
+            </div>
+            <div class="panel stat-card" style="flex:2; min-width:300px; margin-bottom:0; display:flex; flex-direction:column; justify-content:center;">
+                <div style="font-size:0.9rem; color:var(--brand-muted);">Public Answering Link</div>
+                <div style="margin-top:0.5rem;"><a href="answer.php?id=<?= htmlspecialchars($form_id) ?>" target="_blank">answer.php?id=<?= htmlspecialchars($form_id) ?></a></div>
+            </div>
+        </div>
+
+        <?php if ($total_responses > 0): ?>
+            <div class="table-responsive">
+                <table class="responsive-table">
+                    <thead>
+                        <tr>
+                            <th>Date</th>
+                            <th>Respondent</th>
+                            <?php foreach ($form_data['questions'] as $q): ?>
+                                <th><?= htmlspecialchars($questions_map[$q['id']]) ?></th>
+                            <?php endforeach; ?>
+                        </tr>
+                    </thead>
+                    <tbody>
+                        <?php foreach ($submissions as $sub): ?>
+                            <tr>
+                                <td data-label="Date"><?= date('Y-m-d H:i', strtotime($sub['submitted_at'])) ?></td>
+                                <td data-label="Respondent">
+                                    <strong><?= htmlspecialchars($sub['respondent_name']) ?></strong><br>
+                                    <span style="font-size:0.8rem; color:var(--brand-muted);"><?= htmlspecialchars($sub['respondent_email']) ?></span>
+                                </td>
+                                <?php foreach ($form_data['questions'] as $q): ?>
+                                    <td data-label="<?= htmlspecialchars($questions_map[$q['id']]) ?>">
+                                        <?php
+                                        $val = $sub['answers'][$q['id']] ?? '-';
+                                        $str_val = is_array($val) ? implode(', ', $val) : $val;
+                                        echo nl2br(htmlspecialchars($str_val));
+                                        ?>
+                                    </td>
+                                <?php endforeach; ?>
+                            </tr>
+                        <?php endforeach; ?>
+                    </tbody>
+                </table>
+            </div>
+        <?php else: ?>
+            <div class="panel text-center">
+                <h3 style="color:var(--brand-muted);">No responses yet.</h3>
+                <p>Share the public link to start collecting data.</p>
+            </div>
+        <?php endif; ?>
+
+    </main>
+</body>
+</html>

+ 140 - 0
answer.php

@@ -0,0 +1,140 @@
+<?php
+// answer.php - Renders an existing form for answering
+
+$form_id = $_GET['id'] ?? '';
+$form_file = __DIR__ . '/data/forms/' . preg_replace('/[^a-zA-Z0-9_-]/', '', $form_id) . '.json';
+
+if (empty($form_id) || !file_exists($form_file)) {
+    die('<div style="font-family:sans-serif; text-align:center; padding:50px;"><h2>Form Not Found</h2><p>The link may be invalid or expired.</p></div>');
+}
+
+$form_data = json_decode(file_get_contents($form_file), true);
+
+// If editing a response
+$edit_id = $_GET['edit'] ?? null;
+$existing_answers = [];
+if ($edit_id) {
+    // Sanitize edit\_id
+    $safe_edit_id = preg_replace('/[^a-zA-Z0-9_-]/', '', $edit_id);
+    $answer_file = __DIR__ . "/data/answers/{$form_id}_{$safe_edit_id}.json";
+    if (file_exists($answer_file)) {
+        $answer_data = json_decode(file_get_contents($answer_file), true);
+        $existing_answers = $answer_data['answers'] ?? [];
+    }
+}
+?>
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title><?= htmlspecialchars($form_data['title']) ?> - Intranet Forms</title>
+    <link rel="stylesheet" href="assets/css/style.css">
+</head>
+<body>
+    <header class="site-header">
+        <div class="container header-inner">
+            <div class="brand">
+                <span class="brand-title"><?= htmlspecialchars($form_data['title']) ?></span>
+            </div>
+        </div>
+    </header>
+
+    <main class="container">
+        <!-- Error tracking container (JS managed) -->
+        <div id="already-submitted-alert" class="alert alert-warning" style="display:none;">
+            You have already submitted this form. <a href="submit.php?id=<?= htmlspecialchars($form_id) ?>" style="font-weight:bold;">View your answers</a>
+        </div>
+
+        <form action="submit.php" method="POST" id="answer-form">
+            <input type="hidden" name="form_id" value="<?= htmlspecialchars($form_id) ?>">
+            <?php if ($edit_id): ?>
+                <input type="hidden" name="edit_id" value="<?= htmlspecialchars($edit_id) ?>">
+            <?php endif; ?>
+
+            <div class="panel">
+                <h1 class="card-title"><?= htmlspecialchars($form_data['title']) ?></h1>
+                <?php if (!empty($form_data['description'])): ?>
+                    <p style="margin-bottom:1rem; color:var(--brand-muted);"><?= nl2br(htmlspecialchars($form_data['description'])) ?></p>
+                <?php endif; ?>
+                
+                <h3 style="border-bottom: 1px solid var(--brand-border); padding-bottom: 0.5rem; margin-bottom: 1rem;">Respondent Details</h3>
+                
+                <div class="form-group">
+                    <label for="respondent_name">Your Name *</label>
+                    <input type="text" id="respondent_name" name="respondent_name" required>
+                </div>
+                
+                <div class="form-group">
+                    <label for="respondent_email">Your Email (Optional, to receive a copy)</label>
+                    <input type="email" id="respondent_email" name="respondent_email">
+                </div>
+            </div>
+
+            <div class="panel">
+                <h3 style="border-bottom: 1px solid var(--brand-border); padding-bottom: 0.5rem; margin-bottom: 1rem;">Questions</h3>
+                
+                <?php foreach ($form_data['questions'] as $q): ?>
+                    <div class="form-group">
+                        <label><?= htmlspecialchars($q['label']) ?> *</label>
+                        <?php 
+                        $val = $existing_answers[$q['id']] ?? ''; 
+                        $is_array_val = is_array($val);
+                        
+                        if ($q['type'] === 'textarea'): ?>
+                            <textarea id="<?= htmlspecialchars($q['id']) ?>" name="answers[<?= htmlspecialchars($q['id']) ?>]" rows="4" required><?= htmlspecialchars(is_string($val) ? $val : '') ?></textarea>
+                        
+                        <?php elseif ($q['type'] === 'single_choice'): ?>
+                            <div class="options-container" style="display:flex; flex-direction:column; gap:0.5rem; margin-top:0.5rem;">
+                                <?php foreach ($q['options'] as $idx => $opt): 
+                                    $checked = (is_string($val) && $val === $opt) ? 'checked' : '';
+                                ?>
+                                    <label style="font-weight:normal; display:flex; align-items:center; gap:0.5rem;">
+                                        <input type="radio" name="answers[<?= htmlspecialchars($q['id']) ?>]" value="<?= htmlspecialchars($opt) ?>" required <?= $checked ?> style="width:auto; margin:0;">
+                                        <?= htmlspecialchars($opt) ?>
+                                    </label>
+                                <?php endforeach; ?>
+                            </div>
+
+                        <?php elseif ($q['type'] === 'multiple_choice'): ?>
+                            <div class="options-container" style="display:flex; flex-direction:column; gap:0.5rem; margin-top:0.5rem;">
+                                <?php foreach ($q['options'] as $idx => $opt): 
+                                    $checked = ($is_array_val && in_array($opt, $val)) ? 'checked' : '';
+                                ?>
+                                    <label style="font-weight:normal; display:flex; align-items:center; gap:0.5rem;">
+                                        <input type="checkbox" name="answers[<?= htmlspecialchars($q['id']) ?>][]" value="<?= htmlspecialchars($opt) ?>" <?= $checked ?> style="width:auto; margin:0;">
+                                        <?= htmlspecialchars($opt) ?>
+                                    </label>
+                                <?php endforeach; ?>
+                            </div>
+
+                        <?php elseif ($q['type'] === 'dropdown'): ?>
+                            <select id="<?= htmlspecialchars($q['id']) ?>" name="answers[<?= htmlspecialchars($q['id']) ?>]" required>
+                                <option value="">-- Please select --</option>
+                                <?php foreach ($q['options'] as $opt): 
+                                    $selected = (is_string($val) && $val === $opt) ? 'selected' : '';
+                                ?>
+                                    <option value="<?= htmlspecialchars($opt) ?>" <?= $selected ?>><?= htmlspecialchars($opt) ?></option>
+                                <?php endforeach; ?>
+                            </select>
+
+                        <?php else: ?>
+                            <input type="text" id="<?= htmlspecialchars($q['id']) ?>" name="answers[<?= htmlspecialchars($q['id']) ?>]" required value="<?= htmlspecialchars(is_string($val) ? $val : '') ?>">
+                        <?php endif; ?>
+                    </div>
+                <?php endforeach; ?>
+            </div>
+            
+            <div class="panel text-center" style="background:transparent; border:none; box-shadow:none;">
+                <button type="submit" class="btn btn-block" style="font-size: 1.1rem; padding: 0.75rem;">Submit Answers</button>
+            </div>
+        </form>
+    </main>
+
+    <script>
+        const formId = "<?= htmlspecialchars($form_id) ?>";
+        const isEditMode = <?= $edit_id ? 'true' : 'false' ?>;
+    </script>
+    <script src="assets/js/answer.js"></script>
+</body>
+</html>

+ 445 - 0
assets/css/style.css

@@ -0,0 +1,445 @@
+:root {
+  --brand-primary: #2f3541;
+  --brand-primary-dark: #242a33;
+  --brand-danger: #cf2e2e;
+  --brand-danger-dark: #b12727;
+  --brand-accent: #cac300;
+  --brand-dark: #1b1b1b;
+  --brand-text: #f5f7fb;
+  --brand-muted: #c7ccd6;
+  --brand-surface: #2f3541;
+  --brand-surface-alt: #3a4150;
+  --brand-bg: #28292a;
+  --brand-border: #3b4252;
+}
+
+/* Base Primitives */
+* {
+  box-sizing: border-box;
+  margin: 0;
+  padding: 0;
+}
+
+body {
+  background-color: var(--brand-bg);
+  color: var(--brand-text);
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
+  line-height: 1.6;
+}
+
+a {
+  color: var(--brand-accent);
+  text-decoration: none;
+}
+a:hover {
+  color: #e0d700;
+}
+
+.container {
+  max-width: 1200px;
+  margin: 0 auto;
+  padding: 0 20px;
+}
+
+body.admin-page .container {
+  max-width: none;
+}
+
+main {
+  min-height: calc(100vh - 200px);
+  padding: 2rem 0;
+}
+
+/* Typography Helpers */
+.text-center {
+  text-align: center;
+}
+
+/* Header & Navigation */
+.site-header {
+  background-color: var(--brand-primary);
+  color: #ffffff;
+  padding: 1rem 0;
+  box-shadow: 0 2px 6px rgba(0,0,0,0.18);
+}
+
+.header-inner {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 1.5rem;
+}
+
+.brand {
+  display: inline-flex;
+  align-items: center;
+  gap: 1rem;
+  color: #ffffff;
+}
+
+.brand-info {
+  display: flex;
+  flex-direction: column;
+}
+
+.brand-title {
+  font-size: 1.3rem;
+  font-weight: 700;
+  letter-spacing: 0.02em;
+}
+
+.brand-subtitle {
+  font-size: 0.9rem;
+  font-weight: 500;
+  opacity: 0.9;
+}
+
+.brand-logo {
+  height: 52px;
+  filter: drop-shadow(0 2px 4px rgba(0,0,0,0.25));
+}
+
+.site-nav {
+  display: flex;
+  gap: 1rem;
+}
+
+.site-nav a {
+  color: #ffffff;
+  font-weight: 600;
+  letter-spacing: 0.02em;
+}
+
+.site-nav a:hover {
+  color: var(--brand-accent);
+}
+
+/* Forms & Buttons */
+.btn {
+  display: inline-block;
+  background-color: var(--brand-danger);
+  color: #ffffff;
+  padding: 0.5rem 1rem;
+  border: none;
+  border-radius: 4px;
+  font-family: inherit;
+  font-size: 1rem;
+  font-weight: 600;
+  cursor: pointer;
+  transition: background-color 0.3s;
+  text-align: center;
+}
+.btn:hover {
+  background-color: var(--brand-danger-dark);
+  color: #ffffff;
+}
+
+.btn-secondary {
+  background-color: transparent;
+  border: 1px solid var(--brand-border);
+  color: var(--brand-text);
+}
+.btn-secondary:hover {
+  background-color: var(--brand-surface-alt);
+}
+
+.btn-small {
+  padding: 0.25rem 0.5rem;
+  font-size: 0.85rem;
+}
+
+.btn-block {
+  display: block;
+  width: 100%;
+}
+
+.form-group {
+  margin-bottom: 1rem;
+  display: flex;
+  flex-direction: column;
+}
+
+.form-group label {
+  margin-bottom: 0.5rem;
+  font-weight: 600;
+}
+
+.form-group input,
+.form-group textarea,
+.form-group select {
+  background-color: var(--brand-surface-alt);
+  border: 1px solid var(--brand-border);
+  color: var(--brand-text);
+  padding: 0.5rem;
+  border-radius: 4px;
+  font-family: inherit;
+  font-size: 1rem;
+  width: 100%;
+}
+
+.form-group input:focus,
+.form-group textarea:focus,
+.form-group select:focus {
+  outline: none;
+  border-color: var(--brand-accent);
+  box-shadow: 0 0 0 3px rgba(202, 195, 0, 0.2);
+}
+
+/* Panels / Cards */
+.panel {
+  background-color: var(--brand-surface);
+  border: 1px solid var(--brand-border);
+  border-radius: 8px;
+  box-shadow: 0 2px 4px rgba(0,0,0,0.35);
+  padding: 1.5rem;
+  margin-bottom: 1.5rem;
+}
+
+/* Entities / Products grid (Can be used for forms listings) */
+.products-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
+  gap: 1.5rem;
+}
+
+.product-card {
+  background-color: var(--brand-surface);
+  border-radius: 8px;
+  box-shadow: 0 2px 8px rgba(0,0,0,0.4);
+  transition: transform 0.3s, box-shadow 0.3s;
+  overflow: hidden;
+  border: 1px solid var(--brand-border);
+}
+.product-card:hover {
+  transform: translateY(-5px);
+  box-shadow: 0 4px 12px rgba(0,0,0,0.15);
+}
+
+.product-card-content {
+  padding: 1rem;
+}
+
+.card-title {
+  font-size: 1.1rem;
+  font-weight: 600;
+  margin-bottom: 0.5rem;
+}
+
+/* Tables */
+table {
+  width: 100%;
+  border-collapse: collapse;
+  background-color: var(--brand-surface);
+  box-shadow: 0 2px 4px rgba(0,0,0,0.35);
+  border-radius: 8px;
+  overflow: hidden;
+}
+
+th, td {
+  padding: 1rem;
+  text-align: left;
+  border-bottom: 1px solid var(--brand-border);
+}
+
+th {
+  background-color: var(--brand-primary);
+  color: #ffffff;
+  font-weight: 600;
+}
+
+tr:hover td {
+  background-color: var(--brand-surface-alt);
+}
+
+.table-responsive {
+  overflow-x: auto;
+  margin-bottom: 1.5rem;
+  border-radius: 8px;
+}
+
+/* Alerts */
+.alert {
+  background-color: var(--brand-surface);
+  padding: 1rem;
+  border-radius: 4px;
+  margin-bottom: 1rem;
+  border-left: 4px solid var(--brand-border);
+}
+.alert-success {
+  border-left-color: var(--brand-accent);
+}
+.alert-error {
+  border-left-color: var(--brand-danger);
+}
+.alert-info {
+  border-left-color: #ffffff;
+}
+
+/* Status Pills */
+.status {
+  display: inline-block;
+  padding: 0.1rem 0.6rem;
+  border-radius: 999px;
+  font-size: 0.85rem;
+  font-weight: 600;
+  letter-spacing: 0.02em;
+  border: 1px solid;
+}
+.status-open {
+  color: var(--brand-accent);
+  border-color: var(--brand-accent);
+}
+.status-picked {
+  color: #ffffff;
+  border-color: #ffffff;
+}
+
+/* Utilities */
+.mt-1 { margin-top: 0.5rem; }
+.mt-2 { margin-top: 1rem; }
+.mt-3 { margin-top: 1.5rem; }
+.mb-1 { margin-bottom: 0.5rem; }
+.mb-2 { margin-bottom: 1rem; }
+.mb-3 { margin-bottom: 1.5rem; }
+
+/* Responsive Adjustments */
+@media (max-width: 768px) {
+  .header-inner {
+    flex-direction: column;
+    align-items: flex-start;
+  }
+  .site-nav {
+    flex-wrap: wrap;
+  }
+  .brand-logo {
+    height: 46px;
+  }
+  .products-grid {
+    grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+    gap: 1rem;
+  }
+  th, td {
+    padding: 0.75rem;
+  }
+  /* Responsive Table Card Mode for Admin */
+  body.admin-page table.responsive-table {
+    display: block;
+    box-shadow: none;
+    background: transparent;
+  }
+  body.admin-page table.responsive-table thead {
+    display: none;
+  }
+  body.admin-page table.responsive-table tbody,
+  body.admin-page table.responsive-table tr,
+  body.admin-page table.responsive-table td {
+    display: block;
+    width: 100%;
+  }
+  body.admin-page table.responsive-table tr {
+    background-color: var(--brand-surface);
+    margin-bottom: 1rem;
+    border-radius: 8px;
+    border: 1px solid var(--brand-border);
+    box-shadow: 0 2px 4px rgba(0,0,0,0.35);
+  }
+  body.admin-page table.responsive-table td {
+    text-align: right;
+    padding-left: 50%;
+    position: relative;
+    border-bottom: 1px solid var(--brand-border);
+  }
+  body.admin-page table.responsive-table td:last-child {
+    border-bottom: 0;
+  }
+  body.admin-page table.responsive-table td::before {
+    content: attr(data-label);
+    position: absolute;
+    left: 1rem;
+    width: calc(50% - 2rem);
+    white-space: nowrap;
+    text-align: left;
+    font-weight: 600;
+  }
+}
+
+@media (max-width: 480px) {
+  .container {
+    padding: 0 12px;
+  }
+  .products-grid {
+    grid-template-columns: 1fr;
+  }
+  .brand-title {
+    font-size: 1.1rem;
+  }
+  .brand-subtitle {
+    font-size: 0.8rem;
+  }
+}
+
+/* Modals */
+.modal {
+  display: none; /* hidden by default */
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  background-color: rgba(0,0,0,0.6);
+  z-index: 1000;
+  justify-content: center;
+  align-items: center;
+}
+.modal.visible {
+  display: flex;
+}
+.modal-content {
+  background-color: var(--brand-bg);
+  padding: 2rem;
+  border-radius: 8px;
+  max-width: 1000px;
+  width: 90%;
+  max-height: 95vh;
+  overflow-y: auto;
+  position: relative;
+}
+.modal-close {
+  position: absolute;
+  top: 1rem;
+  right: 1rem;
+  background: transparent;
+  border: 1px solid var(--brand-text);
+  color: var(--brand-text);
+  border-radius: 4px;
+  padding: 0.25rem 0.5rem;
+  cursor: pointer;
+}
+
+/* Form Builder specifics */
+.builder-item {
+  background-color: var(--brand-surface);
+  border: 1px solid var(--brand-border);
+  padding: 1rem;
+  margin-bottom: 1rem;
+  border-radius: 8px;
+  cursor: grab;
+  display: flex;
+  flex-direction: column;
+  gap: 0.5rem;
+}
+.builder-item:active {
+  cursor: grabbing;
+}
+.builder-item.sortable-ghost {
+  opacity: 0.4;
+  background-color: var(--brand-surface-alt);
+}
+.builder-handle {
+  cursor: grab;
+  color: var(--brand-muted);
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 0.5rem;
+}

+ 20 - 0
assets/js/answer.js

@@ -0,0 +1,20 @@
+document.addEventListener('DOMContentLoaded', () => {
+    const nameInput = document.getElementById('respondent_name');
+    const emailInput = document.getElementById('respondent_email');
+    const answerForm = document.getElementById('answer-form');
+
+    // 1. Load previous credentials
+    const savedName = localStorage.getItem('intranet_name');
+    const savedEmail = localStorage.getItem('intranet_email');
+
+    if (savedName && nameInput) nameInput.value = savedName;
+    if (savedEmail && emailInput) emailInput.value = savedEmail;
+
+    // 2. Save on Submit
+    if (answerForm) {
+        answerForm.addEventListener('submit', () => {
+            if (nameInput) localStorage.setItem('intranet_name', nameInput.value);
+            if (emailInput) localStorage.setItem('intranet_email', emailInput.value);
+        });
+    }
+});

+ 124 - 0
assets/js/create.js

@@ -0,0 +1,124 @@
+let questionIndex = 0;
+
+// Initialize SortableJS
+document.addEventListener("DOMContentLoaded", function() {
+    var canvas = document.getElementById('builder-canvas');
+    if (canvas) {
+        new Sortable(canvas, {
+            animation: 150,
+            handle: '.builder-handle',
+            ghostClass: 'sortable-ghost',
+        });
+    }
+});
+
+function addQuestion(type) {
+    const canvas = document.getElementById('builder-canvas');
+    const emptyState = document.getElementById('empty-state');
+    if (emptyState) {
+        emptyState.remove();
+    }
+
+    questionIndex++;
+    const id = 'q_' + Date.now() + '_' + questionIndex;
+    
+    let typeLabel = '';
+    const needsOptions = ['single_choice', 'multiple_choice', 'dropdown'].includes(type);
+    
+    switch(type) {
+        case 'text': typeLabel = 'Text Input'; break;
+        case 'textarea': typeLabel = 'Text Area'; break;
+        case 'single_choice': typeLabel = 'Single Choice'; break;
+        case 'multiple_choice': typeLabel = 'Multiple Choice'; break;
+        case 'dropdown': typeLabel = 'Dropdown'; break;
+    }
+    
+    const div = document.createElement('div');
+    div.className = 'builder-item';
+    div.dataset.id = id;
+    div.dataset.type = type;
+    
+    let html = `
+        <div class="builder-handle">
+            <span>☰ ${typeLabel}</span>
+            <button type="button" class="modal-close" style="position:relative; top:0; right:0;" onclick="removeQuestion(this)">&times;</button>
+        </div>
+        <div class="form-group" style="margin-bottom:0;">
+            <label>Question Label</label>
+            <input type="text" class="question-label" required placeholder="Enter your question here">
+        </div>
+    `;
+
+    if (needsOptions) {
+        html += `
+        <div class="form-group mt-2 mb-0">
+            <label>Options (one per line)</label>
+            <textarea class="question-options" rows="3" required placeholder="Option 1\nOption 2\nOption 3"></textarea>
+        </div>
+        `;
+    }
+
+    div.innerHTML = html;
+    canvas.appendChild(div);
+}
+
+function removeQuestion(btn) {
+    btn.closest('.builder-item').remove();
+    const canvas = document.getElementById('builder-canvas');
+    if (canvas.children.length === 0) {
+        canvas.innerHTML = '<div class="alert alert-info" id="empty-state">No questions added yet. Use the buttons above to add one.</div>';
+    }
+}
+
+function prepareSubmission(e) {
+    const questions = [];
+    const items = document.querySelectorAll('.builder-item');
+    
+    if (items.length === 0) {
+        e.preventDefault();
+        alert("Please add at least one question.");
+        return;
+    }
+
+    let hasEmptyLabel = false;
+    let hasEmptyOptions = false;
+
+    items.forEach((item) => {
+        let labelInput = item.querySelector('.question-label');
+        let optionsInput = item.querySelector('.question-options');
+        
+        let label = labelInput ? labelInput.value.trim() : '';
+        if (!label) {
+            hasEmptyLabel = true;
+        }
+
+        let options = [];
+        if (optionsInput) {
+            options = optionsInput.value.split('\\n').map(o => o.trim()).filter(o => o);
+            if (options.length === 0) {
+                hasEmptyOptions = true;
+            }
+        }
+
+        questions.push({
+            id: item.dataset.id,
+            type: item.dataset.type,
+            label: label,
+            options: options
+        });
+    });
+
+    if (hasEmptyLabel) {
+        e.preventDefault();
+        alert("Please fill out all question labels.");
+        return;
+    }
+
+    if (hasEmptyOptions) {
+        e.preventDefault();
+        alert("Please provide at least one option for your choice/dropdown questions.");
+        return;
+    }
+    
+    document.getElementById('questions_input').value = JSON.stringify(questions);
+}

+ 158 - 0
create.php

@@ -0,0 +1,158 @@
+<?php
+// create.php - Form creation handler
+
+$message = '';
+$form_created = false;
+$output_links = [];
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+    $title = trim($_POST['title'] ?? 'Untitled Form');
+    $description = trim($_POST['description'] ?? '');
+    $admin_email = trim($_POST['admin_email'] ?? '');
+    $questions_json = $_POST['questions'] ?? '[]';
+    
+    $questions = json_decode($questions_json, true);
+    
+    if (empty($questions) || !is_array($questions)) {
+        $message = '<div class="alert alert-error">Please add at least one question.</div>';
+    } else {
+        $form_id = uniqid('form_');
+        $admin_token = bin2hex(random_bytes(16));
+        
+        $form_data = [
+            'id' => $form_id,
+            'title' => $title,
+            'description' => $description,
+            'admin_email' => $admin_email,
+            'admin_token' => $admin_token,
+            'created_at' => date('c'),
+            'questions' => $questions
+        ];
+        
+        $forms_dir = __DIR__ . '/data/forms';
+        if (!is_dir($forms_dir)) {
+            mkdir($forms_dir, 0755, true);
+        }
+        
+        file_put_contents("$forms_dir/$form_id.json", json_encode($form_data, JSON_PRETTY_PRINT));
+        
+        $protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' || $_SERVER['SERVER_PORT'] == 443) ? "https://" : "http://";
+        $host = $_SERVER['HTTP_HOST'];
+        $base_url = $protocol . $host . dirname($_SERVER['REQUEST_URI']);
+        if (substr($base_url, -1) !== '/') {
+            $base_url .= '/';
+        }
+        
+        $answer_link = $base_url . "answer.php?id=" . $form_id;
+        $admin_link = $base_url . "admin.php?id=" . $form_id . "&token=" . $admin_token;
+        
+        $output_links = [
+            'answer' => $answer_link,
+            'admin' => $admin_link
+        ];
+
+        // Send email if provided
+        if (!empty($admin_email) && filter_var($admin_email, FILTER_VALIDATE_EMAIL)) {
+            $subject = "Your Intranet Form is Ready: $title";
+            $email_body = "Hello,\n\nYour form '$title' has been created.\n\n";
+            $email_body .= "Public Link for respondents:\n$answer_link\n\n";
+            $email_body .= "Secret Admin Link for you to view responses:\n$admin_link\n\n";
+            $email_body .= "Do not share the admin link.\n\nThank you.";
+            $headers = "From: no-reply@" . $host . "\r\n";
+            @mail($admin_email, $subject, $email_body, $headers);
+        }
+        
+        $form_created = true;
+    }
+}
+?>
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Create Form</title>
+    <link rel="stylesheet" href="assets/css/style.css">
+</head>
+<body>
+    <header class="site-header">
+        <div class="container header-inner">
+            <div class="brand">
+                <a href="index.php" class="brand-title" style="color:white;">Intranet Forms</a>
+            </div>
+        </div>
+    </header>
+
+    <main class="container">
+        <?php if ($form_created): ?>
+            <div class="panel">
+                <h2 class="card-title" style="color: var(--brand-accent)">Form Created Successfully!</h2>
+                <div class="alert alert-success mt-2">
+                    <strong>Public Answering Link (Share this):</strong><br>
+                    <a href="<?= htmlspecialchars($output_links['answer']) ?>" target="_blank"><?= htmlspecialchars($output_links['answer']) ?></a>
+                </div>
+                
+                <div class="alert alert-info mt-2">
+                    <strong>Secret Admin Link (Keep this safe):</strong><br>
+                    <a href="<?= htmlspecialchars($output_links['admin']) ?>" target="_blank"><?= htmlspecialchars($output_links['admin']) ?></a>
+                </div>
+                
+                <?php if (!empty($admin_email)): ?>
+                    <p class="mt-2 text-center text-muted">A notification with these links has been sent to <?= htmlspecialchars($admin_email) ?></p>
+                <?php endif; ?>
+                
+                <div class="mt-3 text-center">
+                    <a href="index.php" class="btn btn-secondary">Return Home</a>
+                </div>
+            </div>
+        <?php else: ?>
+        
+            <?= $message ?>
+            
+            <form method="POST" id="form-builder-form">
+                <div class="panel">
+                    <h2 class="card-title">Form Settings</h2>
+                    <div class="form-group">
+                        <label for="title">Form Title *</label>
+                        <input type="text" id="title" name="title" required placeholder="e.g. Employee Satisfaction Survey">
+                    </div>
+                    <div class="form-group">
+                        <label for="description">Description (Optional)</label>
+                        <textarea id="description" name="description" rows="3" placeholder="Explain the purpose of this form"></textarea>
+                    </div>
+                    <div class="form-group">
+                        <label for="admin_email">Admin Email (Optional)</label>
+                        <input type="email" id="admin_email" name="admin_email" placeholder="We'll send the admin link here">
+                    </div>
+                </div>
+
+                <div class="panel">
+                    <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom: 1rem; flex-wrap:wrap; gap: 0.5rem">
+                        <h2 class="card-title">Questions</h2>
+                        <div>
+                            <button type="button" class="btn btn-small btn-secondary text-center mb-1" onclick="addQuestion('text')">+ Text</button>
+                            <button type="button" class="btn btn-small btn-secondary text-center mb-1" onclick="addQuestion('textarea')">+ Text Area</button>
+                            <button type="button" class="btn btn-small btn-secondary text-center mb-1" onclick="addQuestion('single_choice')">+ Single Choice</button>
+                            <button type="button" class="btn btn-small btn-secondary text-center mb-1" onclick="addQuestion('multiple_choice')">+ Multi Choice</button>
+                            <button type="button" class="btn btn-small btn-secondary text-center mb-1" onclick="addQuestion('dropdown')">+ Dropdown</button>
+                        </div>
+                    </div>
+                    
+                    <div id="builder-canvas">
+                        <div class="alert alert-info" id="empty-state">No questions added yet. Use the buttons above to add one.</div>
+                    </div>
+                    
+                    <input type="hidden" name="questions" id="questions_input" value="[]">
+                </div>
+                
+                <div class="panel" style="text-align: right;">
+                    <a href="index.php" class="btn btn-secondary">Cancel</a>
+                    <button type="submit" class="btn" style="margin-left: 10px;" onclick="prepareSubmission(event)">Save Form</button>
+                </div>
+            </form>
+        <?php endif; ?>
+    </main>
+    <script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
+    <script src="assets/js/create.js"></script>
+</body>
+</html>

+ 1 - 0
data/.htaccess

@@ -0,0 +1 @@
+Deny from all

+ 373 - 0
docs/style_system.md

@@ -0,0 +1,373 @@
+# Portable Intranet Style System Spec
+
+## Purpose and Scope
+This file is the canonical style contract for reproducing the current dark-theme design system across an intranet collection of services.
+
+- Scope included: shared service UI, operations/admin UI, print styles, and HTML email styles.
+- Output target: AI-agent implementation playbook with exact values and selector contracts.
+- Goal: faithful visual parity, not redesign.
+- Runtime/API impact: no runtime API changes.
+
+This style contract defines the portable interface another implementation must expose:
+- CSS custom properties (`--brand-*`)
+- required class names and state classes (`.alert-*`, `.status-*`, etc.)
+- context class (`body.admin-page`)
+- responsive table data-label interface (`td[data-label]`)
+
+The current class names originated in a shop system, but they are intentionally treated here as generic style primitives that can be reused for non-shop domains.
+
+## Quick Start for AI Agents
+1. Create the canonical token block (`:root`) with all `--brand-*` variables exactly as listed in this document.
+2. Apply base primitives first: reset, `body`, `a`, `.container`, `main`.
+3. Implement shared components in dependency order:
+   header/nav -> buttons/forms -> cards/grids -> tables -> alerts/status -> panel/modal -> utilities.
+4. Keep legacy class compatibility (for portability) and optionally add local alias classes in the target system.
+5. Add responsive behavior for `max-width: 768px` and `max-width: 480px`.
+6. Add operations context rules (`body.admin-page`) and responsive table card mode (`td[data-label]` required).
+7. Add print contract for printable transaction/detail pages.
+8. Add email theme contract using the email palette and block styles.
+9. Validate with the parity checklist at the end.
+
+## Style Tokens (Canonical)
+### CSS Variable Tokens (`:root`)
+| Token | Value | Intended usage |
+| --- | --- | --- |
+| `--brand-primary` | `#2f3541` | Top bars, section headers, dense surfaces |
+| `--brand-primary-dark` | `#242a33` | Darker primary accent |
+| `--brand-danger` | `#cf2e2e` | Error/danger/action emphasis |
+| `--brand-danger-dark` | `#b12727` | Danger hover state |
+| `--brand-accent` | `#cac300` | Highlight/open/attention accent |
+| `--brand-dark` | `#1b1b1b` | Deep dark token |
+| `--brand-text` | `#f5f7fb` | Primary foreground text |
+| `--brand-muted` | `#c7ccd6` | Secondary/supporting text |
+| `--brand-surface` | `#2f3541` | Cards/panels/module containers |
+| `--brand-surface-alt` | `#3a4150` | Alternate surfaces, input fills, hover backgrounds |
+| `--brand-bg` | `#28292a` | Page/application background |
+| `--brand-border` | `#3b4252` | Default border color |
+
+### Additional Non-Tokenized Colors (Must Preserve)
+| Value | Where used |
+| --- | --- |
+| `#e0d700` | Generic link hover |
+| `#ffffff` | White text/borders (`.status-notified`, `.status-picked`, footer links, info alert border) |
+| `#9ca3af` | Hidden/archived status text |
+| `#6b7280` | Hidden/archived status border |
+| `#303745` | Email item-panel background |
+| `#4a5263` | Email panel/list item borders |
+| `#e9ecef` | Generic placeholder SVG background |
+| `#6c757d` | Generic placeholder SVG text |
+
+### RGBA Effects (Exact)
+| Value | Usage |
+| --- | --- |
+| `rgba(0,0,0,0.18)` | Header shadow |
+| `rgba(0,0,0,0.25)` | Logo drop shadow |
+| `rgba(0,0,0,0.4)` | Card default shadow |
+| `rgba(0,0,0,0.15)` | Card hover shadow |
+| `rgba(0,0,0,0.35)` | Table/stat/panel/card shadows |
+| `rgba(0,0,0,0.6)` | Modal backdrop |
+| `rgba(0,0,0,0.1)` | Media block image shadow |
+| `rgba(202, 195, 0, 0.2)` | Form focus ring overlay |
+
+## Typography
+- Primary UI font stack:
+  `-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif`
+- Primary line-height:
+  `1.6` on body text.
+- Brand area:
+  - `.brand-title`: `1.3rem`, `700`, `letter-spacing: 0.02em`
+  - `.brand-subtitle`: `0.9rem`, `500`, `opacity: 0.9`
+- Navigation links:
+  - `font-weight: 600`
+  - `letter-spacing: 0.02em`
+- Emphasized record IDs:
+  - web: `'Courier New', monospace`, `letter-spacing: 0.2rem`
+  - email: `monospace` (inline)
+- Status pills:
+  - `font-size: 0.85rem`, `font-weight: 600`, `letter-spacing: 0.02em`
+- Email typography:
+  - `Arial, sans-serif`, `line-height: 1.6`
+
+## Spacing, Radius, Shadows, Motion
+### Spacing primitives in use
+- Common vertical rhythm: `0.5rem`, `1rem`, `1.5rem`, `2rem`.
+- Utility classes:
+  - `.mt-1/.mt-2/.mt-3` = `0.5rem/1rem/1.5rem`
+  - `.mb-1/.mb-2/.mb-3` = `0.5rem/1rem/1.5rem`
+- Container horizontal padding:
+  - default: `20px`
+  - mobile (`<=480px`): `12px`
+
+### Radius values
+- `4px`: buttons, inputs, alerts
+- `6px`: callout/disclaimer boxes
+- `8px`: cards, tables, panels, modal content, code/ID boxes
+- `10px`: email outer card
+- `999px`: status pills
+
+### Shadow system
+- Header: `0 2px 6px rgba(0,0,0,0.18)`
+- Cards: `0 2px 8px rgba(0,0,0,0.4)` -> hover `0 4px 12px rgba(0,0,0,0.15)`
+- Panels/tables/stats: `0 2px 4px rgba(0,0,0,0.35)`
+- Media/image detail view: `0 2px 8px rgba(0,0,0,0.1)`
+
+### Motion
+- `.btn`: `transition: background-color 0.3s`
+- `.product-card`: `transition: transform 0.3s, box-shadow 0.3s`
+- `.product-card:hover`: `transform: translateY(-5px)`
+
+Note:
+- The `product-*` class family can be used as generic entity/service cards if not renamed.
+
+## Layout and Grids
+- `.container`: centered layout, `max-width: 1200px`, side padding.
+- `main`: `min-height: calc(100vh - 200px)`, vertical section padding.
+- Header layout:
+  - `.header-inner`: horizontal flex, center aligned, `justify-content: space-between`, gap-based spacing.
+- Generic module grids (legacy class names retained):
+  - `.products-grid`: auto-fill card grid, `minmax(250px, 1fr)` desktop.
+  - `.product-detail-grid`: two columns desktop, one column mobile.
+  - `.checkout-grid`: two columns desktop, one column mobile.
+- Operations/admin width behavior:
+  - `body.admin-page .container`: full width (`max-width: none`), compact side padding.
+
+## Component Contracts (Class-by-Class)
+This is the portable public style interface. Implement these selectors and semantics exactly.
+
+### Base
+- `body`: dark background (`--brand-bg`), light text (`--brand-text`), system font stack.
+- `a`: accent color; hover uses `#e0d700`.
+- `.container`: width cap and horizontal padding.
+- `main`: page body spacing and minimum viewport height behavior.
+
+### Header, Navigation, Branding
+- `.site-header`: primary background, white text, shadow, vertical padding.
+- `.header-inner`: flex alignment and spacing.
+- `.brand`: inline flex logo + text, white, no underline.
+- `.brand-logo`: fixed height (52px desktop / 46px mobile), drop shadow.
+- `.brand-title` and `.brand-subtitle`: hierarchy and typography values.
+- `.site-nav`: horizontal link group; links are white, accent on hover.
+
+### Buttons and Form Controls
+- `.btn`: primary action button in `--brand-danger`.
+- `.btn:hover`: `--brand-danger-dark`.
+- `.btn-secondary`: transparent with border (`--brand-border`) and text color (`--brand-text`).
+- `.btn-small`: compact button spacing and smaller text.
+- `.form-group`: vertical field spacing.
+- `.form-group input, textarea, select`: dark-alt surface fields, border, inherited font.
+- focus state: accent border + ring `0 0 0 3px rgba(202, 195, 0, 0.2)`.
+- `.quantity-input`: compact numeric field width and centered text.
+
+### Entity / Workflow Components (Legacy Class Names)
+- `.products-grid`: generic service/entity card grid.
+- `.product-card`: dark surface card with hover lift.
+- `.product-card-content`: internal spacing.
+- `.price`: prominent numeric/value field (can represent any key metric).
+- `.stock.in-stock`: positive/available/open state accent.
+- `.stock.out-of-stock`: unavailable/error/depleted state danger color.
+- `.cart-item`: split content/action card row.
+- `.cart-actions`: summary and action group container, desktop right-aligned and mobile stacked.
+
+### Data Display
+- `table`: full width, surface background, shadow, rounded corners.
+- `th`: primary background + white text.
+- `td`: bordered row separation.
+- `tr:hover`: alternate surface.
+- `.table-responsive`: horizontal overflow wrapper.
+- `.responsive-table`: required class for mobile card conversion in operations context.
+- `.table-compact`: denser table variant with reduced paddings/font size.
+
+### Feedback and State
+- `.alert`: neutral surface alert container.
+- `.alert-success`: accent border.
+- `.alert-error`: danger border.
+- `.alert-info`: white border.
+- `.alert-warning`: accent border.
+- `.status`: pill base shape.
+- `.status-open`: open/pending/active text+border.
+- `.status-notified`: notified/informed text+border.
+- `.status-picked`: completed/processed text+border.
+- `.status-expired`: expired/invalid text+border.
+- `.status-hidden`: hidden/archived text+border.
+
+### Structural / Operations / Modal
+- `.panel`: reusable surface box with border, radius, and shadow.
+- `.admin-header`: top section with title/actions.
+- `.admin-stats`: responsive metrics grid.
+- `.stat-card`, `.stat-value`: metric visuals.
+- `.modal`: full-screen backdrop overlay (`display: none` by default, fixed, centered).
+- `.modal-content`: constrained scrollable panel (`max-width: 1000px`, `max-height: 95vh`).
+- `.modal-close`: top-right positioned close action.
+
+### Utilities
+- `.text-center`: text alignment helper.
+- `.mt-1/.mt-2/.mt-3`, `.mb-1/.mb-2/.mb-3`: simple spacing utilities.
+
+### Context and Responsive Table Data Contract
+- `body.admin-page`: enables operations-wide layout/table behavior.
+- `td[data-label]` on responsive tables is required:
+  - mobile uses `td::before { content: attr(data-label); }`
+  - without `data-label`, mobile card mode loses column labels.
+
+### Inline Overrides and Legacy Equivalents
+Current templates include inline styles. For portability, normalize them into reusable classes or exact one-off rules.
+
+| Legacy inline pattern | Portable equivalent |
+| --- | --- |
+| `width: 100%` | `.u-w-full { width: 100%; }` |
+| `text-align: center` | `.text-center` |
+| `margin-top: 1rem` / `1.5rem` / `2rem` | `.mt-2` / `.mt-3` / `section gap rule` |
+| `margin-left: 1rem` / `1.5rem` | `.ml-2` / `.list-indent` helper rule |
+| `padding: 1rem; margin-bottom: 1rem` (compact summary cards) | `.panel.panel-compact` |
+| `padding: 2rem; margin: 2rem 0` (detail blocks) | `.panel.panel-spacious` |
+| `display: inline` on action forms | `.inline-form { display: inline; }` |
+| `display: flex; gap: 1rem; align-items: end; flex-wrap: wrap` | `.filter-form` |
+| `flex: 1; min-width: 200px` | `.filter-field-grow` |
+| `max-width: 400px; margin-top: 4rem` (auth/login container) | `.auth-container` |
+| Inline media image (`width: 100%; border-radius: 8px; box-shadow: ...`) | `.media-hero-image` |
+| Inline full-width primary CTA (`width:100%; text-align:center; margin-top:1rem`) | `.btn-block + .mt-2` |
+
+Fidelity note:
+- The current login page uses a plain `<header>` element instead of `.site-header`. Preserve this behavior if strict parity is required.
+
+## Responsive Behavior
+### Breakpoint: `max-width: 768px`
+- Header stack:
+  - `.header-inner` becomes column layout, left aligned.
+  - `.site-nav` wraps.
+- Footer links become vertical.
+- `.brand-logo` height drops to `46px`.
+- Card grid shifts to `minmax(200px, 1fr)` with tighter gaps.
+- `.product-detail-grid`, `.checkout-grid` switch to 1 column.
+- `.cart-item` stacks vertically; actions expand.
+- `.cart-actions` left aligns; button groups stack vertically.
+- `.modal-content` adds margins and reduced max-height.
+- `.admin-header` stacks with wrapped action buttons.
+- Table cell typography/padding reduced.
+- `.admin-stats` becomes single column.
+- `body.admin-page .table-responsive` gets edge-to-edge compensation paddings.
+- `.table-responsive .btn` allows wrapping.
+- `body.admin-page table.responsive-table` transforms rows into card blocks with `td[data-label]` pseudo labels.
+
+### Breakpoint: `max-width: 480px`
+- `.container` horizontal padding -> `12px`.
+- `main` vertical padding reduced.
+- Brand text sizes reduce (`.brand-title` `1.1rem`, `.brand-subtitle` `0.8rem`).
+- `.products-grid` becomes single column.
+- For non-card tables in narrow view: `.table-responsive table:not(.responsive-table) { min-width: 520px; }`.
+
+## Admin Variant Contract
+- Set `<body class="admin-page">` for operations/control pages to activate admin-specific layout behavior.
+- Operations pages rely on:
+  - wide container (`max-width: none`)
+  - `responsive-table` + `table-responsive` pairing
+  - action-heavy row behavior with wrapped controls on mobile
+  - status pill classes (`.status-*`) for workflow state display
+- Modal details contract:
+  - overlay container `.modal`
+  - content container `.modal-content`
+  - close control `.modal-close`
+
+## Print Contract
+Printable detail/receipt/ticket pages should preserve the existing minimal print mode.
+
+Required print rules:
+```css
+@media print {
+  header, footer, .btn, nav { display: none !important; }
+  body { padding: 20px; }
+  .order-number { page-break-inside: avoid; }
+  table { page-break-inside: avoid; }
+}
+```
+
+Behavioral intent:
+- hide navigation/chrome/actions when printing
+- keep key identifier blocks and tables intact across page breaks
+
+## Email Theme Contract
+Email styles are inline HTML/CSS and must keep the same dark-theme palette.
+
+### Email token mapping
+| Email role | Value |
+| --- | --- |
+| Body background | `#28292a` |
+| Main card background | `#2f3541` |
+| Main card border | `#3b4252` |
+| Primary text | `#f5f7fb` |
+| Accent text/border | `#cac300` |
+| Item panel background | `#303745` |
+| Item panel border | `#4a5263` |
+| Warning panel border | `#cf2e2e` |
+
+### Email typography
+- `font-family: Arial, sans-serif`
+- `line-height: 1.6`
+- key IDs use `font-family: monospace`
+
+### Required structural blocks
+- Outer body wrapper (dark background, padded)
+- Centered email card (`max-width: 640px`, dark surface, border, rounded corners)
+- Identifier box (accent border, emphasized code)
+- Item-list panel (alt background + optional left accent stripe)
+- Warning panel (danger border) for exception or delay notices
+
+## Asset and Branding Contract
+- Required organization logo:
+  - `assets/images/feuerwehr-logo-invers.webp`
+  - If rebranding for another intranet, replace asset file while preserving placement/sizing behavior.
+- Media/image behavior:
+  - source path pattern in current system: `assets/images/<filename>`
+  - detail/media view uses full-width image with `8px` radius and soft shadow.
+- Placeholder fallback behavior:
+  - inline SVG fallback uses:
+    - background `#e9ecef`
+    - text `#6c757d`
+- Brand tone requirements:
+  - dark neutral background/surfaces
+  - yellow accent for highlights and open/positive states
+  - red for warning/danger/error
+  - white/near-white foreground text for contrast
+
+## Implementation Checklist
+- [ ] Add canonical `:root` tokens exactly.
+- [ ] Add all non-tokenized colors and RGBA effects where required.
+- [ ] Implement base primitives (`body`, `a`, `.container`, `main`).
+- [ ] Implement header/nav/branding components.
+- [ ] Implement buttons/form controls/focus states.
+- [ ] Implement entity/workflow cards and split content/action rows.
+- [ ] Implement tables, `.table-responsive`, `.responsive-table`, `.table-compact`.
+- [ ] Implement alerts and status pill states.
+- [ ] Implement panels, stats/layout, and modal system.
+- [ ] Implement utility classes (`.text-center`, `.mt-*`, `.mb-*`).
+- [ ] Implement operations context via `body.admin-page`.
+- [ ] Ensure `td[data-label]` exists where responsive table cards are needed.
+- [ ] Migrate/replace inline styles with reusable equivalents where possible.
+- [ ] Add print rules for transaction/detail page parity.
+- [ ] Apply email theme block styles with exact palette and typography.
+- [ ] Verify logo/media/placeholder behavior.
+
+## Parity Validation Checklist
+### 1) Token fidelity
+- [ ] Every documented token and hardcoded color appears with exact value.
+- [ ] RGBA shadow/focus values match exactly.
+
+### 2) Service UI parity
+- [ ] Header, cards, detail views, action rows, forms, and confirmation/detail pages match hierarchy and style.
+
+### 3) Operations parity
+- [ ] Dashboard stats, data tables, status pills, panels, modal overlays, and action buttons match behavior and appearance.
+
+### 4) Responsive parity
+- [ ] At `<=768px`, layout stacks and table card mode works.
+- [ ] At `<=480px`, compact spacing and single-column card layout are applied.
+
+### 5) Print parity
+- [ ] Printing detail pages hides header/footer/nav/buttons and preserves key blocks from page breaks.
+
+### 6) Email parity
+- [ ] Notification/confirmation emails render with dark card theme, accent highlights, and warning block usage where applicable.
+
+### 7) Accessibility sanity
+- [ ] Text contrast remains readable on dark surfaces.
+- [ ] Form focus ring is visible and consistent.

+ 35 - 0
index.php

@@ -0,0 +1,35 @@
+<?php
+// index.php - Landing page
+?>
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Intranet Forms</title>
+    <link rel="stylesheet" href="assets/css/style.css">
+</head>
+<body>
+    <header class="site-header">
+        <div class="container header-inner">
+            <div class="brand">
+                <div class="brand-info">
+                    <span class="brand-title">Intranet Forms</span>
+                    <span class="brand-subtitle">Fast & Simple Data Collection</span>
+                </div>
+            </div>
+            <nav class="site-nav">
+                <a href="create.php" class="btn btn-secondary">Create Form</a>
+            </nav>
+        </div>
+    </header>
+
+    <main class="container">
+        <div class="panel text-center" style="max-width: 600px; margin: 40px auto;">
+            <h1 class="card-title">Welcome to Intranet Forms</h1>
+            <p style="margin-bottom: 2rem;">Quickly create simple text forms and gather responses from your team.</p>
+            <a href="create.php" class="btn">Create a New Form</a>
+        </div>
+    </main>
+</body>
+</html>

+ 146 - 0
submit.php

@@ -0,0 +1,146 @@
+<?php
+// submit.php - Handles saving an answer and displaying the result
+
+$answers_dir = __DIR__ . '/data/answers';
+if (!is_dir($answers_dir)) {
+    mkdir($answers_dir, 0755, true);
+}
+
+$is_post = ($_SERVER['REQUEST_METHOD'] === 'POST');
+$form_id = $_REQUEST['form_id'] ?? $_GET['id'] ?? '';
+$answer_id = $_REQUEST['answer_id'] ?? $_POST['edit_id'] ?? '';
+
+$form_file = __DIR__ . '/data/forms/' . preg_replace('/[^a-zA-Z0-9_-]/', '', $form_id) . '.json';
+if (empty($form_id) || !file_exists($form_file)) {
+    die('<div style="font-family:sans-serif; text-align:center; padding:50px;"><h2>Form Not Found</h2></div>');
+}
+
+$form_data = json_decode(file_get_contents($form_file), true);
+$questions_map = [];
+foreach ($form_data['questions'] as $q) {
+    $questions_map[$q['id']] = $q['label'];
+}
+
+$injected_js = '';
+
+if ($is_post) {
+    // Process form submission
+    $respondent_name = trim($_POST['respondent_name'] ?? 'Anonymous');
+    $respondent_email = trim($_POST['respondent_email'] ?? '');
+    $answers = $_POST['answers'] ?? [];
+    
+    $is_edit = !empty($answer_id);
+    if (!$is_edit) {
+        $answer_id = uniqid('ans_');
+    }
+    
+    $safe_answer_id = preg_replace('/[^a-zA-Z0-9_-]/', '', $answer_id);
+    
+    $answer_data = [
+        'answer_id' => $safe_answer_id,
+        'form_id' => $form_id,
+        'respondent_name' => $respondent_name,
+        'respondent_email' => $respondent_email,
+        'submitted_at' => date('c'),
+        'answers' => $answers
+    ];
+    
+    file_put_contents("$answers_dir/{$form_id}_{$safe_answer_id}.json", json_encode($answer_data, JSON_PRETTY_PRINT));
+    
+    // No longer injecting JS to prevent double-submit.
+    $injected_js = "";
+    
+    // Email notification if provided
+    if (!empty($respondent_email) && filter_var($respondent_email, FILTER_VALIDATE_EMAIL)) {
+        $subject = "Your submission for: " . $form_data['title'];
+        $body = "Hi $respondent_name,\n\nThank you for your submission.\nHere is what you submitted:\n\n";
+        
+        foreach ($answers as $q_id => $val) {
+            $label = $questions_map[$q_id] ?? 'Question';
+            $val_str = is_array($val) ? implode(', ', $val) : $val;
+            $body .= "$label:\n$val_str\n\n";
+        }
+        
+        $host = $_SERVER['HTTP_HOST'];
+        $headers = "From: no-reply@" . $host . "\r\n";
+        @mail($respondent_email, $subject, $body, $headers);
+    }
+    
+    // Redirect to self as GET to prevent duplicate POSTs on refresh
+    header("Location: submit.php?id=" . urlencode($form_id) . "&answer_id=" . urlencode($safe_answer_id) . "&success=1");
+    exit;
+}
+
+// Display Mode (GET)
+$success = isset($_GET['success']);
+$safe_answer_id = preg_replace('/[^a-zA-Z0-9_-]/', '', $answer_id);
+$answer_file = "$answers_dir/{$form_id}_{$safe_answer_id}.json";
+
+if (empty($safe_answer_id) || !file_exists($answer_file)) {
+    die('<div style="font-family:sans-serif; text-align:center; padding:50px;"><h2>Submission Not Found</h2></div>');
+}
+
+$answer_data = json_decode(file_get_contents($answer_file), true);
+?>
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Submission Complete</title>
+    <link rel="stylesheet" href="assets/css/style.css">
+    <script>
+        <?= $injected_js ?> // Output any localstorage writes if we didn't redirect (e.g. if we chose to drop the header(Location))
+        
+        function clearAndAnswerNew() {
+            // we don't need to clear submissions here anymore since we allow doubles
+            window.location.href = 'answer.php?id=<?= htmlspecialchars($form_id) ?>';
+        }
+    </script>
+</head>
+<body>
+    <header class="site-header">
+        <div class="container header-inner">
+            <div class="brand">
+                <span class="brand-title"><?= htmlspecialchars($form_data['title']) ?></span>
+            </div>
+        </div>
+    </header>
+
+    <main class="container">
+        <?php if ($success): ?>
+            <div class="alert alert-success mt-3 mb-3">
+                Your response has been successfully saved.
+                <?php if (!empty($answer_data['respondent_email'])): ?>
+                    <br><small>A copy of your responses was sent to <?= htmlspecialchars($answer_data['respondent_email']) ?>.</small>
+                <?php endif; ?>
+            </div>
+        <?php endif; ?>
+
+        <div class="panel">
+            <h2 class="card-title">Submitted Answers</h2>
+            <br>
+            <table class="table-compact responsive-table">
+                <tbody>
+                    <tr>
+                        <td data-label="Respondent"><strong><?= htmlspecialchars($answer_data['respondent_name']) ?></strong></td>
+                    </tr>
+                    <?php foreach ($answer_data['answers'] as $q_id => $val): ?>
+                        <tr>
+                            <td data-label="<?= htmlspecialchars($questions_map[$q_id] ?? 'Question') ?>">
+                                <div><strong style="color:var(--brand-muted); font-size:0.85rem;"><?= htmlspecialchars($questions_map[$q_id] ?? 'Question') ?></strong></div>
+                                <div><?= nl2br(htmlspecialchars(is_array($val) ? implode(', ', $val) : $val)) ?></div>
+                            </td>
+                        </tr>
+                    <?php endforeach; ?>
+                </tbody>
+            </table>
+        </div>
+
+        <div class="panel text-center" style="background:transparent; border:none; box-shadow:none;">
+            <a href="answer.php?id=<?= htmlspecialchars($form_id) ?>&edit=<?= htmlspecialchars($answer_data['answer_id']) ?>" class="btn mt-2">Edit Response</a>
+            <button onclick="clearAndAnswerNew()" class="btn btn-secondary mt-2">Add another answer</button>
+        </div>
+    </main>
+</body>
+</html>