feat: add diagnostic web app shell
This commit is contained in:
@@ -70,10 +70,10 @@ interview-ready after each short practice loop.
|
||||
|
||||
### Frontend MVP
|
||||
|
||||
- [ ] **WEB-01**: User can open a web app served by the Go service.
|
||||
- [ ] **WEB-02**: User can create a diagnostic interview session from the web
|
||||
- [x] **WEB-01**: User can open a web app served by the Go service.
|
||||
- [x] **WEB-02**: User can create a diagnostic interview session from the web
|
||||
app.
|
||||
- [ ] **WEB-03**: User can answer a diagnostic question and see rubric feedback.
|
||||
- [x] **WEB-03**: User can answer a diagnostic question and see rubric feedback.
|
||||
- [ ] **WEB-04**: User can see learner memory, readiness, and next challenge
|
||||
after answering.
|
||||
- [ ] **WEB-05**: Operator can ingest source material from the web app.
|
||||
@@ -116,7 +116,7 @@ interview-ready after each short practice loop.
|
||||
| PROG-01..PROG-05 | Phase 4 | Complete |
|
||||
| ONTO-01..ONTO-04 | Phase 5 | Complete |
|
||||
| ASSET-01..ASSET-03 | Phase 6 | Complete |
|
||||
| WEB-01..WEB-03 | Phase 7 | Pending |
|
||||
| WEB-01..WEB-03 | Phase 7 | Complete |
|
||||
| WEB-04 | Phase 8 | Pending |
|
||||
| WEB-05..WEB-08 | Phase 9 | Pending |
|
||||
|
||||
@@ -128,4 +128,4 @@ interview-ready after each short practice loop.
|
||||
|
||||
---
|
||||
*Requirements defined: 2026-04-26*
|
||||
*Last updated: 2026-04-26 after v2 Frontend MVP milestone start.*
|
||||
*Last updated: 2026-04-26 after Phase 7 execution.*
|
||||
|
||||
@@ -7,7 +7,7 @@ See: `.planning/PROJECT.md` (updated 2026-04-26)
|
||||
**Core value:** The user should feel and prove that they are becoming more
|
||||
interview-ready after each short practice loop.
|
||||
|
||||
**Current focus:** v2 Frontend MVP milestone started; ready for Phase 7.
|
||||
**Current focus:** Phase 8 planning: Learning Progress View.
|
||||
|
||||
## Current Decisions
|
||||
|
||||
@@ -37,10 +37,11 @@ interview-ready after each short practice loop.
|
||||
tech-debt items recorded in `.planning/v1-MILESTONE-AUDIT.md`.
|
||||
- v2 Frontend MVP milestone selected to turn the backend learning loop into a
|
||||
usable web service.
|
||||
- Phase 7 web app shell and diagnostic start UI is implemented and verified.
|
||||
|
||||
## Next Actions
|
||||
|
||||
1. Plan and execute Phase 7: Web App Shell and Diagnostic Start.
|
||||
1. Plan and execute Phase 8: Learning Progress View.
|
||||
2. Verify the production OpenAI image model identifier before real image
|
||||
generation calls.
|
||||
3. Add standardized SUMMARY frontmatter or Nyquist validation files if future
|
||||
@@ -76,6 +77,9 @@ interview-ready after each short practice loop.
|
||||
real image generation, and Nyquist validation artifacts remain deferred.
|
||||
- 2026-04-26: v2 Frontend MVP milestone started with WEB-01..WEB-08 mapped to
|
||||
phases 7 through 9.
|
||||
- 2026-04-26: Phase 7 implementation verified with `go test ./...`, OpenSpec
|
||||
validation, root/asset HTTP smoke, and diagnostic API smoke through the
|
||||
server used by the web app.
|
||||
|
||||
---
|
||||
*State initialized: 2026-04-26.*
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Phase 7 Context: Web App Shell and Diagnostic Start
|
||||
|
||||
**Status:** Ready for execution
|
||||
**Started:** 2026-04-26
|
||||
|
||||
## Goal
|
||||
|
||||
Serve the first browser UI from the Go backend and let a job seeker start
|
||||
diagnostic practice without API tooling.
|
||||
|
||||
## Requirements
|
||||
|
||||
- WEB-01: User can open a web app served by the Go service.
|
||||
- WEB-02: User can create a diagnostic interview session from the web app.
|
||||
- WEB-03: User can answer a diagnostic question and see rubric feedback.
|
||||
|
||||
## UX Direction
|
||||
|
||||
Visual thesis: quiet interview coaching workspace, dense but readable, with one
|
||||
clear green accent for action and readiness.
|
||||
|
||||
Content plan:
|
||||
|
||||
- left rail: learner setup
|
||||
- main workspace: active diagnostic questions and answer input
|
||||
- right context: grading feedback and evidence
|
||||
|
||||
Interaction thesis:
|
||||
|
||||
- loading states on API actions
|
||||
- inline error region
|
||||
- selected question state and answer result refresh
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Full progress view.
|
||||
- Material/ontology workspace.
|
||||
- Authentication.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Phase 7 Plan: Web App Shell and Diagnostic Start
|
||||
|
||||
**Status:** Ready for execution
|
||||
**Phase Goal:** Create a working diagnostic web app shell.
|
||||
|
||||
## Tasks
|
||||
|
||||
### 1. Serve embedded web app
|
||||
|
||||
- Add `internal/webapp`.
|
||||
- Embed static app assets.
|
||||
- Register root and asset routes through the existing Go server.
|
||||
|
||||
### 2. Build diagnostic UI
|
||||
|
||||
- Add setup form for user id, target role, stack, and interview timeline.
|
||||
- Create diagnostic session through the real API.
|
||||
- Show returned questions.
|
||||
|
||||
### 3. Build answer and grading UI
|
||||
|
||||
- Let user select a question.
|
||||
- Submit answer through the real API.
|
||||
- Show overall grade, scores, follow-up, and evidence.
|
||||
|
||||
### 4. Verify
|
||||
|
||||
- Add HTTP tests for root web app and asset serving.
|
||||
- Run Go tests, OpenSpec validation, line-count check.
|
||||
- Smoke the rendered app endpoint and diagnostic API flow.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Frontend build tooling.
|
||||
- Authentication.
|
||||
- Phase 8 progress panels.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Phase 7 Research: Web App Shell and Diagnostic Start
|
||||
|
||||
## Findings
|
||||
|
||||
The existing Go backend can serve static assets without adding frontend build
|
||||
tooling. For the first MVP UI, plain HTML/CSS/JavaScript is enough and keeps
|
||||
the repo dependency-light.
|
||||
|
||||
The diagnostic APIs already provide all Phase 7 data:
|
||||
|
||||
- `POST /api/v1/diagnostic-sessions`
|
||||
- `POST /api/v1/diagnostic-sessions/{id}/answers`
|
||||
- `GET /api/v1/diagnostic-sessions/{id}`
|
||||
|
||||
## Recommended Shape
|
||||
|
||||
- Add `internal/webapp` with embedded static assets.
|
||||
- Register web app routes from `httpapi.Handler`.
|
||||
- Keep frontend files small and focused.
|
||||
- Use fetch calls directly against existing API routes.
|
||||
|
||||
## Risks
|
||||
|
||||
- A mock UI would not prove the backend loop. The UI must call real APIs.
|
||||
- A marketing-style landing page would distract from the core product surface.
|
||||
- Overbuilding a frontend stack before interaction validation would violate
|
||||
YAGNI.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Phase 7 Summary
|
||||
|
||||
**Status:** Complete
|
||||
**Completed:** 2026-04-26
|
||||
|
||||
## Delivered
|
||||
|
||||
- Added embedded web app serving from the Go backend.
|
||||
- Added `GET /` app shell and `/assets/*` static asset handling.
|
||||
- Built dependency-light HTML/CSS/JavaScript UI for diagnostic practice.
|
||||
- Added setup form for user id, target role, stack, and timeline.
|
||||
- Added real API-backed diagnostic session creation.
|
||||
- Added question selection, answer submission, and rubric feedback rendering.
|
||||
- Added loading, error, empty, and selected-question states.
|
||||
- Added web app route and asset tests.
|
||||
|
||||
## Verification
|
||||
|
||||
```powershell
|
||||
gofmt -w cmd internal
|
||||
go test ./...
|
||||
openspec validate frontend-mvp --strict
|
||||
openspec validate bootstrap-job-tutor-platform --strict
|
||||
```
|
||||
|
||||
Additional smoke check:
|
||||
|
||||
- `GET /` returned the app shell.
|
||||
- `GET /assets/app.js` returned the browser script.
|
||||
- Diagnostic session creation and answer grading succeeded through the same
|
||||
server used by the app.
|
||||
|
||||
## Deferred
|
||||
|
||||
- Progress panels for memory/readiness/next challenge.
|
||||
- Material and asset workspace.
|
||||
- Browser screenshot audit.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Phase 7 Verification
|
||||
|
||||
## Verdict
|
||||
|
||||
PASS
|
||||
|
||||
## Requirement Coverage
|
||||
|
||||
- WEB-01: PASS. The Go service serves the web app at `/`.
|
||||
- WEB-02: PASS. The browser app can create diagnostic sessions through the real
|
||||
backend API.
|
||||
- WEB-03: PASS. The browser app can submit answers and render typed rubric
|
||||
feedback, scores, follow-up, and evidence.
|
||||
|
||||
## Evidence
|
||||
|
||||
- `go test ./...` passed.
|
||||
- `openspec validate frontend-mvp --strict` passed.
|
||||
- `openspec validate bootstrap-job-tutor-platform --strict` passed.
|
||||
- Live root/asset HTTP smoke passed.
|
||||
- Live diagnostic create/answer smoke passed through the same server.
|
||||
|
||||
## Residual Risk
|
||||
|
||||
The UI was verified with HTTP/API smoke but not yet with a browser screenshot
|
||||
audit. Phase 8 should add browser-backed checks once the progress view is
|
||||
included.
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"tutor/internal/ontology"
|
||||
"tutor/internal/progression"
|
||||
"tutor/internal/teachingassets"
|
||||
"tutor/internal/webapp"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
@@ -52,6 +53,7 @@ func (h Handler) Routes() http.Handler {
|
||||
mux.HandleFunc("GET /api/v1/ontology", h.getOntology)
|
||||
mux.HandleFunc("POST /api/v1/teaching-assets/prompts", h.generateTeachingAssetPrompt)
|
||||
mux.HandleFunc("GET /api/v1/teaching-assets", h.getTeachingAssets)
|
||||
mux.Handle("GET /", webapp.Handler())
|
||||
return mux
|
||||
}
|
||||
|
||||
|
||||
@@ -50,3 +50,28 @@ func TestHealth(t *testing.T) {
|
||||
t.Fatalf("body.ModelKey = %q", body.ModelKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebAppRoute(t *testing.T) {
|
||||
cfg := config.Config{
|
||||
Environment: "test",
|
||||
ModelKey: "deepseek-v4-flash",
|
||||
}
|
||||
memory := learnermemory.NewService(learnermemory.NewMemoryStore())
|
||||
service := interview.NewService(interview.NewMemoryStore(), workflows.NewStubRunner(), memory)
|
||||
progress := progression.NewService(memory)
|
||||
onto := ontology.NewService(ontology.NewMemoryStore())
|
||||
assets := teachingassets.NewService(teachingassets.NewMemoryStore(), onto, cfg.ImageModelKey)
|
||||
handler := NewHandler(cfg, service, memory, progress, onto, assets)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.Routes().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
if rec.Header().Get("Content-Type") != "text/html; charset=utf-8" {
|
||||
t.Fatalf("content-type = %q", rec.Header().Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
|
||||
41
internal/webapp/assets.go
Normal file
41
internal/webapp/assets.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package webapp
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed static/*
|
||||
var assets embed.FS
|
||||
|
||||
func Handler() http.Handler {
|
||||
static, err := fs.Sub(assets, "static")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
files := http.FileServer(http.FS(static))
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/" {
|
||||
serveIndex(w, r, static)
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(r.URL.Path, "/assets/") {
|
||||
http.StripPrefix("/assets/", files).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func serveIndex(w http.ResponseWriter, r *http.Request, static fs.FS) {
|
||||
content, err := fs.ReadFile(static, "index.html")
|
||||
if err != nil {
|
||||
http.Error(w, "web app unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write(content)
|
||||
}
|
||||
36
internal/webapp/assets_test.go
Normal file
36
internal/webapp/assets_test.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package webapp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHandlerServesIndex(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
Handler().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "Interview practice") {
|
||||
t.Fatal("expected app shell content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerServesAsset(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/assets/app.js", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
Handler().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "diagnostic-sessions") {
|
||||
t.Fatal("expected app script content")
|
||||
}
|
||||
}
|
||||
181
internal/webapp/static/app.js
Normal file
181
internal/webapp/static/app.js
Normal file
@@ -0,0 +1,181 @@
|
||||
const state = {
|
||||
session: null,
|
||||
selectedQuestion: null,
|
||||
lastAnswer: null,
|
||||
};
|
||||
|
||||
const els = {
|
||||
sessionForm: document.querySelector("#session-form"),
|
||||
answerForm: document.querySelector("#answer-form"),
|
||||
answerText: document.querySelector("#answer-text"),
|
||||
answerButton: document.querySelector("#answer-button"),
|
||||
questions: document.querySelector("#questions"),
|
||||
feedback: document.querySelector("#feedback"),
|
||||
status: document.querySelector("#status-line"),
|
||||
error: document.querySelector("#error-line"),
|
||||
title: document.querySelector("#session-title"),
|
||||
};
|
||||
|
||||
els.sessionForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
clearError();
|
||||
setStatus("Creating diagnostic session...");
|
||||
|
||||
const payload = {
|
||||
user_id: value("#user-id"),
|
||||
target_role: value("#target-role"),
|
||||
stack: value("#stack").split(",").map((item) => item.trim()).filter(Boolean),
|
||||
interview_timeline: value("#timeline"),
|
||||
};
|
||||
|
||||
try {
|
||||
const session = await request("/api/v1/diagnostic-sessions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
state.session = session;
|
||||
state.selectedQuestion = session.questions[0] || null;
|
||||
state.lastAnswer = null;
|
||||
renderSession();
|
||||
renderFeedback();
|
||||
setStatus(`Session ${session.id} ready`);
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
setStatus("Ready");
|
||||
}
|
||||
});
|
||||
|
||||
els.answerForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
clearError();
|
||||
if (!state.session || !state.selectedQuestion) return;
|
||||
|
||||
setStatus("Submitting answer...");
|
||||
els.answerButton.disabled = true;
|
||||
|
||||
try {
|
||||
const answer = await request(`/api/v1/diagnostic-sessions/${state.session.id}/answers`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
question_id: state.selectedQuestion.id,
|
||||
answer_text: els.answerText.value,
|
||||
}),
|
||||
});
|
||||
state.lastAnswer = answer;
|
||||
renderFeedback();
|
||||
setStatus(`Answer graded as ${answer.grade.overall}`);
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
setStatus("Session ready");
|
||||
} finally {
|
||||
els.answerButton.disabled = !state.selectedQuestion;
|
||||
}
|
||||
});
|
||||
|
||||
function renderSession() {
|
||||
if (!state.session) return;
|
||||
els.title.textContent = `${state.session.target_role} · ${state.session.questions.length} questions`;
|
||||
els.questions.className = "question-list";
|
||||
els.questions.innerHTML = "";
|
||||
|
||||
state.session.questions.forEach((question) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "question-button";
|
||||
button.setAttribute("aria-pressed", String(state.selectedQuestion?.id === question.id));
|
||||
button.innerHTML = `<span class="question-id">${question.id}</span>${escapeHTML(question.prompt)}`;
|
||||
button.addEventListener("click", () => {
|
||||
state.selectedQuestion = question;
|
||||
els.answerText.value = "";
|
||||
renderSession();
|
||||
setStatus(`Selected ${question.id}`);
|
||||
});
|
||||
els.questions.append(button);
|
||||
});
|
||||
|
||||
els.answerButton.disabled = !state.selectedQuestion;
|
||||
}
|
||||
|
||||
function renderFeedback() {
|
||||
if (!state.lastAnswer) {
|
||||
els.feedback.className = "feedback empty-state";
|
||||
els.feedback.textContent = "Submit an answer to see grade, evidence, and follow-up.";
|
||||
return;
|
||||
}
|
||||
|
||||
const grade = state.lastAnswer.grade;
|
||||
els.feedback.className = "feedback";
|
||||
els.feedback.innerHTML = `
|
||||
<div>
|
||||
<div class="grade">${escapeHTML(grade.overall)}</div>
|
||||
<p class="status-line">${escapeHTML(grade.strengths?.[0] || "Answer was graded.")}</p>
|
||||
</div>
|
||||
${scoreRows(grade.scores)}
|
||||
${listBlock("Gaps", grade.gaps)}
|
||||
${followUpBlock(grade.follow_up)}
|
||||
${evidenceBlock(grade.evidence)}
|
||||
`;
|
||||
}
|
||||
|
||||
function scoreRows(scores) {
|
||||
return Object.entries(scores || {})
|
||||
.map(([label, score]) => `
|
||||
<div class="metric-row">
|
||||
<span>${escapeHTML(label.replaceAll("_", " "))}</span>
|
||||
<strong>${score}/4</strong>
|
||||
</div>
|
||||
`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function listBlock(title, items = []) {
|
||||
if (!items.length) return "";
|
||||
return `<section><h2>${title}</h2><ul class="small-list">${items.map((item) => `<li>${escapeHTML(item)}</li>`).join("")}</ul></section>`;
|
||||
}
|
||||
|
||||
function followUpBlock(followUp) {
|
||||
if (!followUp?.needed) return "";
|
||||
return `<section><h2>Follow-up</h2><p class="status-line">${escapeHTML(followUp.question)}</p></section>`;
|
||||
}
|
||||
|
||||
function evidenceBlock(evidence = []) {
|
||||
if (!evidence.length) return "";
|
||||
return `<section><h2>Evidence</h2><ul class="small-list">${evidence.map((item) => `<li>${escapeHTML(item.quote || item.id)}</li>`).join("")}</ul></section>`;
|
||||
}
|
||||
|
||||
async function request(url, options = {}) {
|
||||
const response = await fetch(url, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...options,
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(body.error || `Request failed: ${response.status}`);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function value(selector) {
|
||||
return document.querySelector(selector).value.trim();
|
||||
}
|
||||
|
||||
function setStatus(message) {
|
||||
els.status.textContent = message;
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
els.error.textContent = message;
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
els.error.textContent = "";
|
||||
}
|
||||
|
||||
function escapeHTML(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
68
internal/webapp/static/index.html
Normal file
68
internal/webapp/static/index.html
Normal file
@@ -0,0 +1,68 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Tutor Platform</title>
|
||||
<link rel="stylesheet" href="/assets/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="workspace">
|
||||
<aside class="setup-pane" aria-label="Diagnostic setup">
|
||||
<p class="eyebrow">Tutor Platform</p>
|
||||
<h1>Interview practice</h1>
|
||||
<p class="lede">Start a focused backend interview loop and turn one answer into evidence.</p>
|
||||
|
||||
<form id="session-form" class="stacked-form">
|
||||
<label>
|
||||
User ID
|
||||
<input id="user-id" name="user_id" value="demo-user" autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
Target role
|
||||
<input id="target-role" name="target_role" value="junior backend developer" />
|
||||
</label>
|
||||
<label>
|
||||
Stack
|
||||
<input id="stack" name="stack" value="go, postgres" />
|
||||
</label>
|
||||
<label>
|
||||
Timeline
|
||||
<input id="timeline" name="interview_timeline" value="30 days" />
|
||||
</label>
|
||||
<button id="start-button" type="submit">Start diagnostic</button>
|
||||
</form>
|
||||
|
||||
<p id="status-line" class="status-line" role="status">Ready</p>
|
||||
<p id="error-line" class="error-line" role="alert"></p>
|
||||
</aside>
|
||||
|
||||
<section class="practice-pane" aria-label="Diagnostic practice">
|
||||
<div class="section-heading">
|
||||
<p class="eyebrow">Diagnostic</p>
|
||||
<h2 id="session-title">No active session</h2>
|
||||
</div>
|
||||
<div id="questions" class="question-list empty-state">
|
||||
Start a diagnostic session to load interview questions.
|
||||
</div>
|
||||
|
||||
<form id="answer-form" class="answer-form">
|
||||
<label for="answer-text">Answer</label>
|
||||
<textarea id="answer-text" rows="7" placeholder="Select a question, then answer with concrete production reasoning."></textarea>
|
||||
<button id="answer-button" type="submit" disabled>Submit answer</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<aside class="feedback-pane" aria-label="Feedback">
|
||||
<div class="section-heading">
|
||||
<p class="eyebrow">Feedback</p>
|
||||
<h2>Rubric result</h2>
|
||||
</div>
|
||||
<div id="feedback" class="feedback empty-state">
|
||||
Submit an answer to see grade, evidence, and follow-up.
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
<script src="/assets/app.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
232
internal/webapp/static/styles.css
Normal file
232
internal/webapp/static/styles.css
Normal file
@@ -0,0 +1,232 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f5f7f4;
|
||||
--surface: #ffffff;
|
||||
--surface-muted: #eef2ec;
|
||||
--text: #18201b;
|
||||
--muted: #5b665f;
|
||||
--line: #d8dfd5;
|
||||
--accent: #19764b;
|
||||
--accent-dark: #105c39;
|
||||
--danger: #a93a2f;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 320px) minmax(360px, 1fr) minmax(280px, 360px);
|
||||
gap: 1px;
|
||||
min-height: 100vh;
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.setup-pane,
|
||||
.practice-pane,
|
||||
.feedback-pane {
|
||||
background: var(--surface);
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.practice-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 10px;
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
margin: 0;
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
h1 {
|
||||
max-width: 9ch;
|
||||
font-size: clamp(42px, 6vw, 74px);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.lede {
|
||||
max-width: 28ch;
|
||||
margin: 18px 0 30px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.stacked-form,
|
||||
.answer-form {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #fbfcfa;
|
||||
color: var(--text);
|
||||
padding: 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 160px;
|
||||
resize: vertical;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(25, 118, 75, 0.12);
|
||||
}
|
||||
|
||||
button {
|
||||
min-height: 44px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: var(--accent-dark);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
.status-line,
|
||||
.error-line {
|
||||
min-height: 20px;
|
||||
margin: 18px 0 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.status-line {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.error-line {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.question-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.question-button {
|
||||
border: 1px solid var(--line);
|
||||
background: #fbfcfa;
|
||||
color: var(--text);
|
||||
padding: 16px;
|
||||
min-height: 72px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.question-button[aria-pressed="true"] {
|
||||
border-color: var(--accent);
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.question-id {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 6px;
|
||||
color: var(--muted);
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.feedback {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.metric-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 10px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.grade {
|
||||
color: var(--accent);
|
||||
font-size: 38px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.small-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
h1 {
|
||||
max-width: 100%;
|
||||
font-size: 42px;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
# Tasks
|
||||
|
||||
- [ ] 1. Implement web app shell served by the Go backend.
|
||||
- [ ] 2. Implement diagnostic session start and answer submission UI.
|
||||
- [x] 1. Implement web app shell served by the Go backend.
|
||||
- [x] 2. Implement diagnostic session start and answer submission UI.
|
||||
- [ ] 3. Implement learner memory, readiness, and next challenge UI.
|
||||
- [ ] 4. Implement material ingestion and ontology inspection UI.
|
||||
- [ ] 5. Implement teaching asset prompt candidate UI.
|
||||
|
||||
Reference in New Issue
Block a user