Good day

My Kirdaxia

Where your actions and your wellbeing meet
Avalrem
Value of my actions
Positive+0
Negative0
Net +0 · Plans 0
Majayakaya
Body · mind · emotion
Not checked in yet. It takes 20 seconds.
Act and reflect
Reports · Performance profile · Majayakaya
First badge: Trophy at 10,0000 of 10,000 so far · then a Star, then a Diamond
New to KirV, or need a reminder?
See how it all fits together, any time.
▶ Watch the journeyHow KirV works →Take the tour →

Groups

A project is shared work with people you trust. Only your own actions count toward you; there is no group score. Other members see what you log into the project and its status, but your scores stay yours. Membership is checked inside the app, not as a hard security lock, so give codes only to people you trust.
Your projects

Action Compass

Every plan you've logged, in one place

Settings

·
You
Pilot edition · everything stays on this device
Your priorities
Top 3 Big Questions
Your details
Notifications
Preferences
File storage
Privacy and data
Export my data
One file with everything KirV keeps about you: your profile and priorities, every plan and result with how it was calculated, your My Presence entries, and your actions in Group projects. Your history is yours.
Who can see my actions›
Help and information
Danger zone
Delete my account
Erases your whole KirV history: every plan and result (harms included), your My Presence diary, your profile and priorities, your evidence files, your actions in Group projects and your project memberships. Projects other people still belong to stay for them; if you created one, its creator role (join code and Close) passes to its longest-standing member. This cannot be undone — export first if you want a copy.
KirV logo

Welcome to KirV

A self-reflection companion, not a judge. Built on the Kirdomic Engine.
Signed in as you with your Claude account.
Confidential test version. KirV is still being tested by a small invited group. Please keep what you see within the test, and use the 💬 Comment button to tell us what to improve. © 2026 Dr. Yanyong Thammatucharee. All rights reserved.
KirV is for people aged 18 and over. Your date of birth is kept private in your profile, to confirm your age and keep your age details consistent.
Optional — gives KirV a starting suggestion for your 12 Big Questions. You can change it any time.
Optional — used only in your Performance profile, if you choose to include it.

Terms & disclaimer

Version 2026-10-09

Privacy policy

Version 2026-10-09

Help

New here? Start with the big picture.▶ The journeyHow KirV works →Take the tour →
About KirV & Kirdomicism →
Basics · 6
Why is the main screen called Kirdaxia?
In Kirdomicism, Kirdaxia is the state where how you see things lines up with how things actually are (see “What is Kirdaxia?” under About Kirdomicism). The Kirdaxia screen shows where you stand on the way to your goals: your positive and negative Avalrem, plans that need an answer, what to do next, Appreciative Reality from the Big Questions you follow, your badge progress and your latest result. It is the first screen you see each time you open KirV, and the first button in the bar at the bottom.
What is this app all about?
KirV turns your real actions into a calculated value — Avalrem — so you can see, in plain numbers, where your effort actually goes and what it's actually worth. It's built on Kirdomicism, a philosophy of action-centered living.
Who is this suitable for?
Anyone who wants a clearer, more honest picture of their own follow-through — professionals, founders, students, anyone rebuilding a habit of finishing what they start. It's self-directed; no one is watching or grading you unless you choose to share with a Group.
Are there any restrictions?
You must be 18 or older. KirV is a self-reflection companion, not a substitute for professional, legal, medical, financial or psychological advice — and Avalrem points can never be exchanged for money or anything of value.
How do I write an action statement KirV can read?
A complete statement has up to six parts: (1) I + action word, (2) what / with whom, (3) by when, (4) impact — chosen with the buttons, not written, (5) method, which only ever adds, and (6) how, in which principle. Example: “I will coach the new team practically through guided experience by next Friday, so onboarding time drops, using a buddy system.” Rules KirV keeps: say what you will do, not what you won’t (“I will stay smoke-free this month”, not “I will not smoke”) — negative statements and questions are not accepted; one action per statement — two verbs for one thing may share “and” (“review and approve the budget”), but two separate actions become two statements, and KirV offers to split them for you. Deadlines can be written many ways: “by Friday”, “by 30 November”, “within two weeks”, “before the end of the month”. If KirV does not know your action word yet, it still counts at the baseline and the word is noted so the dictionary can grow. The guide is under the statement box in Action Pulse, and every action word KirV knows is listed in Action words.
Is there a tour of KirV?
Yes — 15 short steps through everything KirV does, each with a picture of the screen and a link to try it. Tap “Read aloud” in the tour and each step is read to you as you go. Start the tour. You can also replay it from Settings.
How scores work · 11
How is my Avalrem calculated?
By the Kirdomic Engine, from four things: the action word itself, where it sits on the Action Grid, how far its effect reached (one person up to the whole world), and how it was done (timing, proof, and whether it served one of your top 3 Big Questions). Never from how you felt about it. It's a structured system built on codified judgment, not artificial intelligence. Every result has a “See how this is calculated” button.
What does an Avalrem number actually mean?
It's our reference point: an action that is the least demanding kind we recognise, done adequately, with no particular urgency and no special priority in your life right now, and a neutral result, is worth exactly 20. We chose it the way an economic index chooses its base year — transparently, so anyone could redo the derivation and land on the same number — not as a claim about some deeper psychological truth. A typical action lands a little above 20; a strong one might reach 45 or more; a partial success can land below 20, meaning it didn't quite meet the baseline; and an action that does real harm can go below zero. Nothing can exceed 100.
How does the grid tilt work?
The Action Grid is a 5 × 5 table: rows say what kind of work it is (Future, Operation, Control, Utilisation, System); columns say what kind of action (Plan, Execution, Analysis, Communication, Exdysivity). Your action word picks the column and your sentence picks the row. Each cell has a small multiplier between 0.95 and 1.10 — its tilt — reflecting how much that kind of action counts in that kind of work. It moves a score by only a few percent: it adds nuance, not weight.
What's the difference between reach and the 12 Big Questions?
They answer different questions. The Big Question is which area of life the action serves (say, Team & People Development). Reach is how many people its effect touched — one person, family, team, organization, society, country, or the world. Reach decides which band of the scale a result lands in; the Big Question adds a small bonus if it's one of your top 3.
Why do my routine entries count only once a day?
An entry with no priority, no deadline and no real effect — a routine task — counts as the baseline of 20. However many of those you log in one day, together they count once. Log as much as you like; add a priority, a deadline or who it reached, and it's calculated in full.
Is the Kirdomic Engine AI?
No — it's a deterministic system: a structured word dictionary, a context grid, and a set of rules, not a trained model. Same input always gives the same output. That's deliberate — it makes every score explainable and auditable.
Can KirV overrule how I judge my own action?
No — it can only flag evidence and ask you to reconsider. The final call is always yours.
What is a “how” phrase (SPD), and does it change my score?
It is the part of your statement that says how you will act, in the way of one of the five principles — for example “calmly under pressure” (Anxergy) or “precisely through experienced technique” (Dexterience). Action Pulse shows the “how” phrase it finds and the principle it reads. If the words could fit more than one principle, KirV says “unclear” rather than guess. Only one “how” phrase is read, and longer wording never helps. “By Friday” is a deadline, not a “how” phrase. For now it does not change your score: that will be decided after testing with real users.
What are the Reality Gap and Action Replay on a result?
The Reality Gap compares what you expected when you planned with what actually happened, and splits the difference into its three causes: the reach you reported, the timing you reported, and proof. It is worked out by the same rules as the score, so a difference always has a reason you can see. Action Replay shows the whole story of one action in date order: the plan, each status update with the forecast at that moment, any testimony, and the result. When you plan something with the same action word again, Action Pulse shows the lessons you noted last time. Each result also says which version of the scoring rules produced it.
What is the World Index?
The middle value (median) of how KirV users' real results with the same action word and reach were valued — for example “repair, benefit to a team: median +28 from 12 results”. It appears in Action Pulse once at least five results exist, and beside “Value it” for readers' estimates of shared stories. It is a reference, never your score, and never changes it. Results join it only from people who turn it on in Settings, without words, names or accounts.
What is two-way Avalrem?
Beside the official Avalrem worked out by the system, you may add your own value — for the plan and for the result — if you see it differently. It must stay within 20% of the system value, or 2 points, whichever is larger, and you must say why. It never changes the official Avalrem: it is kept privately and helps KirV improve its rules. In a Group, other members can also rate your action blind — they give their own value and reason without seeing its score, so no limit applies — and you see their ratings beside yours.
Using KirV · 15
Can a harm be removed from my record?
Only if it was a genuine mistake. Open the result and tap “Request a correction”: say what is wrong and give evidence (a file, or the name of a person who can confirm it, with a description). The founder reviews it and accepts or declines with a reason. The original record is never edited or hidden: if accepted, it stays visible marked Corrected and no longer counts in your totals; if not, it stays as it was, with your request and the decision beside it.
Can I dispute what someone in my Group reported?
Yes. Under a reported result of another member, tap “Dispute this claim” and say what does not match what happened. A dispute never changes anyone’s score and never puts a mark on a person: it is kept beside the result, every member of the Group can read it, and the author can reply or add evidence. You can still give your own blind rating as well.
Can KirV read text aloud?
Yes. Tap the small speaker button beside the founder’s note on the Action Grid or beside a drawn card, and your device reads it aloud; tap it again to stop. It uses your device’s own voice, so nothing is sent anywhere. Notes the founder posts for everyone appear in Notifications.
What's the difference between a plan and a result?
A plan gets an estimate. Once you report what actually happened, the estimate becomes your real score. A plan you've set stays locked — reporting adds to it, never rewrites it.
What happens if I report late?
Report within 24 hours of finishing and it counts as on time. Reported later, it's saved but marked Late, and a claim that you met the deadline or were fast needs evidence. A plan expires 30 days after it was made (Immediate) or 30 days after its deadline (This Year, 5-Year, or 10 years for your own category, which you can renew for another 10 years); life projects never expire and can be refreshed. Once expired it can't be scored — except harm, which can always be reported.
What is an Action Failure Testimony?
When a plan wasn’t done, was withdrawn or expired, KirV asks what happened: in Action Compass (Not done or withdraw), on the plan's page (It didn’t happen), or from the card on Kirdaxia when a plan is past its deadline or close to expiring. You say what happened and name each cause as controllable or not, and where it came from: you, another person, an organization, nature, or a machine, system or AI you relied on. You can add the lesson, any new knowledge, and what you would do differently. The plan closes without a score; the testimony is never scored, and a machine or AI factor is not counted for or against you. If you are not ready, choose Finish later and the plan stays open. If the harm the plan was meant to prevent then happens, see the next answer on failure to act.
What is Risk Exposure, and when does failing to act count as harm?
For plans that reach an organization or wider, Action Pulse shows what failure could cost: Low, Medium and High points inside the harm band of whoever would be hurt. It is exposure, not a score. A Failure to act value is recorded only if your own plan wasn’t carried out, the harm it was meant to prevent actually happened (even years later), and you file a testimony. If you handed it over or escalated in time, with proof, none is recorded: proof means a file or the name of the person who received it, and in time means on or before the plan’s deadline (or its expiry date). It applies only to plans that recorded who would be hurt, is placed by the plan as written, never adds a positive score, and stays on your record once recorded. Only you can report it about yourself.
Can I speak instead of typing?
Yes. Tap the microphone on Action Pulse and say what you did or will do; KirV writes it out for you to check before saving. In this test version the browser may block the microphone; if so, KirV tells you, and you can use the microphone key on your phone’s keyboard (or your computer’s dictation) instead.
How do I add proof, like before-and-after photos?
When you report an outcome, pick Evidence attached or Confirmed by a stakeholder under Proof of Action, then tap Action Evidence. Add a description and files: photos, video clips (MP4, WebM), PDFs or text files up to 20 MB each. For before and after, use two files or one file showing both. Save a PowerPoint or Word file as PDF first, and record a voice note as a short video. Evidence moves a result a little further through its band; a named person confirming it moves it a little more.
How do badges work?
Every 10,000 Avalrem of positive value earns a badge: first a Trophy, then a Star, then a Diamond. Most actions land between about 20 and 60, so a badge reflects months of real, described actions — routine entries count only once a day. Your progress is in Reports, and Notifications tells you when you earn one.
What is Action-Storming?
On the Action Grid, describe a problem in your own words and AI suggests a few starting angles, each related to a focus of the grid and one of the five principles — ideas, not decisions. Keep the ones you like in your idea bank, add your own, and turn any idea into a plan in Action Pulse when you are ready. Ideas are never scored; only the plan you write is measured, by the same fixed rules as always.
Why won't KirV let me log "You decided..." or "He will..."?
KirV tracks your own commitments, not observations about someone else. Write it as your own action — "I will..." or just the action itself, like "Call the vendor..."
How much space is there for proof files, and what happens when it is full?
Settings → File storage shows how many files and how much space the app is using. The space is shared by everyone who uses KirV. Files attached to a saved result are kept as part of its record, as an audit trail: they are never deleted one by one, and are erased only if you delete your account. When storage is full you can still save results, just without new files. To use less space: keep video clips short, put several photos into one PDF, or describe the proof in words (a description of 8 or more characters counts as evidence). Large photos are now made smaller automatically before upload (at most 1600 pixels on the long side), so they take far less space; the photo on your device is untouched. Planned for later: a link to proof kept somewhere else instead of a file.
How does the Appreciative Reality Network work?
Keep something that went well in My Presence as Appreciative Reality, then tap “Share to the network” and choose the Big Question it serves. People who follow that Big Question see it — there are no followers to collect. Choose the Big Questions you follow under My interests; until you do, your top 3 priorities are used. Others can appreciate a story with ☀, comment on it, and “Value it”: give their own Avalrem estimate first, then see the readers' average (and the official value, if you linked your result). Stories from the Big Questions you follow also appear on Kirdaxia. You choose whether your name is shown, and you can remove your story at any time. Nothing shared is ever scored, and your scores are never shared.
What is Simple mode?
In Simple mode, Action Pulse shows only your statement, its impact and Save. KirV picks the priority it serves and the timing from your words — shown in one “Serves:” line you can change — so the calculation is exactly the same as with all options. If a plan reaches an organization or wider, the Risk Exposure panel still appears. It is on for new users; switch it in Settings, or with “Show all options” / “Simple view” in Action Pulse.
About Kirdomicism · 10
Which human flaws is KirV built to counter?
Ten everyday flaws, each met by a part of KirV (the founder’s map):
  1. Postponement — the Time (a) factor: when you act, and whether you act on time, changes the value.
  2. Procrastination — the 12 Big Questions: every plan is tied to what matters most to you now.
  3. Complacency — long-term plans: This Year, 5-Year, your own category and Life Projects keep you reaching further.
  4. Selfishness — the Action Catalog: problems around you become actions for others.
  5. Pessimism — Appreciative Reality: keeping and sharing what went well.
  6. Money-focus and greed — the Avalrem score: value measured by real consequence, never exchangeable for money.
  7. Blaming and accusing on impression — before-and-after proof and the Action Failure Testimony: facts and causes, not opinions.
  8. Lying — evidence and stakeholder confirmation, and records that are never quietly changed.
  9. Envy — Kirdaxia wisdom: your own progress, never ranked against anyone else.
  10. The urge to destroy — Appreciation and My Presence: noticing, valuing and reflecting instead.
What are the 5 Kirdomic principles?
Anxergy (following through on what you start), Exdysivity (change the world requires of you, ready or not), Dexterience (skill built through practice), Avalremy (value measured by real consequence), and Atammayata (letting go of what no longer serves you). Each also has another side: Anxergy means staying steady under pressure, and Atammayata means discipline that needs no outside force, reward or watching. Principle Lens shows all five, with “how” phrases to try.
What's the difference between Exdysivity and Atammayata? They sound similar.
Exdysivity is change the situation forces on you — necessary, not optional, like a snake shedding its skin. Atammayata is change you choose — actively letting go of an attachment or an old way of doing things. One happens to you; the other is a decision you make.
What is a “how” phrase?
A few words in your action that show which principle shapes it, such as “calmly under pressure” or “precisely through experienced technique”. KirV recognises it by its shape: a describing word, a linking word (under, through, despite, with, beyond…) and a word from that principle’s family. Only one counts, and longer wording never raises the value. Principle Lens has examples for all five principles.
What are the 12 Big Questions?
Twelve life domains your actions can serve — from running a business, to your team, to your family, to the world, to your own growth. You choose your top 3 (KirV can suggest them from what you write in My Presence), and actions that serve them count a little more: up to ×1.10. You can change your top 3 once a day.
What's a card reading?
Draw one card at a time, or a full round of 7, from the 100-card Kirdomic deck: 25 major cards (one for each cell of the Action Grid), 12 for each of the 5 principles, and 15 warning cards. Each card comes with its own words to start a real reflection, not to deliver an answer. Tap a card to see it whole, then turn the reading into an action or keep it as a reflection. Open it from Kirdaxia (Think & reflect) or the Cards tab in Action Grid.
What is Kirdaxia?
The highest state of human development Kirdomicism describes — not perfection or having all the answers, but a mature, dynamic condition where how you see things lines up closely with how things actually are. That alignment brings calmer judgment, less psychological suffering, and a sustained ability to keep creating positive Avalrem, even while reality keeps changing around you. Higher Avalrem is a signal you're closer to it, not the destination itself.
What is Foreality?
Weighing what might follow from an action before you commit to it — different possible paths laid side by side, not a single prediction of what will happen.
What is Unknowneology?
The idea that some things stay unknown not because we haven't learned enough yet, but because the unknown is a real, permanent part of reality itself — it doesn't shrink to nothing even as understanding grows. Kirdomicism distinguishes what's unknown-but-identified (a question you know you can't yet answer) from what hasn't even been noticed as a question yet — and what's unknown to you specifically may already be known to someone else, which is simply a fact of life, not a flaw. The aim isn't to eliminate the unknown — that's not possible — but to prepare for it wisely and keep making good decisions in its presence.
Is Kirdomicism from a published book?
Yes — Kirdomicism is Dr. Yanyong Thammatucharee's own philosophy, developed over years of writing before KirV existed. The app is one expression of it, not the whole of it; the book goes further than any FAQ here can.
Safety and privacy · 3
Who can see my actions?
Only you, by default. Your My Presence diary is never shared, except an Appreciative Reality story you choose to share with the network, which every KirV user can read until you remove it. In a Group, the other members see the actions you log into that project — never your personal ones, and there is no group score. Your Performance profile stays private until you share it with another KirV user, and when shared, your negative total always appears beside your positive one.
Is my action history kept forever?
Yes, until you delete it — that's what lets KirV reflect real patterns back to you over time instead of just today's number. You can export or delete it any time from Profile.
Is KirV a mental health service?
No — it's a reflection tool, not a substitute for mental health care. If you're in crisis or thinking about harming yourself, please reach out for real support: in Thailand, the Department of Mental Health Hotline, 1323, free and available 24 hours a day; elsewhere, findahelpline.com lists local crisis lines by country.
Ask the founder directly
Questions about Kirdomicism itself — the philosophy, not the app's mechanics — are answered personally, not by an automated reply.
Answered personally, usually within a few days. Only the founder sees your question in KirV.
Terms & disclaimer› Privacy policy›

The KirV journey

Eight steps, one loop · tap any step

How KirV works

The whole app on one page

About KirV

KirV
What KirV stands for

KirV = Kirdomic + Value. You record what you plan or what you did, and KirV gives each action a value called Avalrem.

The Kirdomic Engine is not AI. It is a fixed set of written rules, so the same action reported the same way always gets the same value, and you can see how it was calculated.

Our principles: a mirror, not a judge · what actually happened counts more than what you meant · good results never erase harm · people come first (machines and AI only support) · Avalrem is never money.

About Kirdomicism

Dr. Yanyong Thammatucharee noticed something over a long career: intelligence and education didn't reliably predict good decisions. Some people with every resource still chose poorly; others, with far less, kept finding thoughtful ways through hard situations.

That observation became a question that followed him for years, across philosophy, psychology, management, economics, and systems thinking: what actually makes human intelligence intelligent — and can it be understood in a way that helps people live wiser, more meaningful lives?

Kirdomicism, developed after his retirement gave him time to bring those threads together, is his answer: a philosophy of adaptive human intelligence, built around refining how we see reality until it lines up more closely with how things actually are. KirV is one expression of it — not the whole of it.

Learn the concepts
Kirdaxia, Unknowneology, and how scoring works — in Help
›
Map of the app
GETTING STARTED
Sign-up — date of birth, consentdone once
Tour — every feature, step by stepOpen ›
EVERY DAY
Kirdaxia — where your goals stand: balance, plans, history›Action Compass — every plan, its status and forecast›Action Pulse — plan it, or report what you did›
Action Insight — the value of one action and how it was calculatedopens from a plan
Action Evidence — proof for one actioninside the report form
Action Failure Testimony — when a plan failsfrom a plan in Action Compass
My Presence — private, never scored›Notifications — what needs you›
UNDERSTANDING YOURSELF
Reports — patterns, strengths, watch-outsOpen ›
Performance profile — private; you choose what to share›
Action Grid — turn a problem into starting anglesOpen ›
Principle Lens — add the “how” through a principleOpen ›
Card reading — when you are not sure where to start›
WITH OTHERS
Groups — projects by invitation code›Appreciative Reality Network — what went well, followed by Big Question›
Project detail — one project: members, actions, statusopens from Groups
© 2026 Dr. Yanyong Thammatucharee. All rights reserved.
KirV, Kirdomicism, Avalrem, the Kirdomic Engine, Kirdaxia, KAL and the Kirdomic Card deck are his work.

My Presence

Action Catalog, Appreciative Reality and Reflection — never scored
What would you like to keep?
Notice what needs action, keep your Appreciative Reality, and reflect. Only you can see it, unless you share an Appreciative Reality story with the network. It is never scored. An Action Catalog item becomes an Action plan when you are ready.
Open plans
See every status in Action Compass
›
0 / 600
Please avoid showing people who can be recognised unless they agree.
If you tap "Suggest from My Presence" when setting your top 3 Big Questions, KirV counts theme words in your entries to make the suggestion (English words only for now). Nothing else reads your entries.

Action Pulse

How to write a complete statement
A complete statement has up to six parts
1I + action word
2what / with whom
3by when
4impact — tap below
5method (bonus)
6how, in which principle
Example: I will coach the new team practically through guided experience by next Friday, so onboarding time drops, using a buddy system.
Parts 1–3 come from your words. Part 4 is chosen with the impact buttons, not read from your text. Part 5 only ever adds. Part 6 shows which of the five principles shapes the action; only one is read, and it does not change the number yet. Not sure? Open Principle Lens.
Rules KirV keeps
• Say what you will do, not what you won’t: “I will stay smoke-free this month”, not “I will not smoke”.
• One action per statement. Two verbs for one thing may share “and” (“review and approve the budget”, “review the plan and approve it”); two words for the same action (“fix and repair”) count once; two separate actions (“fix the bug and write the notes”) become two statements, each with its own Avalrem.
• Write it as a statement, not a question.
If KirV does not know your action word yet, it still counts at the baseline and the word is noted so the dictionary can grow.
Add the “how” — Principle Lens →
0 / 300
Statement completeness0 / 4
Show all options

Action Insight

Action Grid

Storm a problem, keep the ideas, plan when ready
Action-Storming
Describe a problem or opportunity in your own words. KirV relates it to the Action Grid and a principle, and hands back a few starting angles — not a decision.
Explore the grid — tap a cell
Add an idea to your idea bank
Your idea bank
Ideas are private and never scored. Only the plan you make from one is measured.
Not sure where to start? Switch to Principles or Cards at the top.

Appreciative Reality Network

What went well, shared by Big Question — never scored
My interests
Choose the Big Questions you want to follow. You see stories shared under them. Until you choose, your top 3 priorities are used.
Share your own: keep it in My Presence as Appreciative Reality, then “Share to the network” →
Shared stories can be read by everyone who uses KirV. Share only what is yours to share, never private details about someone else without their consent. You can remove your story at any time. Stories are never scored. Use Report on anything that does not belong here.

Appreciative Reality

A shared story — never scored

Tour

Reports

Patterns, strengths, watch-outs — from your own records

Notifications

Settings

Performance profile

Card reading

Draw as you like — one at a time, or a full round

Action words

The KAL dictionary: every action word KirV knows
Tiers are set by the founder: tier 1 everyday actions, tier 2 most work actions, tier 3 the weightiest. A word that “counts as” another is measured with that word’s tier. A word not listed still counts, at the baseline, and is noted so the dictionary can grow.

Principle Lens

Add the “how”: shape an action through one principle

Action Failure Testimony

What happened, and what it taught you

Action Evidence

Majayakaya

Body, mind, emotion, intelligence and action as one whole
My Majayakaya
Check in →
A 20-second daily check-in of body, mind and emotion, and one small action for balance.
Thai Massage HeritageThe story and knowledge of nuad thai, from Wat Pho to Lanna herbal traditions.
Wellness partners · shop mode
Each partner shop appears with its own name, colour and treatment list. Shop mode needs the shop’s own Claude account on its device, because translation is paid from the signed-in account.
Before and after · pilot results
Test this device for shop mode →
Majayakaya

How are you today?

มรดกนวดไทย

Thai Massage Heritage

LIVING HERITAGE
Nuad thai, traditional Thai massage, was inscribed by UNESCO on the Representative List of the Intangible Cultural Heritage of Humanity in 2019. It is a living practice, passed from teacher to student, that cares for body and mind together.
Wat Pho and the inscriptionsWhere knowledge of Thai massage was carved in stone for everyone to learn.

At Wat Pho (Wat Phra Chetuphon) in Bangkok, King Rama III had knowledge of traditional medicine, including figures showing massage points, inscribed on marble plaques in the 1830s, so that anyone who came to the temple could learn from them. The Epigraphic Archives of Wat Pho were added to UNESCO’s Memory of the World Register in 2011. The temple is still home to a well-known school of Thai massage.

Lanna herbal traditionsHerbal compress (luk pra kob) and herbal steam from Northern Thailand.

In the north, the old Lanna kingdom around Chiang Mai, warm herbal compresses and herbal steam are part of everyday care. A compress is a cloth ball of fresh or dried herbs, often plai, turmeric, lemongrass and kaffir lime, steamed and pressed along the muscles. The warmth and the scent work together.

Thai abdominal and womb massageAn old practice of care for the stomach and the womb.

Thai traditional care has long included gentle, slow work on the abdomen. At partner shops it is offered as care for the stomach and womb, not as pregnancy massage, and it is not offered to guests who are pregnant. Guests are asked about recent surgery first.

Majayakaya: why touch calms the mindFrom the Kirdomicism books: body, mind and emotion respond together.

Majayakaya sees body, mind, emotion, intelligence and action as one harmonious whole. Caring touch reaches the body first; the mind slows and the emotions follow. KirV asks guests, with their consent and without their names, how body, mind and emotion feel before and after a treatment, so that this value can be seen rather than only claimed.

More articles will be written with Ananta’s senior therapists.

Shop settings

Device test

Voices, translation and dictation for shop mode
`; } async function saveProfileCopy() { const msg = t => { const e = document.getElementById('profile-msg'); if (e) e.textContent = t; }; let downloads = null; try { downloads = await claude.use('downloads'); } catch (e) { downloads = null; } if (!downloads) { msg('Saving files is not available in this view.'); return; } try { await downloads.save({ filename: 'KirV-performance-profile-' + new Date().toISOString().slice(0, 10) + '.html', data: profileCopyHtml() }); msg('Saved. Send it to whoever you choose.'); } catch (e) { const c = e && e.code; msg(c === 'declined' ? 'Not saved — you cancelled.' : c === 'extension_not_enabled' || c === 'rejected_extension' ? 'Saving this kind of file is switched off here.' : c === 'rate_limited' ? 'Please wait a moment and try again.' : 'Could not save the file right now.'); } } /* ---------- Tester activity log (2026-10-07, founder: "see who accesses the app and does the testing") ---------- One shared doc per person in testerActivity: account id, first and last visit, number of visits, and how often each SCREEN was opened, plus the app version. Never what anyone writes. Shown only to the owner (Settings). Disclosed in the Privacy policy; removed by Delete my account; included in Export my data. Writes are batched (at most one every 20 s, and when the page is hidden). */ const APP_BUILD = 'v89'; let actCol = null, actDoc = null, actDirty = false, actTimer = null; const SCREEN_NAMES = { 'screen-home': 'Kirdaxia', 'screen-compass': 'Action Compass', 'screen-log': 'Action Pulse', 'screen-result': 'Action Insight', 'screen-presence': 'My Presence', 'screen-groups': 'Groups', 'screen-project': 'Project', 'screen-reports': 'Reports', 'screen-settings': 'Settings', 'screen-grid': 'Action Grid', 'screen-lens': 'Principle Lens', 'screen-words': 'Action words', 'screen-network': 'AR Network', 'screen-story': 'AR story', 'screen-notifications': 'Notifications', 'screen-profile': 'Performance profile', 'screen-cards': 'Card reading', 'screen-help': 'Help', 'screen-about': 'About', 'screen-tour': 'Tour', 'screen-signup': 'Sign-up', 'screen-terms': 'Terms', 'screen-privacy': 'Privacy', 'screen-testimony': 'Failure Testimony', 'screen-evidence': 'Action Evidence' }; // The platform rule testerActivity/{self} lets each person write only their own document, named by their id; only the owner reads them all. function actKey() { const u = String(uid || ''); return /^[A-Za-z0-9_\-.~:@+]{1,200}$/.test(u) ? u : ''; } async function startActivityLog() { if (actDoc || !db || !uid || canWriteShared === false || !actKey()) return; actCol = db.collection('testerActivity'); let prev = null; try { const sn = await actCol.doc(actKey()).get(); prev = sn.exists ? sn.data() : null; } catch (e) { prev = null; } const now = Date.now(); actDoc = { uid, firstSeen: (prev && prev.firstSeen) || now, lastSeen: now, visits: ((prev && prev.visits) || 0) + 1, screens: (prev && typeof prev.screens === 'object' && prev.screens) || {}, build: APP_BUILD }; const a = document.querySelector('.screen.active'); if (a) actDoc.screens[a.id] = (actDoc.screens[a.id] || 0) + 1; actDirty = true; flushActivity(); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') flushActivity(); }); } function noteScreen(id) { if (!actDoc || !id) return; actDoc.screens[id] = Math.min(9999, (actDoc.screens[id] || 0) + 1); actDoc.lastSeen = Date.now(); actDirty = true; if (!actTimer) actTimer = setTimeout(() => { actTimer = null; flushActivity(); }, 20000); } let actWriting = false; async function flushActivity() { if (!actCol || !actDoc || !actDirty || actWriting) return; actDirty = false; actWriting = true; try { await actCol.doc(actKey()).set(actDoc); } catch (e) { console.warn('activity not saved', e); } actWriting = false; } async function renderActivityPanel() { const wrap = document.getElementById('settings-activity-wrap'), el = document.getElementById('settings-activity'); if (!wrap || !el) return; if (!viewerIsOwner || !db) { wrap.style.display = 'none'; return; } wrap.style.display = 'block'; el.innerHTML = '
Loading…
'; let rows = []; try { const sn = await db.collection('testerActivity').limit(200).get(); rows = sn.docs.map(d => d.data()).filter(r => r && typeof r.uid === 'string'); } catch (e) { el.innerHTML = '
Could not load activity right now.
'; return; } rows.sort((a, b) => (b.lastSeen || 0) - (a.lastSeen || 0)); let names = {}; try { names = await user.profiles(rows.map(r => r.uid)); } catch (e) { names = {}; } const now = Date.now(); const line = r => { const nm = (names[r.uid] && names[r.uid].name) || 'Someone', sc = Object.entries(r.screens || {}).filter(([k]) => SCREEN_NAMES[k]).sort((a, b) => b[1] - a[1]); const seen = sc.length, top = sc.slice(0, 4).map(([k, v]) => SCREEN_NAMES[k] + ' ' + v).join(' · '); return `
${escapeHtml(nm)}${r.uid === uid ? ' (you)' : ''}${escapeHtml(relTime(r.lastSeen || 0, now))}
${r.visits || 0} visit${r.visits === 1 ? '' : 's'} · first ${escapeHtml(new Date(r.firstSeen || 0).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }))} · ${seen} of ${Object.keys(SCREEN_NAMES).length} screens · ${escapeHtml(String(r.build || '').slice(0, 8))}
${top ? `
${escapeHtml(top)}
` : ''}
`; }; el.innerHTML = `
${rows.length} ${rows.length === 1 ? 'person has' : 'people have'} opened KirV. Screens visited only — never what anyone writes.
${rows.map(line).join('')}`; } /* ---------- Card reading (2026-10-07; design CardReading.dc.html; the founder's Kirdomic Card deck, 100 cards) ---------- 25 major cards (one per Action Grid cell: kind × focus), 12 for each of the 5 principles, 15 warning cards. A reading is general, not personal. The text under each card is the card's own wording; principle cards add the principle's core meaning (Principle Lens). Images are files published beside the page (cards/.jpg), loaded only when drawn. */ const KIRDOMIC_DECK = [{"id":"AN01","type":"principle","principle":"Anxergy","words":["Confirm","Commit"]},{"id":"AN02","type":"principle","principle":"Anxergy","words":["Forecast","Assess"]},{"id":"AN03","type":"principle","principle":"Anxergy","words":["Order","Formalize"]},{"id":"AN04","type":"principle","principle":"Anxergy","words":["Update","Inform"]},{"id":"AN05","type":"principle","principle":"Anxergy","words":["Share","Obtain"]},{"id":"AN06","type":"principle","principle":"Anxergy","words":["Follow-up","Warn"]},{"id":"AN07","type":"principle","principle":"Anxergy","words":["Discuss","Meet"]},{"id":"AN08","type":"principle","principle":"Anxergy","words":["Listen","Learn"]},{"id":"AN09","type":"principle","principle":"Anxergy","words":["Monitor","Observe"]},{"id":"AN10","type":"principle","principle":"Anxergy","words":["Accelerate","Run"]},{"id":"AN11","type":"principle","principle":"Anxergy","words":["Check","Review"]},{"id":"AN12","type":"principle","principle":"Anxergy","words":["Investigate","Identify"]},{"id":"AT01","type":"principle","principle":"Atammayata","words":["Justify","Correct"]},{"id":"AT02","type":"principle","principle":"Atammayata","words":["Stop","Prevent"]},{"id":"AT03","type":"principle","principle":"Atammayata","words":["Force","Push"]},{"id":"AT04","type":"principle","principle":"Atammayata","words":["Examine","Research"]},{"id":"AT05","type":"principle","principle":"Atammayata","words":["Search","Discover"]},{"id":"AT06","type":"principle","principle":"Atammayata","words":["Decide","Determine"]},{"id":"AT07","type":"principle","principle":"Atammayata","words":["Encourage","Promote"]},{"id":"AT08","type":"principle","principle":"Atammayata","words":["Abandon","Clean"]},{"id":"AT09","type":"principle","principle":"Atammayata","words":["Report","Suggest"]},{"id":"AT10","type":"principle","principle":"Atammayata","words":["Explore","Uncover"]},{"id":"AT11","type":"principle","principle":"Atammayata","words":["Involve","Teach"]},{"id":"AT12","type":"principle","principle":"Atammayata","words":["Implement","Execute"]},{"id":"AV01","type":"principle","principle":"Avalremy","words":["Approve","Manage"]},{"id":"AV02","type":"principle","principle":"Avalremy","words":["Analyze","Evaluate"]},{"id":"AV03","type":"principle","principle":"Avalremy","words":["Participate","Network"]},{"id":"AV04","type":"principle","principle":"Avalremy","words":["Adopt","Embrace"]},{"id":"AV05","type":"principle","principle":"Avalremy","words":["Set up","Standardize"]},{"id":"AV06","type":"principle","principle":"Avalremy","words":["Complete","Conclude"]},{"id":"AV07","type":"principle","principle":"Avalremy","words":["Organize","Authorize"]},{"id":"AV08","type":"principle","principle":"Avalremy","words":["Perform","Operate"]},{"id":"AV09","type":"principle","principle":"Avalremy","words":["Collaborate","Cooperate"]},{"id":"AV10","type":"principle","principle":"Avalremy","words":["Invest","Risk"]},{"id":"AV11","type":"principle","principle":"Avalremy","words":["Create","Initiate"]},{"id":"AV12","type":"principle","principle":"Avalremy","words":["Declare","Announce"]},{"id":"DE01","type":"principle","principle":"Dexterience","words":["Start","Move"]},{"id":"DE02","type":"principle","principle":"Dexterience","words":["Prepare","Schedule"]},{"id":"DE03","type":"principle","principle":"Dexterience","words":["Practice","Improve"]},{"id":"DE04","type":"principle","principle":"Dexterience","words":["Measure","Project"]},{"id":"DE05","type":"principle","principle":"Dexterience","words":["Inspect","Act"]},{"id":"DE06","type":"principle","principle":"Dexterience","words":["Train","Mentor"]},{"id":"DE07","type":"principle","principle":"Dexterience","words":["Invite","Request"]},{"id":"DE08","type":"principle","principle":"Dexterience","words":["Allocate","Distribute"]},{"id":"DE09","type":"principle","principle":"Dexterience","words":["Integrate","Synchronize"]},{"id":"DE10","type":"principle","principle":"Dexterience","words":["Adapt","Calibrate"]},{"id":"DE11","type":"principle","principle":"Dexterience","words":["Develop","Experiment"]},{"id":"DE12","type":"principle","principle":"Dexterience","words":["Arrange","Manipulate"]},{"id":"EX01","type":"principle","principle":"Exdysivity","words":["Imagine","Vision"]},{"id":"EX02","type":"principle","principle":"Exdysivity","words":["Design","Adjust"]},{"id":"EX03","type":"principle","principle":"Exdysivity","words":["Institute","Cultivate"]},{"id":"EX04","type":"principle","principle":"Exdysivity","words":["Respond","React"]},{"id":"EX05","type":"principle","principle":"Exdysivity","words":["Educate","Coach"]},{"id":"EX06","type":"principle","principle":"Exdysivity","words":["Eliminate","Abolish"]},{"id":"EX07","type":"principle","principle":"Exdysivity","words":["Change","Transform"]},{"id":"EX08","type":"principle","principle":"Exdysivity","words":["Delegate","Assign"]},{"id":"EX09","type":"principle","principle":"Exdysivity","words":["Scrutinize","Survey"]},{"id":"EX10","type":"principle","principle":"Exdysivity","words":["Facilitate","Support"]},{"id":"EX11","type":"principle","principle":"Exdysivity","words":["Renovate","Repair"]},{"id":"EX12","type":"principle","principle":"Exdysivity","words":["Modify","Revise"]},{"id":"M01","type":"major","letter":"Q","kind":"Plan","focus":"Future","kindWords":"Forecast, Expectation, Projection","focusWords":"Goal, Objective, Target","text":"The value of future and planned actions is derived from visualized target achievement one can envision as a result of the impacts as a consequence of relevant actions generated and taken."},{"id":"M02","type":"major","letter":"W","kind":"Plan","focus":"Operation","kindWords":"Forecast, Expectation, Projection","focusWords":"Process, Action, Function","text":"The expectation of action value generation depends on efficiency, productivity, and effectiveness of interaction among functions and processes optimization and synchronization."},{"id":"M03","type":"major","letter":"E","kind":"Plan","focus":"Control","kindWords":"Forecast, Expectation, Projection","focusWords":"Risk, Assurance, Reliability","text":"To establish proper control and governance system provides confidence in implementation of action plan and execution of strategies in order to achieve the action value creation goal."},{"id":"M04","type":"major","letter":"A","kind":"Execution","focus":"Future","kindWords":"Action, Movement, Decision","focusWords":"Goal, Objective, Target","text":"The future target can become real only if valued actions are considered and effectively taken today through the process involving the success in making first start action happen."},{"id":"M05","type":"major","letter":"S","kind":"Execution","focus":"Operation","kindWords":"Action, Movement, Decision","focusWords":"Process, Action, Function","text":"The existence of an entity means its capability in successfully drive the process of generating product and service required by target markets and customers."},{"id":"M06","type":"major","letter":"D","kind":"Execution","focus":"Control","kindWords":"Action, Movement, Decision","focusWords":"Risk, Assurance, Reliability","text":"The power of control will ensure the target achievement resulting from efforts made through actions in proving the existence and effectivenes of all governance tools and mechanisms."},{"id":"M07","type":"major","letter":"C","kind":"Analysis","focus":"Future","kindWords":"Variance, Justification, Analysis","focusWords":"Goal, Objective, Target","text":"The accuracy of future forecast and expectation can be enhanced through in-depth analysis considering past experiences and future uncertainties before a conclusion of the most likely case can be drawn."},{"id":"M08","type":"major","letter":"X","kind":"Analysis","focus":"Operation","kindWords":"Variance, Justification, Analysis","focusWords":"Process, Action, Function","text":"The operational result is subject to proper analysis to understand the key drivers behind either negative or positive performance which will open up the opportunity for better improvement."},{"id":"M09","type":"major","letter":"V","kind":"Analysis","focus":"Control","kindWords":"Variance, Justification, Analysis","focusWords":"Risk, Assurance, Reliability","text":"To ensure the effectiveness of the control system, adjustment and modification to the current control system should be made as a result of the control analysis."},{"id":"M10","type":"major","letter":"Y","kind":"Communication","focus":"Future","kindWords":"Understanding, Sharing, Influencing","focusWords":"Goal, Objective, Target","text":"The importance of mutual understanding of the future requires effective communication and information sharing regarding the direction toward the goal."},{"id":"M11","type":"major","letter":"U","kind":"Communication","focus":"Operation","kindWords":"Understanding, Sharing, Influencing","focusWords":"Process, Action, Function","text":"To achieve the aimed performance, the responsible functions and interaction within a process must be clearly communicated for the same understanding which will result in successful coordination."},{"id":"M12","type":"major","letter":"I","kind":"Communication","focus":"Control","kindWords":"Understanding, Sharing, Influencing","focusWords":"Risk, Assurance, Reliability","text":"The objectives and design of control system should be made clear to stakeholders so that coordination and cooperation can be obtained from the same understanding."},{"id":"M13","type":"major","letter":"H","kind":"Exdysivity","focus":"Future","kindWords":"Change, Transform, Innovation","focusWords":"Goal, Objective, Target","text":"The rebirth of life requires constructive change and transformation in order to meet the future goal involving the long-term sustainability and existence of a system."},{"id":"M14","type":"major","letter":"J","kind":"Exdysivity","focus":"Operation","kindWords":"Change, Transform, Innovation","focusWords":"Process, Action, Function","text":"As part of the continued assurance for the operation of a process, it is inevitable for necessary and drastic change to be incorporated into the whole cycle of a process."},{"id":"M15","type":"major","letter":"K","kind":"Exdysivity","focus":"Control","kindWords":"Change, Transform, Innovation","focusWords":"Risk, Assurance, Reliability","text":"To be peacefully assured on a journey toward goal achievement, the effectiveness of a control system lies upon the responsive change to be made to irrelevant practices realized by responsible parties."},{"id":"M16","type":"major","letter":"R","kind":"Plan","focus":"Utilisation","kindWords":"Forecast, Expectation, Projection","focusWords":"Resource, Time, Information","text":"Due to the limitation and inherent value, available resources and its utilization plan are crucial in materializing the vision and dream through efficent and effective operation."},{"id":"M17","type":"major","letter":"T","kind":"Plan","focus":"System","kindWords":"Forecast, Expectation, Projection","focusWords":"Individual, Complexity, Technology","text":"To effectively deal with complexity and confusion for long-term stability, well crafted and developed systems ranging from tiny to large ones must be considered as part of successful journey."},{"id":"M18","type":"major","letter":"F","kind":"Execution","focus":"Utilisation","kindWords":"Action, Movement, Decision","focusWords":"Resource, Time, Information","text":"The utimate ecology of resource utilization requires dynamic actions in maximizing the capability of allocation and maneuvering available and limited resources on hand."},{"id":"M19","type":"major","letter":"G","kind":"Execution","focus":"System","kindWords":"Action, Movement, Decision","focusWords":"Individual, Complexity, Technology","text":"The continuity of a system running means the life and growth opportunity of dependents and related stakeholders working together in a concerted efforts to achieve their goals."},{"id":"M20","type":"major","letter":"B","kind":"Analysis","focus":"Utilisation","kindWords":"Variance, Justification, Analysis","focusWords":"Resource, Time, Information","text":"The limited resources shall be maximized and analyzed so that the unnecessary losses can be avoided and prevented with responsive measures accordingly."},{"id":"M21","type":"major","letter":"N","kind":"Analysis","focus":"System","kindWords":"Variance, Justification, Analysis","focusWords":"Individual, Complexity, Technology","text":"For a system to effectively serve operation and to last in the long run, it needs an on-going process of analysis followed by modification and improvement endeavor."},{"id":"M22","type":"major","letter":"O","kind":"Communication","focus":"Utilisation","kindWords":"Understanding, Sharing, Influencing","focusWords":"Resource, Time, Information","text":"The maximum utilization of available resources is critical to the life and existence of humans who have to be fully aware of its importance through full understanding and realization."},{"id":"M23","type":"major","letter":"P","kind":"Communication","focus":"System","kindWords":"Understanding, Sharing, Influencing","focusWords":"Individual, Complexity, Technology","text":"The system development or change is considered a challenge deserving full understanding through well selected communication channels to avoid resistance and also gain support from impacted parties concerned."},{"id":"M24","type":"major","letter":"L","kind":"Exdysivity","focus":"Utilisation","kindWords":"Change, Transform, Innovation","focusWords":"Resource, Time, Information","text":"The development of available resources is critical to the achievement of projects and attempts provided that natural change process is in place over time."},{"id":"M25","type":"major","letter":"M","kind":"Exdysivity","focus":"System","kindWords":"Change, Transform, Innovation","focusWords":"Individual, Complexity, Technology","text":"The only alternative to survival is limited to merely self-change or transformation effectiveness requiring acceptance and full cooperation by the subject system through the sufficient self-force power."},{"id":"W01","type":"warning","title":"Do it now","line":"Be decisive and urgent"},{"id":"W02","type":"warning","title":"Build on strength","line":"Don’t focus on weakness"},{"id":"W03","type":"warning","title":"Be very creative","line":"Think outside and be flexible"},{"id":"W04","type":"warning","title":"Control it","line":"Don’t wait until it worsens"},{"id":"W05","type":"warning","title":"Don’t miss it","line":"Clear outstanding issues"},{"id":"W06","type":"warning","title":"Be prepared","line":"Study feasibility and scenarios"},{"id":"W07","type":"warning","title":"Change your belief","line":"Knowing yourself and be flexible"},{"id":"W08","type":"warning","title":"Ask for help","line":"Eliminate your ego"},{"id":"W09","type":"warning","title":"Relax & wait","line":"Expect emerging situation"},{"id":"W10","type":"warning","title":"Be open","line":"Listen to others"},{"id":"W11","type":"warning","title":"Appreciate it","line":"Recognize, reward and celebrate"},{"id":"W12","type":"warning","title":"Try new things","line":"Explore and dare to fail"},{"id":"W13","type":"warning","title":"Think twice","line":"Be cautious and preventive"},{"id":"W14","type":"warning","title":"Soldier through","line":"Don’t give up and no excuse"},{"id":"W15","type":"warning","title":"Sacrifice it","line":"Walk away from it"}]; const CARD_KIND_VERB = { Plan: 'plan', Execution: 'carry out', Analysis: 'analyze', Communication: 'communicate', Exdysivity: 'change' }; let cardReading = [], cardsOrigin = 'screen-home'; function cardById(id) { return KIRDOMIC_DECK.find(c => c.id === id); } function cardGroup(c) { return c.type === 'major' ? 'Major — ' + c.letter : c.type === 'principle' ? c.principle : 'Warning'; } function cardTitle(c) { return c.type === 'major' ? c.focus + ' × ' + c.kind : c.type === 'principle' ? c.words.join(' · ') : c.title + ' — ' + c.line.charAt(0).toLowerCase() + c.line.slice(1); } function cardPrompt(c) { if (c.type === 'major') return c.text; if (c.type === 'principle') { const L = LENS.find(x => x.name === c.principle); return c.principle + (L ? ': ' + L.core : '.') + ' Action words on this card: ' + c.words.join(' and ').toLowerCase() + '.'; } return 'A warning card asks for urgency. ' + c.title + ': ' + c.line.charAt(0).toLowerCase() + c.line.slice(1) + '.'; } function cardAlt(c) { return 'Kirdomic card, ' + cardGroup(c) + ': ' + cardTitle(c); } function cardImg(c) { return 'cards/' + c.id + '.jpg'; } function goCards(origin) { cardsOrigin = origin || 'screen-home'; renderCards(); show('screen-cards'); } function leaveCards() { if (cardsOrigin === 'screen-grid') goGrid(); else if (cardsOrigin === 'screen-lens') openLens(lensOrigin === 'screen-cards' ? 'screen-home' : lensOrigin); else if (cardsOrigin === 'screen-about' || cardsOrigin === 'screen-help') show(cardsOrigin); else goHome(); } function drawCards(k) { const pool = KIRDOMIC_DECK.filter(c => k === 7 || !cardReading.includes(c.id)); const rnd = n => { try { const a = new Uint32Array(1); crypto.getRandomValues(a); return a[0] % n; } catch (e) { return Math.floor(Math.random() * n); } }; const pick = [], src = pool.slice(); while (pick.length < k && src.length) pick.push(src.splice(rnd(src.length), 1)[0].id); cardReading = k === 7 ? pick : (cardReading.length >= 7 ? pick : cardReading.concat(pick)); renderCards(); const last = document.querySelector('#cards-body [data-card="' + (cardReading[cardReading.length - 1] || '') + '"]'); if (last && k === 1) last.scrollIntoView({ block: 'center', behavior: 'smooth' }); } function openCardZoom(id) { const c = cardById(id), z = document.getElementById('card-zoom'); if (!c || !z) return; z.innerHTML = `
${escapeHtml(cardAlt(c))}
Tap anywhere to close
`; z.style.display = 'flex'; } function closeCardZoom() { const z = document.getElementById('card-zoom'); if (z) { z.style.display = 'none'; z.innerHTML = ''; } } document.addEventListener('keydown', e => { if (e.key === 'Escape') closeCardZoom(); }); function readingToAction() { const c = cardById(cardReading[cardReading.length - 1]); if (!c) return; const verb = c.type === 'principle' ? c.words[0].toLowerCase() : c.type === 'major' ? CARD_KIND_VERB[c.kind] || '' : ''; goLogPersonal(); try { setEntryMode('free'); } catch (e) {} const t = document.getElementById('log-text'); if (t) { t.value = verb ? 'I will ' + verb + ' ' : 'I will '; t.focus(); try { onLogTextInput(); } catch (e) {} } } function readingToReflection() { const cs = cardReading.map(cardById).filter(Boolean); if (!cs.length) return; goPresence(); setPresenceKind('reflection'); const t = document.getElementById('presence-text'); if (t) { t.value = ('Card reading: ' + cs.map(c => cardTitle(c)).join('; ') + '. ').slice(0, 560); t.focus(); try { t.dispatchEvent(new Event('input')); } catch (e) {} } } function renderCards() { const el = document.getElementById('cards-body'); if (!el) return; let h = ''; if (!cardReading.length) h += `
Draw one card whenever a question is on your mind, or a full round of 7 for a broader look. Major cards call for strong attention, the five principle cards guide a dimension, warning cards ask for urgency. Sit with each one before moving to the next — the value is in the conversation it starts, not the card itself.
`; else { h += `
This reading is general, not personal — it may or may not resonate with exactly what’s on your mind right now. Sit with what fits; leave what doesn’t.
`; h += cardReading.map(cardById).filter(Boolean).map(c => `
${escapeHtml(cardGroup(c))}
${escapeHtml(cardTitle(c))}
${c.type === 'major' ? `
${escapeHtml(c.kindWords)} · ${escapeHtml(c.focusWords)}
` : ''}
${escapeHtml(cardPrompt(c))}${sayText('card-' + c.id, cardTitle(c) + '. ' + cardPrompt(c))}
`).join(''); h += `
`; } h += `
The full deck of 100: 25 major cards (one for each cell of the Action Grid), 12 for each of the 5 principles, and 15 warning cards.
`; el.innerHTML = h; } function renderHomeBadge() { const t = document.getElementById('home-badge-title'), sub = document.getElementById('home-badge-sub'); if (!t || !sub) return; const b = badgeState(), goal = (b.earned + 1) * BADGE_STEP, got = round1(b.pos).toLocaleString('en-US'); t.textContent = (b.earned ? 'Next badge: ' : 'First badge: ') + b.next + ' at ' + goal.toLocaleString('en-US'); sub.textContent = got + ' of ' + goal.toLocaleString('en-US') + ' so far' + (b.earned ? ' · earned: ' + Array.from({ length: b.earned }, (_, k) => b.nameOf(k)).join(', ') : ' · then a Star, then a Diamond'); } /* ---------- Help search, Ask the founder, Your details (2026-10-07, design Help.dc.html / Settings.dc.html) ---------- */ function searchHelp(q) { q = (q || '').trim().toLowerCase(); const msg = document.getElementById('help-search-msg'); let hits = 0; document.querySelectorAll('#screen-help details.help-sec').forEach(d => { let any = false; d.querySelectorAll(':scope > div > .card').forEach(c => { const ok = !q || c.textContent.toLowerCase().includes(q); c.style.display = ok ? '' : 'none'; if (ok && q) { any = true; hits++; } }); d.style.display = !q || any ? '' : 'none'; if (q) d.open = any; }); if (msg) msg.textContent = q ? (hits ? hits + ' answer' + (hits === 1 ? '' : 's') + ' found.' : 'Nothing found — try another word, or ask the founder below.') : ''; } // Questions go to a shared collection only the owner's view lists (each person sees only their own). Disclosed in the Privacy policy. // Questions live in founderQuestions/ (one document per person, items inside). Platform rules: each person writes and // reads only their own document (founderQuestions/{self}); only the owner reads and answers them all. (Rules declared at publish.) function qCol() { return db ? db.collection('founderQuestions') : null; } function qDoc(id) { return db && (id || uid) ? db.doc('founderQuestions/' + (id || uid)) : null; } async function readQDoc(id) { try { const sn = await qDoc(id).get(); const d = sn.exists ? sn.data() : null; return d && Array.isArray(d.items) ? d : { uid: id || uid, items: [] }; } catch (e) { return { uid: id || uid, items: [] }; } } async function askFounder() { const t = document.getElementById('ask-text'), m = document.getElementById('ask-msg'), text = ((t && t.value) || '').trim().slice(0, 600); const say = (x, c) => { if (m) { m.textContent = x; m.style.color = c || 'var(--muted)'; } }; if (text.length < 5) { say('Write your question in a sentence first.'); return; } if (!qDoc() || !uid) { say('Not signed in — questions need an account.'); return; } const d = await readQDoc(); if (d.items.length >= 50) { say('You have 50 questions waiting — please wait for answers first.'); return; } d.items.push({ id: 'q' + Date.now(), text, createdAt: Date.now() }); d.updatedAt = Date.now(); d.uid = uid; try { await qDoc().set(d); t.value = ''; say('Sent. The founder will answer here.', 'var(--teal-dark)'); renderMyQuestions(); } catch (e) { say(accessBlocked(e) ? ACCESS_NOTE : 'Could not send it right now — try again later.', 'var(--red)'); } } async function renderMyQuestions() { const el = document.getElementById('ask-mine'); if (!el || !qDoc() || !uid) return; const mine = (await readQDoc()).items.slice().sort((a, b) => b.createdAt - a.createdAt); el.innerHTML = mine.length ? '
Your questions
' + mine.slice(0, 10).map(q => `
${escapeHtml(q.text)}
${q.answer ? `
${q.decision ? (q.decision === 'accepted' ? 'Correction accepted' : 'Correction not accepted') : 'Answer'}: ${escapeHtml(q.answer)}
` : '
Waiting for an answer
'}
`).join('') : ''; } async function renderFounderQuestions() { const wrap = document.getElementById('settings-questions-wrap'), el = document.getElementById('settings-questions'); if (!wrap || !el) return; if (!viewerIsOwner || !qCol()) { wrap.style.display = 'none'; return; } wrap.style.display = 'block'; let qs = []; try { const sn = await qCol().limit(200).get(); sn.docs.forEach(d => { const x = d.data() || {}; (Array.isArray(x.items) ? x.items : []).forEach(q => { if (q && typeof q.text === 'string') qs.push({ ...q, uid: d.id, key: d.id + '|' + q.id }); }); }); } catch (e) { el.textContent = 'Could not load questions right now.'; return; } qs.sort((a, b) => (a.answer ? 1 : 0) - (b.answer ? 1 : 0) || b.createdAt - a.createdAt); let names = {}; try { names = await user.profiles([...new Set(qs.map(q => q.uid))]); } catch (e) {} el.innerHTML = qs.length ? qs.map((q, n) => `
${escapeHtml((names[q.uid] && names[q.uid].name) || 'Someone')} · ${escapeHtml(relTime(q.createdAt))}
${escapeHtml(q.text)}
${q.kind === 'correction' ? `
Harm-record correction · record ${escapeHtml(q.ref || '')} (value ${typeof q.value === 'number' ? fmtSigned(q.value) : '?'})${q.statement ? '
Plan: \u201c' + escapeHtml(q.statement) + '\u201d' : ''}
Evidence: ${escapeHtml(q.evidence || '')}${q.confirmBy ? ' · confirm with ' + escapeHtml(q.confirmBy) : ''}${q.file ? ` · ${escapeHtml(q.file.name || 'file')}` : ''}
` : ''} ${q.answer ? `
${q.decision ? (q.decision === 'accepted' ? 'Accepted' : 'Not accepted') + ':' : 'Your answer:'} ${escapeHtml(q.answer)}
` : q.kind === 'correction' ? `
` : ``}
`).join('') : '
No questions yet.
'; } async function answerQuestion(n) { const t = document.getElementById('ans-' + n), a = ((t && t.value) || '').trim().slice(0, 1000); if (!a || !viewerIsOwner || !t) return; const [who, qid] = String(t.getAttribute('data-key') || '').split('|'); if (!who || !qid) return; const d = await readQDoc(who), q = d.items.find(x => x.id === qid); if (!q) return; q.answer = a; q.answeredAt = Date.now(); try { await qDoc(who).set(d); renderFounderQuestions(); } catch (e) { alert('Could not save the answer right now.'); } } function renderSettingsDetails() { const el = document.getElementById('settings-details'); if (!el) return; const p = contextProfile || {}, opt = (list, v) => list.map(([val, l]) => ``).join(''); const roles = [...document.querySelectorAll('#signup-role option')].map(o => [o.value, o.textContent]), sets = [...document.querySelectorAll('#signup-setting option')].map(o => [o.value, o.textContent]), ages = [...document.querySelectorAll('#signup-age option')].map(o => [o.value, o.textContent]); el.innerHTML = `
Role and setting suggest your starting 12 Big Questions. Age range and profession are optional and used only in your Performance profile.
`; } async function saveSettingsDetails() { const v = id => (document.getElementById(id) || {}).value || '', m = document.getElementById('sd-msg'); const prof = v('sd-prof').trim().slice(0, 60); contextProfile = { ...(contextProfile || {}), role: v('sd-role') || (contextProfile && contextProfile.role), setting: v('sd-setting'), ageRange: v('sd-age'), profession: prof, onboarded: true }; if (!contextProfile.ageRange) delete contextProfile.ageRange; if (!contextProfile.profession) delete contextProfile.profession; try { if (profileRef) await profileRef.set(contextProfile); if (m) { m.textContent = 'Saved.'; m.style.color = 'var(--teal-dark)'; } renderPriorities(); renderContextPrompt(); } catch (e) { if (m) { m.textContent = accessBlocked(e) ? ACCESS_NOTE : 'Saved on this device, but syncing failed.'; m.style.color = 'var(--red)'; } } } /* ---------- Notes from the founder (design Notifications.dc.html / Settings.dc.html) ---------- */ // founderNotes: everyone reads; only the owner writes (platform rule declared at publish). Shown in Notifications › Updates. let founderNotes = [], notesSubscribed = false; const NOTE_TARGETS = { none: ['Nowhere — just the note', ''], grid: ['Action Grid', "goGrid()"], cards: ['Card reading', "goCards('screen-notifications')"], lens: ['Principle Lens', "openLens('screen-home')"], help: ['Help', "goHelp('screen-notifications')"], network: ['Appreciative Reality Network', "goNetwork()"] }; function subscribeNotes() { if (notesSubscribed || !db) return; notesSubscribed = true; db.collection('founderNotes').orderBy('createdAt', 'desc').limit(20).onSnapshot(sn => { founderNotes = sn.docs.map(d => ({ id: d.id, ...d.data() })).filter(n => n && typeof n.text === 'string' && NOTE_TARGETS[n.target || 'none']); renderBell(); if (document.getElementById('screen-notifications').classList.contains('active')) renderNotifications(); renderNotesComposer(); }, e => { console.warn('notes error', e); notesSubscribed = false; }); } function renderNotesComposer() { const wrap = document.getElementById('settings-notes-wrap'), el = document.getElementById('settings-notes'); if (!wrap || !el) return; if (!viewerIsOwner) { wrap.style.display = 'none'; return; } wrap.style.display = 'block'; el.innerHTML = `
${founderNotes.length ? '
Posted
' + founderNotes.map(n => `
${escapeHtml(n.title || 'Note')} · ${escapeHtml(relTime(n.createdAt))}Delete
`).join('') : ''}`; } async function postFounderNote() { const v = id => ((document.getElementById(id) || {}).value || '').trim(), m = document.getElementById('note-msg'); const title = v('note-title').slice(0, 80), text = v('note-text').slice(0, 800), target = NOTE_TARGETS[v('note-target')] ? v('note-target') : 'none'; if (!viewerIsOwner || !db) return; if (text.length < 5) { if (m) { m.textContent = 'Write the note first.'; m.style.color = 'var(--red)'; } return; } try { await db.collection('founderNotes').doc('n' + Date.now()).set({ title: title || 'A note from the founder', text, target, createdAt: Date.now() }); if (m) { m.textContent = 'Posted. Everyone sees it in Notifications.'; m.style.color = 'var(--teal-dark)'; } document.getElementById('note-text').value = ''; document.getElementById('note-title').value = ''; } catch (e) { if (m) { m.textContent = 'Could not post it right now.'; m.style.color = 'var(--red)'; } } } async function deleteFounderNote(id) { if (!viewerIsOwner || !db || !confirm('Delete this note for everyone?')) return; try { await db.collection('founderNotes').doc(id).delete(); } catch (e) { alert('Could not delete it right now.'); } } /* ---------- Read aloud (design 2026-09-26; ActionGrid.dc.html) ---------- */ // Uses the device's own speech (no network, no AI). Hidden where the device has none. Tap again to stop. let speakingKey = null; function canSpeak() { try { return 'speechSynthesis' in window && typeof SpeechSynthesisUtterance === 'function'; } catch (e) { return false; } } function readAloudBtn(key) { return canSpeak() ? `` : ''; } const SAY = {}; function sayText(key, text) { SAY[key] = String(text || '').slice(0, 1200); return readAloudBtn(key); } function readAloud(btn) { if (!canSpeak()) return; const key = btn.getAttribute('data-say'), text = SAY[key]; try { window.speechSynthesis.cancel(); } catch (e) {} if (speakingKey === key) { speakingKey = null; btn.setAttribute('aria-label', 'Read aloud'); return; } if (!text) return; const u = new SpeechSynthesisUtterance(kirvSpoken(text)); u.lang = 'en-GB'; u.rate = 0.95; u.onend = u.onerror = () => { if (speakingKey === key) { speakingKey = null; btn.setAttribute('aria-label', 'Read aloud'); } }; speakingKey = key; btn.setAttribute('aria-label', 'Stop reading'); try { window.speechSynthesis.speak(u); } catch (e) { speakingKey = null; } } /* ---------- Feedback through comments (2026-10-06) ---------- */ // The platform's comments in their "composer only" form: the page can only open the comment box; the platform shows the threads and // Claude can read, answer and resolve them. Anchored to the screen's title bar so no private record text is captured. // Shown to the app's owner by default (the founder's sandbox); anyone may switch it on in Settings. let commentsCap = null, isOwnerView = false; async function initComments() { try { commentsCap = await claude.use('comments'); } catch (e) { commentsCap = null; } try { const u = await claude.use('user'); isOwnerView = !!(u && typeof u.isOwner === 'function' && (await u.isOwner()) === true); } catch (e) { isOwnerView = false; } renderFeedbackBtn(); } // Soft launch (2026-10-07): on for every invited tester by default, so feedback reaches the founder; anyone can switch it off in Settings. const FEEDBACK_DEFAULT_ON = true; function feedbackEnabled() { if (!commentsCap) return false; return contextProfile && typeof contextProfile.feedbackButton === 'boolean' ? contextProfile.feedbackButton : (FEEDBACK_DEFAULT_ON || isOwnerView); } function renderFeedbackBtn() { const b = document.getElementById('fb-btn'); if (b) b.style.display = feedbackEnabled() ? 'flex' : 'none'; const cb = document.getElementById('settings-feedback'), note = document.getElementById('settings-feedback-note'); if (cb) { cb.checked = feedbackEnabled(); cb.disabled = !commentsCap; } if (note) note.textContent = commentsCap ? '' : 'Comments are not available in this view.'; } async function setFeedbackButton(on) { contextProfile = { ...(contextProfile || {}), feedbackButton: !!on }; renderFeedbackBtn(); if (profileRef) { try { await profileRef.set(contextProfile); } catch (e) { console.warn('feedback setting not saved', e); } } } async function openFeedback() { if (!commentsCap) return; const scr = document.querySelector('.screen.active'), target = scr ? (scr.querySelector('.topbar') || scr) : document.body; try { await commentsCap.openComposer({ element: target }); } catch (e) { if (e && e.code !== 'cancelled') alert('Comments are not available right now.'); } } initComments(); /* ---------- Appreciative Reality Network (founder decision 2026-10-07) ---------- */ // No followers: a person follows Big Questions (their "interests", default = their top 3), and sees the Appreciative Reality stories others // CHOSE to share under those Big Questions. Shared collection arNetwork: { v, text, bq, authorUid, showName, photos[], sourceId, createdAt, // hidden }. Appreciations: arCheers/_. Never scored, no Avalrem anywhere. Every field read back is untrusted: escaped, validated. // The db has no per-document ownership rule, so "only the author removes, only the owner hides" is enforced by the page (like Groups). let commentsCol = null, estimatesCol = null, worldCol = null, netComments = [], netEstimates = [], worldDocs = [], worldSubscribed = false, storyId = null, storyOrigin = 'screen-home', lastCommentAt = 0; let netCol = null, cheersCol = null, netPosts = [], netCheers = [], netSubscribed = false, netFilter = 'mine', netOrigin = 'screen-home', arShareOpen = null; const SAFE_ID = /^[A-Za-z0-9_-]{1,80}$/; function myInterests() { const i = contextProfile && Array.isArray(contextProfile.interests) ? contextProfile.interests.filter(k => BQ_NAMES[k]) : null; return i && i.length ? i : currentTop3(); } function interestsDeclared() { return !!(contextProfile && Array.isArray(contextProfile.interests) && contextProfile.interests.length); } async function toggleInterest(k) { if (!BQ_NAMES[k]) return; const cur = interestsDeclared() ? contextProfile.interests.slice() : myInterests().slice(); const next = cur.includes(k) ? cur.filter(x => x !== k) : cur.concat(k); contextProfile = { ...(contextProfile || {}), interests: next }; renderNetwork(); if (profileRef) { try { await profileRef.set(contextProfile); } catch (e) { console.warn('interests not saved', e); } } } function netPostOk(d) { return d && typeof d.text === 'string' && d.text.trim() && BQ_NAMES[d.bq] && typeof d.authorUid === 'string'; } function subscribeNetwork() { if (netSubscribed || !netCol) return; netSubscribed = true; netCol.orderBy('createdAt', 'desc').limit(200).onSnapshot(snap => { netPosts = snap.docs.map(d => ({ id: d.id, ...d.data() })).filter(netPostOk); renderNetwork(); }, e => { console.warn('network error', e); netSubscribed = false; }); if (cheersCol) cheersCol.limit(1000).onSnapshot(snap => { netCheers = snap.docs.map(d => ({ id: d.id, ...d.data() })); renderNetwork(); }, e => console.warn('appreciations error', e)); if (commentsCol) commentsCol.orderBy('createdAt', 'asc').limit(2000).onSnapshot(snap => { netComments = snap.docs.map(d => ({ id: d.id, ...d.data() })).filter(c => c && typeof c.text === 'string' && SAFE_ID.test(String(c.postId || ''))); renderNetwork(); }, e => console.warn('comments error', e)); if (estimatesCol) estimatesCol.limit(5000).onSnapshot(snap => { netEstimates = snap.docs.map(d => ({ id: d.id, ...d.data() })).filter(x => x && isFinite(Number(x.value)) && Math.abs(Number(x.value)) <= 100); renderNetwork(); }, e => console.warn('estimates error', e)); } function goNetwork(origin) { const a = document.querySelector('.screen.active'); netOrigin = origin || (a && a.id !== 'screen-network' ? a.id : 'screen-home'); subscribeNetwork(); renderNetwork(); show('screen-network'); } function leaveNetwork() { if (netOrigin === 'screen-presence') goPresence(); else if (netOrigin === 'screen-about') show('screen-about'); else goHome(); } function cheerCount(postId) { return new Set(netCheers.filter(c => c.postId === postId && typeof c.uid === 'string').map(c => c.uid)).size; } function iCheered(postId) { return netCheers.some(c => c.postId === postId && c.uid === uid); } function cheerDocId(postId) { return String(postId) + '_' + String(uid || 'x').replace(/[^A-Za-z0-9]/g, '').slice(0, 24); } // Only the owner may hide others' stories and comments. Testers invited as editors (needed so they can save) cannot moderate. function canModerate() { return viewerIsOwner === true; } function netVisible(d) { return !d.hidden || d.authorUid === uid; } function netShown() { const mine = myInterests(); return netPosts.filter(netVisible).filter(d => netFilter === 'all' ? true : netFilter === 'mine' ? (mine.includes(d.bq) || d.authorUid === uid) : d.bq === netFilter); } function renderNetwork() { renderHomeAr(); if (storyId && document.getElementById('screen-story').classList.contains('active')) renderStory(); const list = document.getElementById('net-list'); if (!list) return; const mine = myInterests(), chip = (on, gold) => `border-radius:10px;padding:6px 10px;font-size:12px;font-weight:600;white-space:nowrap;${on ? (gold ? 'background:var(--gold);color:var(--on-gold);border:1px solid var(--gold);' : 'background:var(--ink);color:var(--card);border:1px solid var(--ink);') : 'background:var(--card);color:var(--ink);border:1px solid var(--border);'}`; const ib = document.getElementById('net-interests'); if (ib) ib.innerHTML = Object.keys(BQ_NAMES).map(k => ``).join(''); const ic = document.getElementById('net-interests-count'); if (ic) ic.textContent = '· ' + mine.length + (interestsDeclared() ? ' chosen' : ' (your top 3)'); const fb = document.getElementById('net-filter'); if (fb) fb.innerHTML = [['mine', 'My interests'], ['all', 'All']].concat(mine.map(k => [k, k])).map(([k, l]) => ``).join(''); const shown = netShown(); if (!netCol) { list.innerHTML = '
The network is not available in this view.
'; renderNetLive(); return; } list.innerHTML = shown.length ? shown.slice(0, 100).map(d => { const own = d.authorUid === uid, n = cheerCount(d.id), me = iCheered(d.id), id = SAFE_ID.test(d.id) ? d.id : ''; if (!id) return ''; const photos = (Array.isArray(d.photos) ? d.photos : []).filter(f => f && SAFE_ID.test(String(f.id))).slice(0, 3); const who = d.showName ? `${own ? 'You' : 'A KirV member'}` : `${own ? 'You (name hidden)' : 'A KirV member'}`; const date = new Date(Number(d.createdAt) || 0).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); const acts = [ own ? '' : ``, `${n} ${n === 1 ? 'appreciation' : 'appreciations'}`, `Comments · ${commentCount(id)}`, own ? '' : (myEstimate(id) ? 'Valued ✓' : `Value it`), own ? `Remove from network` : '', !own && canModerate() ? `Hide` : '', !own ? `Report` : '', ].join(''); return `
☀ ${escapeHtml(d.bq)} · ${escapeHtml(BQ_NAMES[d.bq])}${date}
${d.hidden ? '
Hidden by the KirV owner — only you can see it.
' : ''} ${escapeHtml(String(d.text).slice(0, 600))} ${photos.length ? `
${photos.map(f => ``).join('')}
` : ''}
${who}
${acts}
`; }).join('') : `
${netFilter === 'mine' ? 'No stories yet under the Big Questions you follow. Choose more under My interests, or look at All.' : 'No stories here yet.'}
`; fillMemberNames(list); renderNetLive(); } async function toggleCheer(postId) { if (!cheersCol || !uid || !SAFE_ID.test(postId)) return; const post = netPosts.find(d => d.id === postId); if (!post || post.authorUid === uid) return; const did = cheerDocId(postId), had = iCheered(postId); if (had) netCheers = netCheers.filter(c => !(c.postId === postId && c.uid === uid)); else netCheers = netCheers.concat({ id: did, postId, uid, at: Date.now() }); renderNetwork(); try { if (had) await cheersCol.doc(did).delete(); else { await cheersCol.doc(did).set({ postId, uid, at: Date.now() }); roomEmit('cheer', { postId }); } } catch (e) { console.warn('appreciation not saved', e); } } async function deleteCheersFor(postId) { if (!cheersCol) return; try { const s = await cheersCol.where('postId', '==', postId).get(); for (const d of s.docs) await cheersCol.doc(d.id).delete(); } catch (e) { console.warn('appreciations not removed', e); } } async function removeNetPost(postId, quiet) { const post = netPosts.find(d => d.id === postId) || { id: postId, authorUid: uid }; if (post.authorUid !== uid) return; if (!quiet && !confirm('Remove this story from the network? It stays in your My Presence.')) return; netPosts = netPosts.filter(d => d.id !== postId); if (netCol) { try { await deleteStoryExtras(postId); await netCol.doc(postId).delete(); } catch (e) { console.warn('network story not removed', e); if (!quiet) alert('Could not remove it right now — try again later.'); return; } } const src = presenceCache.find(x => x.sharedPostId === postId); if (src) await updatePresence(src.id, { sharedPostId: null, sharedBq: null }); renderNetwork(); renderPresenceList(); } async function hideNetPost(postId) { if (!canModerate() || !netCol) return; if (!confirm('Hide this story from everyone except its author?')) return; const d = netPosts.find(x => x.id === postId); if (d) d.hidden = true; renderNetwork(); try { await netCol.doc(postId).update({ hidden: true, hiddenAt: Date.now() }); } catch (e) { console.warn('hide failed', e); } } async function reportNetPost(postId) { const card = document.querySelector('[data-post="' + postId + '"]'); if (commentsCap && card) { try { await commentsCap.openComposer({ element: card }); return; } catch (e) { if (e && e.code === 'cancelled') return; } } alert('Reporting is not available in this view. Tell the KirV team what is wrong with this story.'); } // --- sharing from My Presence --- function arShareHtml(p) { if (p.sharedPostId) return `
☀ Shared to the network under ${escapeHtml(p.sharedBq || '')} · See it · Remove
`; if (!netCol) return ''; if (arShareOpen !== p.id) return `
Share to the network →
`; const pick = BQ_NAMES[p.bq] ? p.bq : (currentTop3()[0] || 'BQ1'), photos = (p.files || []).filter(f => f.kind === 'photo'); return `
${photos.length ? `` : ''} ${linkableResults().length ? ` ` : ''}
Everyone who uses KirV can read it. Share only what is yours to share — no private details about someone else without their consent. Never scored; you can remove it at any time.
`; } async function shareAr(id) { const p = presenceCache.find(x => x.id === id); if (!p || presenceKindOf(p) !== 'ar' || p.sharedPostId) return; const msg = t => { const e = document.getElementById('ars-msg-' + id); if (e) e.textContent = t; }; const bq = (document.getElementById('ars-bq-' + id) || {}).value; if (!BQ_NAMES[bq]) { msg('Choose a Big Question.'); return; } if (!netCol || !uid) { msg('Sharing is not available in this view.'); return; } const text = String(p.text || '').trim(); if (text.length < 10) { msg('Write a little more before sharing (at least 10 characters).'); return; } const withPhotos = !!(document.getElementById('ars-photo-' + id) || {}).checked; const now = Date.now(), postId = 'N' + now + Math.floor(Math.random() * 1000); const doc = { v: 1, text: text.slice(0, 600), bq, authorUid: uid, showName: !!(document.getElementById('ars-name-' + id) || {}).checked, photos: withPhotos ? (p.files || []).filter(f => f.kind === 'photo').slice(0, 3).map(f => ({ id: f.id, kind: 'photo' })) : [], sourceId: id, createdAt: now, hidden: false }; { const lr = (document.getElementById('ars-link-' + id) || {}).value, r = lr ? linkableResults().find(x => x.ref === lr) : null; const plan = r ? cache.find(a => a.kind === 'plan' && a.ref === r.ref) : null; if (r) doc.official = { ref: r.ref, value: round1(r.real) }; const v = (plan && plan.cDetail && plan.cDetail.verb) || ((computeC(text) || {}).verb) || null; if (v && DATA.WORD_DB[v]) doc.verb = v; } try { await netCol.doc(postId).set(doc); } catch (e) { msg(accessBlocked(e) ? ACCESS_NOTE : 'Could not share it right now — try again later.'); return; } netPosts = [{ id: postId, ...doc }].concat(netPosts.filter(d => d.id !== postId)); arShareOpen = null; await updatePresence(id, { sharedPostId: postId, sharedBq: bq }); renderPresenceList(); } /* ---------- Live room (founder decision 2026-10-07) ---------- */ // The room is everyone who has KirV open RIGHT NOW. Used for: who is here now on the Network, a live ☀ when someone appreciates a story, // and live Action-Storming inside a Group. Nothing here is stored: an idea from the live board is kept only when a member keeps it in their // own idea bank. Presence carries only { where, g }: g is a Group's non-secret key (creation time + name hash), never its join code. // Room messages reach every open copy of KirV; a page shows a Group's live ideas only from that Group's members (checked in the app, // like Group membership itself). let room = null, roomPeers = [], liveIdeas = {}, liveKey = null; // The key never contains the join code or the project reference (which carries two characters of the code): creation time + a name hash. function groupKey(p) { if (!p) return null; let h = 5381; for (const ch of String(p.name || '')) h = ((h * 33) ^ ch.charCodeAt(0)) >>> 0; return 'G' + (Number(p.createdAt) || 0) + '-' + h.toString(36); } async function initRoom() { try { room = await claude.use('room'); } catch (e) { room = null; } if (!room) return; room.onPeers(ch => { roomPeers = ch.peers || []; renderNetLive(); renderProjectLive(); }, () => {}); room.on('cheer', m => { const d = m && m.data; if (!d || !SAFE_ID.test(String(d.postId)) || m.sameTab) return; liveBurst(String(d.postId)); }, () => {}); room.on('storm', onLiveStorm, () => {}); const a = document.querySelector('.screen.active'); if (a) roomWhere(a.id); } function roomWhere(id) { if (!room) return; const where = id === 'screen-network' ? 'network' : (id === 'screen-project' && currentProject ? 'group' : 'app'); room.presence({ where, g: where === 'group' ? groupKey(currentProject) : null }).catch(() => {}); } function roomEmit(topic, data) { if (room && room.connected()) room.emit(topic, data).catch(() => {}); } function peersWhere(fn) { const seen = new Set(); return roomPeers.filter(p => p.kind === 'viewer' && p.presence && fn(p.presence, p)).filter(p => { const k = p.by || p.peer; if (seen.has(k)) return false; seen.add(k); return true; }); } function peopleLine(ps) { return ps.map(p => p.isMe ? 'You' : (p.by ? `Someone` : 'Someone')).join(', '); } function renderNetLive() { const el = document.getElementById('net-live'); if (!el) return; if (!room) { el.innerHTML = ''; return; } const here = peersWhere(pr => pr.where === 'network'); el.innerHTML = here.length ? `● ${here.length} here now · ${peopleLine(here.slice(0, 8))}${here.length > 8 ? ' …' : ''}` : ''; fillMemberNames(el); } function liveBurst(postId) { document.querySelectorAll('[data-post="' + postId + '"] .net-burst').forEach(card => { // Home, Network and the story screen can all show it const s = document.createElement('div'); s.textContent = '☀ +1'; s.style.cssText = 'position:relative;top:-6px;font-size:13px;font-weight:700;color:var(--gold);transition:opacity 1.2s,transform 1.2s;'; card.appendChild(s); requestAnimationFrame(() => { s.style.opacity = '0'; s.style.transform = 'translateY(-14px)'; }); setTimeout(() => s.remove(), 1400); }); } // --- Group live Action-Storming --- function onLiveStorm(m) { const d = m && m.data; if (!d || typeof d.g !== 'string' || typeof d.text !== 'string' || !m.by) return; const text = d.text.replace(/\s+/g, ' ').trim().slice(0, 200); if (text.length < 3) return; if (!currentProject || d.g !== groupKey(currentProject) || !(currentProject.memberIds || []).includes(m.by)) return; // only this Group's members const list = liveIdeas[d.g] || (liveIdeas[d.g] = []); if (list.length >= 50) list.shift(); list.push({ text, by: m.by, mine: !!m.isMe, at: Date.now() }); renderProjectLive(); } function renderProjectLive() { const box = document.getElementById('project-live'); if (!box) return; if (!room || !currentProject || currentProject.status === 'closed') { box.innerHTML = ''; liveKey = null; return; } const key = groupKey(currentProject), members = currentProject.memberIds || []; if (liveKey !== key || !document.getElementById('live-storm-text')) { liveKey = key; box.innerHTML = `
Live Action-Storming
For members who have this Group open now. Ideas appear for everyone here and are not saved — keep the ones you like in your own idea bank. Never scored.
`; } const here = peersWhere((pr, p) => pr.where === 'group' && pr.g === key && (p.isMe || members.includes(p.by))); document.getElementById('live-who-count').textContent = here.length ? '● ' + here.length + ' here now' : ''; const who = document.getElementById('live-who'); who.innerHTML = here.length ? peopleLine(here) : 'Only you right now.'; const ideas = (liveIdeas[key] || []).slice().reverse(); document.getElementById('live-storm-list').innerHTML = ideas.map((x, i) => `
${x.mine ? 'You' : `A member`}: ${escapeHtml(x.text)}
`).join(''); fillMemberNames(box); } async function sendLiveIdea() { const el = document.getElementById('live-storm-text'), msg = document.getElementById('live-storm-msg'); if (!el || !currentProject) return; const text = el.value.replace(/\s+/g, ' ').trim(); if (text.length < 3) { msg.textContent = 'Write the idea in a few words.'; return; } if (!room || !room.connected()) { msg.textContent = 'Not connected to the live room right now — try again in a moment.'; return; } msg.textContent = ''; try { await room.emit('storm', { g: groupKey(currentProject), text: text.slice(0, 200) }); el.value = ''; } catch (e) { msg.textContent = 'Could not send it right now.'; } } async function keepLiveIdea(i) { const x = (liveIdeas[groupKey(currentProject)] || [])[i]; if (!x) return; const now = Date.now(), id = 'I' + now + Math.floor(Math.random() * 1000); const doc = { kind: 'idea', v: 1, text: x.text.slice(0, 300), source: 'storming', horizon: 'This Year', cell: null, status: 'open', createdAt: now, fromGroup: currentProject ? currentProject.ref || null : null }; if (actionsCol) { try { await actionsCol.doc(id).set(doc); } catch (e) { console.warn('idea save failed, keeping locally', e); } } if (!cache.some(c => c.id === id)) cache.push({ id, ...doc }); const msg = document.getElementById('live-storm-msg'); if (msg) { msg.style.color = 'var(--teal-dark)'; msg.textContent = 'Kept in your idea bank (Action Grid).'; setTimeout(() => { msg.textContent = ''; msg.style.color = ''; }, 2500); } } /* ---------- Home: Appreciative Reality (founder decision 2026-10-07) ---------- */ function commentCount(postId) { return netComments.filter(c => c.postId === postId && !c.hidden).length; } function myEstimate(postId) { return netEstimates.find(x => x.postId === postId && x.uid === uid) || null; } function renderHomeAr() { const el = document.getElementById('home-ar'); if (!el) return; if (!netCol) { el.innerHTML = ''; return; } const shown = netPosts.filter(netVisible).filter(d => myInterests().includes(d.bq) || d.authorUid === uid).slice(0, 3); const online = room ? peersWhere(() => true).length : 0; const head = `
☀ Appreciative RealitySee all →
What went well, from people who follow your Big Questions${online > 1 ? ' · ' + online + ' people in KirV now' : ''}
`; const card = d => { const id = SAFE_ID.test(d.id) ? d.id : ''; if (!id) return ''; const own = d.authorUid === uid, me = iCheered(id), photo = (Array.isArray(d.photos) ? d.photos : []).find(f => f && SAFE_ID.test(String(f.id))); return `
${escapeHtml(d.bq)} · ${escapeHtml(BQ_NAMES[d.bq])}${new Date(Number(d.createdAt) || 0).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
${escapeHtml(truncate(String(d.text), 220))} ${photo ? `` : ''}
${d.showName ? `${own ? 'You' : 'A KirV member'}` : (own ? 'You (name hidden)' : 'A KirV member')}
${own ? `☀ ${cheerCount(id)}` : ``} Comments · ${commentCount(id)} ${own ? '' : (myEstimate(id) ? 'Valued ✓' : `Value it`)}
`; }; el.innerHTML = head + (shown.length ? shown.map(card).join('') : `
No stories yet under the Big Questions you follow. See all stories →
`) + `Share what went well for you →`; fillMemberNames(el); } /* ---------- One story: Value it (blind) + comments ---------- */ function linkableResults() { return cache.filter(r => r.kind === 'resolution' && !r.failureToAct && typeof r.real === 'number' && !r.projectId).sort((a, b) => (b.resolvedAt || 0) - (a.resolvedAt || 0)).slice(0, 30); } function openStory(postId, origin) { if (!SAFE_ID.test(String(postId))) return; const a = document.querySelector('.screen.active'); storyOrigin = origin || (a && a.id !== 'screen-story' ? a.id : 'screen-home'); storyId = postId; subscribeNetwork(); renderStory(); show('screen-story'); } function closeStory() { storyId = null; if (storyOrigin === 'screen-network') goNetwork(); else goHome(); } function storyVerb(d) { return d && d.verb && DATA.WORD_DB[d.verb] ? d.verb : ((computeC(String(d && d.text || '')) || {}).verb || null); } function renderStory() { const body = document.getElementById('story-body'); if (!body) return; const keep = {}; ['comment-text', 'est-val', 'est-why'].forEach(k => { const e = document.getElementById(k); if (e && e.value) keep[k] = e.value; }); // a live update must not wipe what the person is typing const focused = document.activeElement && document.activeElement.id; const d = netPosts.find(x => x.id === storyId); if (!d || !netVisible(d)) { body.innerHTML = '
This story is no longer shared.
'; return; } const own = d.authorUid === uid, me = iCheered(d.id), mine = myEstimate(d.id), ests = netEstimates.filter(x => x.postId === d.id).map(x => Number(x.value)); const photos = (Array.isArray(d.photos) ? d.photos : []).filter(f => f && SAFE_ID.test(String(f.id))).slice(0, 3); document.getElementById('story-sub').textContent = 'Shared under ' + d.bq + ' · never scored'; const verb = storyVerb(d), wi = verb ? worldEstimateStats(verb) : null; const official = d.official && isFinite(Number(d.official.value)) && Math.abs(Number(d.official.value)) <= 100 ? { ref: String(d.official.ref || '').replace(/[^0-9A-Za-z -]/g, '').slice(0, 20), value: round1(Number(d.official.value)) } : null; let value; if (own) value = `
Readers value your story blind. ${ests.length ? `${ests.length} ${ests.length === 1 ? 'estimate' : 'estimates'}, average ${fmtSigned(round1(ests.reduce((t, x) => t + x, 0) / ests.length))}.` : 'No estimates yet.'}${official ? ' Readers see your official value (' + fmtSigned(official.value) + ', ' + escapeHtml(official.ref) + ') after giving theirs.' : ''}
`; else if (!mine) value = `
What Avalrem would you give the action in this story, from −100 to +100? Give yours first — you see other readers' view only after.
`; else { const avg = round1(ests.reduce((t, x) => t + x, 0) / ests.length); const rows = [['Your estimate', fmtSigned(round1(Number(mine.value)))], ['Readers’ average · ' + ests.length + (ests.length === 1 ? ' estimate' : ' estimates'), fmtSigned(avg)]]; if (official) rows.push(['Official Avalrem of the author’s result ' + official.ref, fmtSigned(official.value)]); if (wi && wi.n >= WORLD_MIN) rows.push(['World Index · readers’ median for “' + verb + '” stories (' + wi.n + ')', fmtSigned(wi.median)]); value = rows.map(([l, v]) => `
${escapeHtml(l)}${v}
`).join('') + (official ? `
You were ${round1(Math.abs(Number(mine.value) - official.value))} ${Number(mine.value) >= official.value ? 'above' : 'below'} the official value.
` : '') + '
Estimates never change anyone’s Avalrem. They help everyone learn to see value, and help KirV check its rules.
'; } const cs = netComments.filter(c => c.postId === d.id && (!c.hidden || c.uid === uid)).slice(-100); const comments = cs.map(c => { const cid = SAFE_ID.test(c.id) ? c.id : ''; if (!cid) return ''; const mineC = c.uid === uid; const acts = [mineC || own ? `Remove` : '', !mineC && canModerate() ? `Hide` : '', !mineC ? `Report` : ''].filter(Boolean).join(' · '); return `
${mineC ? 'You' : `A KirV member`} · ${new Date(Number(c.createdAt) || 0).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
${c.hidden ? '
Hidden by the KirV owner — only you can see it.
' : ''}
${escapeHtml(String(c.text).slice(0, 500))}
${acts ? `
${acts}
` : ''}
`; }).join(''); body.innerHTML = `
${escapeHtml(d.bq)} · ${escapeHtml(BQ_NAMES[d.bq])}${new Date(Number(d.createdAt) || 0).toLocaleDateString()}
${d.hidden ? '
Hidden by the KirV owner — only you can see it.
' : ''}
${escapeHtml(String(d.text).slice(0, 600))}
${photos.length ? `
${photos.map(f => ``).join('')}
` : ''}
${d.showName ? `${own ? 'You' : 'A KirV member'}` : (own ? 'You (name hidden)' : 'A KirV member')}
${own ? '' : ``}${cheerCount(d.id)} appreciations${own ? '' : `Report`}
Value it
${value}
Comments · ${cs.filter(c => !c.hidden).length}
${comments || '
No comments yet.
'}
Everyone who can read the story can read comments. Keep them kind and about the action. The author can remove comments on their story; anyone can report one.
`; Object.entries(keep).forEach(([k, v]) => { const e = document.getElementById(k); if (e) e.value = v; }); if (focused && keep[focused] !== undefined) { const e = document.getElementById(focused); if (e) e.focus(); } fillMemberNames(body); } async function saveEstimate() { const d = netPosts.find(x => x.id === storyId), msg = t => { const e = document.getElementById('est-msg'); if (e) e.textContent = t; }; if (!d || d.authorUid === uid || !estimatesCol || !uid) return; if (myEstimate(d.id)) { renderStory(); return; } const raw = ((document.getElementById('est-val') || {}).value || '').trim(), v = Number(raw); if (!raw || !isFinite(v) || v < -100 || v > 100) { msg('Enter a value from −100 to +100.'); return; } const why = ((document.getElementById('est-why') || {}).value || '').trim().slice(0, 200); const doc = { postId: d.id, uid, value: round1(v), why, verb: storyVerb(d), bq: d.bq, createdAt: Date.now() }; const did = d.id + '_' + String(uid).replace(/[^A-Za-z0-9]/g, '').slice(0, 24); netEstimates = netEstimates.concat({ id: did, ...doc }); renderStory(); try { await estimatesCol.doc(did).set(doc); } catch (e) { console.warn('estimate not saved', e); netEstimates = netEstimates.filter(x => x.id !== did); renderStory(); msg(accessBlocked(e) ? ACCESS_NOTE : 'Could not save your estimate right now — try again later.'); } } async function addComment() { const d = netPosts.find(x => x.id === storyId), el = document.getElementById('comment-text'), msg = t => { const e = document.getElementById('comment-msg'); if (e) e.textContent = t; }; if (!d || !el || !commentsCol || !uid) return; const text = el.value.replace(/\s+/g, ' ').trim(); if (text.length < 2) { msg('Write a few words first.'); return; } if (Date.now() - lastCommentAt < 10000) { msg('Please wait a few seconds before the next comment.'); return; } const today = new Date().toDateString(); if (netComments.filter(c => c.uid === uid && new Date(Number(c.createdAt) || 0).toDateString() === today).length >= 30) { msg('That is 30 comments today — the daily limit.'); return; } lastCommentAt = Date.now(); const cid = 'C' + Date.now() + Math.floor(Math.random() * 1000), doc = { postId: d.id, uid, text: text.slice(0, 500), createdAt: Date.now(), hidden: false }; netComments = netComments.concat({ id: cid, ...doc }); el.value = ''; msg(''); renderStory(); try { await commentsCol.doc(cid).set(doc); } catch (e) { msg(accessBlocked(e) ? ACCESS_NOTE : 'Could not post it right now — try again later.'); netComments = netComments.filter(c => c.id !== cid); renderStory(); } } async function deleteComment(cid) { const c = netComments.find(x => x.id === cid), d = c && netPosts.find(x => x.id === c.postId); if (!c || !(c.uid === uid || (d && d.authorUid === uid))) return; if (!confirm('Remove this comment?')) return; netComments = netComments.filter(x => x.id !== cid); renderStory(); try { await commentsCol.doc(cid).delete(); } catch (e) { console.warn('comment not removed', e); } } async function hideComment(cid) { if (!canModerate() || !commentsCol) return; const c = netComments.find(x => x.id === cid); if (c) c.hidden = true; renderStory(); try { await commentsCol.doc(cid).update({ hidden: true, hiddenAt: Date.now() }); } catch (e) { console.warn('hide failed', e); } } async function reportComment(cid) { const el = document.querySelector('[data-comment="' + cid + '"]'); if (commentsCap && el) { try { await commentsCap.openComposer({ element: el }); return; } catch (e) { if (e && e.code === 'cancelled') return; } } alert('Reporting is not available in this view. Tell the KirV team what is wrong with this comment.'); } async function deleteStoryExtras(postId) { await deleteCheersFor(postId); for (const [col] of [[commentsCol], [estimatesCol]]) { if (!col) continue; try { const s = await col.where('postId', '==', postId).get(); for (const x of s.docs) await col.doc(x.id).delete(); } catch (e) { console.warn('story extras not removed', e); } } } /* ---------- World Index (founder idea 2026-10-07) ---------- */ // The consensus value of similar actions, for everyone: medians of real results by action word and reach (from people who turned it on; // no words, names or accounts — a random key only), and medians of readers' "Value it" estimates by the story's action word. // Shown only from WORLD_MIN values up. A reference, never a score. const WORLD_MIN = 5; function subscribeWorld() { if (worldSubscribed || !worldCol) return; worldSubscribed = true; worldCol.limit(5000).onSnapshot(snap => { worldDocs = snap.docs.map(d => d.data()).filter(x => x && typeof x.verb === 'string' && isFinite(Number(x.real)) && Math.abs(Number(x.real)) <= 100); renderWorldLine(); }, e => { console.warn('world index error', e); worldSubscribed = false; }); } function worldStats(verb, direction, scope) { const xs = worldDocs.filter(x => x.verb === verb && x.direction === direction && Number(x.scope) === Number(scope)).map(x => Number(x.real)); return { n: xs.length, median: xs.length ? round1(median(xs)) : null }; } function worldEstimateStats(verb) { const xs = netEstimates.filter(x => x.verb === verb).map(x => Number(x.value)); return { n: xs.length, median: xs.length ? round1(median(xs)) : null }; } function renderWorldLine() { const el = document.getElementById('log-world'); if (!el) return; const text = (document.getElementById('log-text') || {}).value || '', c = text.trim() ? computeC(text) : null; if (!c || !worldCol) { el.innerHTML = ''; return; } const imp = parseImpact(selectedImpact || 'neutral'), scope = imp.direction === 'neutral' ? 1 : (imp.scope || 1), w = worldStats(c.verb, imp.direction, scope); const what = '“' + escapeHtml(c.verb) + '” ' + (imp.direction === 'neutral' ? 'actions with no real effect' : 'actions with ' + (imp.direction === 'harm' ? 'harm to ' : 'a benefit to ') + scopeWord(scope)); el.innerHTML = w.n >= WORLD_MIN ? `World Index · ${what}: median ${fmtSigned(w.median)} from ${w.n} results by KirV users. A reference, not your score.` : `World Index · fewer than ${WORLD_MIN} results so far for ${what}.`; } function renderWorldSetting() { const cb = document.getElementById('settings-world'), note = document.getElementById('settings-world-note'); if (cb) { cb.checked = !!(contextProfile && contextProfile.worldIndex); cb.disabled = !worldCol; } if (note) note.textContent = worldCol ? '' : 'The World Index is not available in this view.'; } function worldDoc(a, r) { const sc = r.scoring || {}, imp = sc.impact || {}; if (!a || !a.cDetail || !a.cDetail.verb || sc.flattened || r.failureToAct || typeof r.real !== 'number') return null; return { k: contextProfile.wiKey, verb: a.cDetail.verb, principle: a.cDetail.principle || null, direction: imp.direction || r.impactDirection || 'neutral', scope: imp.appliedScope || r.impactAppliedScope || 1, real: round1(r.real), v: SCORING_VERSION, month: new Date(r.resolvedAt || Date.now()).toISOString().slice(0, 7) }; } async function contributeWorld(a, r) { if (!worldCol || !contextProfile || !contextProfile.worldIndex || !contextProfile.wiKey) return; const d = worldDoc(a, r); if (!d) return; try { await worldCol.doc(contextProfile.wiKey + '_' + r.ref).set(d); } catch (e) { console.warn('world index not updated', e); } } async function setWorldIndex(on) { if (!worldCol) return; const note = document.getElementById('settings-world-note'); if (on) { const key = (contextProfile && contextProfile.wiKey) || ('w' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36)); contextProfile = { ...(contextProfile || {}), worldIndex: true, wiKey: key }; if (profileRef) { try { await profileRef.set(contextProfile); } catch (e) { console.warn('setting not saved', e); } } let n = 0; for (const r of cache.filter(x => x.kind === 'resolution' && !x.projectId)) { const a = cache.find(p => p.kind === 'plan' && p.ref === r.ref); const d = worldDoc(a, r); if (!d) continue; try { await worldCol.doc(key + '_' + r.ref).set(d); n++; } catch (e) {} } if (note) note.textContent = 'On. ' + n + ' of your results were added anonymously.'; } else { const key = contextProfile && contextProfile.wiKey; contextProfile = { ...(contextProfile || {}), worldIndex: false }; if (profileRef) { try { await profileRef.set(contextProfile); } catch (e) { console.warn('setting not saved', e); } } let n = 0; if (key) { try { const s = await worldCol.where('k', '==', key).get(); for (const d of s.docs) { await worldCol.doc(d.id).delete(); n++; } } catch (e) {} } if (note) note.textContent = 'Off. ' + n + ' contributions were removed.'; } } /* ---------- Action Grid: inputs for this problem (founder decision 2026-10-07) ---------- */ // Relevant input, never questions: first from the person's own records by fixed rules (each line names its source), then the AI angles. // "Talk it through with Claude" hands the problem and these inputs to the viewer's own Claude chat (room capability), on their click. let gridInputs = null, gridTalk = 'off'; const GRID_STOP = new Set(['about', 'after', 'again', 'their', 'there', 'these', 'those', 'which', 'while', 'would', 'could', 'should', 'being', 'every', 'where', 'other', 'starting', 'keeps', 'really']); function keyWords(t) { return new Set(String(t || '').toLowerCase().replace(/[^a-z0-9฀-๿ ]/g, ' ').split(/\s+/).filter(w => w.length >= 5 && !GRID_STOP.has(w))); } function overlap(a, b) { let n = 0; a.forEach(w => { if (b.has(w)) n++; }); return n; } function gridInputList(g) { const out = [], kw = keyWords(g.problem); const lessons = cache.filter(x => (x.kind === 'resolution' && (x.lesson || '').trim()) || (x.kind === 'testimony' && ((x.lesson || '').trim() || (x.differently || '').trim()))) .map(x => { const p = cache.find(a => a.kind === 'plan' && a.ref === x.ref) || {}; const text = x.kind === 'resolution' ? x.lesson : (x.differently || x.lesson); return { ref: x.ref, text, score: overlap(kw, keyWords((p.statementPlanned || '') + ' ' + text + ' ' + (x.statementActual || x.what || ''))) }; }) .filter(x => x.score > 0).sort((a, b) => b.score - a.score).slice(0, 2); lessons.forEach(l => out.push({ src: 'Your lesson', text: l.ref + ': ' + l.text })); ideasList().filter(i => i.status !== 'planned').map(i => ({ i, score: overlap(kw, keyWords(i.text)) })).filter(x => x.score > 0).sort((a, b) => b.score - a.score).slice(0, 2) .forEach(x => out.push({ src: 'Idea bank', text: '“' + x.i.text + '” (' + (x.i.horizon || '') + ')' })); const bq = detectBQ(g.problem), top = currentTop3(); if (bq && BQ_NAMES[bq]) { const info = bqCoefFor(bq); out.push({ src: 'Priority', text: 'This serves ' + bq + ' ' + BQ_NAMES[bq] + (info.rank ? ', your priority #' + info.rank + ' — actions for it count ×' + info.coef.toFixed(2) + '.' : ', not in your top 3.') }); } else if (top.length) out.push({ src: 'Priority', text: 'Your #1 priority is ' + top[0] + ' ' + (BQ_NAMES[top[0]] || '') + '.' }); if (g.focus && g.kind) { const words = Object.entries(DATA.WORD_DB).filter(([w, e]) => e.focus === g.focus && e.peace === g.kind).sort((a, b) => (b[1].tier || 1) - (a[1].tier || 1)).map(([w]) => w).slice(0, 6); if (words.length) out.push({ src: 'Action words', text: 'For ' + g.focus + ' × ' + g.kind + ': ' + words.join(', ') + '.' }); const wi = words.map(w => ({ w, s: worldStats(w, 'benefit', 3) })).filter(x => x.s.n >= WORLD_MIN).sort((a, b) => b.s.n - a.s.n)[0]; if (wi) out.push({ src: 'World Index', text: '“' + wi.w + '” actions benefiting a team: median ' + fmtSigned(wi.s.median) + ' from ' + wi.s.n + ' results.' }); } const lens = g.principle && LENS.find(l => l.name === g.principle); if (lens) out.push({ src: 'Principle', text: g.principle + ': “' + lens.ph[0] + '” — ' + lens.core }); (g.angles || []).forEach(a => out.push({ src: 'AI', text: a, ai: true })); return out; } async function renderGridInputs() { const el = document.getElementById('grid-inputs'); if (!el) return; if (!gridInputs) { el.innerHTML = ''; return; } const list = gridInputList(gridInputs); try { gridTalk = room && room.canSendToClaudeSession ? await room.canSendToClaudeSession() : 'off'; } catch (e) { gridTalk = 'off'; } const talk = gridTalk === 'available' || gridTalk === 'available_if_summoned'; el.innerHTML = `
${gridInputs.focus && gridInputs.kind ? `
` : ''}
Inputs for this problem
From your own records by fixed rules, then AI. Inputs, not questions — use what helps.
${list.length ? list.map(x => `
${escapeHtml(x.src)}${escapeHtml(x.text)}
`).join('') : '
Nothing in your records matches this yet.
'} ${talk ? `
Hands this problem and these inputs to your own Claude chat, where you can exchange ideas freely. Nothing is saved in KirV until you plan it.
` : ''}
`; } function talkToClaude() { if (!room || !gridInputs) return; const clean = t => String(t || '').replace(/[\u0000-\u0008\u000B-\u001F\u007F-\u009F\u00AD\u200B-\u200F\u2028-\u202F\u2060-\u206F\uFEFF\uE000-\uF8FF]/g, '').slice(0, 300); const data = { label: 'Action Grid problem', problem: clean(gridInputs.problem), cell: gridInputs.focus && gridInputs.kind ? gridInputs.focus + ' x ' + gridInputs.kind : null, principle: gridInputs.principle || null, inputs: gridInputList(gridInputs).slice(0, 12).map(x => clean(x.src + ': ' + x.text)), request: 'Help me think this problem through and end with one KirV Action Language statement: I will + one action word + what or whom + (how) + when. Give input, not questions.' }; const m = t => { const e = document.getElementById('grid-talk-msg'); if (e) e.textContent = t; }; room.sendToClaudeSession(data, { deliver: gridTalk === 'available' ? 'stage' : 'send' }) .then(r => m(r && r.to === 'pane' ? 'In your Claude chat — add a line and send when ready.' : 'Sent to your Claude — check the conversation.')) .catch(e => m(e && e.code === 'rate_limited' ? 'Please wait a moment and try again.' : 'Could not reach your Claude chat from here.')); } /* ---------- UX parity with the approved design (2026-10-07) ---------- */ // One labelled bottom bar on the five main screens (design: LINE-style): Home, Compass, Presence, Groups, Reports. const NAV = [['home', 'Kirdaxia', 'goHome()', ''], ['compass', 'Compass', "compassTab='open';goCompass()", ''], ['majayakaya', 'Majayakaya', 'goMajayakaya()', ''], ['presence', 'Presence', 'goPresence()', ''], ['groups', 'Groups', 'goGroups()', '']]; function renderNavs() { NAV.map(n => n[0]).concat(['reports']).forEach((key) => { const scr = document.getElementById('screen-' + key); if (!scr) return; let bar = scr.querySelector(':scope > .bottomnav'); if (!bar) { bar = document.createElement('nav'); bar.className = 'bottomnav'; scr.appendChild(bar); } bar.setAttribute('aria-label', 'Main'); bar.innerHTML = NAV.map(([k, label, go, svg]) => ``).join(''); }); } // Home › Recent: the latest result (with its proof) and the latest Group project function renderHomeRecent() { const el = document.getElementById('home-recent'); if (!el) return; const r = cache.filter(x => x.kind === 'resolution').sort((a, b) => (b.resolvedAt || 0) - (a.resolvedAt || 0))[0]; const a = r && cache.find(x => x.kind === 'plan' && x.ref === r.ref); const proj = myProjects.slice().sort((x, y) => (y.createdAt || 0) - (x.createdAt || 0))[0]; if (!a && !proj) { el.innerHTML = ''; return; } let html = '
Recent
'; if (a) { const proof = r.proofLevel && r.proofLevel !== 'Self-declared' ? (r.proofLevel === 'Stakeholder-confirmed' ? 'Confirmed by a stakeholder' : 'Evidence attached') : 'Self-declared'; html += ``; } if (proj) html += `
Your project · Project detail${proj.status === 'closed' ? 'Closed' : 'Open'}
${escapeHtml(proj.name || '')}
${escapeHtml(proj.ref || '')} · ${(proj.memberIds || []).length} member${(proj.memberIds || []).length === 1 ? '' : 's'}
`; el.innerHTML = html; } // Action Compass: Open / Resolved / No score, with a horizon filter on Open let compassTab = 'open', compassHz = 'All', statusOpen = new Set(); const HZ = [['All', null], ['Immediate', 'Immediate'], ['This Year', 'This Year'], ['5-Year', '5-Year Plan'], ['Life', 'Life Project']]; function setCompassTab(t) { compassTab = t; renderOutstanding(); } function setCompassHz(h) { compassHz = h; renderOutstanding(); } function toggleStatus(id) { if (statusOpen.has(id)) statusOpen.delete(id); else statusOpen.add(id); renderOutstanding(); } function horizonOf(a) { const h = a.horizon || 'Immediate'; return HZ.some(([, v]) => v === h) ? h : 'Other'; } function renderCompassChrome(open, resolvedN, closedN) { const tabs = document.getElementById('compass-tabs'), fl = document.getElementById('compass-filters'); if (!tabs) return; tabs.innerHTML = [['open', 'Open', open.length], ['resolved', 'Resolved', resolvedN], ['closed', 'No score', closedN]].map(([k, l, n]) => ``).join(''); fl.style.display = compassTab === 'open' && open.length ? 'flex' : 'none'; fl.innerHTML = HZ.map(([l]) => ``).join(''); } // Settings: name, and Help opened at one section async function renderSettingsAccount() { let name = ''; try { if (user) { const me = await user.me(); name = (me && me.name) || ''; } } catch (e) {} const n = document.getElementById('settings-name'), i = document.getElementById('settings-initials'); if (n) n.textContent = name || 'You'; if (i) i.textContent = name ? name.split(/\s+/).map(w => w[0]).slice(0, 2).join('').toUpperCase() : '·'; } function openHelpSection(title) { document.querySelectorAll('#screen-help details.help-sec').forEach(d => { const t = (d.querySelector('summary') || {}).textContent || ''; d.open = t.startsWith(title); }); } // Groups: progress per project (plans resolved, your part), read once per visit async function fillGroupProgress() { if (!projectsCol) return; for (const p of myProjects) { const el = document.getElementById('gp-' + String(p.id).replace(/[^A-Za-z0-9]/g, '')); if (!el) continue; try { const snap = await projectsCol.doc(p.id).collection('actions').get(), docs = snap.docs.map(d => d.data()); const plans = docs.filter(x => x.kind === 'plan'), res = plans.filter(a => docs.some(x => x.kind === 'resolution' && x.ref === a.ref)).length, mine = plans.filter(a => a.loggedBy === uid).length; el.innerHTML = `
${res} of ${plans.length} action${plans.length === 1 ? '' : 's'} resolvedYour part: ${mine} action${mine === 1 ? '' : 's'}
`; } catch (e) { /* the list still works without progress */ } } } // Action Grid › Explore the grid (design screen 9): 5 x 5 cells, tilt, action words, the founder's note, Foreality as statements const GRID_NOTE = { Plan: 'A plan with no name and no date is not a plan. It is a wish. Commit it now, or admit you were never going to do it.', Execution: 'There is no excuse for a good plan that never moves. Execution is where Kirdomicism keeps score — everything before this is just talk.', Analysis: 'Understanding a problem and not acting on it is not wisdom. It is a slower kind of failure.', Communication: 'An action nobody knows about creates value for no one but you. Share it, or it stops at you.', Exdysivity: 'Change is not a choice you get to decline. It is unavoidable and cannot be stopped — prepare for it constantly, or be destroyed by it.' }; const GRID_PATHS = { Future: ['Done well, this opens a door that is closed today.', 'Delayed, the same problem returns in six months, larger.'], Operation: ['Fixed properly now, it stops repeating on its own.', 'Patched quickly instead, it comes back later, and heavier.'], Control: ['Tightened, it costs some flexibility elsewhere.', 'Loosened, the worst case needs to be one you can survive.'], Utilisation: ['Resources committed here are no longer free for something else.', 'Held back, an opportunity can quietly close.'], System: ['Made the standard way, it outlives your own part in it.', 'Changed now, everyone else has to relearn it.'] }; let gridCell = null; function pickGridCell(f, k) { gridCell = { focus: f, kind: k }; renderGridExplore(); } async function noteGridFocus() { if (!gridCell) return; const same = contextProfile && contextProfile.gridFocus && contextProfile.gridFocus.focus === gridCell.focus && contextProfile.gridFocus.kind === gridCell.kind; contextProfile = { ...(contextProfile || {}), gridFocus: same ? null : { ...gridCell, at: Date.now() } }; renderGridExplore(); if (profileRef) { try { await profileRef.set(contextProfile); } catch (e) { console.warn('focus not saved', e); } } } function renderGridExplore() { const el = document.getElementById('grid-explore'); if (!el) return; const fav = contextProfile && contextProfile.gridFocus; if (!gridCell) gridCell = fav && GRID_FOCI.includes(fav.focus) ? { focus: fav.focus, kind: fav.kind } : { focus: 'Operation', kind: 'Execution' }; const head = `
${GRID_KINDS.map(k => `
${k === 'Communication' ? 'Commun.' : k}
`).join('')}
`; const rows = GRID_FOCI.map(f => `
${f}
${GRID_KINDS.map(k => { const on = gridCell.focus === f && gridCell.kind === k, isFav = fav && fav.focus === f && fav.kind === k; return ``; }).join('')}
`).join(''); const key = gridCell.focus + '|' + gridCell.kind, tilt = Number((DATA.GRID || {})[key]) || 1; const words = Object.entries(DATA.WORD_DB).filter(([w, e]) => e.focus === gridCell.focus && e.peace === gridCell.kind).map(([w]) => w).sort().slice(0, 10); const noted = fav && fav.focus === gridCell.focus && fav.kind === gridCell.kind; el.innerHTML = head + rows + `
${gridCell.focus} × ${gridCell.kind}tilt ${tilt.toFixed(2)}
${(() => { const c = KIRDOMIC_DECK.find(x => x.type === 'major' && x.focus === gridCell.focus && x.kind === gridCell.kind); return c ? `` : ''; })()}
Action words here
${words.length ? words.map(w => ``).join('') : 'No action words here yet.'}
${escapeHtml(GRID_NOTE[gridCell.kind] || '')}${sayText('grid-' + gridCell.kind, GRID_NOTE[gridCell.kind] || '')}
— from the founder's own writing
Foreality — possible paths
${(GRID_PATHS[gridCell.focus] || []).map(t => `
${escapeHtml(t)}
`).join('')}
Your current focus is context for you, not a score. Foreality weighs possible paths before you commit — not a prediction.
`; } function useGridWord(w) { goLogPersonal(); setEntryMode('build'); const a = document.getElementById('kb-action'); if (a) { a.value = w; onBuilder(); } } function showStormOnGrid() { if (gridInputs && gridInputs.focus && gridInputs.kind) { gridCell = { focus: gridInputs.focus, kind: gridInputs.kind }; renderGridExplore(); const e = document.getElementById('grid-explore'); if (e) e.scrollIntoView({ behavior: 'smooth', block: 'start' }); } } /* ---------- Tour (founder: comprehensive, covering every feature; design screen 19, extended) ---------- */ const TOUR = [ { c: '#3F6D63', t: 'Welcome to KirV', b: 'A self-reflection companion that turns what you actually do into a clear, calculated value \u2014 the Avalrem, from \u2212100 to +100. Not a judge, a mirror. Every number comes from fixed, published rules, never from AI.' }, { c: '#B8892E', t: 'Write an action in KAL', b: 'KirV reads one shape, the KirV Action Language: I will + one action word + what or whom + (how) + when. Build it part by part, or write freely and let the KAL check show what is found. \u201cHelp me write it\u201d turns your own words \u2014 in Thai too \u2014 into a KAL statement you can accept or change.', go: 'goLogPersonal', label: 'Open Action Pulse' }, { c: '#3F6D57', t: 'Who it helps \u2014 or harms', b: 'Choose the impact with the buttons: benefit, harm or no real effect, and how far it reaches. Writing \u201cfor the whole country\u201d changes nothing; a benefit wider than a team counts in full once you show evidence. For big plans, Risk Exposure records who would be hurt if it fails.' }, { c: '#6B5A3E', t: 'Your priorities', b: 'Pick your top 3 of the 12 Big Questions. Actions that serve them count a little more \u2014 honestly, not automatically. KirV reads the priority from your words; you can always change it.' }, { c: '#4A5A9E', t: 'The \u201chow\u201d: Principle Lens', b: 'Shape an action through one of the five principles \u2014 Anxergy, Dexterience, Exdysivity, Avalremy, Atammayata. Principle Lens gives phrases to try. One \u201chow\u201d is read; it is shown, and will be scored once it has been calibrated.', go: 'openLensFromTour', label: 'Open Principle Lens' }, { c: '#4F6B4A', t: 'Action Grid and your idea bank', b: 'Stuck on a problem? Action-Storming relates it to the Action Grid and a principle and offers starting angles. Keep the good ones \u2014 and your own ideas \u2014 in your idea bank, and turn one into a plan when you are ready. Ideas are never scored.', go: 'goGrid', label: 'Open Action Grid' }, { c: '#7A5C24', t: 'Follow your plans', b: 'Action Compass keeps every plan and its status \u2014 on track, delayed, partly done \u2014 with a forecast that moves as you update it. Plans close to expiring are flagged so nothing quietly disappears.', go: 'goCompass', label: 'Open Action Compass' }, { c: '#2F5D57', t: 'Report the result', b: 'Report within 24 hours of finishing and it counts as on time. Action Insight then shows the value, why it is that value, the Reality Gap between what you expected and what happened, the whole story of the action, and the rules version that measured it.' }, { c: '#8C5E3C', t: 'Back it up', b: 'Attach photos, video, PDFs or before-and-after pictures, or name someone who confirms it. Self-declared always counts; proof counts a little more. Saved evidence is an audit trail and is kept.' }, { c: '#A8505E', t: 'When it does not happen', b: 'A plan that is not done, withdrawn or expired gets an honest testimony: what happened, what caused it, what was within your control, and the lesson. It is never scored \u2014 except harm that a failure to act really caused.' }, { c: '#5E5850', t: 'Two-way Avalrem', b: 'See it differently from the system? Add your own value with your reason, within 20% or 2 points. In a Group, others can rate your action blind, without seeing its score. These never change the official value; they help KirV improve its rules.' }, { c: '#8A6420', t: 'My Presence', b: 'Notice what needs action and keep it in the Action Catalog \u2014 with a photo or video and a reference ending in C \u2014 then turn it into a plan. Keep what went well as Appreciative Reality, highlighted, and share it with people who follow the same Big Questions in the Appreciative Reality Network. Reflect privately. Never scored.', go: 'goPresence', label: 'Open My Presence' }, { c: '#3F6D57', t: 'Groups', b: 'Shared work with people you trust. Your own actions count only toward you \u2014 there is no group score, and scores stay private. Members can rate each other\u2019s actions blind, and join the Group call from its link.', go: 'goGroups', label: 'Open Groups' }, { c: '#2F5D57', t: 'Reports', b: 'Your patterns by week, month or year: strengths, watch-outs and improvement areas from fixed rules, and the change in your behaviour \u2014 how much you complete, how accurately you plan, how much you own. \u201cExplain my report\u201d puts it in plain words.', go: 'goReports', label: 'Open Reports' }, { c: '#8C3F37', t: 'You are in control', b: 'Your records are private. Export or delete everything from Settings at any time. Help answers every question, Action words lists every action KirV knows, and you can replay this tour from Help or Settings. Ready for your first action?' }, ]; let tourI = 0; function goTour() { tourI = 0; renderTour(); show('screen-tour'); } function openLensFromTour() { openLens('screen-home'); } function tourGo(fn) { stopTourVoice(); markTourSeen(); const f = { goLogPersonal, openLensFromTour, goGrid, goCompass, goPresence, goGroups, goReports }[fn]; if (f) f(); } function tourStep(d) { tourI = Math.max(0, Math.min(TOUR.length - 1, tourI + d)); renderTour(); } // [2026-10-08, tester feedback] each step shows a picture of the screen it describes, and the tour can read itself aloud: // switch "Read aloud" on once and every step is read as you move through it (the device's own voice; nothing is sent anywhere). let tourVoice = false; try { tourVoice = localStorage.getItem('kirv.tourVoice') === '1'; } catch (e) {} function tourPlain(st) { const d = document.createElement('div'); d.innerHTML = st.t + '. ' + st.b; return d.textContent; } function tourSpeak() { if (!canSpeak()) return; try { window.speechSynthesis.cancel(); } catch (e) {} if (!tourVoice) return; const u = new SpeechSynthesisUtterance(kirvSpoken(tourPlain(TOUR[tourI]))); u.lang = 'en-GB'; u.rate = 0.95; try { window.speechSynthesis.speak(u); } catch (e) {} } function toggleTourVoice() { tourVoice = !tourVoice; try { localStorage.setItem('kirv.tourVoice', tourVoice ? '1' : '0'); } catch (e) {} renderTour(); } function stopTourVoice() { if (canSpeak()) { try { window.speechSynthesis.cancel(); } catch (e) {} } } async function markTourSeen() { if (contextProfile && !contextProfile.tourSeen) { contextProfile = { ...contextProfile, tourSeen: true }; if (profileRef) { try { await profileRef.set(contextProfile); } catch (e) { console.warn('tour flag not saved', e); } } } const tc = document.getElementById('home-tour-card'); if (tc) tc.style.display = 'none'; } function endTour() { stopTourVoice(); markTourSeen(); goHome(); } function renderTour() { const st = TOUR[tourI], last = tourI === TOUR.length - 1, el = document.getElementById('tour-body'); if (!el) return; document.getElementById('tour-count').textContent = 'Step ' + (tourI + 1) + ' of ' + TOUR.length; const pic = 'tour/t' + String(tourI + 1).padStart(2, '0') + '.jpg'; el.innerHTML = `
${st.t}
${tourI === 0 ? 'KirV logo' : ''}
The ${escapeHtml(st.t.replace(/<[^>]+>/g, ''))} screen in KirV
${canSpeak() ? `` : ''}
${st.b}
${st.go ? `${st.label} \u2192` : ''}
${TOUR.map((x, k) => ``).join('')}
${tourI ? '' : ''}
${last ? '' : 'Skip the tour'}`; tourSpeak(); } /* ---------- AI helpers (founder decision 2026-10-06) ---------- */ // AI (the platform's "sample" capability, paid from the viewer's own Claude usage, asked consent on first use) only SUGGESTS WORDS: // a KAL statement from rough text in any language, actions for an Action Catalog item, a good-news story, a plain-words report. // Nothing is saved until the person accepts it, KAL checks every statement, and AI never sets or changes an Avalrem. // AI text is untrusted: it is only shown as escaped text and kept in aiHold; it never goes into page code. let aiHold = [], arSuggestedBq = null; async function aiCall(prompt, json) { if (!aiSample) { try { aiSample = await claude.use('sample'); } catch (e) { aiSample = null; } } if (!aiSample) { const e = new Error('unavailable'); e.code = 'unavailable'; throw e; } return json ? aiSample.json(prompt, { modelTier: 'quick' }) : aiSample(prompt, { modelTier: 'quick' }); } function aiErrorText(e) { const c = e && e.code; if (c === 'unavailable' || c === 'not_granted') return 'AI help is not available here.'; if (c === 'rate_limited') return 'AI help is busy \u2014 please try again in a minute.'; return 'Couldn\u2019t get a suggestion just now \u2014 you can write it yourself.'; } function aiBusy(id, on, label) { const b = document.getElementById(id); if (b) { b.disabled = on; if (label) b.textContent = on ? 'Thinking\u2026' : label; } } function aiKeep(text) { aiHold.push(String(text || '')); return aiHold.length - 1; } const KAL_RULES_FOR_AI = 'KirV Action Language (KAL): one English sentence, "I will" + ONE action word + what or whom + (optional: one "how" phrase in the way of one principle, e.g. "carefully", "calmly under pressure") + when (keep the person\u2019s own timing words, translated; never invent a deadline) + (optional: ", using" + method). Positive (what the person WILL do, never "not"/"never"), the person\u2019s own action (never someone else), no questions, no idioms, no reach words like "for the whole country", at most about 30 words. Prefer action words from this list: '; // 1. KAL coach: rough text in any language -> a KAL statement (the person accepts or edits it) async function kalCoach() { const out = document.getElementById('kal-coach-out'), raw = (document.getElementById('log-text').value || '').trim(); if (!raw) { out.innerHTML = '
Write what you want to do first \u2014 in Thai or English, in your own words.
'; return; } aiBusy('kal-coach-btn', true, '\u2728 Help me write it \u2014 any language'); try { const r = await aiCall(KAL_RULES_FOR_AI + Object.keys(DATA.WORD_DB).join(', ') + '.\n\nRewrite the person\u2019s text below (it may be Thai or another language) as ONE KAL statement in English that keeps their meaning. Return JSON only: {"statement": "...", "missing": ["deadline" and/or "object" if the person did not give them], "note": "one short sentence in the person\u2019s language explaining the change"}.\n\nTheir text: """' + raw.slice(0, 600) + '"""', true); const st = String((r && r.statement) || '').trim().slice(0, 300); if (!st) throw new Error('empty'); const k = kalAnalyze(st, logMode), i = aiKeep(st); const miss = Array.isArray(r.missing) && r.missing.length ? `
Still to add: ${r.missing.map(m => escapeHtml(String(m))).join(', ')}
` : ''; out.innerHTML = `
Suggested KAL statement (AI) \u00B7 ${k.level === 'conformant' ? 'KAL \u2713' : k.level === 'refused' ? 'needs another try' : k.warnings.length + ' to improve'}
${escapeHtml(st)}
${r.note ? `
${escapeHtml(String(r.note).slice(0, 200))}
` : ''}${miss}
`; } catch (e) { out.innerHTML = `
${aiErrorText(e)}
`; } aiBusy('kal-coach-btn', false, '\u2728 Help me write it \u2014 any language'); } function useCoach(i) { const st = aiHold[i]; if (!st) return; setEntryMode('free'); const ta = document.getElementById('log-text'); ta.value = st; onLogTextInput(); document.getElementById('kal-coach-out').innerHTML = ''; } // 2. Action Catalog: suggest 2-3 KAL actions within the person's control async function catalogSuggest(id) { const p = presenceCache.find(x => x.id === id), out = document.getElementById('cat-sug-' + id); if (!p || !out) return; const ctl = ((document.getElementById('cat-control-' + id) || {}).value || '').trim(); out.innerHTML = '
Thinking\u2026
'; try { const r = await aiCall(KAL_RULES_FOR_AI + Object.keys(DATA.WORD_DB).join(', ') + '.\n\nA person noticed a situation that needs action. Suggest 3 different KAL statements, each an action THEY can take within their own control (small and concrete first). Do not invent deadlines; end each with "by Friday" only as a placeholder they will change. Return JSON only: {"options": ["...", "...", "..."]}.\n\nSituation: """' + String(p.text).slice(0, 500) + '"""\nWhat is within their control (may be empty): """' + ctl.slice(0, 200) + '"""', true); const opts = (r && Array.isArray(r.options) ? r.options : []).map(x => String(x || '').trim().slice(0, 300)).filter(Boolean).slice(0, 3); if (!opts.length) throw new Error('empty'); out.innerHTML = `
Suggested actions (AI) \u2014 pick one, then make it your own:
` + opts.map(o => { const i = aiKeep(o); return `
${escapeHtml(o)}
`; }).join(''); } catch (e) { out.innerHTML = `
${aiErrorText(e)}
`; } } function catalogUseSuggestion(id, i) { const st = aiHold[i]; if (st) catalogToPlan(id, st); } // 3. Appreciative Reality: help tell the good-news story, and suggest the Big Question it serves async function arPolish() { const out = document.getElementById('presence-ai-out'), raw = (document.getElementById('presence-text').value || '').trim(); if (!raw) { out.innerHTML = '
Write a few words about what went well first.
'; return; } out.innerHTML = '
Thinking\u2026
'; try { const bqs = Object.entries(BQ_NAMES).map(([k, v]) => k + ' = ' + v).join('; '); const r = await aiCall('Help a person keep a piece of GOOD NEWS as a short, true, warm story (2-3 sentences, under 500 characters, same language as theirs): what happened, who acted, and what challenge or effort it overcame. Do not add facts they did not give. Also pick the ONE Big Question it serves from: ' + bqs + '. Return JSON only: {"story": "...", "bq": "BQ#"}.\n\nTheir note: """' + raw.slice(0, 600) + '"""', true); const st = String((r && r.story) || '').trim().slice(0, 600), bq = BQ_NAMES[r && r.bq] ? r.bq : null; if (!st) throw new Error('empty'); const i = aiKeep(st); out.innerHTML = `
Suggested story (AI)${bq ? ' \u00B7 serves ' + escapeHtml(bq + ' \u2014 ' + BQ_NAMES[bq]) : ''}
${escapeHtml(st)}
`; } catch (e) { out.innerHTML = `
${aiErrorText(e)}
`; } } function useArStory(i, bq) { const st = aiHold[i]; if (!st) return; const ta = document.getElementById('presence-text'); ta.value = st.slice(0, 600); document.getElementById('presence-charcount').textContent = ta.value.length + ' / 600'; arSuggestedBq = BQ_NAMES[bq] ? bq : null; document.getElementById('presence-ai-out').innerHTML = ''; } // 4. Reports: explain the numbers in plain words (the numbers themselves come only from fixed rules) async function reportExplain() { const out = document.getElementById('rep-ai-out'); if (!out) return; const d = reportData(repPeriod), r = reportRules(d), plain = x => String(x).replace(/<[^>]+>/g, ''); const facts = { period: d.label, positive: d.pos, negative: d.neg, plansLogged: d.logged, onTime: d.onTime, late: d.late, expired: d.expired, withdrawnOrNotDone: d.withdrawn + d.notDone, stillOpen: d.stillOpen, completion: d.behaviour.completion, typicalDays: d.behaviour.medianDays, realityGap: d.behaviour.gapNow, realityGapBefore: d.behaviour.gapBefore, ownership: d.behaviour.ownership, lessons: d.behaviour.lessons, strengths: r.S.map(plain), watchOuts: r.W.map(plain), improvements: r.I.map(plain) }; out.innerHTML = '
Thinking\u2026
'; try { const res = await aiCall('Explain this person\u2019s KirV report to them in plain, kind, honest words: 4-6 short sentences, second person, no new numbers or facts beyond these, no praise inflation, end with one concrete next step taken from the improvements. KirV is an app where people plan actions, report results and learn; Avalrem is the value of an action.\n\nReport (JSON): ' + JSON.stringify(facts)); const t = String((res && res.text) || '').trim().slice(0, 1200); if (!t) throw new Error('empty'); out.innerHTML = `
${escapeHtml(t)}
Written by AI from the numbers above. The numbers themselves come from fixed rules.
`; } catch (e) { out.innerHTML = `
${aiErrorText(e)}
`; } } /* ---------- Reports (Phase 1 step 6; founder's design, screen 8) ---------- */ // Summary and Detailed views for a week, month, year or all time, from the person's own records. Strengths, watch-outs and improvement // areas are drawn from their numbers by FIXED RULES (not AI, not a judgment). Added for the founder's behaviour-change goal: the five // behaviour measures, and how the person's and stakeholders' own assessments compare with the system (two-way Avalrem). let repFmt = 'summary', repPeriod = 'Month'; // Reports › Projects and Groups: each project's status, how many of its actions are resolved, and your own part (founder 29 Sep). function groupsReportCard() { const rows = myProjects.map(pj => { const acts = notifGroupActs.filter(x => x.projectId === pj.id), plans = acts.filter(x => x.kind === 'plan'), res = acts.filter(x => x.kind === 'resolution'); const mine = plans.filter(x => x.loggedBy === uid), myRes = res.filter(x => x.loggedBy === uid && typeof x.real === 'number'); return { name: pj.name || 'A project', status: pj.status === 'closed' ? 'Closed' : 'Open', n: plans.length, done: res.length, mine: mine.length, own: round1(myRes.reduce((t, x) => t + x.real, 0)) }; }); const inner = rows.length ? rows.map(r => `
${escapeHtml(r.name)}${r.status}
${r.done} of ${r.n} actions resolvedYour part: ${r.mine} · own ${fmtSigned(r.own)}
`).join('') : 'Not in any Group yet.'; return `
Projects and Groups
Every project you belong to. Only your own actions count toward you — there is no group score.
${inner}
`; } function goReports() { renderReports(); show('screen-reports'); loadGroupActivity().then(() => { if (document.getElementById('screen-reports').classList.contains('active')) renderReports(); }); } function setRep(k, v) { if (k === 'fmt') repFmt = v; else repPeriod = v; renderReports(); } function periodRange(p, now) { const d = new Date(now); if (p === 'Week') return { start: now - 7 * 864e5, end: now }; if (p === 'Month') return { start: new Date(d.getFullYear(), d.getMonth(), 1).getTime(), end: now }; if (p === 'Year') return { start: new Date(d.getFullYear(), 0, 1).getTime(), end: now }; return { start: 0, end: now }; } function periodLabel(p, now) { const d = new Date(now); return p === 'Week' ? 'The last 7 days' : p === 'Month' ? d.toLocaleString('en-GB', { month: 'long', year: 'numeric' }) : p === 'Year' ? String(d.getFullYear()) : 'All time'; } function median(xs) { if (!xs.length) return null; const s = xs.slice().sort((a, b) => a - b), m = Math.floor(s.length / 2); return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; } function reportData(p, now) { now = now || Date.now(); const { start, end } = periodRange(p, now), inP = t => !!t && t >= start && t <= end; const plans = cache.filter(x => x.kind === 'plan'), planOf = ref => plans.find(a => a.ref === ref); const allRes = cache.filter(x => x.kind === 'resolution'), hasRes = ref => allRes.some(r => r.ref === ref); const results = allRes.filter(r => inP(r.resolvedAt) && typeof r.real === 'number'); const scored = results.filter(r => !r.failureToAct); const tms = cache.filter(x => x.kind === 'testimony' && x.status === 'final' && inP(x.finalizedAt || x.updatedAt)); const pos = round1(results.filter(r => r.real > 0).reduce((t, r) => t + r.real, 0)), neg = round1(results.filter(r => r.real < 0).reduce((t, r) => t + r.real, 0)); const onTime = scored.filter(r => r.late === false).length, late = scored.filter(r => r.late === true).length; const expired = plans.filter(a => { const e = planExpiry(a); return e.applies && e.expired && inP(e.at) && !hasRes(a.ref); }).length; const withdrawn = tms.filter(t => t.outcome === 'withdrawn').length, notDone = tms.filter(t => t.outcome === 'not_done').length; const stillOpen = plans.filter(a => isOpenPlan(a)).length, logged = plans.filter(a => inP(a.createdAt)).length; const top = currentTop3(); const bq = {}; scored.forEach(r => { const a = planOf(r.ref), k = (a && a.bq) || 'none'; const e = bq[k] || (bq[k] = { pos: 0, neg: 0, n: 0, ev: 0 }); e.n++; if (r.real >= 0) e.pos += r.real; else e.neg += r.real; if (r.proofLevel && r.proofLevel !== 'Self-declared') e.ev++; }); const pr = {}; scored.filter(r => r.real > 0).forEach(r => { const a = planOf(r.ref), k = a && a.cDetail && a.cDetail.principle; if (!k) return; const e = pr[k] || (pr[k] = { v: 0, n: 0 }); e.v += r.real; e.n++; }); const reach = [0, 0, 0, 0, 0, 0, 0, 0]; scored.filter(r => r.real > 0).forEach(r => { const k = (r.scoring && r.scoring.impact && r.scoring.impact.appliedScope) || 0; if (k >= 1 && k <= 7) reach[k]++; }); const proof = { 'Self-declared': 0, 'Evidence-based': 0, 'Stakeholder-confirmed': 0 }; scored.forEach(r => { const k = r.proofLevel || 'Self-declared'; if (k in proof) proof[k]++; }); const harm = results.filter(r => r.real < 0).map(r => ({ v: r.real, scope: r.scoring && r.scoring.impact ? r.scoring.impact.appliedScope : null, text: r.statementActual || '', fta: !!r.failureToAct })); const horizons = {}; plans.filter(a => inP(a.createdAt)).forEach(a => { const h = a.horizon || 'Immediate'; const e = horizons[h] || (horizons[h] = { reported: 0, open: 0, closed: 0 }); if (hasRes(a.ref)) e.reported++; else if (isOpenPlan(a)) e.open++; else e.closed++; }); // behaviour change (founder's goal) const ended = scored.length + withdrawn + notDone + expired; const days = scored.map(r => { const a = planOf(r.ref); return a && a.createdAt ? (r.resolvedAt - a.createdAt) / 864e5 : null; }).filter(x => x !== null && x >= 0); const gapOf = rs => { const g = rs.map(r => r.realityGap || (planOf(r.ref) ? realityGap(planOf(r.ref), r) : null)).filter(x => x && !x.flat); return g.length ? round1(g.reduce((t, x) => t + Math.abs(x.gap), 0) / g.length) : null; }; const span = end - start, prevRes = p === 'All time' ? [] : allRes.filter(r => r.resolvedAt >= start - span && r.resolvedAt < start && !r.failureToAct && typeof r.real === 'number'); const factors = tms.flatMap(t => t.factors || []), ctrl = factors.filter(f => f.kind === 'controllable').length; const lessons = scored.filter(r => (r.lesson || '').trim()).length; const self = cache.filter(x => x.kind === 'assessment' && x.by === 'self' && inP(x.updatedAt || x.createdAt)); return { p, label: periodLabel(p, now), pos, neg, net: round1(pos + neg), logged, onTime, late, expired, withdrawn, notDone, stillOpen, results: results.length, scored: scored.length, top, bq, pr, reach, proof, harm, horizons, behaviour: { completion: ended ? scored.length / ended : null, ended, medianDays: days.length ? round1(median(days)) : null, gapNow: gapOf(scored), gapBefore: gapOf(prevRes), ownership: factors.length ? ctrl / factors.length : null, factors: factors.length, lessons }, twoWay: { n: self.length, avgDiff: self.length ? round1(self.reduce((t, x) => t + x.diff, 0) / self.length) : null, higher: self.filter(x => x.diff > 0).length, lower: self.filter(x => x.diff < 0).length } }; } function reportRules(d) { const S = [], W = [], I = [], name = k => BQ_NAMES[k] || k; const bqs = Object.entries(d.bq).filter(([k]) => k !== 'none').sort((a, b) => b[1].pos - a[1].pos); if (bqs.length && bqs[0][1].pos > 0) { const [k, e] = bqs[0]; S.push(`${escapeHtml(name(k))} is your strongest area \u2014 ${fmtSigned(round1(e.pos))} from ${e.n} action${e.n === 1 ? '' : 's'}${e.ev === e.n ? (e.n === 1 ? ', backed by evidence' : ', all backed by evidence') : ''}.`); } if (d.onTime + d.late >= 3 && d.onTime / (d.onTime + d.late) >= 0.75) S.push(`Reliable reporting \u2014 ${d.onTime} of ${d.onTime + d.late} results reported on time.`); const ben = d.reach.reduce((t, x) => t + x, 0), wide = d.reach.slice(3).reduce((t, x) => t + x, 0); if (ben >= 2 && wide / ben >= 0.5) S.push(`Your actions reach beyond you \u2014 ${wide} of ${ben} benefited a team or wider.`); const topPos = d.top.reduce((t, k) => t + ((d.bq[k] && d.bq[k].pos) || 0), 0); if (d.pos > 0 && topPos / d.pos < 0.6) { const k1 = d.top[0], n1 = (d.bq[k1] && d.bq[k1].n) || 0; W.push(`Only ${Math.round(100 * topPos / d.pos)}% of your value served your top 3 priorities; your #1, ${escapeHtml(name(k1))}, had ${n1} action${n1 === 1 ? '' : 's'}.`); I.push(`Plan at least 2 actions for your #1 priority, ${escapeHtml(name(k1))}.`); } const selfN = d.proof['Self-declared']; if (d.scored >= 2 && selfN / d.scored >= 0.5) { W.push(`${selfN} of ${d.scored} results are self-declared \u2014 no evidence or confirmation attached.`); I.push('Attach evidence when you can \u2014 it moves a result up to 10% further through its band.'); } if (d.expired || d.late) { W.push(`${d.expired ? `${d.expired} plan${d.expired === 1 ? '' : 's'} expired unreported` : ''}${d.expired && d.late ? ' and ' : ''}${d.late ? `${d.late} reported late` : ''}.`); I.push('Report within 24 hours of finishing \u2014 Notifications remind you when a dated plan is due.'); } const prs = Object.entries(d.pr).sort((a, b) => a[1].v - b[1].v); if (prs.length >= 2) I.push(`Give ${prs[0][0]} more room \u2014 it appeared in only ${prs[0][1].n} action${prs[0][1].n === 1 ? '' : 's'}.`); return { S, W, I: I.slice(0, 4) }; } function renderReports() { const el = document.getElementById('reports-body'); if (!el) return; const d = reportData(repPeriod), r = reportRules(d), pct = x => x === null ? '\u2014' : Math.round(100 * x) + '%'; const on = 'background:var(--card);color:var(--ink);', off = 'background:transparent;color:var(--muted);'; const card = (title, inner, sub) => `
${title}
${sub ? `
${sub}
` : ''}
${inner}
`; const row = (l, v) => `
${l}${v}
`; const list = (items, mark, col) => items.map(x => `
${mark}${x}
`).join(''); const head = `
${['Week', 'Month', 'Year', 'All time'].map(k => ``).join('')}
${escapeHtml(d.label)}
`; if (!d.results && !d.logged && !d.stillOpen) { el.innerHTML = head + '
Nothing of your own to report for this period yet. Plan an action, report its result, and your patterns will appear here.
' + (myProjects.length ? groupsReportCard() : ''); return; } const top = `
${escapeHtml(d.label)}
Positive
${fmtSigned(d.pos)}
Negative
${d.neg ? fmtSigned(d.neg) : '0'}
Net (for reference) ${fmtSigned(d.net)} \u00A0\u00B7\u00A0 Plans logged ${d.logged}
`; const fig = (n, l, c) => `
${n}
${l}
`; const follow = card('Follow-through', `
${fig(d.onTime, 'on time', 'var(--teal-dark)')}${fig(d.late, 'late', 'var(--gold)')}${fig(d.expired, 'expired', 'var(--red)')}${fig(d.withdrawn + d.notDone, 'withdrawn or not done', 'var(--ink)')}${fig(d.stillOpen, 'still open', 'var(--ink)')}
`, 'Results reported in this period, and plans that ended without a score'); const B = d.behaviour; const trend = B.gapNow === null ? '\u2014' : (B.gapBefore === null ? B.gapNow + ' points' : `${B.gapNow} points (${B.gapNow < B.gapBefore ? 'better than' : B.gapNow > B.gapBefore ? 'wider than' : 'same as'} ${B.gapBefore} before)`); const behaviour = card('Behaviour change', row('Plans completed (of those that ended)', B.ended ? `${pct(B.completion)} \u00B7 ${d.scored} of ${B.ended}` : '\u2014') + row('Typical days from plan to result', B.medianDays === null ? '\u2014' : B.medianDays) + row('Reality Gap (average distance)', trend) + row('Causes you named as within your control', B.factors ? `${pct(B.ownership)} of ${B.factors}` : '\u2014') + row('Results with a lesson recorded', d.scored ? `${B.lessons} of ${d.scored}` : '\u2014'), 'What KirV is for: doing what you plan, planning more accurately, owning causes, learning'); const T = d.twoWay; const twoWay = card('Two-way Avalrem', T.n ? row('Your own assessments', T.n) + row('Average difference from the system', fmtSigned(T.avgDiff)) + row('You rated higher / lower', `${T.higher} / ${T.lower}`) : 'No assessments of your own in this period. Add one on any plan or result in Action Insight.', 'Research data \u2014 never changes the official Avalrem. Group ratings appear in each Group.'); const rules = card('Strengths', r.S.length ? list(r.S, '\u2713', 'var(--teal-dark)') : 'Not enough results yet to name a strength.') + (r.W.length ? card('Watch-outs', list(r.W, '!', 'var(--gold)')) : '') + (r.I.length ? card('Improvement areas', list(r.I.map((x, i) => x), '\u2192', 'var(--teal-dark)'), 'Focus for the next period') : '') + '
Suggestions drawn from your own numbers by fixed rules \u2014 not a judgment, and not AI.
'; const aiRep = `
`; if (repFmt === 'summary') { el.innerHTML = head + top + follow + behaviour + rules + badgeCardHtml() + PROFILE_LINK + twoWay + aiRep; return; } const prTot = Object.values(d.pr).reduce((t, e) => t + e.v, 0); const principles = card('The 5 principles', Object.keys(d.pr).length ? Object.entries(d.pr).sort((a, b) => b[1].v - a[1].v).map(([k, e]) => row(k, `${Math.round(100 * e.v / prTot)}% \u00B7 ${e.n}`)).join('') : 'No positive results yet.', 'Share of your positive value'); const bqRows = Object.entries(d.bq).sort((a, b) => b[1].pos - a[1].pos).map(([k, e]) => row(`${k === 'none' ? 'No priority' : escapeHtml(k + ' \u00B7 ' + (BQ_NAMES[k] || ''))}${d.top.includes(k) ? ' \u2605' + (d.top.indexOf(k) + 1) : ''}`, `${fmtSigned(round1(e.pos))} \u00B7 ${e.n}${e.neg ? ', ' + fmtSigned(round1(e.neg)) + ' harm' : ''}`)).join(''); const untouched = Object.keys(BQ_NAMES).filter(k => !d.bq[k]); const bqCard = card('Your 12 Big Questions', (bqRows || 'No results yet.') + (untouched.length ? `
Not touched: ${untouched.join(', ')}.
` : ''), '\u2605 = your top 3'); const L = ['', 'One person', 'Family', 'Team', 'Organization', 'Society', 'Country', 'The world']; const reachCard = card('Reach \u2014 how far your actions went', L.slice(1).map((l, i) => row(l, d.reach[i + 1])).join(''), 'Beneficial actions, as counted'); const hz = card('Plans by horizon', Object.keys(d.horizons).length ? Object.entries(d.horizons).map(([k, e]) => row(escapeHtml(k), `${e.reported} reported \u00B7 ${e.open} open \u00B7 ${e.closed} closed`)).join('') : 'No plans made in this period.'); const proofCard = card('Proof behind your results', Object.entries(d.proof).map(([k, v]) => row(k, d.scored ? `${v} \u00B7 ${Math.round(100 * v / d.scored)}%` : v)).join('')); const harmCard = card('Harm and lessons', (d.harm.length ? d.harm.map(h => `
${fmtSigned(h.v)}${h.fta ? ' \u00B7 failure to act' : ''} \u00B7 \u201c${escapeHtml(truncate(h.text, 90))}\u201d
`).join('') : 'No harm recorded in this period.') + `
Lessons recorded on ${B.lessons} of ${d.scored} results. Reporting harm honestly is part of the record \u2014 it is never hidden or deleted.
`); el.innerHTML = head + top + principles + bqCard + reachCard + hz + proofCard + harmCard + groupsReportCard() + behaviour + badgeCardHtml() + twoWay + '
Your actions in Groups appear in each Group; only your own actions count toward you.
'; } /* ---------- Group call link (founder idea 2026-10-06; native video calls come with the standalone app) ---------- */ // The creator may set one meeting link from a trusted video service; members open it in a new tab. KirV itself does not carry the call. const CALL_HOSTS = ['meet.google.com', 'zoom.us', 'line.me', 'meet.jit.si', 'teams.microsoft.com', 'teams.live.com']; function validCallLink(u) { let x; try { x = new URL(String(u || '').trim()); } catch (e) { return null; } if (x.protocol !== 'https:') return null; const h = x.hostname.toLowerCase(); return CALL_HOSTS.some(c => h === c || h.endsWith('.' + c)) ? x.href : null; } function callLinkHtml(isCreator) { const link = currentProject && currentProject.callLink ? validCallLink(currentProject.callLink) : null; const join = link ? `Join the call \u2197` : ''; if (!isCreator || currentProject.status === 'closed') return `
Group call
${join || '
No call link yet \u2014 the creator can add one.
'}`; return `
Group call link (LINE, Google Meet, Zoom, Teams or Jitsi)
${join}
`; } async function copyJoinCode() { const code = String(currentProjectId || ''), m = document.getElementById('join-code-msg'); try { await navigator.clipboard.writeText(code); if (m) m.textContent = 'Copied. Give it only to people you trust.'; } catch (e) { const t = document.getElementById('join-code-text'); if (t) { const r = document.createRange(); r.selectNodeContents(t); const sel = getSelection(); sel.removeAllRanges(); sel.addRange(r); } if (m) m.textContent = 'Copying is blocked here — the code is selected; copy it with your device.'; } } async function saveCallLink() { const raw = ((document.getElementById('call-link') || {}).value || '').trim(), msg = t => { const e = document.getElementById('call-msg'); if (e) e.textContent = t; }; if (!currentProject || currentProject.createdBy !== uid) return; const link = raw ? validCallLink(raw) : ''; if (raw && !link) { msg('Use a secure link (https://) from LINE, Google Meet, Zoom, Microsoft Teams or Jitsi.'); return; } if (projectsCol && currentProjectId) { try { await projectsCol.doc(currentProjectId).update({ callLink: link || null }); } catch (e) { msg(accessBlocked(e) ? ACCESS_NOTE : 'Could not save it right now \u2014 try again later.'); return; } } currentProject = { ...currentProject, callLink: link || null }; renderProjectScreen(); } /* ---------- Stakeholder assessment, blind (founder decision 2026-10-06) ---------- */ // A Group member may rate another member's action — the plan, or the result once reported — WITHOUT seeing its score (Groups rule: // scores are private to their owner). No cap applies, since the rater cannot see the system value. The record keeps, for research only, // the system value at the time and the difference; the rater is never shown them. The owner sees the ratings, names and reasons. let stakeOpen = null; function stakeStage(resolved) { return resolved && !resolved.failureToAct ? 'result' : 'plan'; } function myStakeRating(ref, stage) { return projectActionsCache.find(x => x.kind === 'assessment' && x.by === 'stakeholder' && x.ref === ref && x.stage === stage && x.raterUid === uid); } let disputeOpen = null, disputeReplyOpen = null; function disputesFor(ref) { return projectActionsCache.filter(x => x.kind === 'dispute' && x.ref === ref && typeof x.reason === 'string'); } function disputeHtml(a, resolved, own) { if (!resolved) return ''; const ds = disputesFor(a.ref), mine = ds.find(d => d.raterUid === uid), ref = escapeHtml(a.ref); const list = ds.map(d => `
Disputed by ${d.raterUid === uid ? 'you' : 'a member'}: “${escapeHtml(d.reason)}”${d.response ? `
Reply from the author: ${escapeHtml(d.response)}
` : ''}${own && !d.response ? (disputeReplyOpen === d.id ? `
` : `
Reply · or add evidence to your result
`) : ''}
`).join(''); let form = ''; if (!own && !mine) form = disputeOpen === a.ref ? `
Say what in this claim does not match what happened. A dispute never changes anyone's score; it is kept beside the result for the author to answer.
` : ``; return list + form; } async function saveDispute(ref) { const t = document.getElementById('dp-text'), reason = ((t && t.value) || '').trim().slice(0, 400), m = document.getElementById('dp-msg'); const a = projectActionsCache.find(x => x.kind === 'plan' && x.ref === ref); if (!a || a.loggedBy === uid) return; if (reason.length < 20) { if (m) m.textContent = 'Say what does not match (at least 20 characters).'; return; } const id = ref + 'X-' + String(uid || 'x').replace(/[^A-Za-z0-9]/g, '').slice(0, 16), doc = { kind: 'dispute', ref, raterUid: uid, reason, createdAt: Date.now() }; const col = projectsCol && currentProjectId ? projectsCol.doc(currentProjectId).collection('actions') : null; if (col) { try { await col.doc(id).set(doc); } catch (e) { if (m) m.textContent = accessBlocked(e) ? ACCESS_NOTE : 'Could not send it right now.'; return; } } projectActionsCache.push({ id, ...doc }); disputeOpen = null; renderProjectScreen(); } async function saveDisputeReply(id) { const t = document.getElementById('dr-text'), text = ((t && t.value) || '').trim().slice(0, 400); if (text.length < 5) return; const d = projectActionsCache.find(x => x.id === id && x.kind === 'dispute'); if (!d) return; const a = projectActionsCache.find(x => x.kind === 'plan' && x.ref === d.ref); if (!a || a.loggedBy !== uid) return; const col = projectsCol && currentProjectId ? projectsCol.doc(currentProjectId).collection('actions') : null; d.response = text; d.respondedAt = Date.now(); if (col) { try { await col.doc(id).set({ kind: 'dispute', ref: d.ref, raterUid: d.raterUid, reason: d.reason, createdAt: d.createdAt, response: text, respondedAt: d.respondedAt }); } catch (e) { alert(accessBlocked(e) ? ACCESS_NOTE : 'Could not send it right now.'); } } disputeReplyOpen = null; renderProjectScreen(); } function stakeRowHtml(a, resolved) { const stage = stakeStage(resolved), mine = myStakeRating(a.ref, stage), key = a.ref + ':' + stage; const what = stage === 'result' ? 'the result' : 'the plan'; if (stakeOpen === key) return `
How much good (or harm) do you think ${what} brings? Give your own value from \u2212100 to +100 \u2014 you do not see the system\u2019s value, so your judgement is your own.${resolved && resolved.statementActual ? `
Reported: \u201c${escapeHtml(truncate(resolved.statementActual, 140))}\u201d` : ''}
`; return `
${mine ? `Your blind rating of ${what}: ${fmtSigned(mine.value)} \u00B7 ` : ''}${mine ? 'Change' : 'Rate ' + what + ' (blind) \u2192'}
`; } async function saveStakeRating(key) { const [ref, stage] = key.split(':'); const a = projectActionsCache.find(x => x.kind === 'plan' && x.ref === ref), r = projectActionsCache.find(x => x.kind === 'resolution' && x.ref === ref); const msg = t => { const e = document.getElementById('sk-msg'); if (e) e.textContent = t; }; if (!a || a.loggedBy === uid) return; const v = parseFloat((document.getElementById('sk-val') || {}).value), why = ((document.getElementById('sk-why') || {}).value || '').trim(); if (isNaN(v) || v < -100 || v > 100) { msg('Enter a value from \u2212100 to +100.'); return; } if (why.length < 10) { msg('Say why (at least 10 characters).'); return; } const sys = systemValueFor(a, r, stage), old = myStakeRating(ref, stage), now = Date.now(); const id = ref + 'K' + (stage === 'plan' ? 'P' : 'R') + '-' + String(uid || 'x').replace(/[^A-Za-z0-9]/g, '').slice(0, 16); const doc = { kind: 'assessment', v: 1, by: 'stakeholder', blind: true, raterUid: uid, ref, stage, value: round1(v), justification: why.slice(0, 300), system: sys, diff: sys === null ? null : round1(v - sys), withinCap: sys === null ? null : Math.abs(v - sys) <= assessCap(sys), createdAt: old ? old.createdAt : now, updatedAt: now }; const col = projectsCol && currentProjectId ? projectsCol.doc(currentProjectId).collection('actions') : null; if (col) { try { await col.doc(id).set(doc); } catch (e) { console.warn('rating save failed, keeping locally', e); } } const i = projectActionsCache.findIndex(x => x.id === id || (x.kind === 'assessment' && x.by === 'stakeholder' && x.ref === ref && x.stage === stage && x.raterUid === uid)); if (i >= 0) projectActionsCache[i] = { id, ...doc }; else projectActionsCache.push({ id, ...doc }); stakeOpen = null; renderProjectScreen(); } function stakeholderSummaryHtml(a, stage) { const rs = projectActionsCache.filter(x => x.kind === 'assessment' && x.by === 'stakeholder' && x.ref === a.ref && x.stage === stage); if (!a.projectId) return ''; if (!rs.length) return '
Stakeholder assessments: none yet. Other members of this Group can rate it blind, without seeing its score.
'; const avg = round1(rs.reduce((t, x) => t + x.value, 0) / rs.length); return `
Stakeholders (blind) \u00B7 ${rs.length}average ${fmtSigned(avg)}
${rs.map(x => `
A member ${fmtSigned(x.value)} \u2014 \u201c${escapeHtml(x.justification)}\u201d
`).join('')}
`; } /* ---------- My Presence: Action Catalog and Appreciative Reality (2026-10-03) ---------- */ // Founder: Action Catalog = situations one sees that should be addressed (description, photo, video), each with a reference like // 261003-001 C, turned into Action plans later; Appreciative Reality (AR) = good news, highlighted; Reflection = the earlier diary. // Never scored. Files use the evidence file store and rules; a file carried into a plan stays with that plan. const PRESENCE_KINDS = { catalog: { label: 'Action Catalog', textLabel: 'What needs action?', placeholder: 'e.g. The drain in front of the shop overflows every time it rains.', hint: 'Something you see that should be addressed. Describe it, add a photo or video if you can, and turn it into an Action plan when you are ready. It gets a reference such as 261003-001 C.' }, ar: { label: 'Appreciative Reality', textLabel: 'What good thing happened?', placeholder: 'e.g. Our team finished the new booking system a week early, and customers noticed.', hint: 'Something good worth keeping: a success, an award, a new idea, a kindness, an action that overcame a challenge. The world has enough bad news \u2014 keep the good.' }, reflection: { label: 'Reflection', textLabel: "What's on your mind?", placeholder: "e.g. My brother called asking for advice on his son's new business \u2014 I want to help without overstepping.", hint: 'Experiences, knowledge and life plans \u2014 tag it if you like.' }, }; let presenceKind = 'catalog', presenceFiles = [], presenceNewFiles = [], catalogPending = null; function presenceKindOf(p) { return p.kind || (p.tag === 'Appreciation' ? 'ar' : 'reflection'); } function nextCatalogSeq(day) { return presenceCache.filter(p => p.kind === 'catalog' && String(p.ref || '').startsWith(day)).length + 1; } function renderPresenceKinds() { const box = document.getElementById('presence-kinds'); if (!box) return; box.innerHTML = Object.entries(PRESENCE_KINDS).map(([k, v]) => { const on = presenceKind === k, gold = k === 'ar'; return ``; }).join(''); const k = PRESENCE_KINDS[presenceKind]; document.getElementById('presence-kind-hint').textContent = k.hint; document.getElementById('presence-text-label').textContent = k.textLabel; document.getElementById('presence-text').placeholder = k.placeholder; document.getElementById('presence-tags-wrap').style.display = presenceKind === 'reflection' ? 'block' : 'none'; document.getElementById('presence-place-wrap').style.display = presenceKind === 'catalog' ? 'block' : 'none'; document.getElementById('presence-files-wrap').style.display = presenceKind === 'reflection' ? 'none' : 'block'; { const ai = document.getElementById('presence-ai'); if (ai) ai.style.display = presenceKind === 'ar' ? 'block' : 'none'; const o = document.getElementById('presence-ai-out'); if (o) o.innerHTML = ''; } } function setPresenceKind(k) { presenceKind = PRESENCE_KINDS[k] ? k : 'catalog'; renderPresenceKinds(); } // [2026-10-07, design Presence.dc.html] what has been kept, as a donut: the three kinds, and the Reflection tags const PRES_COLORS = { catalog: 'var(--ink)', ar: 'var(--gold)', Reality: '#2F6157', Foreality: '#9A6B1F', Knowledge: '#4A5DA8', Challenge: '#8C6A3F', Appreciation: '#B4505E', Unknowneology: '#5E5A55', reflection: '#7A7368' }; function renderPresenceSummary() { const el = document.getElementById('presence-summary'); if (!el) return; const n = presenceCache.length; if (!n) { el.innerHTML = ''; return; } const parts = {}; presenceCache.forEach(p => { const k = presenceKindOf(p), key = k === 'reflection' ? (p.tag && PRES_COLORS[p.tag] ? p.tag : 'reflection') : k; parts[key] = (parts[key] || 0) + 1; }); const label = k => k === 'catalog' ? 'Action Catalog' : k === 'ar' ? 'Appreciative Reality' : k === 'reflection' ? 'Reflection' : k; let a = 0; const R = 15.915, seg = Object.entries(parts).sort((x, y) => y[1] - x[1]).map(([k, v]) => { const len = 100 * v / n, c = ``; a += len; return c; }).join(''); el.innerHTML = `
${seg}${n}kept
${Object.entries(parts).sort((x, y) => y[1] - x[1]).map(([k, v]) => `${escapeHtml(label(k))} · ${v}`).join('')}
`; } function renderPresenceFiles() { const el = document.getElementById('presence-files'); if (!el) return; el.innerHTML = presenceFiles.map(f => `
${f.kind === 'video' ? '\u25B6' : '\u25A3'} ${escapeHtml(f.name)}
`).join(''); } function removePresenceFile(id) { if (presenceNewFiles.includes(id)) { deleteAssets([id]); presenceNewFiles = presenceNewFiles.filter(x => x !== id); } presenceFiles = presenceFiles.filter(f => f.id !== id); renderPresenceFiles(); } async function onPresenceFiles(input) { const files = Array.from(input.files || []); input.value = ''; const msg = [], set = t => { const m = document.getElementById('presence-file-msg'); if (m) m.textContent = t; }; for (const file of files) { const c = classifyFile(file.name, file.type, file.size); if (!c.ok) { msg.push(file.name + ': ' + c.reason); continue; } if (!/^(image|video)\//.test(c.mime)) { msg.push(file.name + ': photos or video only here.'); continue; } if (!assetsCap) { try { assetsCap = await claude.use('assets'); } catch (e) { assetsCap = null; } } if (!assetsCap) { msg.push('File uploads are not available here.'); break; } try { const res = await uploadAsset(file, c); presenceFiles.push({ id: res.id, kind: /^video\//.test(c.mime) ? 'video' : 'photo', name: String(file.name).slice(0, 80), size: res.size }); presenceNewFiles.push(res.id); } catch (e) { msg.push(file.name + ': ' + uploadErrorText(e)); if (uploadStopsBatch(e)) break; } } set(msg.join(' ')); renderPresenceFiles(); } function renderArStrip() { const el = document.getElementById('presence-ar-strip'); if (!el) return; const ar = presenceCache.filter(p => presenceKindOf(p) === 'ar').sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)); el.innerHTML = `
\u2600 Appreciative Reality${ar.length} good ${ar.length === 1 ? 'story' : 'stories'} kept
${ar.length ? ar.slice(0, 3).map(p => `
\u2022 ${escapeHtml(truncate(p.text || '', 110))}
`).join('') + (ar.length > 3 ? `See all ${ar.length} \u2192` : '') : `
Keep what went well here: a success, an award, a new idea, a kindness, an action that overcame a challenge. Add Appreciative Reality \u2192
`}
`; } function catalogStatusPill(p) { if (p.status === 'addressed') return 'Addressed'; if (p.status === 'planned') return `Plan ${escapeHtml(p.planRef || '')}`; return 'Open'; } function presenceRowExtra(p) { const k = presenceKindOf(p), files = (p.files || []); const fl = files.length ? `
${files.filter(f => f.kind === 'photo').length} photo(s) \u00B7 ${files.filter(f => f.kind === 'video').length} video(s)
` : ''; if (k === 'ar') return fl + arShareHtml(p); if (k !== 'catalog') return fl; const place = p.place ? `
\u2316 ${escapeHtml(p.place)}
` : ''; const control = p.control ? `
Within my control: ${escapeHtml(p.control)}
` : ''; let act = ''; if (p.status !== 'planned' && p.status !== 'addressed') act = ``; if (p.status === 'planned') act = ``; return place + fl + control + act; } function openCatalogAct(id) { const el = document.getElementById('cat-act-' + id); if (!el) return; el.innerHTML = `
`; } async function updatePresence(id, patch) { const i = presenceCache.findIndex(x => x.id === id); if (i < 0) return; presenceCache[i] = { ...presenceCache[i], ...patch }; if (presenceCol) { try { await presenceCol.doc(id).set(presenceCache[i]); } catch (e) { console.warn('presence update failed', e); } } } async function catalogToPlan(id, statement) { const p = presenceCache.find(x => x.id === id); if (!p) return; const ctl = (document.getElementById('cat-control-' + id) || {}).value || ''; if (ctl.trim()) await updatePresence(id, { control: ctl.trim().slice(0, 200) }); goLogPersonal(); catalogPending = { id, ref: p.ref || '', text: p.text || '', control: ctl.trim(), files: (p.files || []).filter(f => f.kind === 'photo').map(f => ({ ...f })) }; renderCatalogBanner(); if (statement) { setEntryMode('free'); const ta = document.getElementById('log-text'); ta.value = statement; onLogTextInput(); } } function renderCatalogBanner() { const el = document.getElementById('log-catalog-banner'); if (!el) return; if (!catalogPending) { el.style.display = 'none'; el.innerHTML = ''; return; } const c = catalogPending; el.style.display = 'block'; el.innerHTML = `From Action Catalog ${escapeHtml(c.ref)}: \u201c${escapeHtml(truncate(c.text, 120))}\u201d${c.control ? `
Within your control: \u201c${escapeHtml(c.control)}\u201d` : ''}
Write the action you will take.${c.files.length ? ' Its photo will be offered as the \u201cbefore\u201d evidence when you report the result.' : ''}`; } function catalogLink() { return catalogPending ? { ref: catalogPending.ref, entryId: catalogPending.id, files: catalogPending.files } : null; } async function markCatalogPlanned(planRef) { if (!catalogPending) return; const id = catalogPending.id; catalogPending = null; renderCatalogBanner(); await updatePresence(id, { status: 'planned', planRef }); } async function markCatalogAddressed(id) { await updatePresence(id, { status: 'addressed', addressedAt: Date.now() }); renderPresenceList(); } /* ---------- Sentence builder (KAL Standard 1, clause 11.3) ---------- */ // [added 2026-10-03] Default for Simple mode and new users; "Write it" keeps free writing. "I already did this" uses free writing. let entryMode = 'free'; function kbVal(id) { const e = document.getElementById(id); return e ? String(e.value || '') : ''; } function kalWordList() { return [...new Set(Object.keys(DATA.WORD_DB).concat(Object.keys(SYNONYMS)))].filter(w => /^[a-z][a-z -]*$/.test(w)).sort(); } function fillBuilderLists() { const dl = document.getElementById('kal-words'); if (dl && !dl.childElementCount) dl.innerHTML = kalWordList().map(w => ``).join(''); const sel = document.getElementById('kb-how'); if (sel && sel.options.length < 2) sel.innerHTML = '' + LENS.map(p => `${p.ph.map(ph => ``).join('')}`).join(''); } function kbWhenText() { const d = kbVal('kb-date'); if (d) { const dt = new Date(d + 'T00:00:00'); if (!isNaN(dt)) return 'by ' + dt.getDate() + ' ' + dt.toLocaleString('en-GB', { month: 'long' }) + (dt.getFullYear() !== new Date().getFullYear() ? ' ' + dt.getFullYear() : ''); } return kbVal('kb-when'); } function composeKal() { const a = kbVal('kb-action').trim().toLowerCase().replace(/^i will\s+/, ''), o = kbVal('kb-object').trim().replace(/[.]+$/, ''), h = kbVal('kb-how'), w = kbWhenText(), m = kbVal('kb-method').trim().replace(/^using\s+/i, '').replace(/[.]+$/, ''); if (!a) return ''; return 'I will ' + a + (o ? ' ' + o : '') + (h ? ' ' + h : '') + (w ? ' ' + w : '') + (m ? ', using ' + m : '') + '.'; } function builderActionNote(a) { a = String(a || '').trim().toLowerCase(); if (!a) return ''; const head = DATA.WORD_DB[a] ? a : mainVerbHead(a.split(' ')[0]); if (!head || !DATA.WORD_DB[head]) return 'Not in the KAL dictionary yet \u2014 it will count at the baseline.'; return (head === a ? '' : 'counts as ' + head + ' \u00B7 ') + 'tier ' + DATA.WORD_DB[head].tier; } function onBuilder(src) { if (src === 'date') { const e = document.getElementById('kb-when'); if (e) e.value = ''; } if (src === 'when') { const e = document.getElementById('kb-date'); if (e) e.value = ''; } const ta = document.getElementById('log-text'); if (!ta) return; ta.value = composeKal(); const n = document.getElementById('kb-action-note'); if (n) n.textContent = builderActionNote(kbVal('kb-action')); onLogTextInput(); } function setEntryMode(m) { entryMode = (m === 'build' && logMode !== 'done') ? 'build' : 'free'; const b = document.getElementById('log-builder'), ta = document.getElementById('log-text'), on = entryMode === 'build'; if (b) b.style.display = on ? 'flex' : 'none'; if (ta) { ta.readOnly = on; ta.style.background = on ? 'var(--sand)' : ''; } const lab = document.getElementById('log-text-label'); if (lab && logMode !== 'done') lab.innerHTML = (on ? 'Your statement \u2014 written by KirV from the parts above' : 'What will you do?') + ' *'; for (const [id, mine] of [['entry-free', !on], ['entry-build', on]]) { const e = document.getElementById(id); if (e) e.style.cssText = 'flex:1;border-radius:10px;padding:7px;font-size:12px;font-weight:600;' + (mine ? 'background:var(--ink);color:var(--card);border:1px solid var(--ink);' : 'background:var(--card);color:var(--ink);border:1px solid var(--border);'); } const tg = document.getElementById('log-entry-toggle'); if (tg) tg.style.display = logMode === 'done' ? 'none' : 'flex'; if (on) { fillBuilderLists(); onBuilder(); } } function resetBuilder() { for (const id of ['kb-action', 'kb-object', 'kb-how', 'kb-date', 'kb-when', 'kb-method']) { const e = document.getElementById(id); if (e) e.value = ''; } const n = document.getElementById('kb-action-note'); if (n) n.textContent = ''; setEntryMode(isSimpleMode() ? 'build' : 'free'); } /* ---------- Simple mode (Phase 1 step 5) ---------- */ // [added 2026-10-02, founder decisions] On by default for NEW users (set once at sign-up), switchable in Settings and in Action Pulse. // Hides priority, timing details, the breakdown and hints; shows one "Serves:" line with the priority KirV suggests from the words // (so a hidden priority is never scored at x0.90 by accident). Risk Exposure still appears when it applies. Display only: the saved // plan and its numbers are exactly what the full view would produce. let simpleOverride = false; // "change" on the Serves line shows all options for this one action only function simpleModeDefault(profile, hasPlans) { if (profile && typeof profile.simpleMode === 'boolean') return profile.simpleMode; return !(profile && profile.consentAt) && !hasPlans; // someone who agreed before, or has plans, is not new } function isSimpleMode() { return !!(contextProfile && contextProfile.simpleMode === true); } function applySimpleMode() { const on = isSimpleMode() && !simpleOverride; const scr = document.getElementById('screen-log'); if (scr) scr.classList.toggle('simple', on); const t = document.getElementById('log-mode-toggle'); if (t) t.textContent = on ? 'Show all options' : 'Simple view'; const g = document.getElementById('log-guide'); if (g) g.open = !on; // the design opens the guide by default; Simple view keeps it folded const cb = document.getElementById('settings-simple'); if (cb) cb.checked = isSimpleMode(); renderSimpleBq(); } async function setSimpleMode(on) { contextProfile = { ...(contextProfile || {}), simpleMode: !!on }; simpleOverride = false; applySimpleMode(); if (profileRef) { try { await profileRef.set(contextProfile); } catch (e) { console.warn('could not save Simple mode', e); } } } function toggleSimpleMode() { setSimpleMode(!(isSimpleMode() && !simpleOverride)); } function pickSimpleBq(k) { const el = document.getElementById('log-bq'); if (el) el.value = k; bqManual = true; refreshBqNote(); renderSimpleBq(); } function showAllOnce() { simpleOverride = true; applySimpleMode(); } function renderSimpleBq() { const el = document.getElementById('log-simple-bq'); if (!el) return; const b = chosenBq(), info = bqCoefFor(b); el.innerHTML = b ? `Serves: ${b} \u2014 ${escapeHtml(BQ_NAMES[b] || '')} \u00B7 ${info.rank ? 'your priority #' + info.rank : 'not in your top 3'} \u00B7 change` : (() => { const top = currentTop3(); return `No priority found in your words yet \u2014 counts at \u00D7${info.coef.toFixed(2)}. Which does it serve? ${top.map(k => ``).join('')}${top.length ? 'another' : 'choose one'}`; })(); } /* ---------- Principle Lens (Phase 1 step 4b) ---------- */ // [added 2026-10-02] The five principles as "how" phrases (founder's design, screen 21). Anxergy and Atammayata keep the engine's meaning // as the core; the strategy paper's meaning is shown as the other side. The "KirV reads" line is computed by the same reader as Action // Pulse (detectSPD), so the Lens can never teach a phrase the app then reads differently (spd_test checks all 25 phrases). Shown, not scored. const LENS = [{"name": "Anxergy", "core": "following through — monitoring, ensuring and completing what was started.", "side": "staying steady and constructive under pressure, tension or uncertainty.", "test": "Keep it moving until it is done, even when it gets tense.", "ph": ["steadily until fully completed", "closely through regular follow-up", "calmly under pressure", "decisively under pressure", "constructively despite tension"], "ex": ["resolve the production bottleneck", "calmly under pressure", "before the next shift"]}, {"name": "Dexterience", "core": "skill joined with experience — technique, practice and capability, including machines that extend them.", "side": "precise, practical execution, with robotic or AI help where it fits.", "test": "Do it with the skill, experience and tools it deserves.", "ph": ["precisely through experienced technique", "productively by machine humanoid", "skilfully with robotic assistance", "practically through guided experience", "systematically from accumulated experience"], "ex": ["repair the machine", "precisely through experienced technique", "before the next shift"]}, {"name": "Exdysivity", "core": "responding to change — adapting, transforming and implementing the new.", "side": "staying sound when reality shifts: feedback, iteration, new evidence.", "test": "Keep the action sound when conditions change.", "ph": ["flexibly after market feedback", "adaptively when conditions change", "iteratively through continuous testing", "openly after new evidence", "dynamically to changing demand"], "ex": ["revise the marketing plan", "flexibly after market feedback", "this week"]}, {"name": "Avalremy", "core": "value and outcome — the real result the action creates.", "side": "value that reaches beyond the immediate act, and lasts.", "test": "Create value that still matters after today.", "ph": ["durably for future users", "systemically beyond the immediate problem", "widely across relevant teams", "multiplicatively through shared capability", "sustainably beyond today’s transaction"], "ex": ["document the solution", "durably for future users", "after the fix"]}, {"name": "Atammayata", "core": "letting go — detaching from what no longer serves, without clinging.", "side": "discipline that needs no outside force, reward or watching.", "test": "Do it — or let it go — as if nobody forced, watched or rewarded you.", "ph": ["freely without attachment", "willingly beyond personal gain", "honestly despite personal consequences", "consistently without supervision", "reliably without prompting"], "ex": ["disclose the mistake", "honestly despite personal consequences", "today"]}]; let lensIdx = 0, lensOrigin = 'screen-log'; function openLens(origin) { lensOrigin = origin || 'screen-log'; renderLens(); show('screen-lens'); } function closeLens() { if (lensOrigin === 'screen-help' || lensOrigin === 'screen-about') show(lensOrigin); else if (lensOrigin === 'screen-log') show('screen-log'); else if (lensOrigin === 'screen-grid') goGrid(); else if (lensOrigin === 'screen-cards') goCards(cardsOrigin === 'screen-lens' ? 'screen-home' : cardsOrigin); else goHome(); } function pickLens(i) { lensIdx = i; renderLens(); } function lensExample(p, phrase) { return 'I will ' + p.ex[0] + ' ' + (phrase || p.ex[1]) + ' ' + p.ex[2] + '.'; } function lensReads(p) { const d = detectSPD(lensExample(p)); if (!d || !d.principle) return 'KirV reads: no clear principle'; return 'KirV reads: principle ' + d.principle + ' \u00B7 ' + (d.conf === 'clear' ? 'clear' : 'likely') + ' \u201chow\u201d phrase'; } // Put a "how" phrase into a statement: it replaces the phrase already there (only one is read), otherwise it goes in before the // deadline ("before Friday", "this week" ...), otherwise at the end. function insertHowPhrase(text, phrase) { const t = (text || '').trim(); if (!t) return phrase; const d = detectSPD(t); if (d && d.phrase && t.includes(d.phrase)) return t.replace(d.phrase, phrase); const end = /[.!?]$/.test(t) ? t.slice(-1) : '', body = end ? t.slice(0, -1) : t; const words = body.split(/\s+/); for (let i = 3; i < words.length; i++) { const rest = words.slice(i).join(' ').toLowerCase().replace(/[\u2018\u2019]/g, "'"); if (SPD_TIME_START.test(rest)) return words.slice(0, i).concat(phrase.split(' '), words.slice(i)).join(' ') + end; } return body + ' ' + phrase + end; } function addLensPhrase(k) { const p = LENS[lensIdx], phrase = p.ph[k]; if (lensOrigin === 'screen-log') { const el = document.getElementById('log-text'); el.value = insertHowPhrase(el.value, phrase).slice(0, 300); show('screen-log'); onLogTextInput(); } else { goLogPersonal(); const el = document.getElementById('log-text'); el.value = lensExample(p, phrase); onLogTextInput(); } } function renderLens() { const body = document.getElementById('lens-body'); if (!body) return; const p = LENS[lensIdx], fromPulse = lensOrigin === 'screen-log'; body.innerHTML = (fromPulse ? '' : `
`) + `
${LENS.map((x, k) => ``).join('')}
${p.name}
Core: ${escapeHtml(p.core)}
Its other side: ${escapeHtml(p.side)}
In practice
${escapeHtml(p.test)}
\u201cHow\u201d phrases to try
${p.ph.map((ph, k) => `
${escapeHtml(ph)}
`).join('')}
Example
I will ${escapeHtml(p.ex[0])} ${escapeHtml(p.ex[1])} ${escapeHtml(p.ex[2])}.
${lensReads(p)}. Shown, not scored yet \u2014 scoring is decided after testing with real users.
KirV finds a \u201chow\u201d phrase by its shape: a describing word (\u201ccalmly\u201d, \u201cprecisely\u201d, \u201cdisciplined\u201d) and the words after it (under, despite, through, with, by, after, beyond, without, until \u2026), up to the deadline or a purpose such as \u201cto reduce \u2026\u201d. Only one \u201chow\u201d phrase is read \u2014 adding more, or longer wording, never raises anything. If the words could fit more than one principle, KirV says \u201cunclear\u201d. \u201cBy Friday\u201d is a deadline, not a principle. No AI is used.
`; } /* ---------- Risk Exposure and Failure to act (Phase 1 step 3) ---------- */ // [added 2026-10-02, founder rules] Risk Exposure: for a personal PLAN whose benefit reaches an organization or wider, Action Pulse // shows what failure could cost: Low / Medium / High at 1/4, 1/2, 3/4 of the HARM band of whoever would be hurt. Exposure, not a score; // "who would be hurt" is locked with the plan. Failure to act: recorded ONLY for one's own plan that recorded who would be hurt, was // closed by a testimony (not done / withdrawn / expired), whose feared harm actually happened (even years later), self-reported and // confirmed. It is placed by the plan's size AS WRITTEN in that harm band and is never positive. None is recorded when the plan was // handed over or escalated in time WITH PROOF: a file or the name of the person who received it, and a handover date on or before the // plan's deadline (or its expiry date when it has no deadline). Once recorded it stays on the record, like every harm. const RISK_MIN_SCOPE = 4; // Organization or wider const RISK_LEVELS = [['Low', 'limited, easily repaired', 0.25], ['Medium', 'serious, slow to repair', 0.5], ['High', 'severe or lasting', 0.75]]; let riskHarmScope = null; // null = the same reach as the plan function harmBand(scope) { const a = ladderEdge('harm', scope - 1), b = ladderEdge('harm', scope); return [Math.max(a, b), Math.min(a, b)]; } // [nearer zero, further] function riskExposureFor(harmScope) { const [near, far] = harmBand(harmScope), at = f => round1(near + (far - near) * f); return { harmScope, low: at(0.25), medium: at(0.5), high: at(0.75), band: [round1(near), round1(far)] }; } function riskApplies(impactKeyVal, planMode, inProject) { if (!planMode || inProject || !impactKeyVal) return false; const imp = parseImpact(impactKeyVal); return imp.direction === 'benefit' && imp.scope >= RISK_MIN_SCOPE; } function renderRiskPanel() { const el = document.getElementById('log-risk'); if (!el) return; if (!riskApplies(selectedImpact, logMode === 'plan', !!currentProjectId)) { el.innerHTML = ''; return; } const H = riskHarmScope || parseImpact(selectedImpact).scope, ex = riskExposureFor(H), vals = [ex.low, ex.medium, ex.high]; el.innerHTML = `
Risk Exposure \u2014 if this plan fails
Exposure, not a score. Nothing is deducted now.
Who would be hurt if it isn\u2019t done?
${SCOPES.map((sc, i) => ``).join('')}
${RISK_LEVELS.map(([n, h], k) => `
${n}
${fmtSigned(vals[k])}
${h}
`).join('')}
Harm to ${scopeWord(H)} lands between ${fmtSigned(ex.band[0])} and ${fmtSigned(ex.band[1])}. If this plan isn\u2019t carried out and the harm then happens \u2014 even years later \u2014 a Failure to act value is recorded with your testimony. Hand it over or escalate in time, with proof, and none is recorded.
`; } function pickRiskScope(k) { riskHarmScope = k; renderRiskPanel(); } function ftaEligible(a) { return !!(a && a.kind === 'plan' && !a.projectId && a.riskExposure && a.riskExposure.harmScope && a.scoring && typeof a.scoring.size === 'number'); } function ftaRecorded(a) { return !!a && cache.some(x => x.kind === 'resolution' && x.ref === a.ref && x.failureToAct); } function handoverDeadline(a) { const ex = planExpiry(a); return ex.applies ? (ex.deadline || ex.at) : null; } function handoverProofValid(d) { return !!(d.handoverFile && d.handoverFile.id) || (d.handoverTo || '').trim().length >= 3; } function handoverInTime(d, a) { if (!d.handoverDate) return false; const lim = handoverDeadline(a), t = new Date(d.handoverDate + 'T00:00:00').getTime(); if (isNaN(t)) return false; return lim ? t <= lim : true; } function handoverExempts(d, a) { return d.handover === 'yes' && handoverProofValid(d) && handoverInTime(d, a); } function handoverGaps(d, a) { const g = []; if (d.handover !== 'yes') return g; if (!handoverProofValid(d)) g.push('no proof yet (a file, or the name of the person who received it)'); if (!d.handoverDate) g.push('no date'); else if (!handoverInTime(d, a)) g.push('the date is after the plan\u2019s ' + (planExpiry(a).deadline ? 'deadline' : 'expiry date')); return g; } function ftaApplies(d, a) { const closing = d.outcome === 'not_done' || d.outcome === 'withdrawn' || d.outcome === 'expired'; return ftaEligible(a) && closing && d.harm === 'yes' && !handoverExempts(d, a); } function ftaValue(a) { const scope = a.riskExposure.harmScope, sc = scoreAtSize(a.scoring.size, 'harm', scope, 1); return { value: Math.min(0, round1(sc.display)), p: sc.p, band: sc.band, size: a.scoring.size, scope }; } function ftaResolutionDoc(a, d, f, now) { return { ref: a.ref, kind: 'resolution', collapsedFrom: a.ref, statementActual: (d.harmNote || '').trim() || 'The harm this plan was meant to prevent happened.', realFA: (a.scoring.fa && typeof a.scoring.fa.value === 'number') ? a.scoring.fa.value : 0, rawReal: f.size, real: f.value, proofLevel: 'Self-declared', proofV: 1, impactDirection: 'harm', impactScope: f.scope, impactAppliedScope: f.scope, impactCapped: false, lesson: (d.lesson || '').trim(), failureToAct: true, testimonyRef: a.ref + 'T', expiredReport: planExpiry(a).expired, resolvedAt: now, createdAt: now, scoring: { v: SCORING_VERSION, failureToAct: true, ladder: { power: LADDER_POWER, neutralTop: NEUTRAL_TOP, selfDeclaredMaxScope: SELF_DECLARED_MAX_SCOPE, sizeMax: round1(sizeMax()) }, flattened: false, impact: { selected: 'harm:' + f.scope, direction: 'harm', scope: f.scope, appliedScope: f.scope, capped: false }, fa: null, proof: { level: 'Self-declared', mult: 1, applied: 1 }, size: f.size, p: f.p, band: f.band, raw: f.size, display: f.value }, }; } /* ---------- Action Failure Testimony ---------- */ // [added 2026-10-02, Phase 1 step 2] A testimony is the person's own honest account of a plan that was not done, withdrawn or expired. // NEVER SCORED. A plan closed this way earns no positive score and its forecast disappears; the plan record itself is never changed // (the testimony is a separate record, kind 'testimony', id = plan reference + 'T', kept in the person's own space). A machine, system or // AI factor is a source like any other and is not counted for or against the person: machines only support people. Recording a failure to // act as HARM (and the Risk Exposure panel) is Phase 1 step 3; the harm and handover answers are stored now and not scored. const TESTIMONY_WARN_DAYS = 7; // "close to expiring" = within this many days of expiry (a choice to confirm with the founder) const T_OUTCOMES = { not_done: 'Not done', withdrawn: 'Withdrawn', expired: 'Expired', partly: 'Partly done' }; const T_SOURCES = [['me', 'Me'], ['person', 'Another person'], ['organization', 'An organization'], ['nature', 'Nature'], ['machine', 'A machine, system or AI I relied on']]; const T_KINDS = [['controllable', 'Controllable'], ['uncontrollable', 'Uncontrollable']]; function findTestimony(ref) { return cache.find(x => x.kind === 'testimony' && x.ref === ref) || null; } function testimonyClosesPlan(tm) { return !!tm && tm.status === 'final' && (tm.outcome === 'not_done' || tm.outcome === 'withdrawn' || tm.outcome === 'expired'); } function planIsClosed(a) { return !!a && !!a.ref && testimonyClosesPlan(findTestimony(a.ref)); } function hasResult(a) { return cache.some(r => r.kind === 'resolution' && r.ref === a.ref); } function isOpenPlan(a) { return a.kind === 'plan' && !hasResult(a) && !planIsClosed(a); } function openPlans() { return cache.filter(isOpenPlan); } function closedPlans() { return cache.filter(a => a.kind === 'plan' && !hasResult(a) && planIsClosed(a)); } // Does this open plan need the person to say what became of it? // [2026-10-08, case study] The date a person writes ("by 9 October", "by Friday", "tomorrow", "within two weeks") is read for // REMINDERS ONLY: Notifications and Action Compass use it. Scoring, expiry and the 24-hour rule are unchanged. const MONTHS = ['january','february','march','april','may','june','july','august','september','october','november','december']; const WDAYS = ['sunday','monday','tuesday','wednesday','thursday','friday','saturday']; function statedDueDate(text, from) { const t = String(text || '').toLowerCase().replace(/[‘’]/g, "'"), base = new Date(from || Date.now()); const eod = d => new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 0).getTime(); const day = (y, m, d) => { const x = new Date(y, m, d); return x.getMonth() === m ? x : null; }; let m; if (/\b(today|tonight)\b/.test(t)) return eod(base); if (/\b(?:by|before|at)\s+\d{1,2}(?::\d{2})?\s*(?:am|pm)\b|\bby\s+(?:noon|midnight)\b/.test(t) && !/\btomorrow\b/.test(t)) return eod(base); if (/\btomorrow\b/.test(t)) return eod(new Date(base.getFullYear(), base.getMonth(), base.getDate() + 1)); if ((m = /\bwithin\s+(\d+|one|two|three|four|a)\s+(day|days|week|weeks|month|months)\b/.exec(t))) { const n = { one: 1, a: 1, two: 2, three: 3, four: 4 }[m[1]] || parseInt(m[1], 10), u = m[2][0]; return eod(new Date(base.getFullYear(), base.getMonth() + (u === 'm' ? n : 0), base.getDate() + (u === 'd' ? n : u === 'w' ? 7 * n : 0))); } if ((m = /\b(?:by|before|on|until)\s+(?:the\s+)?(\d{1,2})(?:st|nd|rd|th)?\s+(?:of\s+)?([a-z]+)(?:\s+(\d{4}))?/.exec(t)) && MONTHS.indexOf(m[2]) >= 0) { const mi = MONTHS.indexOf(m[2]); let y = m[3] ? +m[3] : base.getFullYear(); let d = day(y, mi, +m[1]); if (d && !m[3] && eod(d) < base.getTime() - 864e5) d = day(y + 1, mi, +m[1]); return d ? eod(d) : null; } if ((m = /\b(?:by|before|on|until)\s+([a-z]+)\s+(\d{1,2})(?:st|nd|rd|th)?(?:,?\s+(\d{4}))?\b/.exec(t)) && MONTHS.indexOf(m[1]) >= 0) { const mi = MONTHS.indexOf(m[1]); let y = m[3] ? +m[3] : base.getFullYear(); let d = day(y, mi, +m[2]); if (d && !m[3] && eod(d) < base.getTime() - 864e5) d = day(y + 1, mi, +m[2]); return d ? eod(d) : null; } if ((m = /\b(?:by|before|on|until|this|next)\s+(sunday|monday|tuesday|wednesday|thursday|friday|saturday)\b/.exec(t))) { let add = (WDAYS.indexOf(m[1]) - base.getDay() + 7) % 7; if (add === 0) add = 7; // "next Monday" = the coming Monday return eod(new Date(base.getFullYear(), base.getMonth(), base.getDate() + add)); } if (/\b(by|before)\s+(the\s+)?end\s+of\s+(this\s+|the\s+)?week\b/.test(t)) return eod(new Date(base.getFullYear(), base.getMonth(), base.getDate() + ((7 - base.getDay()) % 7))); if (/\b(by|before)\s+(the\s+)?end\s+of\s+(this\s+|the\s+)?month\b/.test(t)) return eod(new Date(base.getFullYear(), base.getMonth() + 1, 0)); if (/\b(by|before)\s+(the\s+)?end\s+of\s+(this\s+|the\s+)?year\b/.test(t)) return eod(new Date(base.getFullYear(), 11, 31)); return null; } function planDueForReminders(a) { const ex = planExpiry(a); return ex.deadline || (a && a.kind === 'plan' ? statedDueDate(a.statementPlanned, a.createdAt) : null); } function planNeedsAnswer(a, now) { now = now || Date.now(); const ex = planExpiry(a); if (!ex.applies) return { needs: false }; if (now > ex.at) return { needs: true, reason: 'expired' }; if (ex.deadline && now > ex.deadline) return { needs: true, reason: 'deadline' }; const left = ex.at - now; if (left <= TESTIMONY_WARN_DAYS * 864e5) return { needs: true, reason: 'soon', days: Math.max(0, Math.ceil(left / 864e5)) }; return { needs: false }; } // A closed plan stays closed: reopening its testimony never offers Partly done (that would quietly reopen it). function testimonyOutcomes(a) { if (planExpiry(a).expired) return ['expired', 'not_done', 'withdrawn']; return planIsClosed(a) ? ['not_done', 'withdrawn'] : ['not_done', 'withdrawn', 'partly']; } function testimonyProblems(d, a) { const p = []; if (!d.outcome || !testimonyOutcomes(a).includes(d.outcome)) p.push('Choose what became of the plan.'); if ((d.what || '').trim().length < 10) p.push('Say what happened, in a sentence or two.'); if (!(d.factors || []).length) p.push('Name at least one thing that caused it, and say whether you could control it.'); if (ftaApplies(d, a) && !ftaRecorded(a) && !d.ftaConfirm) p.push('Tick the box to confirm the harm happened, so the failure to act can be recorded \u2014 or change the harm answer.'); return p; } function testimonyHasContent(d) { return !!(d.outcome || (d.what || '').trim() || (d.factors || []).length || (d.lesson || '').trim() || (d.knowledge || '').trim() || (d.differently || '').trim() || d.handover || d.harm); } function buildTestimony(a, d, status, prev, fta) { const now = Date.now(), t = (x) => (x || '').trim(); return { ref: a.ref, kind: 'testimony', planRef: a.ref, outcome: d.outcome, status, what: t(d.what), factors: (d.factors || []).map(f => ({ kind: f.kind, source: f.source, text: t(f.text) })), handover: d.handover || null, handoverNote: d.handover === 'yes' ? t(d.handoverNote) : '', harm: d.harm || null, harmNote: d.harm === 'yes' ? t(d.harmNote) : '', handoverTo: d.handover === 'yes' ? t(d.handoverTo) : '', handoverDate: d.handover === 'yes' ? (d.handoverDate || '') : '', handoverFile: d.handover === 'yes' ? (d.handoverFile || null) : null, failureToAct: fta || (prev && prev.failureToAct) || null, lesson: t(d.lesson), knowledge: t(d.knowledge), differently: t(d.differently), scored: false, createdAt: prev ? prev.createdAt : now, updatedAt: now, finalizedAt: status === 'final' ? ((prev && prev.finalizedAt) || now) : null, }; } function draftFromTestimony(a, prev) { if (prev) return { outcome: prev.outcome, what: prev.what || '', factors: (prev.factors || []).map(f => ({ ...f })), handover: prev.handover || null, handoverNote: prev.handoverNote || '', handoverTo: prev.handoverTo || '', handoverDate: prev.handoverDate || '', handoverFile: prev.handoverFile || null, ftaConfirm: false, harm: prev.harm || null, harmNote: prev.harmNote || '', lesson: prev.lesson || '', knowledge: prev.knowledge || '', differently: prev.differently || '' }; return { outcome: planExpiry(a).expired ? 'expired' : null, what: '', factors: [], handover: null, handoverNote: '', handoverTo: '', handoverDate: '', handoverFile: null, ftaConfirm: false, harm: null, harmNote: '', lesson: '', knowledge: '', differently: '' }; } // --- entry points (small pieces of markup shared by Compass, the plan page and Home) --- function testimonyLinkHtml(a, origin) { if (!a || !a.ref || a.projectId) return ''; return `It didn\u2019t happen \u2192`; } function testimonyRowHtml(a) { const na = planNeedsAnswer(a), prev = findTestimony(a.ref), draft = prev && prev.status === 'draft'; const tag = (txt, red) => ` ${txt}`; let tags = ''; if (na.needs) tags += na.reason === 'expired' ? tag('Expired', true) : (na.reason === 'deadline' ? tag('Past deadline', false) : tag('Expires in ' + na.days + (na.days === 1 ? ' day' : ' days'), false)); if (draft) tags += tag('Testimony draft', false); const label = draft ? 'Continue testimony \u2192' : (na.needs ? 'What happened? \u2192' : 'Not done or withdraw \u2192'); const button = `${label}`; return { tags, button }; } function needsAnswerCount() { return openPlans().filter(a => planNeedsAnswer(a).needs).length; } function renderNeedsAnswer() { const el = document.getElementById('home-needs-answer'); if (!el) return; const n = needsAnswerCount(); if (!n) { el.style.display = 'none'; el.innerHTML = ''; return; } el.style.display = 'flex'; el.innerHTML = `${n} ${n === 1 ? 'plan needs' : 'plans need'} an answer
Past its deadline or close to expiring \u2014 tell KirV what happened.
\u203A`; } function closedPlanCardHtml(a) { const tm = findTestimony(a.ref); if (!tm) return ''; return `
This plan was closed without a score (${T_OUTCOMES[tm.outcome]}, ${new Date(tm.finalizedAt).toLocaleDateString()}). Only a harm can still be reported here. Read the testimony \u2192
`; } function renderClosedPlans(closed) { const card = document.getElementById('closed-card'), list = document.getElementById('closed-list'); if (!card || !list) return; card.style.display = closed.length ? 'block' : 'none'; list.innerHTML = closed.slice(0, 50).map(a => { const tm = findTestimony(a.ref); return `
${a.ref} P${T_OUTCOMES[tm.outcome]} \u00B7 ${new Date(tm.finalizedAt).toLocaleDateString()} \u00B7 no score
${escapeHtml(truncate(a.statementPlanned || '', 90))}
Testimony \u2192
`; }).join(''); } // --- the screen --- let tm = null; // { plan, draft, kind, source, text, msg, origin, editing } function openTestimony(ref, origin) { const a = cache.find(x => x.kind === 'plan' && x.ref === ref); if (!a || a.projectId) return; if (hasResult(a) && !ftaRecorded(a)) { alert('This plan already has a result.'); return; } const prev = findTestimony(ref); tm = { plan: a, draft: draftFromTestimony(a, prev), kind: 'controllable', source: 'me', text: '', msg: '', fileMsg: '', newFiles: [], origin: origin || 'screen-compass', editing: !!(prev && prev.status === 'final') }; renderTestimony(); show('screen-testimony'); } function closeTestimony() { const o = tm && tm.origin; if (tm && tm.newFiles.length) deleteAssets(tm.newFiles); // proof uploaded here but never saved tm = null; if (o === 'screen-result') show('screen-result'); else if (o === 'screen-home') goHome(); else goCompass(); } function tmOptions(list, current, fn, labels) { return list.map(k => { const on = current === k; return ``; }).join(''); } function renderTestimony() { const body = document.getElementById('testimony-body'); if (!body || !tm) return; const a = tm.plan, d = tm.draft, req = '*'; const outs = testimonyOutcomes(a), srcLabels = Object.fromEntries(T_SOURCES), kindLabels = Object.fromEntries(T_KINDS); const hasMachine = d.factors.some(f => f.source === 'machine') || tm.source === 'machine'; const yn = (cur, v, fn, label) => ``; body.innerHTML = ` ${planCardHtml(a)}
What became of this plan? ${req}
${tmOptions(outs, d.outcome, 'setTOutcome', T_OUTCOMES)}
${d.outcome === 'partly' ? `
Partly done is scored for what was done. Report it on the outcome form; this testimony is kept beside it and the plan stays open until you do.
` : ''} ${d.outcome && d.outcome !== 'partly' ? `
The plan closes without a score and stays in your history. Only a harm can still be reported on it.
` : ''}
What caused it ${req} \u2014 at least one
${d.factors.map((f, i) => `
${kindLabels[f.kind]} \u00B7 ${escapeHtml(srcLabels[f.source] || '')}
${escapeHtml(f.text)}
${f.source === 'machine' ? '
A machine or AI that supports you \u2014 not counted for or against you.
' : ''}
`).join('')}
Add a factor
${tmOptions(T_KINDS.map(k => k[0]), tm.kind, 'pickTKind', kindLabels)}
${tmOptions(T_SOURCES.map(k => k[0]), tm.source, 'pickTSource', srcLabels)}
${hasMachine ? '
Machines and AI only support you in carrying out your plan. A factor from one is kept with your testimony and is not counted for or against you.
' : ''}
Name every factor honestly. Admitting a controllable one never makes your value worse, and naming an uncontrollable one doesn\u2019t make a harm smaller.
${handoverHarmHtml()}
What it taught you \u2014 optional
KirV will show you this lesson the next time you plan something with the same action word.
${escapeHtml(tm.msg)}
Your testimony is never scored. It is kept with this plan in your record, and only you can report a failure to act about yourself.
${tm.editing ? '' : '
Saves a draft. The plan stays open until you save the testimony.
'}`; } function tmYN(cur, v, fn, label) { return ``; } function handoverHarmHtml() { const a = tm.plan, d = tm.draft, elig = ftaEligible(a), lim = handoverDeadline(a), gaps = handoverGaps(d, a); const prior = cache.find(x => x.kind === 'resolution' && x.ref === a.ref && x.failureToAct); let status = ''; if (prior) status = `
A failure-to-act value of ${fmtSigned(prior.real)} was recorded on ${new Date(prior.resolvedAt).toLocaleDateString()}. It stays on your record; you can still add to this testimony.
`; else if (d.harm === 'yes') { if (!elig) status = `
This plan did not record who would be hurt if it failed, so no failure-to-act value can be placed. You can report what happened as a harm on the plan\u2019s page.
`; else if (!(d.outcome === 'not_done' || d.outcome === 'withdrawn' || d.outcome === 'expired')) status = `
A failure to act is recorded only for a plan that was not carried out: choose Not done, Withdrawn or Expired above.
`; else if (handoverExempts(d, a)) status = `
Handed over or escalated in time, with proof: no failure-to-act value is recorded.
`; else { const f = ftaValue(a), near = fmtSigned(round1(Math.max(...f.band))), far = fmtSigned(round1(Math.min(...f.band))); status = `
Failure to act \u00B7 harm to ${scopeWord(f.scope)}${fmtSigned(f.value)}
Placed by your plan\u2019s size as written: ${pctOf(f.p)}% of the way through the band ${near} to ${far}. It never earns a positive score, and once recorded it stays on your record.
${gaps.length ? `
Your handover doesn\u2019t count: ${gaps.join('; ')}.
` : ''}
`; } } else if (d.harm === 'not_yet') status = `
Nothing is recorded now. If the harm happens later \u2014 even years later \u2014 come back to this testimony from Action Compass and report it here.
`; return `
Did you hand it over or escalate in time? \u2014 optional
${tmYN(d.handover, 'no', 'setTHandover', 'No')}${tmYN(d.handover, 'yes', 'setTHandover', 'Yes')}
${d.handover === 'yes' ? `
${d.handoverFile ? `
${KIND_LABEL[d.handoverFile.kind] || 'FILE'}${escapeHtml(d.handoverFile.name || '')}
` : ``} ${tm.fileMsg ? `
${escapeHtml(tm.fileMsg)}
` : ''}
It counts only with proof \u2014 a file, or the name of the person who received it \u2014 and a date${lim ? ' on or before ' + new Date(lim).toLocaleDateString() + ' (the plan\u2019s ' + (planExpiry(a).deadline ? 'deadline' : 'expiry date') + ')' : ''}. Named by you; KirV can\u2019t verify it.
` : ''}
Did the harm this plan was meant to prevent happen? \u2014 optional
${tmYN(d.harm, 'not_yet', 'setTHarm', 'Not yet')}${tmYN(d.harm, 'yes', 'setTHarm', 'Yes, it happened')}
${d.harm === 'yes' ? `` : ''} ${status}
`; } function pickTFile() { tm.fileMsg = ''; let input = document.getElementById('testimony-file-input'); if (!input) { input = document.createElement('input'); input.type = 'file'; input.id = 'testimony-file-input'; input.style.display = 'none'; input.onchange = () => onTFile(input); document.body.appendChild(input); } input.accept = 'image/jpeg,image/png,image/gif,image/webp,video/mp4,video/webm,application/pdf,.txt,.md,text/plain,text/markdown'; input.value = ''; input.click(); } async function onTFile(input) { const file = input.files && input.files[0]; if (!file || !tm) return; const c = classifyFile(file.name, file.type, file.size); if (!c.ok) { tm.fileMsg = c.reason; renderTestimony(); return; } if (!assetsCap) { try { assetsCap = await claude.use('assets'); } catch (e) { assetsCap = null; } } if (!assetsCap) { tm.fileMsg = 'File uploads are not available here. Name the person who received it instead.'; renderTestimony(); return; } try { const res = await uploadAsset(file, c); const old = tm.draft.handoverFile; if (old && tm.newFiles.includes(old.id)) { deleteAssets([old.id]); tm.newFiles = tm.newFiles.filter(x => x !== old.id); } tm.draft.handoverFile = { id: res.id, kind: c.kind, name: String(file.name).slice(0, 80), size: res.size }; tm.newFiles.push(res.id); tm.fileMsg = ''; } catch (e) { tm.fileMsg = uploadErrorText(e); } renderTestimony(); } function removeTFile() { const f = tm.draft.handoverFile; if (f && tm.newFiles.includes(f.id)) { deleteAssets([f.id]); tm.newFiles = tm.newFiles.filter(x => x !== f.id); } // saved files stay: audit trail tm.draft.handoverFile = null; renderTestimony(); } function setTOutcome(k) { tm.draft.outcome = k; tm.msg = ''; renderTestimony(); } function onTText(f, el) { tm.draft[f] = el.value; } function setTHandover(v) { tm.draft.handover = v; renderTestimony(); } function setTHarm(v) { tm.draft.harm = v; renderTestimony(); } function pickTKind(k) { tm.kind = k; renderTestimony(); } function pickTSource(k) { tm.source = k; renderTestimony(); } function addTFactor() { const text = (tm.text || '').trim(); if (text.length < 5) { tm.msg = 'Describe the factor in a sentence.'; renderTestimony(); return; } tm.draft.factors.push({ kind: tm.kind, source: tm.source, text }); tm.text = ''; tm.msg = ''; renderTestimony(); } function removeTFactor(i) { tm.draft.factors.splice(i, 1); renderTestimony(); } async function saveTestimony(status) { if (!tm) return; const a = tm.plan, d = tm.draft; if (status === 'final') { const p = testimonyProblems(d, a); if (p.length) { tm.msg = p.join(' '); renderTestimony(); document.getElementById('tm-msg').scrollIntoView({ block: 'center' }); return; } } else if (!testimonyHasContent(d)) { closeTestimony(); return; } const prev = findTestimony(a.ref), recordFta = status === 'final' && ftaApplies(d, a) && d.ftaConfirm && !hasResult(a); const fta = recordFta ? ftaValue(a) : null; const doc = buildTestimony(a, d, status, prev, fta ? { value: fta.value, scope: fta.scope, recordedAt: Date.now() } : null), id = a.ref + 'T'; try { if (actionsCol) await actionsCol.doc(id).set(doc); } catch (e) { console.warn('testimony save failed, keeping locally', e); alert(syncFailMsg(e)); } if (tm) tm.newFiles = []; // saved with the testimony: kept as part of the record const rec = { id, ...doc }, i = cache.findIndex(x => x.kind === 'testimony' && x.ref === a.ref); if (i >= 0) cache[i] = rec; else cache.unshift(rec); if (fta) { const rdoc = ftaResolutionDoc(a, d, fta, Date.now()), rid = a.ref + 'A'; try { if (actionsCol) await actionsCol.doc(rid).set(rdoc); } catch (e) { console.warn('failure-to-act save failed, keeping locally', e); alert(syncFailMsg(e)); } if (!cache.some(x => x.kind === 'resolution' && x.ref === a.ref)) cache.unshift({ id: rid, ...rdoc }); } const partly = status === 'final' && d.outcome === 'partly'; tm = null; renderHome(); if (fta) { openResult(a.ref); show('screen-result'); } else if (partly) { openResult(a.ref, true); show('screen-result'); } else { if (status === 'final') compassTab = 'closed'; goCompass(); } // the closed plan is shown where it went } function reportPartly() { if (!tm) return; const a = tm.plan, d = tm.draft; if (testimonyHasContent(d)) { saveTestimony('draft').then(() => { openResult(a.ref, true); show('screen-result'); }); return; } tm = null; openResult(a.ref, true); show('screen-result'); } /* ---------- File storage (shared by everyone who uses this app) ---------- */ let storageUsage = null; function fmtBytes(n) { if (n >= 1073741824) { const g = n / 1073741824; return (g >= 10 ? Math.round(g) : Math.round(g * 10) / 10) + ' GB'; } if (n >= 1048576) return Math.round(n / 1048576) + ' MB'; if (n >= 1024) return Math.round(n / 1024) + ' KB'; return n + ' B'; } function storageText(u) { return u.files + ' of ' + u.maxFiles + ' files \u00B7 ' + fmtBytes(u.bytes) + ' of ' + fmtBytes(u.maxBytes); } function storageRatio(u) { return Math.max(u.maxFiles ? u.files / u.maxFiles : 0, u.maxBytes ? u.bytes / u.maxBytes : 0); } function storagePercent(u) { return Math.round(100 * storageRatio(u)); } function storageWarning(u) { if (!u) return ''; const p = storagePercent(u); return storageRatio(u) >= 0.8 ? 'File storage is ' + p + '% full (' + storageText(u) + '). When it is full you can still save results, just without new files.' : ''; } async function refreshStorage() { if (!assetsCap) { try { assetsCap = await claude.use('assets'); } catch (e) { assetsCap = null; } } if (!assetsCap) { storageUsage = null; return null; } try { const r = await assetsCap.list(); storageUsage = (r && r.usage) || null; } catch (e) { storageUsage = null; } return storageUsage; } async function renderStorage() { const card = document.getElementById('storage-card'); if (!card) return; const u = await refreshStorage(); if (!u) { card.style.display = 'none'; return; } // people who cannot attach files do not see it card.style.display = 'block'; document.getElementById('storage-text').textContent = storageText(u); const bar = document.getElementById('storage-bar'), p = storagePercent(u); bar.style.width = Math.min(100, p) + '%'; bar.style.background = storageRatio(u) >= 0.8 ? 'var(--gold)' : 'var(--teal)'; document.getElementById('storage-warn').textContent = storageWarning(u); } /* ---------- Personal intelligence core (Phase 1 step 5) ---------- */ // [added 2026-10-02, approved blueprint] Views of data KirV already keeps — nothing here changes a score. // REALITY GAP: expected (the plan as written) vs actual (the result), split EXACTLY into three causes by re-placing the same numbers one // change at a time: (1) reach/direction as reported, (2) timing as reported, (3) proof. The three parts add up to the gap (rounding aside), // so the gap is always explained by a rule, never by a person. Saved on every new result (calibration data) and computed for older ones. function realityGap(a, r) { const sc = r && r.scoring, ps = a && a.scoring; if (!sc || !(sc.v >= 4) || sc.failureToAct || !sc.impact || !ps || typeof ps.size !== 'number' || !ps.impactPlanned || typeof r.real !== 'number' || typeof r.rawReal !== 'number') return null; const ip = ps.impactPlanned, pDir = ip.direction || 'neutral', pScope = ip.appliedScope || ip.scope || 1; const E = (sc.flattened || ps.flattened) ? (typeof ps.display === 'number' ? ps.display : NEUTRAL_TOP) : scoreAtSize(ps.size, pDir, pScope, 1).display; if (sc.flattened || ps.flattened) return { v: 1, expected: round1(E), actual: round1(r.real), gap: round1(r.real - E), flat: true, parts: null }; const aDir = sc.impact.direction, aScope = sc.impact.appliedScope || 1; const S1 = scoreAtSize(ps.size, aDir, aScope, 1).display; // same size, reach/direction as reported const S2 = scoreAtSize(r.rawReal, aDir, aScope, 1).display; // + the timing as reported (C is locked, so only timing changes the size) return { v: 1, expected: round1(E), actual: round1(r.real), gap: round1(r.real - E), flat: false, parts: { reach: round1(S1 - E), timing: round1(S2 - S1), proof: round1(r.real - S2) } }; } function fmtGap(v) { return v === 0 ? '0' : fmtSigned(v); } // a part that did not move reads "0", not "+0" function impactWords(direction, scope) { return direction === 'neutral' ? 'no real effect' : (direction === 'harm' ? 'harm to ' : 'a benefit to ') + scopeWord(scope); } function realityGapSentence(a, r, g) { if (!g) return ''; if (g.flat) return 'Counted at the flat baseline, so there is nothing to compare in detail.'; if (Math.abs(g.gap) < 0.5) return 'The result landed close to what you expected.'; const p = g.parts, names = { reach: Math.abs(p.reach), timing: Math.abs(p.timing), proof: Math.abs(p.proof) }; const big = Object.keys(names).sort((x, y) => names[y] - names[x])[0]; const ip = a.scoring.impactPlanned, sc = r.scoring, fa = sc.fa || {}, pd = a.scoring.fa && a.scoring.fa.deadline; const why = { reach: 'you planned ' + impactWords(ip.direction, ip.appliedScope || ip.scope || 1) + ' and reported ' + impactWords(sc.impact.direction, sc.impact.appliedScope || 1), timing: 'the timing: you planned the deadline as ' + (WHY_DEADLINE[pd] || 'not stated') + ', and reported it ' + (WHY_DEADLINE[fa.deadline] || 'not stated') + ' with the speed ' + (WHY_SPEED[fa.speed] || 'as expected'), proof: 'proof: ' + (r.proofLevel || 'Self-declared') + (sc.proof && sc.proof.applied > 1 ? ' moved it a little further' : ''), }; return 'Most of the difference came from ' + (big === 'reach' ? 'the reach: ' : '') + why[big] + '.'; } // LESSONS RESURFACING: earlier actions with the same main action word that left a lesson (a result's "What you'd do differently", or a // testimony's lesson / what I would do differently). Most recent first. Same word = a plain, checkable rule. function similarLessons(verb, limit) { if (!verb) return []; const planOf = ref => cache.find(x => x.kind === 'plan' && x.ref === ref); const out = []; cache.forEach(x => { if (x.kind !== 'resolution' && x.kind !== 'testimony') return; const p = planOf(x.ref); if (!p || !p.cDetail || p.cDetail.verb !== verb) return; const text = x.kind === 'resolution' ? (x.lesson || '').trim() : ((x.differently || '').trim() || (x.lesson || '').trim()); if (!text) return; out.push({ ref: x.ref, at: x.kind === 'resolution' ? (x.resolvedAt || 0) : (x.updatedAt || x.createdAt || 0), text, statement: p.statementPlanned || '', from: x.kind === 'resolution' ? 'result' : 'testimony' }); }); return out.sort((x, y) => y.at - x.at).slice(0, limit || 2); } function lessonsHtml(verb) { const ls = similarLessons(verb, 2); if (!ls.length) return ''; return `
Lessons from earlier actions with the same action word
${ls.map(l => `
${l.ref} \u00B7 \u201c${escapeHtml(truncate(l.statement, 50))}\u201d \u2014 ${l.from === 'result' ? 'you noted' : 'your testimony'}:
${escapeHtml(l.text)}
`).join('')}
`; } // ENGINE VERSION LABEL: which version of the scoring rules produced a record. function engineLabel(sc) { const v = sc && sc.v; if (!v) return 'Kirdomic Engine, earlier rules (before version 4)'; return 'Kirdomic Engine, scoring rules version ' + v + (v !== SCORING_VERSION ? ' (current rules: version ' + SCORING_VERSION + ')' : ''); } // ACTION REPLAY: one action's whole story, in time order, from the records it already has. function actionReplayEvents(a, r) { const ev = [], d = ms => new Date(ms).toLocaleDateString(); if (a.createdAt) { const bits = []; if (typeof a.estLow === 'number') bits.push('estimate ' + estText(a)); if (a.bq) bits.push(a.bq + (a.bqRank ? ' (priority #' + a.bqRank + ')' : '')); if (a.scoring && a.scoring.spd && a.scoring.spd.phrase) bits.push('\u201chow\u201d: ' + a.scoring.spd.phrase + (a.scoring.spd.principle ? ' (' + a.scoring.spd.principle + ')' : '')); if (a.riskExposure) bits.push('who would be hurt if it failed: ' + scopeWord(a.riskExposure.harmScope)); ev.push({ at: a.createdAt, what: 'Planned', detail: bits.join(' \u00B7 ') }); } const ups = (a.updates || []).filter(u => u.status && STATUS_LABELS[u.status]); ups.forEach((u, i) => { const f = forecastPlan({ ...a, updates: ups.slice(0, i + 1) }); ev.push({ at: u.at, what: 'Status: ' + STATUS_LABELS[u.status], detail: f ? 'forecast then ' + fmtSigned(f.centre) + ' (' + fmtSigned(f.low) + ' to ' + fmtSigned(f.high) + ')' : '' }); }); const tm = cache.find(x => x.kind === 'testimony' && x.ref === a.ref); if (tm && tm.status === 'final') ev.push({ at: tm.finalizedAt || tm.updatedAt, what: 'Testimony: ' + (T_OUTCOMES[tm.outcome] || tm.outcome), detail: (tm.factors || []).length + ((tm.factors || []).length === 1 ? ' cause named' : ' causes named') }); if (r) { const files = (r.evidenceFiles || []).length + (r.beforeAfter ? 1 : 0); const g = r.realityGap || realityGap(a, r); const detail = [fmtSigned(r.real), r.failureToAct ? 'failure to act' : (r.late === true ? 'late' : (r.late === false ? 'on time' : '')), r.failureToAct ? '' : (r.proofLevel || 'Self-declared') + (files ? ', ' + files + (files === 1 ? ' file' : ' files') : ''), g && !g.flat ? 'Reality Gap ' + fmtSigned(g.gap) : ''].filter(Boolean).join(' \u00B7 '); ev.push({ at: r.resolvedAt, what: r.failureToAct ? 'Failure to act recorded' : 'Result reported', detail }); } return ev.filter(e => e.at).sort((x, y) => x.at - y.at).map(e => ({ ...e, date: d(e.at) })); } /* ---------- Action Insight: read-only result ---------- */ // [added 2026-10-02, Phase 1 step 1a] Every sentence on this screen is BUILT from numbers and words stored on the saved plan // and result, never typed by hand, so it cannot disagree with the score. insight_view_test.js checks each number. // [Phase 1 step 4, done 2026-10-02] The five-principle ("how" phrase) part comes from spdSentence(): shown, not scored. function fmtStamp(ms) { const d = new Date(ms); return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ', ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false }); } function pctOf(x) { return Math.round(x * 100); } function scopeWord(n) { const sc = SCOPES[(n || 1) - 1]; return sc ? sc.label.toLowerCase() : 'team'; } function estText(a) { if (!a || typeof a.estLow !== 'number' || typeof a.estHigh !== 'number') return ''; const lo = Math.min(a.estLow, a.estHigh), hi = Math.max(a.estLow, a.estHigh); return lo === hi ? fmtSigned(lo) : fmtSigned(lo) + ' to ' + fmtSigned(hi); } function estimateNote(a, r) { const t = estText(a); if (!t || typeof r.real !== 'number') return ''; const lo = Math.min(a.estLow, a.estHigh), hi = Math.max(a.estLow, a.estHigh), v = round1(r.real); if (v >= lo && v <= hi) return (lo === hi ? 'matching the ' : 'within the ') + t + ' estimate'; return (v > hi ? 'above' : 'below') + ' the ' + t + ' estimate'; } function planMetaLine(a) { const bits = []; if (a.bq) bits.push(a.bq + ' \u00B7 ' + (BQ_NAMES[a.bq] || '') + (a.bqRank ? ' (your priority #' + a.bqRank + ')' : ' (not in your top 3)')); const ip = a.scoring && a.scoring.impactPlanned; if (ip && ip.direction && ip.direction !== 'neutral' && (ip.appliedScope || ip.scope)) { bits.push('expected ' + (ip.direction === 'harm' ? 'harm' : 'benefit') + ' to ' + scopeWord(ip.appliedScope || ip.scope)); } return bits.join(' \u00B7 '); } function planCardHtml(a) { const est = estText(a); const when = a.createdAt ? new Date(a.createdAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : ''; const right = [when, est ? 'estimate ' + est : ''].filter(Boolean).join(' \u00B7 '); const meta = planMetaLine(a); return `
Planned \u00B7 ${a.ref} P${escapeHtml(right)}
${escapeHtml(a.statementPlanned || '')}
${meta ? `
${escapeHtml(meta)}
` : ''}
`; } function waitingCardHtml(a) { const ex = planExpiry(a); if (ex.expired || planIsClosed(a)) return ''; const tail = ex.applies ? 'Report the outcome by ' + new Date(ex.at).toLocaleDateString() + '; after that the plan expires and can no longer be scored \u2014 except harm, which can always be reported.' : 'This plan has no expiry date, so you can report the outcome whenever it is done.'; return `
Waiting for you to report what happened
Report within 24 hours of finishing and it counts as on time. Reported later, it is saved but marked Late. ${tail}
${testimonyLinkHtml(a, 'screen-result')}
`; } // The arithmetic, in words. The size share is shown BEFORE proof is applied, so the numbers in the sentence always add up. function calcSentence(a, r) { const sc = r.scoring; if (sc.failureToAct) return 'Placed by the plan as written: its size was ' + round1(r.rawReal) + ' of a possible ' + sc.ladder.sizeMax + ', so it sits ' + pctOf(sc.p) + '% of the way through the harm band of ' + scopeWord(sc.impact.appliedScope) + '. Proof never changes a harm score.'; if (sc.flattened) return 'Reported without a priority, deadline or real impact, so it\'s counted at the flat baseline of ' + NEUTRAL_TOP + '. This doesn\'t grow or shrink with how the outcome went. Other routine entries you log the same day are combined with this one, not added on top of it.'; const applied = sc.proof && typeof sc.proof.applied === 'number' ? sc.proof.applied : 1; const sizeMaxV = sc.ladder.sizeMax; const baseP = applied > 1 ? Math.min(1, Math.max(0, r.rawReal / sizeMaxV)) : sc.p; let t = 'Your action\'s size was C ' + round1(a.c) + ' + timing ' + round1(r.realFA) + ' = ' + round1(r.rawReal) + ' of a possible ' + sizeMaxV + ', so on its own it sits ' + pctOf(baseP) + '% of the way through its band.'; if (applied > 1) t += ' ' + (r.proofLevel || 'Proof') + ' moved it to ' + pctOf(sc.p) + '% (\u00D7' + applied.toFixed(2) + ').'; else if (r.proofV > 1) t += ' Proof is kept as a record, but it never changes a harm score.'; else t += ' It was self-declared, so nothing moves it further.'; return t; } const WHY_DEADLINE = { favorable: 'met', not_specified: 'not stated', poor: 'missed' }; const WHY_SPEED = { favorable: 'faster than expected', not_specified: 'as expected', poor: 'slower than expected' }; // "Why this score?" as a list of sentences, each reading one stored field. function whyThisScore(a, r) { const sc = r.scoring; if (!sc || !(sc.v >= 4)) return []; const LOCK = 'What you planned (context, timing, strategy) stays fixed; later detail adds to the evidence.'; if (sc.failureToAct) { const lo = Math.round(Math.min(...sc.band)), hi = Math.round(Math.max(...sc.band)); return ['A failure to act: the plan was not carried out, the harm it was meant to prevent happened, and it was not handed over or escalated in time with proof.', 'It is placed in the harm band of ' + scopeWord(sc.impact.appliedScope) + ' (between ' + lo + ' and ' + hi + ') by the plan\'s size as written: ' + pctOf(sc.p) + '% of the way through.', 'A failure to act never earns a positive score. It is recorded only because you reported it, and it stays on your record.', LOCK]; } if (sc.flattened) return ['It was reported without a priority, deadline or real impact, so it is counted at the flat baseline of ' + NEUTRAL_TOP + '.', LOCK]; const out = [], imp = sc.impact; const lo = Math.round(Math.min(...sc.band)), hi = Math.round(Math.max(...sc.band)); if (imp.direction === 'benefit') out.push('Reach decides the band: a benefit to ' + scopeWord(imp.appliedScope) + ' lands between ' + lo + ' and ' + hi + '.'); else if (imp.direction === 'harm') out.push('Reach decides the band: harm to ' + scopeWord(imp.appliedScope) + ' lands between ' + lo + ' and ' + hi + '.'); else out.push('With no real effect on anyone, it can only score in the low band below ' + NEUTRAL_TOP + '.'); if (imp.capped) out.push('You said it reached ' + scopeWord(imp.scope) + ', but without evidence or a named confirmer it counts as ' + scopeWord(imp.appliedScope) + '.'); else if (imp.direction === 'benefit' && imp.scope > SELF_DECLARED_MAX_SCOPE) out.push('Your evidence or named confirmer let the wider reach count in full.'); const fa = sc.fa || {}; if (fa.deadline && fa.speed) { let t = 'Timing counted: deadline ' + WHY_DEADLINE[fa.deadline] + ', speed ' + WHY_SPEED[fa.speed] + '.'; if (fa.adjustedForLateReport) t += ' You reported more than 24 hours after finishing without proof, so a "met" or "faster" claim counted as the neutral choice.'; out.push(t); } const applied = sc.proof && typeof sc.proof.applied === 'number' ? sc.proof.applied : 1; if (imp.direction === 'harm') { if (r.proofV > 1) out.push('Proof is kept as a record, but it never makes a harm score smaller or larger.'); } else if (applied > 1) out.push((r.proofLevel || 'Proof') + ' moved it slightly further through its band (\u00D7' + applied.toFixed(2) + ').'); else out.push('It was self-declared, so your word counts in full and nothing moves it further.'); const spdS = spdSentence(a); // [Phase 1 step 4] the five-principle "how" part if (spdS) out.push(spdS); out.push(LOCK); return out; } function toggleCalc() { const p = document.getElementById('calc-panel'), b = document.getElementById('calc-toggle'); if (!p || !b) return; const open = p.style.display === 'none'; p.style.display = open ? 'flex' : 'none'; b.textContent = open ? 'See how this is calculated \u25B4' : 'See how this is calculated \u25BE'; } function followUpAction(ref) { const a = cache.concat(projectActionsCache).find(x => x.kind === 'plan' && x.ref === ref); if (a && a.projectId && a.projectId === currentProjectId) goLog(); else goLogPersonal(); } function resolvedHtml(a, r) { const sc = r.scoring, v4 = !!(sc && sc.v >= 4); const stamp = r.resolvedAt ? fmtStamp(r.resolvedAt) : ''; const timePill = r.failureToAct ? `Failure to act \u00B7 ${stamp}` : r.late === true ? `Late \u00B7 ${stamp}` : (r.late === false ? `On time \u00B7 ${stamp}` : (stamp ? `${stamp}` : '')); const est = r.failureToAct ? '' : estimateNote(a, r); const why = whyThisScore(a, r); const lvl = r.proofLevel || 'Self-declared'; const applied = v4 && sc.proof && typeof sc.proof.applied === 'number' ? sc.proof.applied : null; const proofNote = (r.proofV || 1) <= 1 ? 'baseline confidence (100%)' : (v4 && (r.impactDirection === 'harm' || applied === 1) ? 'kept as a record; it never changes a harm score' : Math.round(r.proofV * 100) + '% of baseline confidence'); return ` ${planCardHtml(a)}
Reported \u00B7 ${r.ref} A${timePill}
${r.expiredReport ? '
Harm reported after expiry
' : ''}
Collapsed from ${r.collapsedFrom} P on ${new Date(r.resolvedAt).toLocaleDateString()} \u2014 both records kept permanently, neither overwritten
${escapeHtml(r.statementActual || '')}
Resolved Avalrem
${fmtSigned(r.real)}
${est ? `
${est}
` : ''} ${!v4 && r.proofV !== undefined && r.proofV > 1 ? `
${Math.round(r.proofV*100)}% of ${fmtSigned(r.rawReal)} baseline
` : ''}
${v4 ? `
Action size
${sc.flattened ? 'flat' : pctOf(sc.p) + '%'}
Reach
${sc.impact.direction === 'neutral' ? 'No effect' : SCOPES[(sc.impact.appliedScope || 1) - 1].label}
Band
${Math.round(Math.min(...sc.band))} to ${Math.round(Math.max(...sc.band))}
BQ
\u00D7${(a.bqCoef || 1).toFixed(2)}
${calcSentence(a, r)}
${a.scoring && a.bq ? `
C already includes your BQ priority: ${a.bq} \u2014 ${BQ_NAMES[a.bq]} (${a.bqRank ? 'your priority #' + a.bqRank : 'not in your top 3'}, \u00D7${(a.bqCoef || 1).toFixed(2)}). Locked from the plan, same as C.
` : ''} The full formula, explained in Help \u2192
` : `
Score
${fmtSigned(r.real)}
Recorded before the scope ladder existed, on the earlier scale \u2014 shown as saved.
`} ${r.failureToAct ? `
Recorded with your Action Failure Testimony. Read the testimony \u2192
` : `
Proof of Action \u2014 as reportedView evidence \u2192
${lvl} \u2014 ${proofNote}
${r.proofEvidenceNote ? `
Evidence: ${escapeHtml(r.proofEvidenceNote)}
` : ''} ${r.proofStakeholderNote ? `
Confirmed by: ${escapeHtml(r.proofStakeholderNote)}
` : ''} ${proofFilesHtml(r)}
`} ${(() => { const g = r.realityGap || realityGap(a, r); if (!g) return ''; return `
Reality Gap \u2014 expected vs actual
Expected ${fmtSigned(g.expected)} \u2192 actual ${fmtSigned(g.actual)}${fmtSigned(g.gap)}
${g.parts ? `
Reach ${fmtGap(g.parts.reach)} \u00B7 Timing ${fmtGap(g.parts.timing)} \u00B7 Proof ${fmtGap(g.parts.proof)}
` : ''}
${escapeHtml(realityGapSentence(a, r, g))}
`; })()} ${assessmentHtml(a, r, 'result')} ${why.length ? `
Why this score?
${why.map(escapeHtml).join(' ')}
` : ''} ${r.lesson ? `
What you'd do differently
${escapeHtml(r.lesson)}
` : ''}
Action Replay \u2014 this action's whole story
${actionReplayEvents(a, r).map(e => `
${e.date}${escapeHtml(e.what)}${e.detail ? `
${escapeHtml(e.detail)}` : ''}
`).join('')}
${correctionHtml(r)}
${engineLabel(sc)}. Calculated by the Kirdomic Engine \u2014 a deterministic system, not AI. Commitment scoring (C) stayed fixed to what you planned; this outcome only supplied the execution and impact evidence.
`; } /* ---------- result / resolve ---------- */ function openResult(ref, forceReport) { // [changed 2026-10-01] forceReport opens the shared outcome form for a long plan currentResultId = ref; const refEl = document.getElementById('result-ref'); if (refEl) refEl.textContent = ref; // [added 2026-09-27] a ref may live in the personal cache or (if opened from // a project screen) the currently-open project's cache — check both rather // than threading an extra parameter through every call site. const a = cache.find(x => x.kind === 'plan' && x.ref === ref) || projectActionsCache.find(x => x.kind === 'plan' && x.ref === ref); const r = cache.find(x => x.kind === 'resolution' && x.ref === ref) || projectActionsCache.find(x => x.kind === 'resolution' && x.ref === ref); const body = document.getElementById('result-body'); if (!a) { body.innerHTML = ''; return; } // [changed 2026-10-01, Phase 0 item 4] a long plan shows its progress updates until it is reported; once resolved it // shows its result like any plan. "Report the outcome" opens the same shared form (founder: every plan reports // progress along the way, and every plan can be reported — a harm always). if (a.status === 'ongoing' && !r && !forceReport) { const updates = a.updates || []; const updatesHtml = updates.length ? updates.slice().reverse().map(u => `
${new Date(u.at).toLocaleDateString()}
${(u.refresh ? '' + (u.refresh === 'renew' ? 'Renewed' : 'Refreshed') + ' ' : '') + (u.status ? '' + STATUS_LABELS[u.status] + ' ' : '') + escapeHtml(u.text || '')}
`).join('') : '
No updates yet — add one whenever something changes.
'; body.innerHTML = `
${a.ref} P${escapeHtml(a.horizon || 'Ongoing')}
${escapeHtml(a.statementPlanned)}
Updates
${updatesHtml}
${(() => { const ex = planExpiry(a); return `
${ex.applies ? (ex.deadline ? 'Deadline ' + new Date(ex.deadline).toLocaleDateString() + '. ' : '') + (ex.expired ? 'Expired ' + new Date(ex.at).toLocaleDateString() + ': it can no longer be scored, but a harm can always be reported.' : 'Report the outcome by ' + new Date(ex.at).toLocaleDateString() + '.') : 'Life project — no deadline. Report the outcome whenever it is done.'}
`; })()} ${refreshPlanHtml(a)} ${testimonyLinkHtml(a, 'screen-result')} `; show('screen-result'); return; } if (r) { body.innerHTML = resolvedHtml(a, r); } else { const gatedNote = a.status === 'pending' ? `
This verb needs evidence of outcome before it counts — report back to resolve it.
` : ''; body.innerHTML = ` ${planCardHtml(a)} ${gatedNote} ${waitingCardHtml(a)}${assessmentHtml(a, null, 'plan')} ${planIsClosed(a) ? closedPlanCardHtml(a) : ''} ${planExpiry(a).expired ? `
This plan expired on ${new Date(planExpiry(a).at).toLocaleDateString()}. It can no longer be scored — except a harm, which can always be reported here. You can still write about it in My Presence.
` : ''}
`; renderImpactRow('resolve-impact', pickResolveImpact, null); resetEvidence(); renderProofBlock(); } show('screen-result'); } let resolveSelectedImpact = null; function pickResolveImpact(v) { resolveSelectedImpact = v; renderImpactRow('resolve-impact', pickResolveImpact, v); } // ===== [2026-10-08, founder decision 4] RENEW / REFRESH LONG PLANS ===== // An own-category plan lasts 10 years from the day it was made or last renewed; renewing continues it for another 10 years. // A life project never expires; refreshing records, with a date, that it is still yours and what has changed. Neither // touches the statement or its estimate (to change the aim, make a new plan) and an expired plan cannot be renewed. function refreshPlanHtml(a) { if (!a || a.status !== 'ongoing' || planExpiry(a).expired) return ''; const own = isOwnHorizon(a); if (!own && a.horizon !== 'Life Project') return ''; const last = a.refreshedAt ? ' Last ' + (own ? 'renewed ' : 'refreshed ') + new Date(a.refreshedAt).toLocaleDateString() + '.' : ''; return `
${own ? 'Your own category lasts 10 years from the day the plan was made or last renewed. Renew it to continue for another 10 years. Write what has changed in the update box first if you like.' : 'A life project never expires. Refresh it to record that it is still yours; write what has changed in the update box first if you like.'}${last}
`; } async function refreshPlan() { const a = cache.find(x => x.kind === 'plan' && x.ref === currentResultId); if (!a) return; if (planExpiry(a).expired) { alert('This plan has expired, so it can no longer be renewed. Make a new plan with the same aim.'); return; } const own = isOwnHorizon(a); if (!own && a.horizon !== 'Life Project') return; const box = document.getElementById('update-text'); const note = ((box && box.value) || '').trim().slice(0, 600), now = Date.now(); const updates = [...(a.updates || []), { text: note || (own ? 'Continued for another 10 years.' : 'Still mine.'), refresh: own ? 'renew' : 'refresh', at: now }]; const patch = { updates, refreshedAt: now }; try { if (actionsCol) await actionsCol.doc(a.id).update(patch); } catch (e) { console.warn('refresh failed, keeping locally', e); alert(syncFailMsg(e)); } Object.assign(a, patch); openResult(a.ref); renderOutstanding(); } // ===== [2026-10-08, founder decision 3] HARM-RECORD CORRECTION ===== // A harm stays on the record. The only exception is a genuine mistake, and whoever asks to correct it must give a // justification AND evidence (a file, or a named person who can confirm it, plus a description). The request goes to the // founder, who accepts or declines it with a reason. The original record is never edited or hidden: if accepted it stays // visible, marked "Corrected", with its old value shown and taken out of the totals; if the request was untrue, the old // record is simply still there, with the request and the decision beside it. Personal records only for now. function isHarmRecord(r) { return !!r && r.kind === 'resolution' && (r.impactDirection === 'harm' || !!r.failureToAct || (typeof (r.realOriginal ?? r.real) === 'number' && (r.realOriginal ?? r.real) < 0)); } let corrFile = null, corrBusy = false; function correctionHtml(r) { if (!isHarmRecord(r) || r.projectId || !cache.some(x => x.id === r.id)) return ''; const reqs = Array.isArray(r.correctionRequests) ? r.correctionRequests : []; const st = q => q.status === 'accepted' ? `Accepted ${q.decidedAt ? new Date(q.decidedAt).toLocaleDateString() : ''}` : q.status === 'declined' ? `Not accepted ${q.decidedAt ? new Date(q.decidedAt).toLocaleDateString() : ''}` : 'Waiting for the founder'; const list = reqs.map(q => `
Requested ${new Date(q.at).toLocaleDateString()} · ${st(q)}
Why: ${escapeHtml(q.reason || '')}
Evidence: ${escapeHtml(q.evidence || '')}${q.confirmBy ? ' · can be confirmed by ' + escapeHtml(q.confirmBy) : ''}${q.file ? ` · ${escapeHtml(q.file.name || 'file')}` : ''}
${q.note ? `
Founder: ${escapeHtml(q.note)}
` : ''}
`).join(''); const pending = reqs.some(q => q.status === 'requested'), done = r.correctedOut; const head = done ? `
Corrected. This record was a genuine mistake, shown with evidence and accepted by the founder. It stays here as it was saved (value ${fmtSigned(r.realOriginal)}) but no longer counts in your totals.
` : ''; const form = (done || pending) ? '' : `
Request a correction
Only for a genuine mistake or error. Give your reason and your evidence; the founder reviews it. The original record always stays visible, whatever the decision.
${corrFile ? 'Attached: ' + escapeHtml(corrFile.name) : 'A file or a named person is required, with the description above.'}
`; return `
Harm record correction
${head}${list}${form || (pending ? '
Your request is with the founder. The decision will appear here and in Help → Ask the founder.
' : '')}
`; } async function onCorrectionFile(input) { const f = input.files && input.files[0]; if (!f) return; const m = document.getElementById('corr-file-name'), say = t => { if (m) m.textContent = t; }; const c = classifyFile(f.name, f.type, f.size); if (!c.ok) { say(c.reason); return; } if (!assetsCap) { try { assetsCap = await claude.use('assets'); } catch (e) { assetsCap = null; } } if (!assetsCap) { say('File uploads are not available here \u2014 name a person who can confirm it instead.'); return; } say('Uploading ' + f.name + '\u2026'); corrBusy = true; try { const res = await uploadAsset(f, c); corrFile = { id: res.id, kind: c.kind, name: String(f.name).slice(0, 80), size: res.size }; say('Attached: ' + corrFile.name); } catch (e) { say(uploadErrorText(e)); } corrBusy = false; } async function sendCorrectionRequest(ref) { const r = cache.find(x => x.kind === 'resolution' && x.ref === ref); if (!r || !isHarmRecord(r)) return; const v = id => ((document.getElementById(id) || {}).value || '').trim(); const reason = v('corr-reason').slice(0, 600), evidence = v('corr-evidence').slice(0, 600), confirmBy = v('corr-confirm').slice(0, 80); const m = document.getElementById('corr-msg'), say = (t, c) => { if (m) { m.textContent = t; m.style.color = c || 'var(--red)'; } }; if (corrBusy) { say('Wait for the file to finish uploading.', 'var(--muted)'); return; } if (reason.length < 20) { say('Give your reason in a sentence or two: what is wrong and what really happened.'); return; } if (evidence.length < 8) { say('Describe your evidence.'); return; } if (!corrFile && confirmBy.length < 2) { say('Attach a file, or name a person who can confirm it.'); return; } const now = Date.now(), id = 'c' + now; const req = { id, at: now, reason, evidence, confirmBy: confirmBy || null, file: corrFile, status: 'requested' }; const reqs = [...(Array.isArray(r.correctionRequests) ? r.correctionRequests : []), req]; try { if (actionsCol) await actionsCol.doc(r.id).update({ correctionRequests: reqs }); } catch (e) { say(accessBlocked(e) ? ACCESS_NOTE : syncFailMsg(e)); return; } r.correctionRequests = reqs; const plan = cache.find(x => x.kind === 'plan' && x.ref === ref) || {}; if (qDoc() && uid) { try { const d = await readQDoc(); d.items.push({ id, kind: 'correction', ref, value: r.realOriginal ?? r.real, statement: String(plan.statementPlanned || '').slice(0, 300), text: 'Correction request for record ' + ref + ': ' + reason, reason, evidence, confirmBy: confirmBy || null, file: corrFile, createdAt: now }); d.updatedAt = now; d.uid = uid; await qDoc().set(d); } catch (e) { console.warn('correction request not sent to the founder', e); } } corrFile = null; openResult(ref); show('screen-result'); } // The founder's decision arrives in the founderQuestions document; the person's own app copies it onto their record. async function syncCorrections() { if (!qDoc() || !uid || !actionsCol) return; const d = await readQDoc(); for (const q of d.items.filter(q => q && q.kind === 'correction' && (q.decision === 'accepted' || q.decision === 'declined'))) { const r = cache.find(x => x.kind === 'resolution' && x.ref === q.ref); if (!r || !Array.isArray(r.correctionRequests)) continue; const i = r.correctionRequests.findIndex(x => x.id === q.id && x.status === 'requested'); if (i < 0) continue; const reqs = r.correctionRequests.map((x, j) => j === i ? { ...x, status: q.decision, decidedAt: q.answeredAt || Date.now(), note: q.answer || '' } : x); const patch = { correctionRequests: reqs }; if (q.decision === 'accepted') patch.correction = { status: 'accepted', at: q.answeredAt || Date.now(), requestId: q.id, note: q.answer || '' }; try { await actionsCol.doc(r.id).update(patch); } catch (e) { console.warn('could not apply the correction decision', e); } } } async function decideCorrection(n, accept) { const t = document.getElementById('ans-' + n), note = ((t && t.value) || '').trim().slice(0, 1000); if (!viewerIsOwner || !t) return; if (note.length < 5) { alert('Give the reason for your decision \u2014 the person will see it beside their record.'); return; } const [who, qid] = String(t.getAttribute('data-key') || '').split('|'); if (!who || !qid) return; const d = await readQDoc(who), q = d.items.find(x => x.id === qid); if (!q) return; q.answer = note; q.decision = accept ? 'accepted' : 'declined'; q.answeredAt = Date.now(); try { await qDoc(who).set(d); renderFounderQuestions(); } catch (e) { alert('Could not save the decision right now.'); } } async function addUpdate() { const a = cache.find(x => x.kind === 'plan' && x.ref === currentResultId); if (!a) return; if (planExpiry(a).expired) { alert('This plan passed its deadline more than 30 days ago, so it can no longer be updated. A harm can still be reported.'); return; } const text = (document.getElementById('update-text').value || '').trim().slice(0, 600); if (!text) { alert('Write something for the update, or come back later.'); return; } const entry = { text, at: Date.now() }; const updates = [...(a.updates || []), entry]; if (actionsCol) { try { await actionsCol.doc(a.id).update({ updates }); a.updates = updates; } catch (e) { console.warn('update save failed, keeping locally', e); a.updates = updates; alert(syncFailMsg(e)); } } else { a.updates = updates; renderHome(); } openResult(a.ref); } /* ---------- Action Evidence (proof for one result) ---------- */ // [rebuilt 2026-10-02, Phase 1 step 1b] The three Proof of Action choices are exclusive: Self-declared (x1.00), Evidence attached (x1.10), // Confirmed by a stakeholder (x1.15). Files of any supported kind can back the last two. Proof is still a CLAIM the app cannot verify: // "Evidence" needs a description of 8+ characters or at least one file; "stakeholder" needs the confirmer named (3+ characters). // The state lives in `ev` while the outcome form is open; nothing is saved until "Resolve action". let assetsCap = null; const EVIDENCE_MAX_BYTES = 20 * 1024 * 1024; // the platform's file-store limit for photos, video, PDF and text const PROOF_CHOICES = [ { key: 'self', label: 'Self-declared', mult: '\u00D71.00', hint: 'Your own word. Counts in full; no files needed.' }, { key: 'evidence', label: 'Evidence attached', mult: '\u00D71.10', hint: 'A description of at least 8 characters, or at least one file.' }, { key: 'stakeholder', label: 'Confirmed by a stakeholder', mult: '\u00D71.15', hint: 'Name who confirmed it; attach their confirmation or other proof if you have it.' }, ]; const EVIDENCE_TYPES = { 'image/jpeg': 'photo', 'image/png': 'photo', 'image/gif': 'photo', 'image/webp': 'photo', 'video/mp4': 'video', 'video/webm': 'video', 'application/pdf': 'pdf', 'text/plain': 'text', 'text/markdown': 'text' }; const EVIDENCE_EXT = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', mp4: 'video/mp4', webm: 'video/webm', pdf: 'application/pdf', txt: 'text/plain', md: 'text/markdown' }; const KIND_LABEL = { photo: 'PHOTO', video: 'VIDEO', pdf: 'PDF', text: 'TEXT' }; // What may be attached, and the plain-language reason when something may not. function classifyFile(name, type, size) { const ext = (String(name || '').split('.').pop() || '').toLowerCase(), ty = String(type || '').toLowerCase(); const mime = EVIDENCE_TYPES[ty] ? ty : (EVIDENCE_EXT[ext] || null); if (!mime) { if (/^audio\//.test(ty) || ['mp3', 'wav', 'm4a', 'aac', 'ogg', 'oga', 'flac', 'opus', 'wma', 'amr'].includes(ext)) return { ok: false, reason: 'Audio-only files can\u2019t be stored yet. Record a voice note as a short video clip instead.' }; if (['doc', 'docx', 'ppt', 'pptx', 'xls', 'xlsx', 'odt', 'odp', 'ods', 'rtf', 'pages', 'key', 'numbers'].includes(ext) || /officedocument|msword|ms-powerpoint|ms-excel/.test(ty)) return { ok: false, reason: 'Word, PowerPoint and Excel files aren\u2019t stored. Save it as a PDF first.' }; if (ext === 'mov' || ty === 'video/quicktime') return { ok: false, reason: 'MOV videos aren\u2019t stored yet. Use an MP4 or WebM clip.' }; return { ok: false, reason: 'That file type isn\u2019t supported. Use a photo (JPG, PNG, GIF, WebP), a video clip (MP4, WebM), a PDF or a text file.' }; } if (!(size > 0)) return { ok: false, reason: 'That file is empty.' }; if (size > EVIDENCE_MAX_BYTES) return { ok: false, reason: 'That file is over the 20 MB limit.' }; return { ok: true, kind: EVIDENCE_TYPES[mime], mime }; } function newEvidenceState() { return { choice: 'self', note: '', who: '', files: [], ba: { mode: 'two', before: null, after: null, one: null }, msg: '', busy: false }; } let ev = newEvidenceState(), evPickSlot = 'list'; function evAllFiles() { return ev.files.concat([ev.ba.before, ev.ba.after, ev.ba.one].filter(Boolean)); } function evFileCount() { return ev.files.length + (ev.ba.mode === 'two' ? [ev.ba.before, ev.ba.after] : [ev.ba.one]).filter(Boolean).length; } async function deleteAssets(ids) { if (!ids || !ids.length) return; if (!assetsCap) { try { assetsCap = await claude.use('assets'); } catch (e) { assetsCap = null; } } if (!assetsCap) return; for (const id of ids) { try { await assetsCap.delete(id); } catch (e) { console.warn('could not delete file', id, e); } } } // Leaving the form without resolving: files uploaded for it but never saved are removed (best effort). function resetEvidence() { const ids = evAllFiles().filter(f => !f.fromCatalog).map(f => f.id); ev = newEvidenceState(); deleteAssets(ids); } function proofState() { const evOn = ev.choice === 'evidence', stOn = ev.choice === 'stakeholder'; const evidence = evOn && (ev.note.trim().length >= 8 || evFileCount() > 0); const stakeholder = stOn && ev.who.trim().length >= 3; return { evOn, stOn, evidence, stakeholder }; } function proofHint() { const p = proofState(); if (p.evOn && !p.evidence) return 'Describe the proof (8+ characters) or add a file for it to count.'; if (p.stOn && !p.stakeholder) return 'Name who confirmed it (3+ characters) for this to count.'; return ''; } function proofChoicesHtml() { return PROOF_CHOICES.map(c => { const on = ev.choice === c.key; return ``; }).join(''); } function proofSummary() { const bits = [], n = evFileCount(); if (ev.note.trim()) bits.push('description added'); if (n) bits.push(n + (n === 1 ? ' file' : ' files') + ' attached'); if (ev.choice === 'stakeholder' && ev.who.trim()) bits.push('confirmed by ' + ev.who.trim()); return bits.length ? bits.join(' \u00B7 ') : 'Nothing attached yet'; } function renderProofBlock() { const box = document.getElementById('proof-block'); if (!box) return; const hint = proofHint(); box.innerHTML = `
${proofChoicesHtml()}
${ev.choice !== 'self' ? `Photos, video, PDF or text for the proofAction Evidence \u2192
${escapeHtml(proofSummary())}
${hint ? `
${hint}
` : ''}` : ''}`; } function setProofChoice(k) { ev.choice = k; renderProofBlock(); if (document.getElementById('screen-evidence').classList.contains('active')) renderEvidence(); } let evView = null; // the reference of the saved result being viewed, or null while the outcome form is being filled in const PROOF_KEY = { 'Self-declared': 'self', 'Evidence-based': 'evidence', 'Stakeholder-confirmed': 'stakeholder' }; // A saved result's proof, read-only: what was chosen, what was said, which files, and what proof changes. function evidenceViewHtml(r) { const key = PROOF_KEY[r.proofLevel] || 'self', ch = PROOF_CHOICES.find(c => c.key === key), harm = r.impactDirection === 'harm'; const files = proofFilesHtml(r); return `
View only. This result is saved and locked, so its proof can\u2019t be changed now. Proof is added on the outcome form while you report the action.
How sure is this result?
\u25CF ${ch.label}${ch.mult}
${harm && key !== 'self' ? 'Kept as a record; it never changes a harm score.' : ch.hint}
${key === 'self' ? '
No files were needed. This result counted in full on your own word.
' : ''} ${r.proofStakeholderNote ? `
Who confirmed it
${escapeHtml(r.proofStakeholderNote)}
Named by you \u2014 KirV can\u2019t verify it.
` : ''} ${r.proofEvidenceNote ? `
Description of the proof
${escapeHtml(r.proofEvidenceNote)}
` : ''} ${files ? `
Files
${files}
` : (key !== 'self' ? '
No files were attached.
' : '')} ${proofRulesHtml()}
Proof is a record you add \u2014 KirV can\u2019t verify it. It stays with this action, and your files are erased if you delete your account.
`; } function viewEvidence(ref) { const r = cache.concat(projectActionsCache).find(x => x.kind === 'resolution' && x.ref === ref); if (!r) return; evView = ref; document.getElementById('evidence-sub').textContent = ref + ' \u00B7 saved result'; document.querySelector('#screen-evidence .iconbtn').setAttribute('aria-label', 'Back to Action Insight'); document.getElementById('evidence-body').innerHTML = evidenceViewHtml(r); show('screen-evidence'); } function openEvidence() { const a = cache.find(x => x.kind === 'plan' && x.ref === currentResultId) || projectActionsCache.find(x => x.kind === 'plan' && x.ref === currentResultId); document.getElementById('evidence-sub').textContent = a ? a.ref + ' \u00B7 ' + truncate(a.statementPlanned || '', 38) : ''; evView = null; document.querySelector('#screen-evidence .iconbtn').setAttribute('aria-label', 'Back to the outcome form'); renderEvidence(); show('screen-evidence'); refreshStorage().then(() => { if (!evView && document.getElementById('screen-evidence').classList.contains('active')) renderEvidence(); }); } function closeEvidence() { if (evView) { evView = null; show('screen-result'); return; } // back from the view-only page to the saved result renderProofBlock(); show('screen-result'); } function evFileRowHtml(f, slot) { return `
${KIND_LABEL[f.kind] || 'FILE'} ${escapeHtml(f.name)}
`; } function evSlotHtml(slot, label) { const f = ev.ba[slot]; return `
${label}
${f ? `
${KIND_LABEL[f.kind] || 'FILE'}
${escapeHtml(f.name)}
` : ``}
`; } function renderEvidence() { const body = document.getElementById('evidence-body'); if (!body) return; const needsFiles = ev.choice !== 'self', two = ev.ba.mode === 'two'; const on = 'background:var(--card);color:var(--ink);', off = 'background:transparent;color:var(--muted);'; const hint = proofHint(); body.innerHTML = `${catalogOfferHtml()}
How sure is this result?
${proofChoicesHtml()}
${ev.choice === 'self' ? `
No files needed. Your result counts in full on your own word. You can switch to evidence or a stakeholder at any time before you press Resolve action.
` : ''} ${ev.choice === 'stakeholder' ? `
Named by you \u2014 KirV can\u2019t verify it and does not contact this person. Coming later: if they have a KirV account, they can confirm inside KirV, and the result will show \u201Cconfirmed in KirV\u201D. Attach their confirmation below if you have it (a signed note, a message screenshot, a short video).
` : ''} ${needsFiles ? `
Attach proof${ev.files.length ? ' (' + ev.files.length + ')' : ''} \u2014 as many files as you need; pick several at once
${ev.files.map((f, i) => evFileRowHtml(f, 'list:' + i)).join('')}
Photos (JPG, PNG, GIF, WebP), video clips (MP4, WebM), PDF documents and text files, up to 20 MB each. Word or PowerPoint: save as PDF first. A voice note: record it as a short video clip \u2014 audio-only files can\u2019t be stored yet.
Before and after (optional)
${two ? `
${evSlotHtml('before', 'Before')}${evSlotHtml('after', 'After')}
` : `
${evSlotHtml('one', 'Before & after \u2014 one file')}
`}
One file can be a PDF of your slides or a single photo that shows both.
` : ''} ${storageWarning(storageUsage) ? `
${escapeHtml(storageWarning(storageUsage))}
` : ''}
${escapeHtml(ev.msg)}
${hint}
${proofRulesHtml()}
Proof is a record you add \u2014 KirV can\u2019t verify it. It stays with this action, and your files are erased if you delete your account.
Returns to the outcome form. Nothing is final until you press Resolve action.
`; } function proofRulesHtml() { return `
What proof changes
\u00B7 Benefit: evidence or a named confirmer moves the result a little further through its band.
\u00B7 Reach: a benefit wider than \u201Cteam\u201D counts in full only with evidence or a confirmer; on your word alone it counts as \u201Cteam\u201D.
\u00B7 Late reports: reported more than 24 hours after finishing, a \u201Cmet the deadline\u201D or \u201Cfaster\u201D claim counts only with proof.
\u00B7 Harm: proof is kept as a record but never makes a harm score smaller or larger.
`; } function onEvidenceText(which, el) { ev[which] = el.value; const h = document.getElementById('ev-hint'); if (h) { const t = proofHint(); h.textContent = t; h.style.display = t ? '' : 'none'; } } function setBaMode(m) { ev.ba.mode = m; renderEvidence(); } function pickEvidenceFile(slot, accept) { ev.msg = ''; evPickSlot = slot; const input = document.getElementById('evidence-file-input'); input.accept = accept; input.multiple = (slot === 'list'); input.value = ''; input.click(); } // Plain-language text for each error code the platform's file store can return (codes as the platform defines them). function uploadErrorText(e) { switch (e && e.code) { case 'too_large': return 'That file is over the 20 MB limit.'; case 'unsupported_type': return 'The file store doesn\u2019t accept that file type.'; case 'invalid_request': return 'That file couldn\u2019t be read. If it is a text file, save it as plain UTF-8 text.'; case 'quota_or_state': return 'This app\u2019s file storage is full (too many files, or no space left). Remove files you no longer need, or try a smaller file.'; case 'rate_limited': return 'Too many uploads at once. Wait a moment and try again.'; case 'store_unavailable': return 'The file store is busy. Try again in a moment.'; case 'upstream_auth': return 'Please reload the page or sign in again, then retry.'; case 'not_granted': case 'capability_disabled': case 'capability_removed': return 'File uploads are not available here.'; default: return 'Upload failed \u2014 try again.'; } } // After these errors, trying the remaining files of a batch cannot succeed. // Large photos are reduced to at most 1600 px on the long side (JPEG) before upload, so the shared file store lasts longer. // The original file on the person's device is untouched; video, PDF, text and GIF are uploaded as they are. async function shrinkPhoto(file, c) { try { if (!c || c.kind !== 'photo' || !/^image\/(jpeg|png|webp)$/.test(c.mime) || file.size < 500 * 1024 || typeof createImageBitmap !== 'function') return { blob: file, mime: c.mime }; const bmp = await createImageBitmap(file), k = Math.min(1, 1600 / Math.max(bmp.width, bmp.height)); const cv = document.createElement('canvas'); cv.width = Math.round(bmp.width * k); cv.height = Math.round(bmp.height * k); cv.getContext('2d').drawImage(bmp, 0, 0, cv.width, cv.height); const blob = await new Promise(r => cv.toBlob(r, 'image/jpeg', 0.85)); return blob && blob.size < file.size ? { blob, mime: 'image/jpeg', shrunk: true } : { blob: file, mime: c.mime }; } catch (e) { return { blob: file, mime: c.mime }; } } async function uploadAsset(file, c) { const s = await shrinkPhoto(file, c); const res = await assetsCap.upload(s.blob, { type: s.mime }); return { ...res, size: s.blob.size || file.size, shrunk: !!s.shrunk }; } function uploadStopsBatch(e) { return !!e && ['quota_or_state', 'rate_limited', 'upstream_auth', 'not_granted', 'capability_disabled', 'capability_removed'].includes(e.code); } async function onEvidenceFile(input) { const files = Array.from(input.files || []), slot = evPickSlot; if (!files.length) return; if (!assetsCap) { try { assetsCap = await claude.use('assets'); } catch (e) { assetsCap = null; } } if (!assetsCap) { ev.msg = 'File uploads are not available here.'; ev.busy = false; renderEvidence(); return; } const problems = [], many = files.length > 1; let done = 0; for (const file of files) { const c = classifyFile(file.name, file.type, file.size); const why = !c.ok ? c.reason : (slot !== 'list' && c.kind !== 'photo' && c.kind !== 'pdf' ? 'Before and after takes a photo or a PDF.' : null); if (why) { problems.push((many ? file.name + ': ' : '') + why); continue; } ev.msg = 'Uploading ' + (many ? (done + problems.length + 1) + ' of ' + files.length + ': ' : '') + file.name + '\u2026'; ev.busy = true; renderEvidence(); try { const res = await uploadAsset(file, c); const rec = { id: res.id, kind: c.kind, name: String(file.name).slice(0, 80), size: res.size }; if (slot === 'list') ev.files.push(rec); else { const old = ev.ba[slot]; ev.ba[slot] = rec; if (old && !old.fromCatalog) deleteAssets([old.id]); } done++; } catch (e) { console.warn('evidence upload failed', e); problems.push((many ? file.name + ': ' : '') + uploadErrorText(e)); if (uploadStopsBatch(e)) break; // no point trying the rest } } ev.busy = false; ev.msg = problems.join(' '); renderEvidence(); } function removeEvidenceFile(slot) { let f = null; if (slot.startsWith('list:')) f = ev.files.splice(parseInt(slot.slice(5), 10), 1)[0]; else { f = ev.ba[slot]; ev.ba[slot] = null; } ev.msg = ''; renderEvidence(); if (f && !f.fromCatalog) deleteAssets([f.id]); // a photo carried from the Action Catalog belongs to that entry } // What is saved with the result, and which uploaded files are NOT kept (so they can be deleted). function evidenceRecord() { const p = proofState(), all = evAllFiles(); if (!(p.evidence || p.stakeholder)) return { fields: { proofEvidenceNote: '', proofStakeholderNote: '', evidenceFiles: [], beforeAfter: null, beforeImageId: null, afterImageId: null }, discard: all.filter(f => !f.fromCatalog).map(f => f.id) }; const ba = ev.ba, two = ba.mode === 'two'; const beforeAfter = two ? ((ba.before || ba.after) ? { mode: 'two', before: ba.before, after: ba.after } : null) : (ba.one ? { mode: 'one', one: ba.one } : null); const unused = two ? [ba.one] : [ba.before, ba.after]; return { fields: { proofEvidenceNote: ev.note.trim().slice(0, 500), proofStakeholderNote: p.stakeholder ? ev.who.trim().slice(0, 120) : '', evidenceFiles: ev.files.slice(), beforeAfter, beforeImageId: two && ba.before && ba.before.kind === 'photo' ? ba.before.id : null, // older readers still find the two photos afterImageId: two && ba.after && ba.after.kind === 'photo' ? ba.after.id : null, }, discard: unused.filter(Boolean).filter(f => !f.fromCatalog).map(f => f.id), }; } // Every file id a saved record points to (used when an account is deleted). function collectAssetIds(d, set) { if (!d) return; if (d.beforeImageId) set.add(d.beforeImageId); if (d.afterImageId) set.add(d.afterImageId); (d.evidenceFiles || []).forEach(f => { if (f && f.id) set.add(f.id); }); if (d.handoverFile && d.handoverFile.id) set.add(d.handoverFile.id); (d.files || []).forEach(f => { if (f && f.id) set.add(f.id); }); // My Presence photos and video (Action Catalog, Appreciative Reality) const ba = d.beforeAfter; if (ba) ['before', 'after', 'one'].forEach(k => { if (ba[k] && ba[k].id) set.add(ba[k].id); }); } // The proof files of a saved result, shown on Action Insight. function proofFilesHtml(r) { const tile = (f, label) => { if (!f) return `
${label}
`; return ``; }; let out = ''; const files = r.evidenceFiles || []; if (files.length) out += ``; const ba = r.beforeAfter; if (ba && ba.mode === 'one' && ba.one) out += `
${tile(ba.one, 'Before & after')}
`; else if (ba && ba.mode === 'two') out += `
${tile(ba.before, 'Before')}${tile(ba.after, 'After')}
`; else if (!ba && (r.beforeImageId || r.afterImageId)) out += `
${tile(r.beforeImageId ? { id: r.beforeImageId, kind: 'photo' } : null, 'Before')}${tile(r.afterImageId ? { id: r.afterImageId, kind: 'photo' } : null, 'After')}
`; return out; } function effectiveTiming(deadline, speed, late, proofV) { // Only claims that RAISE the score need trust: a favorable claim made more than 24 hours after the fact counts as // "not specified" unless evidence backs it. An unfavorable claim ("missed", "slower") stands, so reporting late can never // improve a poor result — there is no incentive to delay. if (late && !(proofV > 1)) { const d = deadline === 'favorable' ? 'not_specified' : deadline, s = speed === 'favorable' ? 'not_specified' : speed; return { deadline: d, speed: s, adjusted: d !== deadline || s !== speed }; } return { deadline, speed, adjusted: false }; } function proofMultiplier() { const p = proofState(); return p.stakeholder ? 1.15 : (p.evidence ? 1.10 : 1.0); } async function resolveAction() { // [added 2026-09-27] a plan being resolved may belong to a project — check // both caches, and if found in the project cache, write the resolution back // to that same project's collection (a resolution always lives beside its plan). let a = cache.find(x => x.kind === 'plan' && x.ref === currentResultId); let inProject = false; if (!a) { a = projectActionsCache.find(x => x.kind === 'plan' && x.ref === currentResultId); inProject = !!a; } if (!a) return; const text = document.getElementById('resolve-text').value.trim().slice(0, 600); if (!text) { alert('Report what actually happened.'); return; } const impactSelected = resolveSelectedImpact || 'neutral'; const imp = parseImpact(impactSelected); if (imp.direction !== 'neutral' && !imp.scope) { alert('Say who was affected — one person, family, team, organization, society, country or the world.'); return; } const expiry = planExpiry(a); if (!inProject && planIsClosed(a) && imp.direction !== 'harm') { alert('This plan was closed without a score on ' + new Date(findTestimony(a.ref).finalizedAt).toLocaleDateString() + '. Only a harm can still be reported.'); return; } if (expiry.expired && imp.direction !== 'harm') { alert('This plan expired on ' + new Date(expiry.at).toLocaleDateString() + ', so it can no longer be scored. Only a harm can still be reported. You can write about it in My Presence.'); return; } const proofV = proofMultiplier(); const proofLevel = proofV === 1.15 ? 'Stakeholder-confirmed' : (proofV === 1.10 ? 'Evidence-based' : 'Self-declared'); const evRec = evidenceRecord(); // [rebuilt 2026-10-02] what proof is kept, and which uploaded files are not // a self-declared benefit cannot claim to have reached wider than "team": it is counted as "team" (both are recorded) const eff = effectiveScope(imp.direction, imp.scope, proofV); // [added 2026-09-28] 24-hour rule for late confirmation: the person's own timing claims are trusted while the action is fresh. // More than 24 hours after finishing, a FAVORABLE claim counts as "not specified" unless evidence or stakeholder proof backs it. const whenEl = document.getElementById('resolve-when'); const finishedLate = !!(whenEl && whenEl.value === 'later'); const claimedDeadline = document.getElementById('resolve-deadline').value; const claimedSpeed = document.getElementById('resolve-speed').value; const timing = effectiveTiming(claimedDeadline, claimedSpeed, finishedLate, proofV); const deadline = timing.deadline, speed = timing.speed; const realFA = computeFA(deadline, speed, 'not_specified'); // C (with its BQ coefficient) was locked at planning; only the execution and the impact are new. const rawParts = assembleScore({ cValue: a.c, mergeBonus: 0, bqCoef: 1, fa: realFA, direction: imp.direction, scope: eff.scope || 1, proofV }); // At resolution the plan's own qualification (its BQ tag, its deadline) still applies; a plan that qualified as flat can // still graduate here if the ACTUAL impact reported turned out not to be neutral — the escape valve applies at either stage. const qualifiesFlat = qualifiesForBaseline({ bqChosen: a.bq, deadline: a.deadlinePlanned, impactKeyVal: impactSelected, modifier: a.scoring && a.scoring.modifier, mergedWith: a.scoring && a.scoring.secondVerb }); const parts = applyFlatBaseline(rawParts, qualifiesFlat); const rawReal = parts.size, proofApplied = parts.proofApplied, real = round1(parts.display); const lesson = (document.getElementById('resolve-lesson').value || '').trim().slice(0, 400); const resolutionId = a.ref + 'A'; const resolutionDoc = { ref: a.ref, kind: 'resolution', collapsedFrom: a.ref, statementActual: text, realFA, rawReal, real, // rawReal = the action's SIZE (C + f(a)); real = where that size lands in its band proofLevel, proofV, ...evRec.fields, lesson, impactSelected, impactDirection: imp.direction, impactScope: imp.scope, impactAppliedScope: eff.scope, impactCapped: eff.capped, scoring: { v: SCORING_VERSION, actor: ACTOR_DEFAULT, ladder: { power: LADDER_POWER, neutralTop: NEUTRAL_TOP, selfDeclaredMaxScope: SELF_DECLARED_MAX_SCOPE, sizeMax: round1(sizeMax()), scopes: SCOPES.map(s => s.key) }, flattened: parts.flattened, impact: { selected: impactSelected, direction: imp.direction, scope: imp.scope, appliedScope: eff.scope, capped: eff.capped, reason: eff.capped ? 'self-declared benefit cannot claim to reach wider than the team; counted as team' : null }, fa: { deadline, speed, urgency: 'not_specified', value: realFA, claimed: { deadline: claimedDeadline, speed: claimedSpeed }, finishedWithin24h: !finishedLate, adjustedForLateReport: timing.adjusted }, proof: { level: proofLevel, mult: proofV, applied: proofApplied, note: proofApplied === proofV ? null : 'not applied to a harmful result' }, plan: { c: a.c, bq: a.bq || null, bqCoef: a.bqCoef || null, top3: a.scoring && a.scoring.bq ? a.scoring.bq.top3 : null }, size: parts.size, p: parts.p, band: parts.band, raw: parts.size, display: real, }, resolvedAt: Date.now(), createdAt: Date.now(), late: finishedLate, // [added 2026-10-01] reported more than 24 hours after finishing — shown as "Late" expiredReport: expiry.expired, // [added 2026-10-01] a harm reported after the plan expired ...(inProject ? { projectId: a.projectId, loggedBy: uid } : {}), }; resolutionDoc.realityGap = realityGap(a, resolutionDoc); // [Phase 1 step 5] expected vs actual, kept on the record for calibration const targetCol = inProject ? projectsCol.doc(a.projectId).collection('actions') : actionsCol; if (targetCol) { try { await targetCol.doc(resolutionId).set(resolutionDoc); if (inProject) projectActionsCache.unshift({ id: resolutionId, ...resolutionDoc }); else cache.unshift({ id: resolutionId, ...resolutionDoc }); } catch (e) { console.warn('resolve save failed, keeping locally', e); if (inProject) projectActionsCache.unshift({ id: resolutionId, ...resolutionDoc }); else cache.unshift({ id: resolutionId, ...resolutionDoc }); alert(syncFailMsg(e)); } } else { if (inProject) projectActionsCache.unshift({ id: resolutionId, ...resolutionDoc }); else { cache.unshift({ id: resolutionId, ...resolutionDoc }); renderHome(); } } if (!inProject) contributeWorld(a, resolutionDoc); // [2026-10-07] only if the person turned the World Index on resolveSelectedImpact = null; ev = newEvidenceState(); deleteAssets(evRec.discard); // the result is saved: its files are kept, any others are removed if (inProject) { renderProjectScreen(); show('screen-project'); } else goHome(); } async function refreshAll() { if (actionsCol) { const snap = await actionsCol.orderBy('createdAt', 'desc').limit(200).get(); cache = snap.docs.map(d => normRec({ id: d.id, ...d.data() })); } renderHome(); } /* ---------- accessibility safety net (2026-10-07) ---------- */ // Every role=button element can be reached with Tab and pressed with Enter or Space; a field without a label is named from its placeholder. function a11yFix(root) { (root || document).querySelectorAll('[role=button]:not([tabindex])').forEach(e => e.setAttribute('tabindex', '0')); (root || document).querySelectorAll('input:not([aria-label]):not([type=hidden]), select:not([aria-label]), textarea:not([aria-label])').forEach(e => { if (e.closest('label') || (e.id && document.querySelector('label[for="' + e.id + '"]'))) return; const ph = e.getAttribute('placeholder'); if (ph) e.setAttribute('aria-label', ph.replace(/^e\.g\.\s*/i, '').slice(0, 80)); }); } document.addEventListener('keydown', e => { const t = e.target; if ((e.key === 'Enter' || e.key === ' ') && t && t.getAttribute && t.getAttribute('role') === 'button' && !/^(BUTTON|A|INPUT|TEXTAREA|SELECT)$/.test(t.tagName)) { e.preventDefault(); t.click(); } }); try { new MutationObserver(() => a11yFix()).observe(document.body, { childList: true, subtree: true }); } catch (e) {} a11yFix(); renderNavs(); /* ===================== MAJAYAKAYA + PARTNER SHOP MODE (v86, 2026-10-14) ===================== Founder direction 2026-10-10..14: KirV becomes a Thai Massage Heritage support tool. Home = My Kirdaxia with Avalrem and Majayakaya side by side. Partner shop mode (first partner: Ananta, Chiang Mai) lets a guest choose a language, see the treatments (no prices), tell pressure, body areas and health by tapping, talk with staff through translation, and rate body / mind / emotion before and after. Health answers are shown only and never stored. Before/after ratings are stored only with the guest's consent and without a name. */ const MJ_LANGS = [ { k: 'en', label: 'English', speech: 'en-US', name: 'English' }, { k: 'zh', label: '中文', speech: 'zh-CN', name: 'Simplified Chinese' }, { k: 'ja', label: '日本語', speech: 'ja-JP', name: 'Japanese' }, { k: 'ko', label: '한국어', speech: 'ko-KR', name: 'Korean' }, { k: 'th', label: 'ไทย', speech: 'th-TH', name: 'Thai' }]; const MJ_T = { tapLang: { en: 'Please tap your language', zh: '请选择您的语言', ja: '言語をお選びください', ko: '언어를 선택해 주세요', th: 'กรุณาเลือกภาษา' }, treatments: { en: 'Our treatments', zh: '我们的护理项目', ja: '施術メニュー', ko: '저희 테라피', th: 'บริการของเรา' }, askPrice: { en: 'Please ask our staff for prices, times and today’s offers.', zh: '价格、时长和今日优惠,请咨询我们的员工。', ja: '料金・時間・本日のお得な情報はスタッフにお尋ねください。', ko: '가격, 시간, 오늘의 혜택은 직원에게 문의해 주세요.', th: 'สอบถามราคา ระยะเวลา และโปรโมชันวันนี้กับพนักงานได้เลย' }, certShort: { en: 'Therapists registered with the Ministry of Public Health · UNESCO-listed heritage of Thai massage', zh: '按摩师已在泰国公共卫生部登记 · 泰式按摩为联合国教科文组织非物质文化遗产', ja: 'セラピストはタイ保健省に登録済み · タイ古式マッサージはユネスコ無形文化遺産', ko: '테라피스트는 태국 보건부 등록 · 타이 마사지는 유네스코 무형문화유산', th: 'ผู้ให้บริการขึ้นทะเบียนกับกระทรวงสาธารณสุข · นวดไทยเป็นมรดกโลกของยูเนสโก' }, certFull: { en: 'All our therapists are registered with Thailand’s Ministry of Public Health as Service Providers in Health Establishments (Certificate Form H.S.S. 14, issued by the Department of Health Service Support).', zh: '我们所有的按摩师均已在泰国公共卫生部登记为健康场所服务人员(H.S.S. 14 号证书,由卫生服务支持厅颁发)。', ja: '当店のセラピストは全員、タイ保健省に健康施設サービス提供者として登録されています(保健サービス支援局発行の証明書 H.S.S. 14)。', ko: '저희 테라피스트는 모두 태국 보건부에 건강업소 서비스 제공자로 등록되어 있습니다(보건서비스지원국 발급 증명서 H.S.S. 14).', th: 'ผู้ให้บริการของเราทุกคนขึ้นทะเบียนเป็นผู้ให้บริการในสถานประกอบการเพื่อสุขภาพกับกระทรวงสาธารณสุข (ใบรับรอง สบส. 14 ออกโดยกรมสนับสนุนบริการสุขภาพ)' }, unesco: { en: 'Nuad Thai, traditional Thai massage, is on UNESCO’s list of the Intangible Cultural Heritage of Humanity (2019).', zh: '泰式按摩(Nuad Thai)于2019年列入联合国教科文组织人类非物质文化遗产代表作名录。', ja: 'タイ古式マッサージ(ヌアッド・タイ)は2019年、ユネスコ無形文化遺産に登録されました。', ko: '타이 전통 마사지(누앗 타이)는 2019년 유네스코 인류무형문화유산 대표목록에 등재되었습니다.', th: 'นวดไทยได้รับการขึ้นทะเบียนเป็นมรดกภูมิปัญญาทางวัฒนธรรมที่จับต้องไม่ได้ของมนุษยชาติโดยยูเนสโก (พ.ศ. 2562)' }, talkStaff: { en: 'Talk with staff', zh: '与员工交谈', ja: 'スタッフと話す', ko: '직원과 대화', th: 'คุยกับพนักงาน' }, back: { en: 'Back', zh: '返回', ja: '戻る', ko: '뒤로', th: 'กลับ' }, allTreat: { en: 'All treatments', zh: '全部项目', ja: 'すべてのメニュー', ko: '전체 메뉴', th: 'บริการทั้งหมด' }, mayFeel: { en: 'What you may feel · Majayakaya', zh: '您可能的感受 · Majayakaya', ja: '感じられること · Majayakaya', ko: '느낄 수 있는 것 · Majayakaya', th: 'สิ่งที่คุณอาจรู้สึก · Majayakaya' }, body: { en: 'Body', zh: '身体', ja: '体', ko: '몸', th: 'กาย' }, mind: { en: 'Mind', zh: '心', ja: '心', ko: '마음', th: 'ใจ' }, emotion: { en: 'Emotion', zh: '情绪', ja: '気持ち', ko: '감정', th: 'อารมณ์' }, choose: { en: 'Choose this · start consultation', zh: '选择此项 · 开始咨询', ja: 'これにする · 問診へ', ko: '이것으로 선택 · 상담 시작', th: 'เลือกบริการนี้ · เริ่มสอบถาม' }, ask: { en: 'Ask a question', zh: '提问', ja: '質問する', ko: '질문하기', th: 'ถามคำถาม' }, step1: { en: 'Step 1 of 3', zh: '第 1 步,共 3 步', ja: 'ステップ 1/3', ko: '1/3 단계', th: 'ขั้นที่ 1 จาก 3' }, step2: { en: 'Step 2 of 3', zh: '第 2 步,共 3 步', ja: 'ステップ 2/3', ko: '2/3 단계', th: 'ขั้นที่ 2 จาก 3' }, step3: { en: 'Step 3 of 3', zh: '第 3 步,共 3 步', ja: 'ステップ 3/3', ko: '3/3 단계', th: 'ขั้นที่ 3 จาก 3' }, pressure: { en: 'Your pressure', zh: '您喜欢的力度', ja: '圧の強さ', ko: '원하는 압력', th: 'แรงกดที่ต้องการ' }, p1: { en: 'Very light', zh: '很轻', ja: 'とても弱め', ko: '아주 약하게', th: 'เบามาก' }, p2: { en: 'Light', zh: '轻', ja: '弱め', ko: '약하게', th: 'เบา' }, p3: { en: 'Medium', zh: '中等', ja: '普通', ko: '보통', th: 'ปานกลาง' }, p4: { en: 'Strong', zh: '重', ja: '強め', ko: '강하게', th: 'แรง' }, p5: { en: 'Very strong', zh: '很重', ja: 'とても強め', ko: '아주 강하게', th: 'แรงมาก' }, tapBody: { en: 'Tap the body', zh: '点击身体部位', ja: '体の部位をタップ', ko: '몸을 눌러 주세요', th: 'แตะที่ร่างกาย' }, tapHint: { en: 'Choose a colour, then tap the body. Tap again to clear.', zh: '先选择颜色,再点击身体部位。再点一次可取消。', ja: '色を選んでから体をタップ。もう一度タップで解除。', ko: '색을 고른 뒤 몸을 누르세요. 다시 누르면 지워집니다.', th: 'เลือกสีก่อน แล้วแตะที่ร่างกาย แตะซ้ำเพื่อลบ' }, focus: { en: 'Focus here', zh: '重点按摩', ja: 'ここを重点的に', ko: '여기 집중', th: 'เน้นตรงนี้' }, avoid: { en: 'Please avoid', zh: '请避开', ja: '避けてください', ko: '피해 주세요', th: 'หลีกเลี่ยง' }, pain: { en: 'Pain / injury', zh: '疼痛 / 受伤', ja: '痛み・けが', ko: '통증 / 부상', th: 'ปวด / บาดเจ็บ' }, front: { en: 'Front', zh: '正面', ja: '前', ko: '앞', th: 'ด้านหน้า' }, backSide: { en: 'Back', zh: '背面', ja: '後ろ', ko: '뒤', th: 'ด้านหลัง' }, nextHealth: { en: 'Next: your health', zh: '下一步:健康状况', ja: '次へ:健康状態', ko: '다음: 건강 상태', th: 'ต่อไป: สุขภาพของคุณ' }, health: { en: 'Your health', zh: '您的健康状况', ja: '健康状態', ko: '건강 상태', th: 'สุขภาพของคุณ' }, healthSub: { en: 'Tap anything that applies. This helps us care for you safely. Your answers are not saved.', zh: '请点击符合您的情况,以便我们安全地为您服务。您的回答不会被保存。', ja: '当てはまるものをタップしてください。安全な施術のためです。回答は保存されません。', ko: '해당하는 항목을 눌러 주세요. 안전한 관리를 위한 것이며, 답변은 저장되지 않습니다.', th: 'แตะข้อที่ตรงกับคุณ เพื่อให้เราดูแลคุณได้อย่างปลอดภัย คำตอบของคุณจะไม่ถูกบันทึก' }, pregNote: { en: 'If you are pregnant, we will not offer abdominal and womb massage. This is not a medical check.', zh: '如您已怀孕,我们不提供腹部及子宫按摩。这不是医学检查。', ja: '妊娠中の方には腹部・子宮のマッサージは行いません。これは医学的な検査ではありません。', ko: '임신 중이시면 복부·자궁 마사지는 제공하지 않습니다. 이것은 의료 검사가 아닙니다.', th: 'หากคุณตั้งครรภ์ เราจะไม่ให้บริการนวดท้องและมดลูก นี่ไม่ใช่การตรวจทางการแพทย์' }, stretchQ: { en: 'Thai massage includes stretching. Is that OK?', zh: '泰式按摩包含拉伸动作,可以吗?', ja: 'タイ古式マッサージにはストレッチが含まれます。よろしいですか?', ko: '타이 마사지에는 스트레칭이 포함됩니다. 괜찮으신가요?', th: 'นวดไทยมีการยืดเหยียด คุณโอเคไหม' }, sYes: { en: 'Yes', zh: '可以', ja: 'はい', ko: '네', th: 'ได้' }, sGentle: { en: 'Gentle only', zh: '只要轻柔的', ja: 'やさしく', ko: '부드럽게만', th: 'เบา ๆ เท่านั้น' }, sNo: { en: 'No stretching', zh: '不要拉伸', ja: 'ストレッチなし', ko: '스트레칭 없이', th: 'ไม่ยืดเหยียด' }, nextFeel: { en: 'Next: how you feel', zh: '下一步:您的感受', ja: '次へ:今の気分', ko: '다음: 지금 기분', th: 'ต่อไป: ความรู้สึกตอนนี้' }, beforeTag: { en: 'Majayakaya · before', zh: 'Majayakaya · 按摩前', ja: 'Majayakaya · 施術前', ko: 'Majayakaya · 마사지 전', th: 'Majayakaya · ก่อนนวด' }, afterTag: { en: 'Majayakaya · after', zh: 'Majayakaya · 按摩后', ja: 'Majayakaya · 施術後', ko: 'Majayakaya · 마사지 후', th: 'Majayakaya · หลังนวด' }, feelNow: { en: 'How do you feel right now?', zh: '您现在感觉如何?', ja: '今の気分はいかがですか?', ko: '지금 기분이 어떠세요?', th: 'ตอนนี้คุณรู้สึกอย่างไร' }, askAgain: { en: 'We will ask again after your treatment.', zh: '按摩结束后我们会再问一次。', ja: '施術後にもう一度お聞きします。', ko: '마사지 후에 다시 여쭤볼게요.', th: 'เราจะถามอีกครั้งหลังนวดเสร็จ' }, hBody: { en: '1 relaxed · 5 very tense', zh: '1 放松 · 5 非常紧绷', ja: '1 ゆるんでいる · 5 とても張っている', ko: '1 편안함 · 5 매우 뭉침', th: '1 ผ่อนคลาย · 5 ตึงมาก' }, hMind: { en: '1 calm · 5 very stressed', zh: '1 平静 · 5 压力很大', ja: '1 落ち着いている · 5 とてもストレス', ko: '1 차분함 · 5 스트레스 많음', th: '1 สงบ · 5 เครียดมาก' }, hEmo: { en: '1 low · 5 bright', zh: '1 低落 · 5 愉快', ja: '1 沈んでいる · 5 明るい', ko: '1 가라앉음 · 5 밝음', th: '1 หม่น · 5 สดใส' }, consent: { en: 'I agree that my answers, without my name, may be used to study the benefits of Thai massage.', zh: '我同意我的回答(不含姓名)可用于研究泰式按摩的益处。', ja: '名前を含まない私の回答を、タイ古式マッサージの効果の研究に使うことに同意します。', ko: '이름 없이 제 답변을 타이 마사지의 효과 연구에 사용하는 것에 동의합니다.', th: 'ฉันยินยอมให้ใช้คำตอบของฉัน (ไม่ระบุชื่อ) เพื่อศึกษาประโยชน์ของนวดไทย' }, start: { en: 'Start my treatment', zh: '开始按摩', ja: '施術を始める', ko: '마사지 시작', th: 'เริ่มนวด' }, feelAfter: { en: 'How do you feel now?', zh: '您现在感觉如何?', ja: '今の気分はいかがですか?', ko: '지금은 기분이 어떠세요?', th: 'ตอนนี้รู้สึกอย่างไรบ้าง' }, before: { en: 'before', zh: '按摩前', ja: '施術前', ko: '전', th: 'ก่อน' }, noteQ: { en: 'A few words for your therapist? (optional)', zh: '想对按摩师说几句话吗?(可选)', ja: 'セラピストへひとこと(任意)', ko: '테라피스트에게 한마디? (선택)', th: 'อยากบอกอะไรผู้นวดไหม (ไม่บังคับ)' }, shareOk: { en: 'You may share my words as a story, without my name.', zh: '可以匿名分享我的话。', ja: '名前を出さずに私の言葉を紹介してもかまいません。', ko: '이름 없이 제 글을 소개해도 좋습니다.', th: 'นำคำของฉันไปแบ่งปันได้ โดยไม่ระบุชื่อ' }, send: { en: 'Send', zh: '发送', ja: '送る', ko: '보내기', th: 'ส่ง' }, thanks: { en: 'Thank you for choosing {shop}. Healthy body, peaceful mind.', zh: '感谢您选择 {shop}。身体健康,心灵平静。', ja: '{shop} をお選びいただきありがとうございます。健やかな体と穏やかな心を。', ko: '{shop}을(를) 찾아 주셔서 감사합니다. 건강한 몸, 평온한 마음.', th: 'ขอบคุณที่เลือก {shop} ขอให้กายแข็งแรง ใจสงบ' }, typeHint: { en: 'Or type your answer. You can use your keyboard’s microphone.', zh: '或输入您的回答,也可以用键盘上的麦克风。', ja: '入力もできます。キーボードのマイクも使えます。', ko: '직접 입력해도 됩니다. 키보드의 마이크도 쓸 수 있어요.', th: 'หรือพิมพ์คำตอบ ใช้ไมโครโฟนบนแป้นพิมพ์ได้' }, qYes: { en: 'Yes, please', zh: '好的,谢谢', ja: 'はい、お願いします', ko: '네, 부탁해요', th: 'ได้ค่ะ/ครับ' }, qNo: { en: 'No, thank you', zh: '不用了,谢谢', ja: 'いいえ、結構です', ko: '아니요, 괜찮아요', th: 'ไม่เป็นไร ขอบคุณ' }, qSofter: { en: 'Softer, please', zh: '请轻一点', ja: 'もう少し弱めに', ko: '조금 약하게 해 주세요', th: 'เบาลงหน่อย' }, qStronger: { en: 'Stronger, please', zh: '请重一点', ja: 'もう少し強めに', ko: '조금 세게 해 주세요', th: 'แรงขึ้นหน่อย' }, qRepeat: { en: 'Could you say that again?', zh: '可以再说一遍吗?', ja: 'もう一度お願いします', ko: '다시 한 번 말씀해 주세요', th: 'พูดอีกครั้งได้ไหม' }, guest: { en: 'Guest', zh: '客人', ja: 'お客様', ko: '손님', th: 'ลูกค้า' }, noTrans: { en: 'Translation is not available right now. Please point, or use the buttons.', zh: '暂时无法翻译,请用手指示或使用按钮。', ja: '今は翻訳できません。指さしかボタンをお使いください。', ko: '지금은 번역할 수 없습니다. 손으로 가리키거나 버튼을 써 주세요.', th: 'ตอนนี้แปลไม่ได้ กรุณาชี้หรือใช้ปุ่ม' }, notPreg: { en: 'Not offered during pregnancy', zh: '孕期不提供', ja: '妊娠中は受けられません', ko: '임신 중에는 받을 수 없음', th: 'ไม่ให้บริการขณะตั้งครรภ์' }, waitStaff: { en: 'Thank you. Please show this screen to our staff.', zh: '谢谢。请把此画面给员工看。', ja: 'ありがとうございます。この画面をスタッフにお見せください。', ko: '감사합니다. 이 화면을 직원에게 보여 주세요.', th: 'ขอบคุณ กรุณาแสดงหน้าจอนี้ให้พนักงานดู' } }; // Body zones (front/back map + staff summary) const MJ_ZONES = { head: { en: 'Head', zh: '头部', ja: '頭', ko: '머리', th: 'ศีรษะ' }, neck: { en: 'Neck and shoulders', zh: '颈部和肩膀', ja: '首と肩', ko: '목과 어깨', th: 'คอ บ่า ไหล่' }, upperback: { en: 'Upper back', zh: '上背部', ja: '背中の上部', ko: '등 윗부분', th: 'หลังส่วนบน' }, lowerback: { en: 'Lower back', zh: '下背部', ja: '腰', ko: '허리', th: 'หลังส่วนล่าง / เอว' }, chest: { en: 'Chest', zh: '胸部', ja: '胸', ko: '가슴', th: 'หน้าอก' }, abdomen: { en: 'Abdomen', zh: '腹部', ja: 'お腹', ko: '복부', th: 'ท้อง' }, arms: { en: 'Arms', zh: '手臂', ja: '腕', ko: '팔', th: 'แขน' }, hands: { en: 'Hands', zh: '手', ja: '手', ko: '손', th: 'มือ' }, hips: { en: 'Hips', zh: '臀部', ja: 'お尻・腰回り', ko: '엉덩이', th: 'สะโพก' }, thighs: { en: 'Thighs', zh: '大腿', ja: '太もも', ko: '허벅지', th: 'ต้นขา' }, knees: { en: 'Knees', zh: '膝盖', ja: '膝', ko: '무릎', th: 'เข่า' }, calves: { en: 'Lower legs', zh: '小腿', ja: 'ふくらはぎ・すね', ko: '종아리', th: 'น่อง / หน้าแข้ง' }, feet: { en: 'Feet', zh: '脚', ja: '足', ko: '발', th: 'เท้า' } }; const MJ_HEALTH = [ ['preg', { en: 'Pregnant', zh: '怀孕', ja: '妊娠中', ko: '임신 중', th: 'ตั้งครรภ์' }], ['bp', { en: 'High blood pressure', zh: '高血压', ja: '高血圧', ko: '고혈압', th: 'ความดันโลหิตสูง' }], ['heart', { en: 'Heart condition', zh: '心脏病', ja: '心臓の病気', ko: '심장 질환', th: 'โรคหัวใจ' }], ['surgery', { en: 'Surgery or injury, last 3 months', zh: '近3个月内手术或受伤', ja: '3か月以内の手術・けが', ko: '최근 3개월 내 수술·부상', th: 'ผ่าตัดหรือบาดเจ็บใน 3 เดือน' }], ['fever', { en: 'Fever or unwell', zh: '发烧或身体不适', ja: '発熱・体調不良', ko: '발열 또는 몸이 안 좋음', th: 'มีไข้หรือไม่สบาย' }], ['blood', { en: 'Blood-thinning medicine', zh: '服用抗凝血药', ja: '血液をサラサラにする薬', ko: '혈액 희석제 복용', th: 'ทานยาละลายลิ่มเลือด' }], ['veins', { en: 'Varicose veins', zh: '静脉曲张', ja: '静脈瘤', ko: '하지정맥류', th: 'เส้นเลือดขอด' }], ['bones', { en: 'Fragile bones', zh: '骨质疏松', ja: '骨がもろい', ko: '약한 뼈(골다공증)', th: 'กระดูกเปราะ' }], ['skin', { en: 'Skin condition or oil allergy', zh: '皮肤问题或精油过敏', ja: '皮膚の病気・オイルアレルギー', ko: '피부 질환 또는 오일 알레르기', th: 'โรคผิวหนังหรือแพ้น้ำมัน' }], ['diabetes', { en: 'Diabetes', zh: '糖尿病', ja: '糖尿病', ko: '당뇨병', th: 'เบาหวาน' }]]; // The eight treatment categories (no prices: they change with season and promotion; staff give details). // Each: name, one line, a short description, and what the guest may feel (body / mind / emotion). const MJ_CATS = [ { id: 'thai', th: 'นวดแผนไทย', name: { en: 'Thai Traditional Massage', zh: '传统泰式按摩', ja: 'タイ古式マッサージ', ko: '타이 전통 마사지', th: 'นวดแผนไทย' }, line: { en: 'Pressure and stretching, with or without herbal balm', zh: '指压与拉伸,可选草药膏', ja: '指圧とストレッチ。ハーブバーム付きも可', ko: '지압과 스트레칭, 허브 밤 선택 가능', th: 'กดจุดและยืดเหยียด จะใช้ยาหม่องสมุนไพรหรือไม่ก็ได้' }, desc: { en: 'Fully clothed. The therapist uses palms, thumbs, elbows and gentle stretches along the body’s energy lines, the way it has been taught in Thailand for generations.', zh: '穿着衣服进行。按摩师用手掌、拇指、手肘和轻柔的拉伸沿着身体的能量线按摩,这是泰国代代相传的方式。', ja: '着衣のまま行います。手のひら、親指、肘とやさしいストレッチで、体のエネルギーラインに沿ってほぐします。タイで代々受け継がれてきた方法です。', ko: '옷을 입은 채 진행합니다. 손바닥, 엄지, 팔꿈치와 부드러운 스트레칭으로 몸의 에너지 라인을 따라 풀어 줍니다. 태국에서 대대로 전해 온 방식입니다.', th: 'สวมชุดนวด ผู้นวดใช้ฝ่ามือ นิ้วโป้ง ศอก และการยืดเหยียดเบา ๆ ไปตามเส้นพลังงานของร่างกาย ตามที่สืบทอดกันมาในไทย' }, feel: { en: ['Looser joints, lighter muscles', 'Clearer, steadier', 'Refreshed'], zh: ['关节更灵活,肌肉更轻松', '头脑更清醒、稳定', '神清气爽'], ja: ['関節がゆるみ、筋肉が軽く', 'すっきり落ち着く', 'リフレッシュ'], ko: ['관절이 풀리고 근육이 가벼움', '맑고 차분함', '상쾌함'], th: ['ข้อต่อคลาย กล้ามเนื้อเบา', 'ใจปลอดโปร่ง มั่นคง', 'สดชื่น'] } }, { id: 'stretch', th: 'นวดยืดเหยียดแบบไทย', name: { en: 'Thai Stretching Massage', zh: '泰式拉伸按摩', ja: 'タイ式ストレッチマッサージ', ko: '타이 스트레칭 마사지', th: 'นวดยืดเหยียดแบบไทย' }, line: { en: 'Full body, or neck, back and shoulders', zh: '全身,或颈部、背部和肩膀', ja: '全身、または首・背中・肩', ko: '전신, 또는 목·등·어깨', th: 'ทั้งตัว หรือ คอ หลัง บ่า ไหล่' }, desc: { en: 'Assisted stretches that open the hips, back and shoulders. Good after long flights, trekking or desk work.', zh: '辅助拉伸,舒展臀部、背部和肩膀。适合长途飞行、徒步或久坐办公之后。', ja: 'セラピストが補助するストレッチで股関節・背中・肩を開きます。長時間のフライトやトレッキング、デスクワークの後に。', ko: '테라피스트가 도와주는 스트레칭으로 고관절, 등, 어깨를 열어 줍니다. 장거리 비행, 트레킹, 오랜 사무 업무 후에 좋아요.', th: 'ยืดเหยียดโดยผู้นวดช่วย เปิดสะโพก หลัง และไหล่ เหมาะหลังเดินทางไกล เดินป่า หรือนั่งทำงานนาน' }, feel: { en: ['Longer, more open', 'Awake and clear', 'Light'], zh: ['舒展、开阔', '清醒', '轻松'], ja: ['のびやかに', '目が覚める感じ', '軽やか'], ko: ['길고 열린 느낌', '맑게 깨어남', '가벼움'], th: ['ยืดยาว โล่ง', 'ตื่นตัว แจ่มใส', 'เบาสบาย'] } }, { id: 'oil', th: 'นวดน้ำมันอโรมา', name: { en: 'Aroma Oil Massage', zh: '芳香精油按摩', ja: 'アロマオイルマッサージ', ko: '아로마 오일 마사지', th: 'นวดน้ำมันอโรมา' }, line: { en: 'Full body, or neck, back and shoulders', zh: '全身,或颈部、背部和肩膀', ja: '全身、または首・背中・肩', ko: '전신, 또는 목·등·어깨', th: 'ทั้งตัว หรือ คอ หลัง บ่า ไหล่' }, desc: { en: 'Slow, flowing strokes with warm aromatic oil. Please tell us about any oil or scent allergy.', zh: '用温热的芳香精油进行缓慢、流畅的按摩。如对精油或香味过敏,请告诉我们。', ja: '温かいアロマオイルで、ゆっくり流れるように施術します。オイルや香りのアレルギーがあればお知らせください。', ko: '따뜻한 아로마 오일로 천천히 부드럽게 마사지합니다. 오일이나 향 알레르기가 있으면 알려 주세요.', th: 'ลูบไล้ช้า ๆ ด้วยน้ำมันหอมอุ่น ๆ หากแพ้น้ำมันหรือกลิ่นใด โปรดแจ้งเรา' }, feel: { en: ['Soft, warm', 'Slow, quiet', 'Soothed'], zh: ['柔软温暖', '缓慢安静', '舒缓'], ja: ['やわらかく温かい', 'ゆっくり静か', '癒される'], ko: ['부드럽고 따뜻함', '느리고 고요함', '편안해짐'], th: ['นุ่ม อุ่น', 'ช้าลง เงียบสงบ', 'ผ่อนคลาย'] } }, { id: 'head', th: 'นวดศีรษะ คอ บ่า ไหล่', name: { en: 'Head, Neck and Shoulders', zh: '头、颈、肩按摩', ja: '頭・首・肩', ko: '머리·목·어깨', th: 'นวดศีรษะ คอ บ่า ไหล่' }, line: { en: 'Thai or aroma, with or without balm', zh: '泰式或芳香精油,可选药膏', ja: 'タイ式またはアロマ。バーム付きも可', ko: '타이 또는 아로마, 밤 선택 가능', th: 'แบบไทยหรืออโรมา ใช้ยาหม่องหรือไม่ก็ได้' }, desc: { en: 'Focused work where screens and travel bags leave their mark: the head, neck, shoulders and upper back.', zh: '集中放松因看屏幕和背包而疲劳的部位:头、颈、肩和上背部。', ja: '画面や荷物の疲れがたまりやすい頭・首・肩・背中の上部を集中的にほぐします。', ko: '화면과 가방 때문에 뭉치기 쉬운 머리, 목, 어깨, 등 윗부분을 집중적으로 풀어 줍니다.', th: 'เน้นจุดที่ล้าจากหน้าจอและกระเป๋าเดินทาง: ศีรษะ คอ บ่า ไหล่ และหลังส่วนบน' }, feel: { en: ['Lighter shoulders', 'Clearer head', 'Relieved'], zh: ['肩膀轻松', '头脑清醒', '如释重负'], ja: ['肩が軽く', '頭がすっきり', 'ほっとする'], ko: ['어깨가 가벼움', '머리가 맑음', '후련함'], th: ['ไหล่เบา', 'หัวโล่ง', 'โล่งใจ'] } }, { id: 'foot', th: 'นวดเท้า', name: { en: 'Foot Massage', zh: '足部按摩', ja: 'フットマッサージ', ko: '발 마사지', th: 'นวดเท้า' }, line: { en: 'Thai foot massage, or with oil', zh: '泰式足部按摩,或用精油', ja: 'タイ式フット、またはオイル', ko: '타이 발 마사지, 또는 오일', th: 'นวดเท้าแบบไทย หรือใช้น้ำมัน' }, desc: { en: 'Thai foot massage with a wooden stick on the reflex points of the feet and lower legs.', zh: '泰式足部按摩,用木棒按压脚部和小腿的反射点。', ja: '木の棒を使い、足と下腿の反射点を刺激するタイ式フットマッサージ。', ko: '나무 막대로 발과 종아리의 반사점을 눌러 주는 타이식 발 마사지입니다.', th: 'นวดเท้าแบบไทย ใช้ไม้กดจุดสะท้อนที่เท้าและน่อง' }, feel: { en: ['Light legs', 'Grounded', 'Content'], zh: ['双腿轻盈', '踏实', '满足'], ja: ['脚が軽い', '地に足がつく', '満ち足りる'], ko: ['다리가 가벼움', '안정됨', '만족스러움'], th: ['ขาเบา', 'มั่นคง', 'อิ่มใจ'] } }, { id: 'scrub', th: 'ขัดผิวและอาบน้ำนมข้าว', name: { en: 'Body Scrub and Rice Milk Bath', zh: '身体磨砂和米乳浴', ja: 'ボディスクラブとライスミルクバス', ko: '바디 스크럽과 라이스 밀크 배스', th: 'ขัดผิวและอาบน้ำนมข้าว' }, line: { en: 'Scrub, oil massage, rice milk bath', zh: '磨砂、精油按摩、米乳浴', ja: 'スクラブ、オイル、ライスミルクバス', ko: '스크럽, 오일 마사지, 라이스 밀크 배스', th: 'ขัดผิว นวดน้ำมัน แช่น้ำนมข้าว' }, desc: { en: 'A natural scrub, then an oil massage and a warm rice milk bath for the skin.', zh: '天然磨砂,接着精油按摩和温热的米乳浴,呵护肌肤。', ja: '天然のスクラブの後、オイルマッサージと温かいライスミルクバスで肌を整えます。', ko: '천연 스크럽 후 오일 마사지와 따뜻한 라이스 밀크 배스로 피부를 가꿉니다.', th: 'ขัดผิวด้วยสครับธรรมชาติ ตามด้วยนวดน้ำมันและแช่น้ำนมข้าวอุ่น ๆ' }, feel: { en: ['Smooth skin', 'Unhurried', 'Pampered'], zh: ['肌肤细滑', '从容', '被宠爱'], ja: ['すべすべの肌', 'ゆったり', '大切にされている'], ko: ['매끈한 피부', '여유로움', '대접받는 느낌'], th: ['ผิวเนียน', 'ไม่เร่งรีบ', 'รู้สึกได้รับการดูแล'] } }, { id: 'herbal', th: 'อบสมุนไพรและประคบสมุนไพรล้านนา', name: { en: 'Lanna Herbal Steam and Compress', zh: '兰纳草药蒸汽与热敷', ja: 'ランナー式ハーブスチームとハーブボール', ko: '란나 허브 스팀과 허브 볼 찜질', th: 'อบสมุนไพรและประคบสมุนไพรล้านนา' }, line: { en: 'Northern Thai herbal tradition', zh: '泰北草药传统', ja: '北タイのハーブの伝統', ko: '북부 태국의 허브 전통', th: 'ภูมิปัญญาสมุนไพรล้านนา' }, desc: { en: 'A gentle herbal steam opens the body. Warm compresses of Thai herbs are then pressed along tired muscles.', zh: '温和的草药蒸汽让身体舒展,然后用温热的泰国草药包按压疲劳的肌肉。', ja: 'やさしいハーブスチームで体をほぐし、温かいタイハーブのボールで疲れた筋肉を押していきます。', ko: '부드러운 허브 스팀으로 몸을 연 다음, 따뜻한 태국 허브 볼로 지친 근육을 눌러 줍니다.', th: 'อบไอน้ำสมุนไพรอ่อน ๆ ให้ร่างกายผ่อนคลาย แล้วประคบลูกประคบสมุนไพรอุ่น ๆ ตามกล้ามเนื้อที่เมื่อยล้า' }, feel: { en: ['Warmth, softer muscles', 'Quieter, slower thoughts', 'Calm, cared for'], zh: ['温暖,肌肉柔软', '思绪安静缓慢', '平静,被照顾'], ja: ['温かく、筋肉がやわらぐ', '思考が静かにゆっくり', '穏やか、守られている'], ko: ['따뜻함, 부드러운 근육', '고요하고 느린 생각', '차분함, 보살핌 받는 느낌'], th: ['อุ่น กล้ามเนื้อนุ่มลง', 'ความคิดช้าลง เงียบลง', 'สงบ รู้สึกได้รับการดูแล'] } }, { id: 'womb', th: 'นวดท้องและมดลูกแบบไทย', pregnancyExcluded: true, name: { en: 'Thai Abdominal and Womb Massage', zh: '泰式腹部与子宫按摩', ja: 'タイ式腹部・子宮マッサージ', ko: '타이 복부·자궁 마사지', th: 'นวดท้องและมดลูกแบบไทย' }, line: { en: 'Traditional care for the stomach and womb', zh: '传统的腹部和子宫护理', ja: 'お腹と子宮をいたわる伝統的なケア', ko: '배와 자궁을 위한 전통 관리', th: 'การดูแลท้องและมดลูกแบบดั้งเดิม' }, desc: { en: 'Gentle, slow work on the abdomen from Thai traditional care. Not offered during pregnancy. Please tell us about recent surgery.', zh: '源自泰国传统护理的温和、缓慢的腹部按摩。孕期不提供。如近期做过手术请告诉我们。', ja: 'タイの伝統的なケアによる、お腹へのやさしくゆっくりした施術。妊娠中は受けられません。最近の手術はお知らせください。', ko: '태국 전통 관리에서 온 부드럽고 느린 복부 마사지입니다. 임신 중에는 받을 수 없습니다. 최근 수술을 받으셨다면 알려 주세요.', th: 'นวดท้องอย่างอ่อนโยนและช้า ๆ ตามการดูแลแบบไทยดั้งเดิม ไม่ให้บริการขณะตั้งครรภ์ หากเพิ่งผ่าตัดโปรดแจ้ง' }, feel: { en: ['Warm, settled belly', 'Calm', 'Gentle with myself'], zh: ['腹部温暖安定', '平静', '善待自己'], ja: ['お腹が温かく落ち着く', '穏やか', '自分にやさしく'], ko: ['따뜻하고 편안한 배', '차분함', '나에게 다정해짐'], th: ['ท้องอุ่น สบาย', 'สงบ', 'อ่อนโยนกับตัวเอง'] } }]; // Thai labels for the staff summary const MJ_TH_PRESS = ['', 'เบามาก', 'เบา', 'ปานกลาง', 'แรง', 'แรงมาก']; const MJ_TH_STRETCH = { yes: 'ยืดได้', gentle: 'เบา ๆ เท่านั้น', no: 'ไม่ยืดเหยียด' }; // Staff quick phrases (Thai -> guest language, translated live; common ones fixed here so they work without AI) const MJ_STAFF_FIXED = [ { th: 'แรงกดโอเคไหมคะ/ครับ', en: 'Is the pressure OK?', zh: '力度可以吗?', ja: '圧の強さは大丈夫ですか?', ko: '압력 괜찮으세요?' }, { th: 'เจ็บตรงไหนบอกได้เลยนะคะ/ครับ', en: 'Please tell me if anything hurts.', zh: '如有疼痛请告诉我。', ja: '痛いところがあれば教えてください。', ko: '아프면 말씀해 주세요.' }, { th: 'กรุณาเปลี่ยนชุดนะคะ/ครับ', en: 'Please change into these clothes.', zh: '请换上这套衣服。', ja: 'こちらの服にお着替えください。', ko: '이 옷으로 갈아입어 주세요.' }, { th: 'กรุณานอนคว่ำค่ะ/ครับ', en: 'Please lie face down.', zh: '请趴下。', ja: 'うつ伏せになってください。', ko: '엎드려 주세요.' }, { th: 'กรุณานอนหงายค่ะ/ครับ', en: 'Please lie on your back.', zh: '请仰卧。', ja: '仰向けになってください。', ko: '바로 누워 주세요.' }, { th: 'ดื่มชาสมุนไพรก่อนนะคะ/ครับ', en: 'Please enjoy some herbal tea first.', zh: '请先喝点草本茶。', ja: 'まずはハーブティーをどうぞ。', ko: '먼저 허브차 한 잔 드세요.' }]; const MJ_PARTNER_DEFAULT = { id: 'ananta', name: 'Ananta', nameLocal: 'อนันตา', place: 'Massage & Wellness · Chiang Mai', accent: '#E5C27A', cats: MJ_CATS.map(c => c.id), pinHash: null, houseRule: 'ปฏิบัติตามแนวทางของร้านสำหรับลูกค้าที่มีภาวะนี้ หากไม่แน่ใจให้ถามหัวหน้าผู้นวดก่อนเริ่ม' }; const MJ_ACCENTS = ['#E5C27A', '#7FC2B3', '#E3A08A', '#B8A3E0', '#9CC57A', '#F0F0F0']; // Personal check-in words (scale directions follow the approved mockup: Body/Mind 1 = good, Emotion 5 = good) const MJ_WORDS = { b: ['', 'relaxed', 'loose', 'a bit tense', 'tense', 'very tense'], m: ['', 'calm', 'settled', 'busy', 'stressed', 'very stressed'], e: ['', 'low', 'flat', 'steady', 'good', 'bright'] }; /* ---------- Majayakaya: personal check-in, hub, heritage, partner shops ---------- */ let mjCol = null, mjCache = [], partnersCol = null, partnersDb = [], visitsCol = null, visitsCache = [], mjFrom = 'screen-home', mjPick = { b: 0, m: 0, e: 0 }; let mjEditId = null, mjVoices = []; const mjEsc = s => escapeHtml(String(s == null ? '' : s)); const mjT = (k, l) => { const e = MJ_T[k]; return e ? (e[l] || e.en) : k; }; function mjPartners() { const list = partnersDb.slice(); if (!list.some(p => p.id === MJ_PARTNER_DEFAULT.id)) list.unshift({ ...MJ_PARTNER_DEFAULT }); return list.map(p => ({ ...MJ_PARTNER_DEFAULT, ...p })); } function mjPartner(id) { return mjPartners().find(p => p.id === id) || { ...MJ_PARTNER_DEFAULT }; } function mjInit() { if (!db) return; if (uid && profileRef) { mjCol = profileRef.collection('majayakaya'); mjCol.orderBy('createdAt', 'desc').limit(60).onSnapshot(sn => { mjCache = sn.docs.map(d => ({ id: d.id, ...d.data() })); renderHomeMj(); renderMjHub(); }, e => console.warn('majayakaya', e)); } partnersCol = db.collection('partners'); partnersCol.onSnapshot(sn => { partnersDb = sn.docs.map(d => ({ id: d.id, ...d.data() })).filter(p => SAFE_ID.test(p.id)); renderMjHub(); if (shop) shopRender(); }, e => console.warn('partners', e)); visitsCol = db.collection('shopVisits'); visitsCol.orderBy('createdAt', 'desc').limit(400).onSnapshot(sn => { visitsCache = sn.docs.map(d => d.data()); renderMjHub(); renderHomeMj(); }, e => console.warn('visits', e)); let saved = null; try { saved = localStorage.getItem('kirv.shopMode'); } catch (e) {} if (saved && SAFE_ID.test(saved)) openShop(saved, true); } function mjLatest() { return mjCache.slice().sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0))[0] || null; } function mjDayLabel(ts) { const d = new Date(ts), t = new Date(); if (d.toDateString() === t.toDateString()) return 'Today'; const y = new Date(t); y.setDate(t.getDate() - 1); if (d.toDateString() === y.toDateString()) return 'Yesterday'; return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }); } function renderHomeMj() { const el = document.getElementById('home-mj'); if (!el) return; const l = mjLatest(); if (!l) { el.innerHTML = '
Not checked in yet. It takes 20 seconds.
'; } else el.innerHTML = `
Body${MJ_WORDS.b[l.b] || '–'}
Mind${MJ_WORDS.m[l.m] || '–'}
Emotion${MJ_WORDS.e[l.e] || '–'}
${mjDayLabel(l.createdAt)}
`; const btn = document.getElementById('home-mj-btn'); if (btn) btn.textContent = l && mjDayLabel(l.createdAt) === 'Today' ? 'Check in again' : 'Check in today'; const st = document.getElementById('home-guest-story'); if (!st) return; const s = visitsCache.find(v => v.shareNote && (v.noteEn || v.note)); st.style.display = s ? 'flex' : 'none'; if (s) st.innerHTML = `
☀ From a partner shop
“${mjEsc(truncate(String(s.noteEn || s.note), 200))}”
A guest at ${mjEsc(mjPartner(s.partner).name)} · shared with consent, without a name
`; } /* --- Majayakaya hub --- */ function goMajayakaya(section) { renderMjHub(); show('screen-majayakaya'); if (section === 'shops') { const s = document.getElementById('mj-shops'); if (s) s.scrollIntoView({ block: 'start' }); } } function mjAvg(list, side, k) { const v = list.map(x => x && x[side] && x[side][k]).filter(n => n >= 1 && n <= 5); return v.length ? (v.reduce((a, b) => a + b, 0) / v.length) : null; } function renderMjHub() { const me = document.getElementById('mj-mine'); if (!me) return; const days = []; for (let i = 6; i >= 0; i--) { const d = new Date(); d.setDate(d.getDate() - i); const key = d.toDateString(); days.push({ lab: d.toLocaleDateString('en-GB', { weekday: 'narrow' }), rec: mjCache.find(r => new Date(r.createdAt).toDateString() === key) }); } const dot = (v, good) => v ? `${v}` : '·'; me.innerHTML = `
${days.map(d => `${d.lab}`).join('')} Body${days.map(d => dot(d.rec && d.rec.b, true)).join('')} Mind${days.map(d => dot(d.rec && d.rec.m, true)).join('')} Emotion${days.map(d => dot(d.rec && d.rec.e, false)).join('')}
Last 7 days. Body and mind: 1 is easy, 5 is tense. Emotion: 5 is bright.
`; const sh = document.getElementById('mj-shop-list'); if (sh) sh.innerHTML = mjPartners().map(p => `
${mjEsc(p.nameLocal ? p.nameLocal + ' · ' : '')}${mjEsc(p.name)}
${mjEsc(p.place || '')}
Guests choose their language, see the treatments, answer by tapping and talk with staff through translation. Full screen; exit with the staff PIN.
${viewerIsOwner ? `Shop settings →` : ''}
`).join('') + (viewerIsOwner ? `` : ''); const rs = document.getElementById('mj-results'); if (!rs) return; const ps = mjPartners(); rs.innerHTML = ps.map(p => { const v = visitsCache.filter(x => x.partner === p.id && x.before && x.after); if (!v.length) return `
${mjEsc(p.name)}: no guest ratings yet. They appear here once guests agree to share.
`; const row = (lab, k, lowGood) => { const b = mjAvg(v, 'before', k), a = mjAvg(v, 'after', k); if (b == null || a == null) return ''; const better = lowGood ? a < b : a > b; return `
${lab}${b.toFixed(1)} → ${a.toFixed(1)} (${(a - b >= 0 ? '+' : '') + (a - b).toFixed(1)})
`; }; const stories = v.filter(x => x.shareNote && (x.noteEn || x.note)).slice(0, 3); return `
${mjEsc(p.name)} · ${v.length} guest${v.length === 1 ? '' : 's'} rated before and after
${row('Body tension', 'b', true)}${row('Mind stress', 'm', true)}${row('Emotion', 'e', false)} ${stories.map(s => `
“${mjEsc(truncate(String(s.noteEn || s.note), 180))}”
`).join('')}
`; }).join('') + '
Averages of anonymous ratings, only from guests who agreed. Health answers are never stored. Body and mind: lower is better. Emotion: higher is better.
'; } /* --- personal check-in --- */ function goMjCheck(from) { mjFrom = from || 'screen-majayakaya'; const l = mjLatest(); mjEditId = 'd' + localDateStr(); mjPick = l && l.id === mjEditId ? { b: l.b, m: l.m, e: l.e } : { b: 0, m: 0, e: 0 }; renderMjCheck(); show('screen-mjcheck'); } function mjSet(k, v) { mjPick[k] = v; renderMjCheck(); } function mjAdvice() { const { b, m, e } = mjPick; if (!b || !m || !e) return null; if (b >= 4) return { t: 'Your body is carrying tension. Try ten slow breaths with your shoulders dropped, then a short walk.', kal: 'I will take ten slow breaths with my shoulders dropped today.' }; if (m >= 4) return { t: 'Your mind is busy. Write down one worry and the first small step you can take on it.', kal: 'I will write down one worry and its first small step tonight.' }; if (e <= 2) return { t: 'Your feeling is low. Reach out to one person you trust, or step outside for ten minutes.', kal: 'I will call one friend I trust today.' }; return { t: 'You are in balance today. Notice what helped, and thank someone who was part of it.', kal: 'I will thank one person who helped me today.' }; } function renderMjCheck() { const el = document.getElementById('mjcheck-body'); if (!el) return; const row = (k, lab, hint) => `
${lab}${hint}
${[1, 2, 3, 4, 5].map(v => ``).join('')}
`; const a = mjAdvice(); el.innerHTML = row('b', 'Body', '1 relaxed · 5 very tense') + row('m', 'Mind', '1 calm · 5 very stressed') + row('e', 'Emotion', '1 low · 5 bright') + (a ? `
One small action for balance
${a.t}
Make it a plan in KirV →
` : '') + `
Majayakaya: body, mind, emotion, intelligence and action as one whole. Your check-ins are private to you and are not part of Avalrem.
`; } async function mjSave() { const m = document.getElementById('mjcheck-msg'); if (!mjCol) { if (m) m.textContent = 'Sign in to save check-ins.'; return; } try { await mjCol.doc(mjEditId).set({ b: mjPick.b, m: mjPick.m, e: mjPick.e, createdAt: Date.now() }); if (m) m.textContent = 'Saved.'; setTimeout(() => show(mjFrom === 'screen-home' ? 'screen-home' : 'screen-majayakaya'), 500); } catch (e) { if (m) m.textContent = syncFailMsg(e); } } function mjMakePlan() { const a = mjAdvice(); if (!a) return; goLogPersonal(); try { setEntryMode('free'); } catch (e) {} const ta = document.getElementById('log-text'); if (ta) { ta.value = a.kal; onLogTextInput(); } } /* --- heritage --- */ function goHeritage(from) { mjFrom = from || 'screen-majayakaya'; show('screen-heritage'); } /* --- partner settings (artifact owner only; the shop account cannot change them) --- */ let psId = ''; function goPartnerSettings(id) { if (!viewerIsOwner) return; psId = id || ''; const p = id ? mjPartner(id) : { ...MJ_PARTNER_DEFAULT, id: '', name: '', nameLocal: '', place: '' }; const el = document.getElementById('partner-body'); el.innerHTML = `
Colour
${MJ_ACCENTS.map(c => ``).join('')}
Treatments shown to guests
${MJ_CATS.map(c => ``).join('')}
Prices are never shown in KirV: they change with the season and promotions, so staff give them. Only you, as the owner of KirV, can change these settings.
`; el.dataset.accent = p.accent; show('screen-partner'); } function psAccent(c) { const el = document.getElementById('partner-body'); el.dataset.accent = c; el.querySelectorAll('[data-acc]').forEach(b => { b.style.borderColor = b.dataset.acc === c ? 'var(--ink)' : 'transparent'; }); } async function mjHash(pin) { const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode('kirv-shop-pin:' + pin)); return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join(''); } async function psSave() { const msg = t => { document.getElementById('ps-msg').textContent = t; }; const name = document.getElementById('ps-name').value.trim(); if (!name) return msg('Please give the shop a name.'); const id = psId || name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || ('shop' + Date.now()); const cats = Array.from(document.querySelectorAll('#partner-body [data-cat]')).filter(i => i.checked).map(i => i.dataset.cat); if (!cats.length) return msg('Choose at least one treatment.'); const pin = document.getElementById('ps-pin').value.trim(); if (pin && !/^\d{4,8}$/.test(pin)) return msg('The PIN must be 4 to 8 digits.'); const old = psId ? mjPartner(psId) : {}; const doc = { name, nameLocal: document.getElementById('ps-local').value.trim(), place: document.getElementById('ps-place').value.trim(), accent: document.getElementById('partner-body').dataset.accent || '#E5C27A', cats, houseRule: document.getElementById('ps-rule').value.trim().slice(0, 300), pinHash: pin ? await mjHash(pin) : (old.pinHash || null), updatedAt: Date.now() }; try { await partnersCol.doc(id).set(doc); msg('Saved.'); setTimeout(() => goMajayakaya('shops'), 500); } catch (e) { msg(syncFailMsg(e)); } } /* ===================== PARTNER SHOP MODE (full screen) ===================== */ let shop = null, shopTimer = null; function shopFresh(pid) { return { pid, view: 'lang', lang: 'en', cat: null, press: 0, marks: {}, mode: 'focus', health: [], stretch: '', before: { b: 0, m: 0, e: 0 }, after: { b: 0, m: 0, e: 0 }, consent: false, note: '', share: false, noteTh: '', talk: { g: '', gT: '', s: '', sT: '', busy: false, flip: true, ret: 'services' }, pin: '' }; } function openShop(pid, restoring) { shop = shopFresh(SAFE_ID.test(pid || '') ? pid : MJ_PARTNER_DEFAULT.id); try { localStorage.setItem('kirv.shopMode', shop.pid); } catch (e) {} document.getElementById('shop').style.display = 'block'; document.body.style.overflow = 'hidden'; if (!restoring) { try { const r = document.documentElement.requestFullscreen && document.documentElement.requestFullscreen(); if (r && r.catch) r.catch(() => {}); } catch (e) {} } mjLoadVoices(); shopRender(); } function closeShop() { shop = null; clearTimeout(shopTimer); try { localStorage.removeItem('kirv.shopMode'); } catch (e) {} try { speechSynthesis.cancel(); } catch (e) {} document.getElementById('shop').style.display = 'none'; document.body.style.overflow = ''; try { if (document.fullscreenElement) document.exitFullscreen(); } catch (e) {} goMajayakaya('shops'); } function shopGo(v) { if (!shop) return; shop.view = v; shopRender(); const s = document.getElementById('shop'); if (s) s.scrollTop = 0; } function shopLang(l) { if (!shop) return; const pid = shop.pid; shop = shopFresh(pid); shop.lang = l; shopGo('services'); } function shopCats() { const p = mjPartner(shop.pid); return MJ_CATS.filter(c => p.cats.includes(c.id)); } const SV = { back: '', next: '', talk: '', lock: '', speak: '', shield: '', globe: '' }; function shopTop(backView, right) { const L = shop.lang; return `
${backView ? `` : ''}${right || ''}
`; } function shopTalkBtn(ret) { return ``; } function shopScale(side, k, hint) { const L = shop.lang, v = shop[side][k], bef = side === 'after' ? shop.before[k] : 0; return `
${mjT(k === 'b' ? 'body' : k === 'm' ? 'mind' : 'emotion', L)}${bef ? mjT('before', L) + ' ' + bef : mjT(hint, L)}
${[1, 2, 3, 4, 5].map(n => ``).join('')}
`; } function shopSet(side, k, n) { shop[side][k] = n; shopRender(); } function shopRender() { const root = document.getElementById('shop'); if (!root || !shop) return; const p = mjPartner(shop.pid), L = shop.lang, v = shop.view; root.style.setProperty('--sa', p.accent || '#E5C27A'); root.setAttribute('lang', L === 'zh' ? 'zh-CN' : L); let h = ''; if (v === 'lang') { h = `
${mjEsc(p.nameLocal || p.name)}${mjEsc((p.name + ' · ' + (p.place || '')).toUpperCase())}
Welcome
ยินดีต้อนรับ · 欢迎 · ようこそ · 환영합니다
Please tap your language
${MJ_LANGS.map(l => ``).join('')}
${SV.shield}${mjT('certFull', 'en')}
${SV.globe}${mjT('unesco', 'en')}
`; } else if (v === 'services') { h = shopTop('lang', shopTalkBtn('services')) + `

${mjT('treatments', L)}

${mjT('askPrice', L)}
${mjT('certShort', L)}
${shopCats().map(c => ``).join('')}
`; } else if (v === 'detail') { const c = MJ_CATS.find(x => x.id === shop.cat) || shopCats()[0]; shop.cat = c.id; const preg = shop.health.includes('preg') && c.pregnancyExcluded; h = shopTop('services', shopTalkBtn('detail')) + `
${mjEsc(c.th)}

${c.name[L]}

${c.desc[L]}
${mjT('mayFeel', L)}
${['body', 'mind', 'emotion'].map((k, i) => `
${mjT(k, L)}${c.feel[L][i]}
`).join('')}
${mjT('askPrice', L)}
${preg ? `
${mjT('notPreg', L)}
` : ``} `; } else if (v === 'consult') { h = shopTop('detail', `${mjT('step1', L)}`) + `

${mjT('pressure', L)}

${[1, 2, 3, 4, 5].map(n => ``).join('')}

${mjT('tapBody', L)}

${mjT('tapHint', L)}
${['focus', 'avoid', 'pain'].map(m => ``).join('')}
${mjBodySvg('front')}${mjBodySvg('back')}
${mjT('front', L)}${mjT('backSide', L)}
${Object.keys(shop.marks).map(z => `${MJ_ZONES[z][L]} · ${mjT(shop.marks[z], L)}`).join('')}
`; } else if (v === 'health') { const c = MJ_CATS.find(x => x.id === shop.cat), preg = shop.health.includes('preg') && c && c.pregnancyExcluded; h = shopTop('consult', `${mjT('step2', L)}`) + `

${mjT('health', L)}

${mjT('healthSub', L)}
${MJ_HEALTH.map(([k, t]) => ``).join('')}
${mjT('pregNote', L)}
${preg ? `
${mjT('notPreg', L)} · ${c.name[L]}
` : ''} ${shopCats().some(x => x.id === 'thai' || x.id === 'stretch') ? `
${mjT('stretchQ', L)}
${[['yes', 'sYes'], ['gentle', 'sGentle'], ['no', 'sNo']].map(([k, t]) => ``).join('')}
` : ''} `; } else if (v === 'before') { const ok = shop.before.b && shop.before.m && shop.before.e; h = shopTop('health', `${mjT('step3', L)}`) + `
${mjT('beforeTag', L).toUpperCase()}

${mjT('feelNow', L)}

${mjT('askAgain', L)}
${shopScale('before', 'b', 'hBody')}${shopScale('before', 'm', 'hMind')}${shopScale('before', 'e', 'hEmo')}
${mjT('waitStaff', L)}
`; } else if (v === 'staff') { const c = MJ_CATS.find(x => x.id === shop.cat), zs = m => Object.keys(shop.marks).filter(z => shop.marks[z] === m).map(z => MJ_ZONES[z].th).join(' · ') || '–'; const hl = MJ_HEALTH.filter(([k]) => shop.health.includes(k)).map(([, t]) => t.th), preg = shop.health.includes('preg'); const lang = MJ_LANGS.find(l => l.k === L); h = `
สำหรับพนักงาน

สรุปสำหรับผู้นวด

ลูกค้า: ${lang ? lang.name : ''} (${lang ? lang.label : ''})
${[['บริการ', c ? c.th : '–'], ['แรงกด', shop.press ? shop.press + ' · ' + MJ_TH_PRESS[shop.press] : 'ไม่ได้เลือก'], ['เน้น', zs('focus')], ['หลีกเลี่ยง', zs('avoid')], ['ปวด / บาดเจ็บ', zs('pain')], ['ยืดเหยียด', MJ_TH_STRETCH[shop.stretch] || 'ไม่ได้ตอบ']].map(([a, b]) => `
${a}${mjEsc(b)}
`).join('')}
${preg && c && c.pregnancyExcluded ? '
ลูกค้าตั้งครรภ์ · ไม่ให้บริการนวดท้องและมดลูก กรุณาแนะนำบริการอื่น
' : ''} ${hl.length ? `
สุขภาพ: ${hl.map(mjEsc).join(' · ')}
${mjEsc(p.houseRule || MJ_PARTNER_DEFAULT.houseRule)}
` : '
สุขภาพ: ลูกค้าไม่ได้เลือกข้อใด
'}
ก่อนนวด (ลูกค้าเลือกเอง)
${[['b', 'กายตึง'], ['m', 'ใจเครียด'], ['e', 'อารมณ์']].map(([k, t]) => `
${shop.before[k] || '–'}
${t}
`).join('')}
${shop.consent ? 'ลูกค้ายินยอมให้ใช้คะแนน (ไม่ระบุชื่อ)' : 'ลูกค้าไม่ได้ยินยอม · จะไม่บันทึกคะแนน'}
ข้อมูลสุขภาพแสดงบนจอเท่านั้น ไม่ถูกบันทึก
`; } else if (v === 'after') { const ok = shop.after.b && shop.after.m && shop.after.e; h = shopTop('', '') + `
${mjT('afterTag', L).toUpperCase()}

${mjT('feelAfter', L)}

${shopScale('after', 'b', 'hBody')}${shopScale('after', 'm', 'hMind')}${shopScale('after', 'e', 'hEmo')} ${shop.consent ? `` : ''} `; } else if (v === 'thanks') { h = `
🙏
${mjEsc(mjT('thanks', L).replace('{shop}', p.name))}
${shop.note ? `
ข้อความถึงผู้นวด
${mjEsc(shop.noteTh || shop.note)}
${shop.noteTh ? '' : '
(แปลไม่ได้ในตอนนี้ แสดงข้อความต้นฉบับ)
'}
` : ''}
`; clearTimeout(shopTimer); shopTimer = setTimeout(() => { if (shop && shop.view === 'thanks') shopNext(); }, 60000); } else if (v === 'talk') { h = shopTalkHtml(); } else if (v === 'pin') { h = `

ใส่ PIN พนักงาน

${'•'.repeat(shop.pin.length)}
${['1', '2', '3', '4', '5', '6', '7', '8', '9', 'C', '0', 'OK'].map(k => ``).join('')}
ออกจากโหมดร้าน · กลับไปที่ KirV
`; } root.innerHTML = `
${h}
`; } function shopHealth(k) { const i = shop.health.indexOf(k); if (i >= 0) shop.health.splice(i, 1); else shop.health.push(k); shopRender(); } function mjZone(z) { if (!shop || !MJ_ZONES[z]) return; if (shop.marks[z] === shop.mode) delete shop.marks[z]; else shop.marks[z] = shop.mode; shopRender(); } function mjBodySvg(side) { const mk = z => shop.marks[z] || '', fill = { focus: '#1F4A42', avoid: '#3A2E14', pain: '#5A2A20' }, stroke = { focus: '#7FC2B3', avoid: '#D9A64A', pain: '#C0604A' }, line = { focus: '#2E7F70', avoid: '#B08433', pain: '#A8513E' }; const L = shop.lang, lab = z => mjEsc(MJ_ZONES[z][L]); const area = (z, inner) => { const m = mk(z); return `${inner}`; }; const limb = (z, d, w) => { const m = mk(z); return `${d.map(p => ``).join('')}`; }; let s = area('head', ''); if (side === 'front') s += area('neck', '') + area('chest', '') + area('abdomen', ''); else s += area('neck', '') + area('upperback', '') + area('lowerback', ''); s += area('hips', '') + limb('arms', ['M58 92 L40 190 L33 240', 'M142 92 L160 190 L167 240'], 18) + area('hands', '') + limb('thighs', ['M80 244 L77 312', 'M120 244 L123 312'], 26) + area('knees', '') + limb('calves', ['M75 352 L72 392', 'M125 352 L128 392'], 22) + area('feet', ''); return `${s}`; } async function shopPin(k) { if (!shop) return; const msg = t => { const e = document.getElementById('sh-pin-msg'); if (e) e.textContent = t; }; if (k === 'C') { shop.pin = shop.pin.slice(0, -1); shopRender(); return; } if (k !== 'OK') { if (shop.pin.length < 8) shop.pin += k; shopRender(); return; } const p = mjPartner(shop.pid), want = p.pinHash || await mjHash('1234'), got = await mjHash(shop.pin); if (got === want) closeShop(); else { shop.pin = ''; shopRender(); msg('PIN ไม่ถูกต้อง'); } } function shopNext() { if (!shop) return; clearTimeout(shopTimer); const pid = shop.pid; shop = shopFresh(pid); shopRender(); } async function shopFinish() { if (!shop) return; const b = document.getElementById('sh-send'); if (b) { b.disabled = true; b.textContent = '…'; } const L = shop.lang; shop.note = String(shop.note || '').trim().slice(0, 400); shop.noteTh = ''; let noteEn = ''; if (shop.note) { if (L === 'th') shop.noteTh = shop.note; try { const r = await aiCall('Translate this short message from a massage guest to their therapist. Keep the tone warm and natural. Return JSON only: {"th": "Thai translation", "en": "English translation"}.\n\nMessage: """' + shop.note + '"""', true); if (r) { if (!shop.noteTh) shop.noteTh = String(r.th || '').slice(0, 600); noteEn = String(r.en || '').slice(0, 600); } } catch (e) {} if (L === 'en') noteEn = shop.note; } if (shop.consent && visitsCol) { const rec = { partner: shop.pid, lang: L, cat: shop.cat || null, before: { ...shop.before }, after: { ...shop.after }, createdAt: Date.now(), shareNote: !!(shop.share && shop.note) }; if (rec.shareNote) { rec.note = shop.note; rec.noteEn = noteEn || null; rec.noteTh = shop.noteTh || null; } try { await visitsCol.doc('v' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7)).set(rec); } catch (e) { console.warn('visit save', e); } } shopGo('thanks'); } /* --- Talk: guest side on top (turned toward the guest), staff side below in Thai --- */ const mjTransCache = new Map(); async function mjTranslate(text, from, to) { if (from === to) return text; const key = from + '>' + to + ':' + text; if (mjTransCache.has(key)) return mjTransCache.get(key); const nm = k => (MJ_LANGS.find(l => l.k === k) || {}).name || 'English'; const r = await aiCall('You translate between a Thai massage shop’s staff and a guest. Translate the text from ' + nm(from) + ' to ' + nm(to) + '. Keep it short, polite and natural, as spoken in a massage shop. Do not add anything. Return JSON only: {"t": "translation"}.\n\nText: """' + String(text).slice(0, 500) + '"""', true); const t = String((r && r.t) || '').trim().slice(0, 800); if (!t) throw new Error('empty'); mjTransCache.set(key, t); return t; } function shopTalk(ret) { if (!shop) return; shop.talk.ret = ret || 'services'; shopGo('talk'); } function shopTalkHtml() { const L = shop.lang, t = shop.talk, lang = MJ_LANGS.find(l => l.k === L); const guest = `
${mjT('guest', L)} · ${lang.label}
${t.sT ? mjEsc(t.sT) : '…'}
${t.sT ? `` : ''}
${['qYes', 'qNo', 'qSofter', 'qStronger', 'qRepeat'].map(q => ``).join('')}
`; const staff = `
พนักงาน · ไทย
${t.gT ? mjEsc(t.gT) : 'ข้อความของลูกค้าจะแปลเป็นไทยที่นี่'}
${t.busy ? '
กำลังแปล…
' : ''}${t.err ? `
${mjEsc(t.err)}
` : ''}
${MJ_STAFF_FIXED.map((f, i) => ``).join('')}
`; return `
${guest}${staff}
`; } function shopGuestQuick(q) { const t = shop.talk; t.g = mjT(q, shop.lang); t.gT = MJ_T[q].th; t.err = ''; shopRender(); } function shopStaffFixed(i) { const f = MJ_STAFF_FIXED[i], t = shop.talk; t.s = f.th; t.sT = shop.lang === 'th' ? f.th : f[shop.lang]; t.err = ''; shopRender(); mjSpeak(t.sT, shop.lang); } async function shopGuestSend() { const i = document.getElementById('sh-gin'), txt = (i && i.value || '').trim(); if (!txt) return; const t = shop.talk; t.g = txt; t.busy = true; t.err = ''; shopRender(); try { t.gT = await mjTranslate(txt, shop.lang, 'th'); } catch (e) { t.gT = ''; t.err = 'แปลไม่ได้ตอนนี้ · ' + mjT('noTrans', 'th') + ' (ข้อความลูกค้า: ' + txt + ')'; } t.busy = false; if (shop.view === 'talk') shopRender(); } async function shopStaffSend() { const i = document.getElementById('sh-sin'), txt = (i && i.value || '').trim(); if (!txt) return; const t = shop.talk; t.s = txt; t.busy = true; t.err = ''; shopRender(); try { t.sT = await mjTranslate(txt, 'th', shop.lang); mjSpeak(t.sT, shop.lang); } catch (e) { t.sT = ''; t.err = 'แปลไม่ได้ตอนนี้ (' + aiErrorText(e) + ')'; } t.busy = false; if (shop.view === 'talk') shopRender(); } /* --- speech output (the browser's own voices; no microphone permission is needed: input uses the keyboard's dictation key) --- */ function mjLoadVoices() { try { mjVoices = speechSynthesis.getVoices() || []; if (!mjVoices.length) speechSynthesis.onvoiceschanged = () => { mjVoices = speechSynthesis.getVoices() || []; if (document.getElementById('screen-devtest').classList.contains('active')) renderDevTest(); }; } catch (e) { mjVoices = []; } } function mjVoiceFor(l) { const sp = (MJ_LANGS.find(x => x.k === l) || {}).speech || 'en-US'; const lc = sp.toLowerCase(), pre = lc.slice(0, 2); return mjVoices.find(v => v.lang && v.lang.toLowerCase().replace('_', '-') === lc) || mjVoices.find(v => v.lang && v.lang.toLowerCase().startsWith(pre)) || null; } function mjSpeak(text, l) { if (!text || !('speechSynthesis' in window)) return false; try { speechSynthesis.cancel(); const u = new SpeechSynthesisUtterance(String(text)); u.lang = (MJ_LANGS.find(x => x.k === l) || {}).speech || 'en-US'; const v = mjVoiceFor(l); if (v) u.voice = v; u.rate = 0.95; speechSynthesis.speak(u); return !!v; } catch (e) { return false; } } /* --- device test (for the shop iPad and the receptionists' phones) --- */ const MJ_TEST_LINE = { en: 'Welcome. How would you like your pressure today?', zh: '欢迎光临。今天您想要什么力度?', ja: 'ようこそ。今日の圧の強さはいかがなさいますか?', ko: '어서 오세요. 오늘 압력은 어느 정도로 해 드릴까요?', th: 'ยินดีต้อนรับค่ะ วันนี้อยากได้แรงกดแบบไหนคะ' }; let devTrans = {}; function goDevTest() { mjLoadVoices(); renderDevTest(); show('screen-devtest'); } function renderDevTest() { const el = document.getElementById('devtest-body'); if (!el) return; const ua = navigator.userAgent, dev = /iPad|Macintosh/.test(ua) && 'ontouchend' in document ? 'iPad' : /iPhone/.test(ua) ? 'iPhone' : /Android/.test(ua) ? (/SM-|Samsung/i.test(ua) ? 'Samsung (Android)' : 'Android') : 'Computer'; el.innerHTML = `
This device: ${dev} · voices found: ${mjVoices.length}
Tap each test below. Write down anything that fails and tell Claude.
1 · Read aloud (each language)
${MJ_LANGS.map(l => { const v = mjVoiceFor(l.k); return `
${l.label} ${l.name}
${v ? 'Voice: ' + mjEsc(v.name) : 'No voice found on this device'}
`; }).join('')}
Missing a voice? iPad / iPhone: Settings › Accessibility › Spoken Content › Voices, download that language. Samsung: Settings › General management › Text-to-speech, choose Google speech engine and install the language.
2 · Translation (uses this account’s Claude)
Thai: “ผ่อนคลายไหมคะ เจ็บตรงไหนบอกได้นะคะ”
${Object.keys(devTrans).map(k => `
${k} ${mjEsc(devTrans[k])}
`).join('')}
3 · Keyboard dictation
Tap the box, then the microphone key on the keyboard, and speak Thai. iPad / iPhone: Settings › General › Keyboard › Enable Dictation, and add the Thai keyboard. Samsung: use Google voice typing (Gboard microphone).
4 · Full screen and lock
Open shop mode from Majayakaya. On the shop iPad, turn on Guided Access (Settings › Accessibility › Guided Access) and triple-click to lock the iPad to KirV during the day. Exit shop mode with the staff PIN (default 1234 until the owner sets one).
`; } async function devTranslate() { const out = document.getElementById('dev-tr-out'); aiBusy('dev-tr-btn', true, 'Translate into the four languages'); devTrans = {}; const src = 'ผ่อนคลายไหมคะ เจ็บตรงไหนบอกได้นะคะ'; for (const l of MJ_LANGS.filter(x => x.k !== 'th')) { try { devTrans[l.label] = await mjTranslate(src, 'th', l.k); } catch (e) { devTrans[l.label] = '✗ ' + aiErrorText(e); } if (out) out.innerHTML = Object.keys(devTrans).map(k => `
${k} ${mjEsc(devTrans[k])}
`).join(''); } aiBusy('dev-tr-btn', false, 'Translate into the four languages'); } /* ---------- boot ---------- */ let userTapped = false; document.addEventListener('pointerdown', () => { userTapped = true; }, { once: true, capture: true }); (async function boot() { try { if (user) { /* placeholder */ } } catch (e) {} await initStorage(); try { mjInit(); } catch (e) { console.warn('majayakaya init', e); } // [2026-10-07, founder] everyone starts on Home (sign-up or the re-agree screen first only when needed), unless they already tapped somewhere if (!userTapped && !needsSignup()) { const a = document.querySelector('.screen.active'); if (!a || a.id !== 'screen-home') goHome(); } if (!needsSignup()) startActivityLog(); subscribeNotes(); // only once the person has agreed to the current Privacy policy initRoom(); let initials = 'YT'; try { if (user) { const me = await user.me(); if (me && me.name) initials = me.name.split(' ').map(w=>w[0]).slice(0,2).join('').toUpperCase(); } } catch (e) {} // [changed 2026-10-01] the avatar is now the Settings gear; initials are no longer shown try { if (user) { const me = await user.me(); if (me && me.name) document.getElementById('signup-name').textContent = me.name; } } catch (e) {} renderImpactRow('log-impact', pickLogImpact, null); })();