Kindred: a thoughtful support workspace and complete help center
Build Kindred as a genuinely new customer-support software concept, not a recolored planning board, analytics dashboard, or inherited marketing template. Deliver a rich original homepage, a functional local inbox, a searchable help center, and six complete article-detail pages. The product narrative must connect conversation context, reusable knowledge, and a clear next step. A visitor should be able to explore the working inbox, choose a fictional conversation, assign an owner, change status, write an internal note, compose a reply draft, insert a relevant answer, download the draft, search full help-article text, and record local article feedback.
The interface must be honest about its boundary. All included people and conversations are fictional. Replies are drafts and no action sends email, chat, notifications, feedback, or any other communication outside the browser. There is no account, provider integration, server synchronization, or live support promise. A complete local interaction is more valuable than a fake Send button. Do not invent adoption metrics, customer logos, service-level guarantees, prices, testimonials, or AI capabilities to fill space.
Actual reference research
Use the discovery source https://saaspo.com/pages/help-scout-product-page. Inspect the live individual reference at https://www.helpscout.com/, https://www.helpscout.com/inbox/, and https://www.helpscout.com/knowledge-base/. The observed homepage combines a human editorial headline with a detailed product story. The dedicated inbox page places an approximately1280px application demonstration beneath a centered heading and continues through channel consolidation, ownership, internal notes, contextual customer details, response workflows, reusable replies, proof, and a large closing invitation. Its desktop headline measured about57px at1471×847, with section headings around35px and a very large closing display.
The knowledge-base page uses layered, substantial help-center interfaces. Its narrative continues into article creation, brand customization, self-service, answers inside conversations, content analytics, examples, feedback, and close. The transferable pattern is the relationship between a support conversation and the knowledge that helps answer it. These are two connected products with real detail, not one hero mockup and three vague benefit cards. Product screenshots and explicit play controls were visually inspected; exact video timing and source code were not copied. A cookie notice partially covered some screenshots, so do not claim hidden areas were visually verified.
Kindred translates that structural depth into its own warm editorial identity. Do not copy the reference's logo, exact headline, gradients, illustrations, screenshots, customer records, percentages, portraits, or code. The new visual direction is cream paper, deep forest green, sage, peach, and a restrained serif display. Its original photograph is a fictional adult listening thoughtfully near a sunlit window, used as a human opening rather than a staff testimonial. The real inbox and help pages carry the product proof. Do not replace those with a photograph or inaccessible screenshot.
Typography, readable scale, and palette
Bundle Geist Variable for interface and body text, with font-display swap and its SIL Open Font License. Bundle DM Serif Display for main display headings, with its own license. Main prose should generally be16–18px. Actual control text must be at least14px. Secondary labels and metadata must be at least12px. Do not shrink a whole desktop application to fit a phone or use tiny6–10px pseudo-interface labels. At narrow widths, remove secondary panes, simplify the visible composition, and keep the meaningful conversation text readable.
Use paper #f8f6ef, ink #203c34, muted #5d6b61, rules #cdd6cb, sage #dfe8d9, green #234e3d, and peach #f0dfcf. Light surfaces dominate. Deep green is reserved for clear actions, the product title bar, and the closing section. Status text uses green on pale sage with a border. Original owner initials have peach, blue-gray, or sage circles. These are fictional sample identities, not real customer avatars. Use text labels in addition to color for status and selection.
The brand is lowercase kindred in Geist, around30px with tight tracking and weight600. Its original native mark is two rounded leaf-like columns, one deep green and one muted sage, tilted toward each other. It is a simple CSS identity, not a copied logo. Use a single licensed Lucide icon family through scripts/kindred/ui.mjs. The helper must reject unknown icon names rather than silently drawing the wrong symbol. Do not import rendering structure from another demo.
The page container is max-width1320px with30px inner gutters. Header height is84px at desktop, sticky at top zero, with a nearly opaque cream backdrop and a fine bottom rule. It aligns to the1260px interior rail. Navigation contains Inbox, Help center, Our approach, and a real Try the workspace action. Current pages use aria-current. The mobile header retains essential links while removing the redundant CTA and approach link. All pages have a skip link, main landmark, one h1, meaningful titles, viewport metadata, and ordinary relative anchors. No favicon links or assistant/platform branding belong in public files.
Homepage opening
Use an asymmetrical two-column hero with an80px gap and65px top padding. The left column contains a12px introductory label, a large serif heading reading “Good help feels like being heard.”, a concise18px explanation, and two useful links: the inbox and help center. The display grows to92px at large desktop widths, with tight line height near.98 and intentional line breaks. Avoid a six-line narrow slogan. The right column holds the original photograph, cropped into a tall arch with rounded top corners and a quiet rectangular bottom. The image is560px tall at desktop, covers its box, and remains large enough to convey the human subject naturally.
A small cream note overlaps the lower-left image edge. It contains a heart icon and two short lines about seeing the person before the question, with15px text. It is not a testimonial, quotation from the generated subject, or claim that the person works for the product. Keep the overlap within the page width. On phone it moves to the image edge and retains comfortable padding. The photo should never carry essential text baked into pixels. Use a real image element with explicit dimensions and truthful alt text.
After the hero, introduce the inbox product with a centered serif heading about keeping context close. Give the product stage a large rail, a fine green-gray border, eight-to-nine-pixel corners, and a restrained grounding shadow. The stage is a semantic HTML composition with actual links, original text, and meaningful status labels. Its deep-green top bar is54px high and contains the workspace description and a real Open the inbox link.
At desktop the sample product interior uses200px navigation,280px queue, and a flexible conversation pane, with at least640px height. The navigation links to the real inbox, Open and Waiting filters, and help center. The queue shows the first three original conversations, with owner initials, names, subjects, short message excerpts, and states. The main pane shows the first conversation and a suggested draft in a pale writing surface. It should be obvious which parts are customer context and which are draft wording. The sample draft link opens the actual matching conversation.
At1100px remove the sample navigation pane. At800px remove the sample queue and retain a full-width readable conversation. Do not scale down all three panes. Increase its meaningful message prose to16px when it is the sole pane. The sample remains static educational content except for working navigation; it is labeled as a browser-local workspace and draft-only experience. It does not pretend to be a synchronized view of any real support account.
Complete product narrative and interactions
The approach section follows with a two-column statement: a large serif idea on the left and a stronger explanatory paragraph on the right. Beneath, three original principles explain understanding the question, making handoffs visible, and reusing knowledge. These have numbered context labels, a fine top rule,21px headings, and16px body. Do not add fabricated performance numbers. On mobile the principles become simple readable rows rather than tiny tiles.
The next feature region changes the background to sage and uses a two-column layout: explanatory controls on the left and a substantial contextual visual on the right. The controls are Keep the context, Start from a useful answer, and Name the next step. Each is a real button with a heading, explanatory sentence, and aria-pressed state. Selecting one replaces the right-hand panel. The Context panel shows a useful internal note; Answer shows a complete suggested article preview and a real article link; Next shows owner and status plus a matching conversation link. Do not create buttons that only recolor themselves while leaving the same image visible.
The feature visual has at least480px height,45px inset, a muted green field, and an original paper-note composition. A note sheet uses warm cream, readable16px prose,33px serif heading, and a restrained offset shadow. Slight rotation is acceptable because it supports the paper metaphor, but reduce it in reduced-motion mode. The note must not be confused with the reply draft: it is internal context that is not included in downloaded replies.
The self-service region pairs a substantial original help-center preview with a large serif explanation. The preview contains Kindred help branding, a real search-route link, and three specific article links. Those article previews must open complete pages. The help-center story explains that someone can find an answer without opening a conversation, while the inbox remains available when a question needs context. Use a pale blue-gray surface to distinguish this visual from the sage note panel without introducing an unrelated color system.
Follow with three complete article-topic cards in sage, peach, and blue-gray. Each uses a category, actual original title, summary, reading-time label, and whole-card link. This is a knowledge collection, not a customer-logo wall. The closing FAQ explains the local boundary, draft behavior, search capability, and absence of outbound communication. Finish with a full-width deep-green section, generous serif display, and a useful workspace action. Maintain the narrative length and variety instead of ending after the first interface demonstration.
Working inbox layout and ticket state
inbox.html is a complete workspace with its own serif introduction and plain statement that the conversations are fictional and nothing is sent. A toolbar combines search, a status selector, and an actual result count. The main workspace has a330px conversation list and a flexible detail pane inside one bordered white surface. At1100px reduce the list to290px. At800px place a two-column conversation chooser above the detail; it may have a bounded330px list scroll region so the active conversation remains reachable. At500px the chooser becomes a one-column list with a300px maximum, while the detail retains normal page scrolling.
Include exactly five original conversations K-101 through K-105. The people, questions, drafts, and notes are fictional. They cover recovering a local draft, choosing conversation states, exporting a draft, finding a help article, and understanding local-data boundaries. Source records are included below in the interaction handler. Each record has id, name, initials, subject, message, status, owner, article, draft, and note. States are Open, Waiting, and Resolved. Owners are Unassigned, Mina, Ellis, and Sam. Changing an owner is a local field update, not a notification or real team assignment.
Search checks name, subject, original message, and internal note, case-insensitively. The status filter intersects search. The result count reflects the current filtered set. When a selected conversation no longer matches, select the first remaining result. If there are no results, hide the detail and show a useful empty state with Show all conversations. Clearing resets both search and status. Known ticket and status query parameters initialize the workspace; unknown values must not crash it.
The detail starts with stable ticket ID and native labeled Owner and Status selects. The subject is an interface heading in Geist, around28px, not an oversized decorative serif. The original message is displayed separately with the fictional customer initials and name. It is read-only context. Below it, an Internal note textarea sits in a pale yellow-green region. The suggested-answer area links to the correct full article and has an explicit Insert answer button. The reply textarea is visibly labeled Reply draft and uses16px text, generous line height, and a4000-character bound.
Draft and internal-note fields persist as the user types. They are stored separately per conversation. Do not replace a typed reply merely because another ticket was selected and then reopened. Status and owner changes also persist locally. Save status must reflect actual success. If local storage rejects the write, retain in-memory values and say changes are page-only. Do not report “saved” when the write failed. Switching queue views must not discard the current in-memory object.
Insert answer appends the relevant article’s original short answer to the current draft, separated by a blank line if needed. It must not silently overwrite existing text. If the combined wording would exceed4000 characters, leave the draft unchanged and explain that the user should shorten it first. Do not truncate the user’s existing writing to fit a suggestion. The inserted material remains editable and does not imply a message was sent or an AI model generated an answer.
Download draft builds a text/plain file from the current selected conversation and the current draft. Include subject and ID, the reply text or a clear empty-draft statement, and a note that this is unsent local wording. Do not include internal notes in that reply export. Use a safe ID-derived filename, a Blob URL, and revoke it after a short delay. The status should say prepared for download, not claim that the browser finished saving. No Send button, network call, email address, or remote provider credential belongs in this flow.
Searchable help center and full articles
help.html begins with a pale-sage hero, a large serif help invitation, a one-sentence explanation, and a real search field. The search is immediate and case-insensitive. It must inspect article title, summary, and full section text, so a visitor can find an answer without knowing its title. Topic buttons include All topics, Getting started, Working together, and Keeping context. Each is a real filter with aria-pressed. Search and category work together; changing a topic does not erase the search text.
The results region shows the exact number of matching articles and a two-column set of substantial article rows. Each contains an original category, reading time, title, summary, and a real destination. Use21px titles and15px summaries, with12px metadata. On phones stack results in one column. A no-results state explicitly says that the search found no answer and provides Show all articles, which clears both query and topic. Do not silently show unrelated popular articles under a no-match query.
Generate six complete article pages: article-saved-drafts.html, article-conversation-status.html, article-export-draft.html, article-find-an-answer.html, article-internal-notes.html, and article-your-data.html. Every article has a meaningful original summary, a concise short-answer box, and three or four complete explanatory sections. The content must correspond to actual implemented behavior. For example, storage is browser-local, status does not send, ownership does not notify, and internal notes do not appear in reply downloads. Avoid unrelated billing policies or security promises that the concept cannot demonstrate.
At desktop each article has a250px sticky side rail and an approximately800px content column, separated by75px. The rail has a back link, category, section anchors, and inbox action. The article h1 is around54px serif, summary18px, body17px with1.85 line height, and section headings25px Geist. The short-answer box uses sage and a fine border. At800px the rail becomes a simple back link above the document, and secondary section navigation disappears. The main text reflows naturally; do not compress a desktop page into a phone-sized inset.
Each article offers Helpful and Needs more detail controls with aria-pressed. Record the chosen value under a known article-specific local key. Restore it on return. The visible status states that feedback is stored in this browser only and is not sent to the author. If storage fails, show the selection for the current page and report that limitation. Do not invent public vote totals or pretend the local click updated a real knowledge-base rating. Finish with a related article in the same category and a working link.
Persistence, safety, and resilience
Use kindred-conversations-v1 for the five-record local conversation array. Recover only a correctly shaped array of the expected length, with every known ID present, recognized status and owner, and bounded string fields. Otherwise fall back to original sample records. Keep the sample data in a single shared source module used by both builder and client. User drafts and notes must be rendered through values or textContent. Any user-controlled strings included in queue markup must pass a complete escape function covering ampersand, angle brackets, quotes, and apostrophes.
Article feedback keys are scoped to known article IDs. Never clear unrelated browser storage. There is no global destructive reset required for this concept. Local state is not a promise of permanent storage: private browsing, clearing data, or switching devices can make it unavailable. The help articles explain these boundaries. Do not collect or transmit data through analytics scripts, forms, third-party embeds, or hidden network services. Fonts and images are bundled locally so the exported folder remains useful offline.
Keyboard access matters as much as pointer interaction. Use actual buttons, links, native inputs and selects, visible focus rings, semantic navigation, and live status regions. Do not use clickable divs or remove outlines. Field labels remain visible instead of relying only on placeholder text. The active conversation and selected topic must have both visual treatment and aria-pressed. Keep controls at least44px tall where practical and never reduce their text below14px to fit a layout.
Motion, responsive rules, and original assets
Motion should clarify a transition without simulating progress. Feature-panel changes animate from opacity.2 and translateY(14px) to their final position over300ms with ease-out, only when reduced motion is off. IntersectionObserver at threshold.2 triggers one-time entrances for the product stage, help preview, and article grid. The stage and help preview enter over.6 seconds; article cards enter over.5 seconds with .1 and .2-second stagger. The shift is22px and content is visible by default. No autoplay typing or fake send delay changes a real draft. Reduced motion disables animations, transitions, smooth scrolling, and unnecessary note rotation.
At1100px reduce major gaps to45–50px and remove the sample product sidebar. At800px the header becomes74px, essential navigation remains14px, and the sample queue disappears. Hero remains a balanced two-column composition until500px, with62px serif type and a440px image. Feature and self-service sections stack, article-topic cards stack, and the inbox chooser moves above the selected conversation. At500px the header is68px, gutters20px, the hero stacks, display is64px with a reduced line-break pattern, and the photo is370px tall. Main section headings settle around36–43px. Actual text stays readable; secondary labels never drop below12px.
Bundle assets/conversation.webp at1440×1080 and assets/hero.webp at960×720, with exact original-image provenance and credits. The image depicts a fictional adult, not a staff member or customer endorsement. It is generated original editorial imagery. Preserve the source prompt in provenance.json, and describe the creation method generically in public credits without assistant/platform branding. Bundle geist-variable.woff2, dm-serif.ttf, and the original font and icon licenses. No reference assets or logos are copied.
The original image brief is a thoughtful fictional adult woman with short dark curly hair and a soft sea-green knit sweater at a pale wooden table near a sunlit window, with a closed notebook and ceramic cup. Use natural cream and sage surroundings, tactile materials, and premium editorial realism. There should be no readable text, brands, headset, visible computer screen, or celebrity likeness. The photograph supports the human product story; it does not replace interface proof or carry essential copy.
Build contract and quality verification
Export buildKindred() returning {prompt}. Generate the nine complete owned HTML pages, their local assets, public/demos/kindred/PROMPT.md, and public/prompts/kindred.md. Keep source in scripts/kindred only and do not modify shared catalogs, deployment code, credentials, or other demos. The assembled recreation prompt must be at least3000 substantive words and include the actual records, validation, search, persistence, and interaction code under the exact heading Reference interaction handler. Preserve exact source behavior in the prompt instead of padding with generic design advice.
Run scoped lint and syntax checks, then meaningful tests for article full-text search, topic intersection, malformed ticket recovery, and record validity. In the real browser verify selecting conversations, typing a draft, switching away and back, persistence after reload, internal-note separation, status and owner updates, no-result recovery, answer insertion without overwrite, draft download, article search by body text, topic filters, article navigation, feedback persistence, and mobile reflow. Inspect the actual desktop hero, product stage, inbox, help center, and article content, not only Lighthouse scores.
At390px, verify there is no horizontal document overflow. A bounded conversation chooser is acceptable, but the active message and draft must remain readable in normal page flow. Check that long titles wrap, fields stay inside their container, and line-break removal does not join words. Confirm minimum text sizes and contrast across cream, sage, peach, and dark-green surfaces. Report unavailable-storage or reduced-motion branches honestly if they were inspected in code rather than force-tested. The result is complete only when design, content, actions, and local-data disclosures agree.
Final readable-text contrast
Use #526448 for approach numbers, feature-button explanatory text, inactive feature headings, and article-card body text. This darker green meets4.5:1 contrast against the implemented cream, sage, peach, and blue-gray surfaces while retaining the softer visual hierarchy. Do not use paler green text as a substitute for spacing or font weight. Control labels remain14px minimum, metadata12px minimum, and primary reading copy16–18px.
Reference interaction handler
const kdStatuses=["Open","Waiting","Resolved"];const kdOwners=["Unassigned","Mina","Ellis","Sam"];const kdTickets=[{"id":"K-101","name":"Mara Ellis","initials":"ME","subject":"Where did my saved draft go?","message":"I wrote a reply yesterday and closed the tab before finishing. I am back on the same laptop. Where should I look for the draft?","status":"Open","owner":"Mina","article":"saved-drafts","draft":"","note":"The customer is returning on the same device. Check local storage context before suggesting a new draft."},{"id":"K-102","name":"Theo Park","initials":"TP","subject":"A clearer way to track our conversations","message":"We have a few questions waiting for more detail and others that are finished. What is the best way to tell them apart?","status":"Open","owner":"Ellis","article":"conversation-status","draft":"","note":""},{"id":"K-103","name":"Lina Ortega","initials":"LO","subject":"Can I take a copy of my reply?","message":"I would like to keep the wording in our own notes before I leave this page. Is there a way to download the current draft?","status":"Waiting","owner":"Sam","article":"export-draft","draft":"Hi Lina, you can download the current reply draft as a plain-text file from the conversation. Nothing is sent from this demo.","note":"Waiting for confirmation that the local file meets their needs."},{"id":"K-104","name":"Rowan Moss","initials":"RM","subject":"Finding the right answer in the help center","message":"I remember reading an article about keeping context, but I cannot remember its title. Can I search the text inside articles?","status":"Open","owner":"Unassigned","article":"find-an-answer","draft":"","note":""},{"id":"K-105","name":"Noor Reed","initials":"NR","subject":"Who can see what I write here?","message":"Before I try the workspace, I want to understand whether my notes are uploaded anywhere or shared with another person.","status":"Resolved","owner":"Mina","article":"your-data","draft":"Hi Noor, this concept keeps your drafts, notes, ownership, and status changes in this browser. It does not send them to a server or another person.","note":"Explained the browser-local boundary."}];const kdArticles=[{"id":"saved-drafts","title":"Pick up a reply where you left off","category":"Getting started","minutes":3,"summary":"How local drafts are saved, restored, and kept separate from a sent message.","answer":"Your reply draft saves in this browser as you type. Open the same conversation on the same browser and device to continue. A draft is never sent by this demo.","sections":[["Start in the conversation","Open the Inbox and select the conversation you were working on. The Reply draft field contains your current saved wording. Switching between conversations keeps a separate draft for each one."],["Understand the save message","A successful save displays “Draft saved in this browser.” If storage is unavailable, the draft remains on the current page and the status explains that limitation. Download a copy before leaving a page-only draft."],["If the draft is not there","Check that you are using the same browser profile and device. Private browsing, clearing site data, or switching browsers can change which local records are available. This concept has no account recovery or cloud synchronization."],["Keep a portable copy","Use Download draft in the conversation to create a plain-text copy of the current wording. The file is a local handoff; it does not send a reply to the person in the sample conversation."]]},{"id":"conversation-status","title":"Give every conversation a clear next step","category":"Working together","minutes":3,"summary":"Use Open, Waiting, and Resolved to describe the actual state of a question.","answer":"Use Open when the conversation needs attention, Waiting when more information is needed, and Resolved when you have finished the local review. Changing status does not send a message.","sections":[["Open means attention is needed","New questions begin Open. Use this state when there is a useful next action for the person responsible: reading context, drafting a response, or checking a relevant article."],["Waiting keeps uncertainty visible","Choose Waiting when you need more information before the next step. Add an internal note explaining what is missing so the next person does not have to reconstruct your thinking."],["Resolved is a local decision","Choose Resolved when the conversation no longer needs attention in this sample workflow. This changes its place in the queue. It does not prove that a real customer received or accepted an answer."],["A conversation can reopen","Every status is editable. If there is a new question or unfinished work, choose Open again. Status should describe the current situation rather than hide a remaining concern."]]},{"id":"export-draft","title":"Take your draft with you","category":"Keeping context","minutes":2,"summary":"Download a plain-text copy without sending or publishing anything.","answer":"Use Download draft to save the current reply as a text file. The export includes the conversation subject and your draft. It does not send or publish a message.","sections":[["Review the wording first","Open the correct conversation and read the Reply draft field. The download uses the current text, including edits that have not been saved because browser storage is unavailable."],["Download a local copy","Choose Download draft. The browser prepares a text file with the conversation subject and reply wording. Your browser decides where to save it and may ask you to choose a location."],["What the action does not do","It does not send an email, post a chat message, create a ticket with a provider, or publish an article. Kindred is a self-contained local concept, so there is no outbound communication service."]]},{"id":"find-an-answer","title":"Find an answer without knowing its title","category":"Getting started","minutes":2,"summary":"Search article titles, summaries, and full text, then narrow by category.","answer":"The help-center search checks article titles, summaries, and the full article text. You can combine a search term with a category, or clear the category to see every matching answer.","sections":[["Use a concrete word","Try words that describe the action or problem, such as draft, status, download, browser, or owner. Search is case-insensitive and checks the article body as well as its title."],["Narrow by category","Getting started covers the first steps, Working together explains queue decisions, and Keeping context covers notes and portability. A category and search term work together."],["When there is no match","Clear the category or try a simpler term. The help center shows an explicit no-results state rather than silently suggesting an unrelated article. You can also open the local inbox to practice a conversation draft."]]},{"id":"internal-notes","title":"Leave context for the next person","category":"Working together","minutes":3,"summary":"Keep an internal note beside the original question and your reply draft.","answer":"Use the Internal note field to record what you checked, what is missing, and the next useful step. It stays separate from the reply draft and is not included in the reply download.","sections":[["Write for someone arriving fresh","Explain the useful context in plain language. A good note states what was checked and what remains uncertain, rather than merely saying that someone looked at the question."],["Keep internal and external wording separate","The Internal note field is distinct from Reply draft. Suggested answer text goes into the reply only when you explicitly choose Insert answer. Internal notes are not added to reply exports."],["Name the next step","If the conversation is Waiting, record what information is needed. If you change the owner, leave enough context that the next person can continue without rereading every detail."],["Know the local boundary","This demo saves notes in your current browser. Changing the owner is a local field update, not a notification or real assignment to a teammate."]]},{"id":"your-data","title":"Understand what stays in your browser","category":"Keeping context","minutes":3,"summary":"The local-only boundaries of conversations, drafts, notes, and feedback.","answer":"Kindred stores demo changes only in this browser. There is no account, cloud sync, outgoing email, live chat service, or visitor tracking. Download drafts you want to keep beyond this browser.","sections":[["No external support connection","The included conversations and people are fictional examples. The inbox is not connected to an email address, customer account, or helpdesk provider. Its controls demonstrate a workflow locally."],["What is stored","The browser can store conversation status, owner, reply draft, internal note, and article feedback. These records are scoped to this concept and are not shared with other designs or applications."],["Storage is not guaranteed","Private browsing, storage restrictions, or clearing site data can remove local records or prevent saving. The visible save status tells you whether a current write succeeded."],["Feedback is also local","Helpful and Needs more detail record your choice in this browser. They do not send an evaluation to an author or update a public rating. The interface reports that boundary explicitly."]]}];
function kdValidTicket(t) {
return (
t &&
typeof t === 'object' &&
[
'id',
'name',
'initials',
'subject',
'message',
'draft',
'note',
'article',
].every((k) => typeof t[k] === 'string' && t[k].length <= 4000) &&
kdStatuses.includes(t.status) &&
kdOwners.includes(t.owner)
);
}
function kdFindArticles(query, category) {
const q = query.trim().toLowerCase();
return kdArticles.filter(
(a) =>
(category === 'All topics' || a.category === category) &&
[a.title, a.summary, ...a.sections.flat()]
.join(' ')
.toLowerCase()
.includes(q),
);
}
const kdReduced = matchMedia('(prefers-reduced-motion: reduce)');
const kdStore = {
read(key) {
try {
return JSON.parse(localStorage.getItem(key) || 'null');
} catch {
return null;
}
},
write(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
return true;
} catch {
return false;
}
},
};
const kdEsc = (text) =>
String(text).replace(
/[&<>"']/g,
(c) =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[
c
],
);
const kdSaved = kdStore.read('kindred-conversations-v1');
const kdConversations =
Array.isArray(kdSaved) &&
kdSaved.length === kdTickets.length &&
kdSaved.every(kdValidTicket) &&
kdTickets.every((t) => kdSaved.some((s) => s.id === t.id))
? kdSaved
: structuredClone(kdTickets);
let kdSelected =
new URLSearchParams(location.search).get('ticket') || kdConversations[0].id;
function kdCurrent() {
return kdConversations.find((t) => t.id === kdSelected);
}
function kdPersist(message) {
const ok = kdStore.write('kindred-conversations-v1', kdConversations);
document.getElementById('kd-save-status').textContent = ok
? message
: 'Storage unavailable. Changes are kept on this page only.';
}
function kdFiltered() {
const q = document
.getElementById('kd-ticket-search')
.value.trim()
.toLowerCase();
const status = document.getElementById('kd-status-filter').value;
return kdConversations.filter(
(t) =>
(status === 'All' || t.status === status) &&
[t.name, t.subject, t.message, t.note]
.join(' ')
.toLowerCase()
.includes(q),
);
}
function kdRenderQueue() {
const filtered = kdFiltered();
if (!filtered.some((t) => t.id === kdSelected)) kdSelected = filtered[0]?.id;
document.getElementById('kd-count').textContent =
filtered.length +
(filtered.length === 1 ? ' conversation' : ' conversations');
document.getElementById('kd-ticket-list').innerHTML = filtered
.map(
(t, n) =>
`<button data-ticket="${kdEsc(t.id)}" aria-pressed="${t.id === kdSelected}"><span class="queue-person"><span class="avatar tone-${n % 3}">${kdEsc(t.initials)}</span><b>${kdEsc(t.name)}</b><span>${kdEsc(t.status)}</span></span><strong>${kdEsc(t.subject)}</strong><p>${kdEsc(t.message.slice(0, 90))}…</p><small>${kdEsc(t.owner)}${t.draft ? ' · Draft saved' : ''}</small></button>`,
)
.join('');
document.getElementById('kd-conversation').hidden = !filtered.length;
document.getElementById('kd-empty-queue').hidden = !!filtered.length;
kdRenderConversation();
}
function kdRenderConversation() {
const t = kdCurrent();
if (!t) return;
for (const [id, key] of [
['kd-ticket-id', 'id'],
['kd-subject', 'subject'],
['kd-initials', 'initials'],
['kd-name', 'name'],
['kd-message', 'message'],
])
document.getElementById(id).textContent = t[key];
document.getElementById('kd-owner').value = t.owner;
document.getElementById('kd-ticket-status').value = t.status;
document.getElementById('kd-note').value = t.note;
document.getElementById('kd-draft').value = t.draft;
const article = kdArticles.find((a) => a.id === t.article) || kdArticles[0];
const link = document.getElementById('kd-article-link');
link.textContent = article.title;
link.href = 'article-' + article.id + '.html';
document.getElementById('kd-export-status').textContent = '';
}
if (document.getElementById('kd-ticket-list')) {
const requested = new URLSearchParams(location.search).get('status');
if (kdStatuses.includes(requested))
document.getElementById('kd-status-filter').value = requested;
kdRenderQueue();
document
.getElementById('kd-ticket-list')
.addEventListener('click', (event) => {
const button = event.target.closest('[data-ticket]');
if (button) {
kdSelected = button.dataset.ticket;
kdRenderQueue();
}
});
document
.getElementById('kd-ticket-search')
.addEventListener('input', kdRenderQueue);
document
.getElementById('kd-status-filter')
.addEventListener('change', kdRenderQueue);
document.getElementById('kd-clear-search').addEventListener('click', () => {
document.getElementById('kd-ticket-search').value = '';
document.getElementById('kd-status-filter').value = 'All';
kdRenderQueue();
});
for (const [id, key] of [
['kd-draft', 'draft'],
['kd-note', 'note'],
])
document.getElementById(id).addEventListener('input', (event) => {
const t = kdCurrent();
if (!t) return;
t[key] = event.target.value;
kdPersist(
key === 'draft'
? 'Draft saved in this browser.'
: 'Internal note saved in this browser.',
);
});
document.getElementById('kd-owner').addEventListener('change', (event) => {
const t = kdCurrent();
if (!t) return;
t.owner = event.target.value;
kdPersist('Owner updated locally. No notification was sent.');
kdRenderQueue();
});
document
.getElementById('kd-ticket-status')
.addEventListener('change', (event) => {
const t = kdCurrent();
if (!t) return;
t.status = event.target.value;
kdPersist('Status updated locally. No message was sent.');
kdRenderQueue();
});
document.getElementById('kd-insert-answer').addEventListener('click', () => {
const t = kdCurrent();
if (!t) return;
const a = kdArticles.find((a) => a.id === t.article) || kdArticles[0];
const addition = (t.draft ? '\n\n' : '') + a.answer;
if (t.draft.length + addition.length > 4000) {
document.getElementById('kd-save-status').textContent =
'The draft is too long to add this answer. Shorten it first; your writing has not changed.';
return;
}
t.draft += addition;
document.getElementById('kd-draft').value = t.draft;
kdPersist(
'Suggested answer added to your local draft. Review the wording before using it.',
);
});
document.getElementById('kd-download').addEventListener('click', () => {
const t = kdCurrent();
if (!t) return;
const text =
'Kindred reply draft\nConversation: ' +
t.id +
' — ' +
t.subject +
'\n\n' +
(t.draft || 'No reply draft has been written yet.') +
'\n\nLocal draft only. This message has not been sent.\n';
const url = URL.createObjectURL(
new Blob([text], { type: 'text/plain;charset=utf-8' }),
);
const a = document.createElement('a');
a.href = url;
a.download = 'kindred-' + t.id.toLowerCase() + '-draft.txt';
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
document.getElementById('kd-export-status').textContent =
'Reply draft prepared for download. Nothing was sent.';
});
}
let kdTopic = 'All topics';
function kdRenderArticles() {
const rows = kdFindArticles(
document.getElementById('kd-help-search').value,
kdTopic,
);
document.getElementById('kd-article-count').textContent =
rows.length + (rows.length === 1 ? ' article' : ' articles');
document.getElementById('kd-article-results').innerHTML = rows
.map(
(a) =>
`<a class="help-article-row" href="article-${a.id}.html"><span class="article-symbol">↗</span><div><span>${a.category} · ${a.minutes} min read</span><h3>${a.title}</h3><p>${a.summary}</p></div><span class="row-arrow">→</span></a>`,
)
.join('');
document.getElementById('kd-no-articles').hidden = rows.length > 0;
}
if (document.getElementById('kd-help-search')) {
kdRenderArticles();
document
.getElementById('kd-help-search')
.addEventListener('input', kdRenderArticles);
document.querySelectorAll('[data-topic]').forEach((button) =>
button.addEventListener('click', () => {
kdTopic = button.dataset.topic;
document
.querySelectorAll('[data-topic]')
.forEach((b) => b.setAttribute('aria-pressed', String(b === button)));
kdRenderArticles();
}),
);
document.getElementById('kd-clear-articles').addEventListener('click', () => {
document.getElementById('kd-help-search').value = '';
kdTopic = 'All topics';
document
.querySelectorAll('[data-topic]')
.forEach((b) =>
b.setAttribute('aria-pressed', String(b.dataset.topic === kdTopic)),
);
kdRenderArticles();
});
}
const kdFeedback = document.querySelector('[data-article]');
if (kdFeedback) {
const key = 'kindred-feedback-' + kdFeedback.dataset.article;
const saved = kdStore.read(key);
document.querySelectorAll('[data-feedback]').forEach((button) => {
button.setAttribute(
'aria-pressed',
String(button.dataset.feedback === saved),
);
button.addEventListener('click', () => {
const ok = kdStore.write(key, button.dataset.feedback);
document
.querySelectorAll('[data-feedback]')
.forEach((b) => b.setAttribute('aria-pressed', String(b === button)));
document.getElementById('kd-feedback-status').textContent = ok
? 'Thanks. Your feedback is recorded in this browser only.'
: 'Storage unavailable. Your choice is shown on this page only.';
});
});
}
document.querySelectorAll('[data-feature]').forEach((button) =>
button.addEventListener('click', () => {
document
.querySelectorAll('[data-feature]')
.forEach((b) => b.setAttribute('aria-pressed', String(b === button)));
document.querySelectorAll('[data-feature-panel]').forEach((panel) => {
panel.hidden = panel.dataset.featurePanel !== button.dataset.feature;
if (!panel.hidden && !kdReduced.matches)
panel.animate(
[
{ opacity: 0.2, transform: 'translateY(14px)' },
{ opacity: 1, transform: 'none' },
],
{ duration: 300, easing: 'ease-out' },
);
});
}),
);
if ('IntersectionObserver' in window) {
const kdObserver = new IntersectionObserver(
(entries) =>
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('entered');
kdObserver.unobserve(entry.target);
}
}),
{ threshold: 0.2 },
);
document
.querySelectorAll('.inbox-preview,.help-preview,.answer-grid')
.forEach((el) => kdObserver.observe(el));
}