<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: owen</title>
    <description>The latest articles on DEV Community by owen (@owen090).</description>
    <link>https://dev.to/owen090</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4063284%2F91e16f9f-c744-4bc3-be5b-98257619ac0f.png</url>
      <title>DEV Community: owen</title>
      <link>https://dev.to/owen090</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/owen090"/>
    <language>en</language>
    <item>
      <title>Skip Is a Range: Honest Scoring for an Incomplete 100-Item Checklist</title>
      <dc:creator>owen</dc:creator>
      <pubDate>Thu, 08 Oct 2026 09:12:16 +0000</pubDate>
      <link>https://dev.to/owen090/skip-is-a-range-honest-scoring-for-an-incomplete-100-item-checklist-1ila</link>
      <guid>https://dev.to/owen090/skip-is-a-range-honest-scoring-for-an-incomplete-100-item-checklist-1ila</guid>
      <description>&lt;p&gt;In &lt;a href="https://dev.to/owen090/skip-is-not-zero-scoring-a-private-quiz-without-a-database-7cn"&gt;my previous post&lt;/a&gt;, I wrote about preserving &lt;code&gt;null&lt;/code&gt; in a 1–5 quiz instead of silently turning a skipped statement into zero. The implementation kept answers in React memory and made sharing an explicit user action.&lt;/p&gt;

&lt;p&gt;The next checklist created a different version of the same modeling problem.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://kinkweave.com/rice-purity-test" rel="noopener noreferrer"&gt;Rice Purity Test on KinkWeave&lt;/a&gt; has 100 yes/no items. The page is 18+ and some prompts are sensitive, but the engineering question is ordinary: what should the result mean when a person skips an item or has not reached it yet?&lt;/p&gt;

&lt;p&gt;The tempting implementation is &lt;code&gt;100 - yesCount&lt;/code&gt;. It is also wrong for an incomplete attempt. With ten unanswered items, that number assumes all ten answers are “No.” The interface would be displaying certainty the data does not contain.&lt;/p&gt;

&lt;p&gt;I decided that an incomplete binary checklist should return an interval.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preserve two kinds of missingness
&lt;/h2&gt;

&lt;p&gt;The answer type has three explicit values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;RiceAnswer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;yes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;skip&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;RiceAnswers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;RiceAnswer&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An unanswered item is not a fourth stored value. It is an absent key. That distinction lets the interface say whether somebody deliberately chose Skip or simply left an item untouched. Both remain unresolved for scoring, but they are not identical interaction states.&lt;/p&gt;

&lt;p&gt;The scoring function counts all four cases and refuses to manufacture a definite score:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;scoreRice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bank&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RiceBank&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RiceAnswers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;validateRiceBank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bank&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;yes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;no&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;skipped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unanswered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;bank&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;yes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;yes&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;no&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;skip&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;skipped&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nx"&gt;unanswered&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;unresolved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;skipped&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;unanswered&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;yes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;no&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;skipped&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unanswered&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unresolved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unresolved&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unresolved&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;yes&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;min&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;yes&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;unresolved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;yes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The upper bound assumes every unresolved item would be No, so none of them lowers &lt;code&gt;100 - yes&lt;/code&gt;. The lower bound assumes every unresolved item would be Yes, so each one lowers it by one.&lt;/p&gt;

&lt;p&gt;For example, suppose one item is Yes, one is No, one is explicitly skipped, and 97 are unanswered. The only honest result is &lt;code&gt;1–99 / 100&lt;/code&gt;. The code does not call either endpoint the score. &lt;code&gt;score&lt;/code&gt; stays &lt;code&gt;null&lt;/code&gt;, and the share text says the result is incomplete and “not a definite score.”&lt;/p&gt;

&lt;p&gt;This is a small type-level choice with a visible product consequence: uncertainty survives all the way to the UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reset should restore absence
&lt;/h2&gt;

&lt;p&gt;Changing an answer introduces another easy bug. If Reset assigns No, the control looks cleared while the score still contains an invented answer.&lt;/p&gt;

&lt;p&gt;The reducer instead deletes the key. The same reducer also invalidates the current result and any prepared share state whenever an answer changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;answer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;answers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;answers&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unanswered&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="nx"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nx"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;phase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;questions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;sharePreview&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;shareConfirmed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;copyStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;idle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;revision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;revision&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;revision&lt;/code&gt; counter handles a less obvious race. Copying to the clipboard is asynchronous. A person can start a copy, change an answer, and then receive the old promise callback. The reducer accepts &lt;code&gt;copied&lt;/code&gt; and &lt;code&gt;copy-failed&lt;/code&gt; only when the callback revision still matches the current state, the result screen is active, the share preview is open, confirmation is still present, and a copy is actually in flight.&lt;/p&gt;

&lt;p&gt;So a late callback cannot label a changed result as copied. Sharing also requires a preview followed by explicit confirmation; it is not a one-click side effect of viewing the result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validate the content contract at both boundaries
&lt;/h2&gt;

&lt;p&gt;A score is only comparable when the input bank has the expected shape and edition. The validator requires exactly 100 questions, IDs &lt;code&gt;1&lt;/code&gt; through &lt;code&gt;100&lt;/code&gt; in order, non-empty reviewed text, and no duplicate text after trimming, whitespace normalization, and lowercasing. It also distinguishes the development fixture from the reviewed traditional bank.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bank&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;bank&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Unreviewed or unsupported question bank&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;texts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;bank&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Invalid question manifest&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Duplicate question&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server-side loader validates the installed JSON and checks its pinned version and scoring mode. The API returns that fixed bank with &lt;code&gt;private, no-store&lt;/code&gt; caching. The client validates the response again and accepts only the expected traditional edition and version. This is deliberate duplication: the server protects deployment integrity, while the client protects the state transition that begins an attempt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Clearing during load is a state transition, not just a button
&lt;/h2&gt;

&lt;p&gt;The question bank is fetched only after Start. That creates another race: a person can click Cancel loading or Clear while the request is in flight. Aborting the request is useful, but I did not make correctness depend on abort winning the race.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useRef&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;AbortController&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;generation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;clear&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCallback&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;generation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;setLoading&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;setError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;analyticsCtl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clearAttempt&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;rawDispatch&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;clear&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;analyticsCtl&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AbortController&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;currentGeneration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;generation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/rice-questions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no-store&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bank&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nf"&gt;validateRiceBank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bank&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentGeneration&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;generation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;rawDispatch&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;start&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bank&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clear increments a generation and aborts the active controller. Even if a response has already crossed the network boundary, its captured generation is stale, so it cannot repopulate the cleared session.&lt;/p&gt;

&lt;p&gt;The same principle appears in the event journal. Events come from a closed allowlist, contain only the event name, test identifier, and bank version, and are recorded at most once per attempt. There are no answer IDs, timings, or counts in those event objects. An incomplete result view also does not become a completion event.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tests should attack the uncertainty
&lt;/h2&gt;

&lt;p&gt;The most valuable tests are not only the happy endpoints of 100 and 0. They assert that an empty attempt returns &lt;code&gt;0–100&lt;/code&gt; with &lt;code&gt;score: null&lt;/code&gt;; a mixed partial attempt returns the expected range; Reset removes the key; and an old clipboard revision cannot emit a successful copy event.&lt;/p&gt;

&lt;p&gt;Those tests encode the product promise more directly than a snapshot can: missing data remains missing, and stale asynchronous work cannot rewrite the meaning of the current state.&lt;/p&gt;

&lt;p&gt;The broader lesson for me is that incomplete input is not an error to hide. Sometimes it is the most accurate state the application has. On a binary checklist, preserving that state means returning bounds instead of pretending to know a point.&lt;/p&gt;

&lt;p&gt;Where else have you found that a range, set, or explicit unknown was a better result than a convenient single number?&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>react</category>
      <category>nextjs</category>
      <category>privacy</category>
    </item>
    <item>
      <title>How I built six self-discovery quizzes around a Next.js registry</title>
      <dc:creator>owen</dc:creator>
      <pubDate>Thu, 08 Oct 2026 08:16:07 +0000</pubDate>
      <link>https://dev.to/owen090/how-i-built-six-self-discovery-quizzes-around-a-nextjs-registry-published-5202</link>
      <guid>https://dev.to/owen090/how-i-built-six-self-discovery-quizzes-around-a-nextjs-registry-published-5202</guid>
      <description>&lt;p&gt;&lt;a href="https://loretypes.com/" rel="noopener noreferrer"&gt;Loretypes&lt;/a&gt; is a small site with six self-discovery quizzes: Aura Color, Archetype, Moral Alignment, Past Life, Color Season, and Spirit Animal. They're meant for self-reflection and fun, not diagnosis. Past Life is openly fictional storytelling, and Color Season asks which color combinations you enjoy rather than looking at your face or skin.&lt;/p&gt;

&lt;p&gt;From an engineering point of view, the interesting part is that the six quizzes share most of their plumbing (routing, submission, storage, share links, images, SEO) while their scoring rules and result screens differ. The &lt;a href="https://loretypes.com/archetype/" rel="noopener noreferrer"&gt;Archetype quiz&lt;/a&gt; scores bipolar dimensions; the &lt;a href="https://loretypes.com/aura/" rel="noopener noreferrer"&gt;Aura quiz&lt;/a&gt; sums answers across seven energy dimensions and maps them to one of eight colors, with White as the result when they're balanced. This post walks through how the codebase holds those together, and where the shared abstraction deliberately stops.&lt;/p&gt;

&lt;h2&gt;
  
  
  One registry, many routes
&lt;/h2&gt;

&lt;p&gt;Everything starts in &lt;code&gt;lib/tests/registry.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;testRegistry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="nx"&gt;auraColorDefinition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;archetypeDefinition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;moralAlignmentDefinition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;pastLifeDefinition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;colorSeasonDefinition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;spiritAnimalDefinition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;satisfies&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;TestDefinition&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;

&lt;span class="nf"&gt;validateRegistry&lt;/span&gt;&lt;span class="p"&gt;([...&lt;/span&gt;&lt;span class="nx"&gt;testRegistry&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getTestByCluster&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cluster&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;TestDefinition&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;testRegistry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;definition&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;definition&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cluster&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;cluster&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each definition lives in &lt;code&gt;lib/tests/definitions/&lt;/code&gt; and implements the &lt;code&gt;TestDefinition&lt;/code&gt; interface from &lt;code&gt;lib/tests/types.ts&lt;/code&gt;: questions, answer scale, scoring configuration, result types, SEO copy, theme tokens, a visualization id, an OG template id, a version, and a key for saving drafts in the browser. A quiz has two identifiers: a &lt;code&gt;slug&lt;/code&gt; used by the API and storage, and a &lt;code&gt;cluster&lt;/code&gt; used in URLs. They can differ: Moral Alignment's slug is &lt;code&gt;moral-alignment&lt;/code&gt;, while its URL is &lt;code&gt;/alignment/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;validateRegistry&lt;/code&gt; runs when the module loads, so a bad definition fails early. It rejects reserved route segments, duplicate slugs, and route segments that collide across quizzes.&lt;/p&gt;

&lt;p&gt;The App Router pages are thin consumers of that registry. The quiz landing page at &lt;code&gt;app/[cluster]/page.tsx&lt;/code&gt; enumerates its routes straight from it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateStaticParams&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;testRegistry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;definition&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;cluster&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;definition&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cluster&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dynamicParams&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same definition supplies that page's metadata and its FAQ structured data. The saved-result page uses it to resolve result copy, and the image route uses its OG template id to pick a renderer.&lt;/p&gt;

&lt;p&gt;That said, this isn't "drop in a config file and get a new quiz." Definitions contain functions (for example &lt;code&gt;mapVisualizationScores&lt;/code&gt;), and the landing page explicitly branches into a specialized React experience per quiz. The registry connects content and infrastructure; it doesn't remove quiz-specific UI work.&lt;/p&gt;

&lt;h2&gt;
  
  
  One runner, scoring on the server
&lt;/h2&gt;

&lt;p&gt;All six quiz experiences render the same &lt;code&gt;QuizRunner&lt;/code&gt; component (&lt;code&gt;components/quiz/quiz-runner.tsx&lt;/code&gt;). When you answer the last question, it posts only the answer array:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/tests/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;definition&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/results/`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;finalAnswers&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The handler in &lt;code&gt;app/api/tests/[slug]/results/route.ts&lt;/code&gt; caps the body at 4 KB, applies a rate limit to result creation, looks up the definition by slug, and then calls &lt;code&gt;processSubmission&lt;/code&gt;. The browser never tells the server which result it got. &lt;code&gt;validateSubmission&lt;/code&gt; checks that &lt;code&gt;answers&lt;/code&gt; is an array of exactly &lt;code&gt;questionCount&lt;/code&gt; integers within the quiz's allowed range, and the server computes the result itself.&lt;/p&gt;

&lt;p&gt;Scoring dispatch is a small strategy table in &lt;code&gt;lib/scoring/engine.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;scoreAnswers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;definition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TestDefinition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="nx"&gt;ResultPayload&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;runner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;strategyRegistry&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;definition&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scoring&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;runner&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Scoring strategy is not implemented: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;definition&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scoring&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;runner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;definition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four strategies are implemented: dimensional sum, bipolar dimensions, an alignment axis grid, and a seasonal profile. The strategy table is typed as a &lt;code&gt;Partial&lt;/code&gt; record, which is honest: one strategy name in the type union (&lt;code&gt;weighted-type&lt;/code&gt;) has no runner yet, and the engine throws instead of guessing.&lt;/p&gt;

&lt;p&gt;The alignment strategy shows how simple the arithmetic can be. In &lt;code&gt;lib/scoring/strategies/axis-grid.ts&lt;/code&gt;, each 1–5 answer is centered on 3 and multiplied by the question's direction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Simplified: the question-kind check is omitted.&lt;/span&gt;
&lt;span class="nx"&gt;definition&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;contribution&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;question&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;question&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;axis&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lawChaos&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;lawChaos&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;contribution&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nx"&gt;goodEvil&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;contribution&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cell&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cellFromAxes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lawChaos&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;goodEvil&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dimensional scoring works differently. It sums answers per dimension, breaks ties by a configured order, and can return a "balanced" result when the spread between dimensions is small. The quizzes share an endpoint, not a formula.&lt;/p&gt;

&lt;h2&gt;
  
  
  A share link points to a stored, versioned result
&lt;/h2&gt;

&lt;p&gt;After scoring, &lt;code&gt;saveResultWithRetry&lt;/code&gt; in &lt;code&gt;lib/results.ts&lt;/code&gt; stores a record with a 12-character lowercase alphanumeric share id (nanoid with a custom alphabet), plus the test slug, test version, scored payload, creation time, and an expiry that new records set to &lt;code&gt;null&lt;/code&gt;. The raw answer array isn't part of that record.&lt;/p&gt;

&lt;p&gt;On an id collision, the save loop tries a fresh id, up to five attempts, and rethrows any other storage error. The store, id factory, and clock are all injectable, which matters for testing (more below).&lt;/p&gt;

&lt;p&gt;Storing the version is only useful if reads respect it. &lt;code&gt;lib/tests/result-version.ts&lt;/code&gt; maps a stored record back to the definition it was scored with. Archetype keeps an explicit archived &lt;code&gt;0.2.0&lt;/code&gt; definition, so old results keep rendering with the copy and types they were created with. A small read-only adapter also translates an older payload shape into the current field names, without rewriting the stored data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Explicit archive, never a fallback to whichever definition is current.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;archetypeVersions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;TestDefinition&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0.2.0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v020&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not every quiz has a frozen archive yet. Alignment, Past Life, and Color Season have explicit version maps, Spirit Animal accepts only its current version, and Aura falls through to the current definition. The point is that each quiz has an explicit read policy, rather than a version field nobody checks.&lt;/p&gt;

&lt;p&gt;The result page, &lt;code&gt;app/[cluster]/r/[shareId]/page.tsx&lt;/code&gt;, validates the id format, loads the record, resolves the definition for its version, and picks the right result component. An unknown id or incompatible version ends in a 404.&lt;/p&gt;

&lt;h2&gt;
  
  
  Static pages, dynamic results, and metadata
&lt;/h2&gt;

&lt;p&gt;Content routes are statically generated. The landing page, the detail index and type pages, and the learn articles all export &lt;code&gt;generateStaticParams&lt;/code&gt; derived from the registry, with &lt;code&gt;dynamicParams = false&lt;/code&gt;, so unknown slugs 404 instead of rendering on demand. The parameter lists aren't a blind cross product. A definition can turn detail pages off with &lt;code&gt;detailPages: false&lt;/code&gt;, and Past Life currently has no type pages or learn articles.&lt;/p&gt;

&lt;p&gt;Saved results are the opposite: there can be any number of them, so the result page looks records up by id rather than enumerating them. The result image and poster routes declare &lt;code&gt;runtime = "nodejs"&lt;/code&gt; and &lt;code&gt;dynamic = "force-dynamic"&lt;/code&gt;, and so does the sitemap.&lt;/p&gt;

&lt;p&gt;Metadata is centralized in &lt;code&gt;lib/seo/metadata.ts&lt;/code&gt;, which builds titles, descriptions, canonical URLs, Open Graph, and Twitter cards. Landing pages and articles point at pre-rendered static images. Result pages get a per-result image and are deliberately kept out of the index:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;alternates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;canonical&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;robots&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;follow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;openGraph&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;images&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;twitter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;card&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;summary_large_image&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;images&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sitemap is built from &lt;code&gt;indexableClusterPaths&lt;/code&gt;, which leaves out individual results and filters Archetype type pages through a content-quality gate. &lt;code&gt;robots.ts&lt;/code&gt; allows crawling and points to the sitemap. Indexing is then controlled per page: crawlers can follow a result link, but results don't compete with the content pages in search.&lt;/p&gt;

&lt;h2&gt;
  
  
  Share images are rendered on demand
&lt;/h2&gt;

&lt;p&gt;Each result has an OG image at &lt;code&gt;/[cluster]/og/[shareId].png&lt;/code&gt;. The route delegates to &lt;code&gt;resultImageResponse&lt;/code&gt; in &lt;code&gt;lib/result-image-route.ts&lt;/code&gt;, which validates the id, loads the record, resolves its versioned definition, and picks a renderer from &lt;code&gt;lib/result-image/index.ts&lt;/code&gt; using the definition's OG template id. The renderers use &lt;code&gt;ImageResponse&lt;/code&gt; from &lt;code&gt;next/og&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A successful render returns a PNG with &lt;code&gt;Cache-Control: public, max-age=31536000, immutable&lt;/code&gt;, which is safe because a stored result is never updated (the store interface only has &lt;code&gt;create&lt;/code&gt; and &lt;code&gt;find&lt;/code&gt;). A render failure returns a 500 with &lt;code&gt;no-store&lt;/code&gt;, so a broken image doesn't get cached for a year. Nothing is written to storage; the PNG is regenerated from the saved payload when a cache misses it.&lt;/p&gt;

&lt;p&gt;Posters are a separate, opt-in capability. Past Life has no poster. Aura renders a 1200×630 OG image, a 1080×1080 square poster, and a 1080×1920 story poster. Archetype and Alignment currently use one square layout for both poster routes.&lt;/p&gt;

&lt;p&gt;Share buttons build their URLs with a tiny helper in &lt;code&gt;lib/share-url.ts&lt;/code&gt;, which strips any query or hash and adds a channel marker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;buildShareUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ShareChannel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cleanPathname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;?#&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cleanPathname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;s&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  612 tests, mostly about contracts
&lt;/h2&gt;

&lt;p&gt;The suite runs on Vitest. At the time of writing it's 72 test files and 612 tests. &lt;code&gt;vitest.config.mts&lt;/code&gt; defaults to the Node environment. The files that need a DOM opt in with a &lt;code&gt;// @vitest-environment jsdom&lt;/code&gt; comment at the top.&lt;/p&gt;

&lt;p&gt;A few patterns do most of the work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Registry tests&lt;/strong&gt; build deliberately invalid definitions (reserved segments, duplicate slugs, colliding routes, inconsistent SEO) and assert that &lt;code&gt;validateRegistry&lt;/code&gt; throws.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persistence tests&lt;/strong&gt; inject a fake store that throws duplicate-id errors, to check both "succeeds on a later attempt" and "gives up after five."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server components rendered to strings.&lt;/strong&gt; About a dozen test files &lt;code&gt;await&lt;/code&gt; an async server component directly, pass the tree to &lt;code&gt;renderToStaticMarkup&lt;/code&gt;, and assert on headings, links, accessible table markup, and JSON-LD, without a browser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Golden HTML fixtures&lt;/strong&gt; in &lt;code&gt;tests/__fixtures__/&lt;/code&gt;. The homepage test renders &lt;code&gt;&amp;lt;HomePage /&amp;gt;&lt;/code&gt; and compares the result with &lt;code&gt;toBe&lt;/code&gt; against &lt;code&gt;home-portal.html&lt;/code&gt;, byte for byte. The aura learn-article tests compare against their fixtures after stripping the shared footer, and separately assert one specific cross-link paragraph.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Two lessons
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A version field needs a read policy.&lt;/strong&gt; Writing &lt;code&gt;testVersion&lt;/code&gt; into every record is easy. The hard part is deciding what happens when an old record is read after the copy or result types change. For Archetype, that meant keeping an explicit archive of the old definition and a read-only adapter for an earlier payload shape. Making the policy explicit per quiz (archive, exact match, or "current is fine") has been more useful than pretending every quiz is equally frozen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Byte-for-byte fixtures are noisy on purpose.&lt;/strong&gt; The homepage fixture fails on any markup change, including a new footer link. That sounds annoying, but it turns "did this change anything else on the page?" into a diff you review. When the change is intended, you regenerate the fixture in the same commit and the diff shows exactly what moved. For pages where only part of the markup matters, like the learn articles, stripping the shared footer first keeps the fixture focused on the content it's meant to protect.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I used AI assistance to help draft this post; the code and project are my own.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>nextjs</category>
      <category>react</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Building a quiz result page that refuses to be one label</title>
      <dc:creator>owen</dc:creator>
      <pubDate>Sat, 19 Sep 2026 07:16:05 +0000</pubDate>
      <link>https://dev.to/owen090/building-a-quiz-result-page-that-refuses-to-be-one-label-ccb</link>
      <guid>https://dev.to/owen090/building-a-quiz-result-page-that-refuses-to-be-one-label-ccb</guid>
      <description>&lt;p&gt;Most personality quizzes collapse you into a stamp, then hide the rest behind an email wall. I wanted the opposite: a short test, a complete result with no&lt;br&gt;
  account, and a page that still shows the scores around the leader.&lt;br&gt;
  That site is &lt;a href="https://loretypes.com" rel="noopener noreferrer"&gt;Loretypes&lt;/a&gt;. The homepage line is the product decision: &lt;em&gt;your whole pattern, not one label.&lt;/em&gt;&lt;br&gt;
  &lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdfijsjymvhzbhzp5x850.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdfijsjymvhzbhzp5x850.png" alt="Loretypes homepage with three quiz cards" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  What you can try
&lt;/h2&gt;

&lt;p&gt;Three quizzes, 21–24 questions each:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Aura Color&lt;/strong&gt; — eight colors, neighboring scores stay on the spectrum&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Archetype&lt;/strong&gt; — a leading type plus a supporting type&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Moral Alignment&lt;/strong&gt; — a nine-part chart and both axes
If "aura" sounds like nonsense, start at &lt;a href="https://loretypes.com/alignment/" rel="noopener noreferrer"&gt;Moral Alignment&lt;/a&gt;. Same scoring rules, no mysticism.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  The result is the product
&lt;/h2&gt;

&lt;p&gt;The interesting part is not the questions. It is what happens after submit.&lt;br&gt;
  The browser does not compute a type. It posts an answers array. The server validates length and range, runs one scoring strategy for that quiz, and stores the&lt;br&gt;
  payload. The client only gets a 12-character &lt;code&gt;shareId&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;  &lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SHARE_ID_ALPHABET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;abcdefghijklmnopqrstuvwxyz0123456789&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SHARE_ID_LENGTH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
http
  POST /api/tests/aura-color/results/
  Content-Type: application/json

  { "answers": [/ 21 integers, 1-5 /] }

  → 201 { "shareId": "xxxxxxxxxxxx" }

  Same answers on a phone and a laptop have to match. That is why scoring is server-side. Share links are `/aura/r/{shareId}/` (and the same shape for the other
  quizzes). OG images are generated from the stored payload, so the preview is the reading you just got, not a generic card.
  Results do not expire. A shared link should still resolve later.
  ![Aura result with the full color spectrum visible](https://loretypes.com/aura/og/egorpo1ezeih.png)
  There are three scoring strategies behind that one POST:
  - `dimensional-sum` for Aura
  - `bipolar-dimensional` for Archetype
  - `axis-grid` for Alignment
  Each quiz has one published method. The result page is allowed to name a leader. It is not allowed to pretend the leader is the whole person.

## What I left out of the default path
  After Archetype, you can optionally generate a short reflection for a situation (teamwork, an ordinary day). It stays off until you ask. Original answers are
  not sent. The text is not written into the share URL. If that request fails, the scored result is still there.
  The about page says this is reflection, not an assessment. I would rather over-say that than dress a quiz up as science.
  Stack is ordinary: Next.js App Router, Postgres for results, Redis for create-result rate limits.

## What I want criticism on
  - Three quizzes on the home page: clear, or three products pretending to be one?
  - 21–24 items: already too long for a no-signup quiz?
  - Does the result page still feel like a stamp even with neighboring scores visible?
  - Should the optional reflection exist at all?
  [Try it](https://loretypes.com). No account. The full result is the point.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>webdev</category>
      <category>ux</category>
    </item>
    <item>
      <title>Skip is not zero: scoring a private quiz without a database</title>
      <dc:creator>owen</dc:creator>
      <pubDate>Sat, 19 Sep 2026 07:14:08 +0000</pubDate>
      <link>https://dev.to/owen090/skip-is-not-zero-scoring-a-private-quiz-without-a-database-7cn</link>
      <guid>https://dev.to/owen090/skip-is-not-zero-scoring-a-private-quiz-without-a-database-7cn</guid>
      <description>&lt;p&gt;I built a 30-question preference quiz for consenting adults. The product constraint was sharper than the topic: &lt;strong&gt;answers should not outlive the tab&lt;/strong&gt;.&lt;br&gt;
  No account. No result URL. No &lt;code&gt;localStorage&lt;/code&gt;. Refresh or close the tab, and the map is gone.&lt;br&gt;
  That sounds like a privacy-policy sentence. It is actually a state-management problem. This is how &lt;a href="https://kinkweave.com/" rel="noopener noreferrer"&gt;KinkWeave&lt;/a&gt; is wired.&lt;/p&gt;
&lt;h2&gt;
  
  
  What we refused to do
&lt;/h2&gt;

&lt;p&gt;A lot of quizzes persist by accident:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Scores encoded in the query string, so the result is a shareable, indexable document&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;localStorage&lt;/code&gt;, so a shared laptop keeps last night’s answers&lt;/li&gt;
&lt;li&gt;A “resume later” cookie that becomes a user record&lt;/li&gt;
&lt;li&gt;Treating “skip” as &lt;code&gt;0&lt;/code&gt;, so silence looks like dislike
We wanted a map someone could look at, copy by choice, and then destroy by closing a tab.
The quiz still has to survive in-app navigation. Privacy, Terms, and the result page all need the same answers. A hard reset on every route change would make
the product unusable.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  One provider, memory only
&lt;/h2&gt;

&lt;p&gt;The app is Next.js App Router. A client provider wraps the tree. Answers live in a reducer. Scoring is a &lt;code&gt;useMemo&lt;/code&gt; over that state.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useMemo&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;completed&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;scoreQuiz&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;completed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sessionStorage&lt;/code&gt; holds one boolean: the 18+ confirmation. It does not hold answers, scores, or a result name.&lt;br&gt;
  That split is load-bearing. Age confirmation should last for the browser session, or people get gated again on every internal link. Preference data should not.&lt;br&gt;
  If storage is blocked, the quiz errors on the age check and still does not fall back to writing answers somewhere else.&lt;br&gt;
  Same-tab routing keeps the provider alive, so Privacy and Terms do not wipe progress. A refresh unmounts the tree. That is the delete button.&lt;/p&gt;
&lt;h2&gt;
  
  
  Skip is &lt;code&gt;null&lt;/code&gt;, not &lt;code&gt;0&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Each question is a 1–5 rating or an explicit skip.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;  &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Answer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;null&lt;/code&gt; means “this item is not in the math.” It is not a low score.&lt;br&gt;
  There are six axes and five questions each. An axis only gets a number when at least four answers are scored:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;  &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;n&lt;/code&gt; is the count of scored answers on that axis. Skips are excluded from both &lt;code&gt;sum&lt;/code&gt; and &lt;code&gt;n&lt;/code&gt;. All 1s map to 0. All 5s map to 100. Fewer than four scored answers&lt;br&gt;
  → &lt;code&gt;score = null&lt;/code&gt;, and the result is a partial map. Missing scores are not drawn as zero on the radar.&lt;br&gt;
  “Unsure” is the scale midpoint (&lt;code&gt;3&lt;/code&gt;) for arithmetic. A row of Unsure answers therefore lands on 50. The UI has to say that out loud, or people read 50 as&lt;br&gt;
  moderate interest. The number and the coverage line have to travel together: how many answered, how many skipped, how many Unsure.&lt;br&gt;
  If you ship any instrument with an optional item, decide this before you write the first reducer. The bug is not the formula. The bug is letting &lt;code&gt;undefined&lt;/code&gt;&lt;br&gt;
  collapse into &lt;code&gt;0&lt;/code&gt; because it is easier to chart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two axes, not one slider
&lt;/h2&gt;

&lt;p&gt;Leadership and Surrender are separate. A high score on one does not subtract from the other.&lt;br&gt;
  That is a product rule, but it is also a data-model rule. A bipolar “dominant ↔ submissive” scale is simpler to plot and worse to answer. People can want both,&lt;br&gt;
  want neither, or want one today and not the other. Independent axes make the radar honest; they also make “highest axis wins” a weak identity label.&lt;br&gt;
  We only name a pattern when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;all six scores exist&lt;/li&gt;
&lt;li&gt;one axis is uniquely highest&lt;/li&gt;
&lt;li&gt;that high score is at least 60&lt;/li&gt;
&lt;li&gt;the spread from min to max is at least 15
Otherwise the result is an Open Sketch. Ties, mild scores, and flat maps should not mint a personality type. The thresholds are editorial, not norms. The copy
has to say that, or the chart will overclaim.
## Sharing without a result document
The obvious “private but shareable” trick is to stuff scores into the URL. Then the link &lt;em&gt;is&lt;/em&gt; the database.
We do not do that. The public quiz link opens the introduction, not a stored result. It carries no scores and no tracking parameters.
If someone wants a copy, they choose it: a text note with the summary and axis scores, or an image generated in the browser. Raw answers never go into that
export. Once the note is on the clipboard or a PNG is on disk, it is outside the page. Closing the tab cannot unsay it. The UI has to say that before the copy
button, not in a footer.
## What still leaves the machine
“Nothing is stored” is a lie if you ignore infrastructure.
The browser still requests pages, fonts, and the question bank. That quiz request has no answers. Hosting logs can still show that &lt;code&gt;/api/questions&lt;/code&gt; was hit.
Optional analytics, if the person allows it, measures start / progress / completion with fixed labels — not answers, not scores, not result names.
Be precise in the privacy page. “Answers stay in this tab” is true. “The website has no logs” is not.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I would repeat
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Put the retention rule in the state shape.&lt;/strong&gt; If skip is &lt;code&gt;null&lt;/code&gt;, every scorer, chart, and export has to understand &lt;code&gt;null&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not use the URL as a database for sensitive results.&lt;/strong&gt; Shareable links are just public records with extra steps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep session UX and preference data on different storage.&lt;/strong&gt; Age-gate in &lt;code&gt;sessionStorage&lt;/code&gt;; answers in memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Say what a middle score is.&lt;/strong&gt; Midpoint math plus missing data will otherwise invent a personality.
If you want to see the constraints in the UI, the quiz is at &lt;a href="https://kinkweave.com/" rel="noopener noreferrer"&gt;kinkweave.com&lt;/a&gt;. I am more interested in how other people model “prefer not
to answer” than in whether the radar looks nice.
How do you keep skip / unknown / unset from turning into zero in your own scoring code?&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>webdev</category>
      <category>nextjs</category>
      <category>programming</category>
    </item>
    <item>
      <title>I built a tool that turns aurora data into a single decision: GO, MAYBE, or NO</title>
      <dc:creator>owen</dc:creator>
      <pubDate>Fri, 28 Aug 2026 03:47:14 +0000</pubDate>
      <link>https://dev.to/owen090/i-built-a-tool-that-turns-aurora-data-into-a-single-decision-go-maybe-or-no-2h8i</link>
      <guid>https://dev.to/owen090/i-built-a-tool-that-turns-aurora-data-into-a-single-decision-go-maybe-or-no-2h8i</guid>
      <description>&lt;p&gt;Every time a geomagnetic storm makes the news, the same thing happens in my household: someone asks "can we see the northern lights tonight?", and I open three tabs — a Kp index chart, a cloud forecast, and a light pollution map — and still can't give a straight answer.&lt;/p&gt;

&lt;p&gt;The existing aurora tools are built by and for space-weather people. They show you the raw data: planetary K-index, aurora oval plots, solar wind speed. That's great if you know what "Bz = -12 nT" means for your driveway. Most people don't. Some sites go the other way and print invented precision like "73% chance of aurora tonight" — a number nobody can honestly produce.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;Aurora Tonight&lt;/strong&gt; (&lt;a href="https://aurora-tonight.com" rel="noopener noreferrer"&gt;aurora-tonight.com&lt;/a&gt;): enter your city or ZIP, get one of four answers — GO, MAYBE, NO, or UNKNOWN — plus the best viewing window, the main obstacle in plain language, and which direction to look.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;p&gt;Nothing exotic, and that was the point:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js&lt;/strong&gt; on Vercel. Forecast pages are served via ISR — the answer for a given city only changes as fast as the upstream data does, so per-request rendering was just burning invocations for identical bytes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NOAA's aurora oval data&lt;/strong&gt; for geomagnetic activity. This is the authoritative public source; I deliberately don't layer a proprietary "prediction" on top of it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open-Meteo&lt;/strong&gt; for cloud cover snapshots. Clouds are the #1 reason a strong geomagnetic night is still a NO, and most aurora sites ignore them entirely.&lt;/li&gt;
&lt;li&gt;Darkness, moonlight, and light pollution are computed or looked up server-side. Everything is cached at the edge; the whole site is static-ish and free to run, which is why the tool can be free with no signup.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The interesting design decision: what not to compute
&lt;/h2&gt;

&lt;p&gt;The hardest part wasn't the data pipeline — it was deciding the output contract. Three rules I ended up with:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No fake percentages.&lt;/strong&gt; The model's output is a categorical call, not a probability. If you can't defend "73%", don't print it. GO / MAYBE / NO maps to what a person actually does with the answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UNKNOWN is a first-class state.&lt;/strong&gt; If the cached data snapshot is stale, the page says UNKNOWN instead of extrapolating a confident-looking guess from old data. This costs us some "always has an answer" slickness, and it's worth it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The main obstacle is part of the answer.&lt;/strong&gt; "NO" alone is useless. "NO — overcast until after 1am" tells you whether to set an alarm.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I wrote up the full scoring logic on the &lt;a href="https://aurora-tonight.com/methodology" rel="noopener noreferrer"&gt;methodology page&lt;/a&gt; if you're curious how the calls are made — it documents exactly what goes into a GO versus a MAYBE.&lt;/p&gt;

&lt;h2&gt;
  
  
  Current scope and honest limitations
&lt;/h2&gt;

&lt;p&gt;It covers 15+ indexed US states and cities — Fairbanks and Alaska down through mid-latitude places like Colorado, Ohio, and Chicago, where aurora is a rare-event question rather than a nightly one. Northern hemisphere, mid-to-high latitudes only. It won't tell you anything useful in Texas, and it says so.&lt;/p&gt;

&lt;p&gt;If you're building anything in the "raw public data → plain-language decision" space, I'd genuinely like to hear how you handled the honesty-vs-slickness tradeoff. The temptation to print a number that looks more confident than the data deserves is real.&lt;/p&gt;

</description>
      <category>weather</category>
    </item>
    <item>
      <title>I built a roof calculator that does all the math in your browser — no server, no signup, no lead form</title>
      <dc:creator>owen</dc:creator>
      <pubDate>Fri, 28 Aug 2026 02:49:17 +0000</pubDate>
      <link>https://dev.to/owen090/i-built-a-roof-calculator-that-does-all-the-math-in-your-browser-no-server-no-signup-no-lead-3n1a</link>
      <guid>https://dev.to/owen090/i-built-a-roof-calculator-that-does-all-the-math-in-your-browser-no-server-no-signup-no-lead-3n1a</guid>
      <description>&lt;p&gt;Nearly every "roofing calculator" on the internet is a contractor lead form in disguise. You type in your measurements, hit calculate, and the answer is locked until you hand over your phone number. The math was never the product — your contact info was.&lt;/p&gt;

&lt;p&gt;I'm a developer, not a roofer, but I wanted to see what happens if you build the opposite: &lt;strong&gt;&lt;a href="https://pitchcalc.com" rel="noopener noreferrer"&gt;PitchCalc&lt;/a&gt;&lt;/strong&gt;, five roof-math calculators where the answer shows up in about ten seconds, all computation happens locally in your browser, and there is no backend to store anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual math is one triangle
&lt;/h2&gt;

&lt;p&gt;Everything in residential roofing reduces to one right triangle: &lt;code&gt;rise&lt;/code&gt; over &lt;code&gt;run&lt;/code&gt;. The whole engine is a handful of lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ratio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rise&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pitch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;rise&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/12`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                        &lt;span class="c1"&gt;// X:12 notation (run is usually 12")&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;degrees&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;atan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ratio&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PI&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// 6/12 → 26.6°&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;slopePercent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ratio&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                  &lt;span class="c1"&gt;// 6/12 → 50%&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;multiplier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;ratio&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;ratio&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// 6/12 → 1.118&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rafterLength&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;rise&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From there it's just unit conventions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sloped roof area&lt;/strong&gt; = footprint × multiplier&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Roofing squares&lt;/strong&gt; = area ÷ 100 (a "square" is 100 sq ft, the unit shingles are sold in)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shingle bundles&lt;/strong&gt; = ceil(squares × 3 × (1 + waste)) — three bundles per square, typically 10% waste&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Five pages, one shared engine: &lt;a href="https://pitchcalc.com" rel="noopener noreferrer"&gt;roof pitch&lt;/a&gt;, &lt;a href="https://pitchcalc.com/roofing-calculator/" rel="noopener noreferrer"&gt;roofing&lt;/a&gt; (area/squares/bundles), &lt;a href="https://pitchcalc.com/roof-area-calculator/" rel="noopener noreferrer"&gt;roof area&lt;/a&gt;, &lt;a href="https://pitchcalc.com/rafter-calculator/" rel="noopener noreferrer"&gt;rafter&lt;/a&gt;, and &lt;a href="https://pitchcalc.com/shingle-calculator/" rel="noopener noreferrer"&gt;shingles&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Local-first as a feature, not a limitation
&lt;/h2&gt;

&lt;p&gt;The page says "Measurements stay in this browser" and means it literally: there is no database, no account system, no analytics event carrying your inputs anywhere. Results recompute on every keystroke, so there's no submit button at all.&lt;/p&gt;

&lt;p&gt;One detail I like: the input state lives in the URL hash, so a calculation is shareable as a plain link — &lt;a href="https://pitchcalc.com/#rise=6&amp;amp;run=12" rel="noopener noreferrer"&gt;pitchcalc.com/#rise=6&amp;amp;run=12&lt;/a&gt; opens with a 6/12 pitch preloaded. No encoding endpoint, no shortened URLs, just &lt;code&gt;location.hash&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trust through visible formulas
&lt;/h2&gt;

&lt;p&gt;Every result panel prints the formula next to the number — &lt;code&gt;√(1 + (rise ÷ run)²)&lt;/code&gt; right beside the multiplier value. If you don't trust the output you can check it with a pencil. That's also why the copy button produces something that reads like a takeoff sheet: the audience is a homeowner about to talk to a contractor, or an estimator checking numbers from a truck. The output had to survive being read aloud.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stack notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Next.js, fully static — the whole site is pre-rendered pages plus a small client-side engine&lt;/li&gt;
&lt;li&gt;No form libraries or state management; controlled inputs and derived values&lt;/li&gt;
&lt;li&gt;Print stylesheet treats the result panel as the document, not the page chrome&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd add next
&lt;/h2&gt;

&lt;p&gt;Waste factors per roof complexity, valleys/hips geometry, metric units. If you do roof math by hand for something else, tell me in the comments — genuinely collecting the backlog.&lt;/p&gt;

&lt;p&gt;Try it: &lt;a href="https://pitchcalc.com" rel="noopener noreferrer"&gt;pitchcalc.com&lt;/a&gt; — free, no signup, and it will never ask for your phone number.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>There's no superscript "q" in Unicode — and other things I learned building a superscript generator</title>
      <dc:creator>owen</dc:creator>
      <pubDate>Thu, 27 Aug 2026 09:16:55 +0000</pubDate>
      <link>https://dev.to/owen090/theres-no-superscript-q-in-unicode-and-other-things-i-learned-building-a-superscript-generator-55gk</link>
      <guid>https://dev.to/owen090/theres-no-superscript-q-in-unicode-and-other-things-i-learned-building-a-superscript-generator-55gk</guid>
      <description>&lt;p&gt;Quick quiz: how do you write "x² + y³" in plain text, no HTML, no LaTeX?&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Easy, right? `²` and `³` are just characters. Now write "E = mcᵠ" — wait, that should be a superscript "q". Try to find it. I'll wait.

 You won't, because **Unicode has no superscript q**. When I built a [free superscript converter](https://literalkit.com/superscript-generator), this turned out to be the tip of
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;a very weird iceberg. Here's what I learned.&lt;/p&gt;

&lt;h2&gt;
  
  
  Superscripts aren't formatting — they're separate characters
&lt;/h2&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Most people assume superscript works like bold: take a character, apply a style. That's how `&amp;lt;sup&amp;gt;` works in HTML, and how rich text editors work.

 Unicode plain text doesn't have styles. Instead, every superscript character is its own pre-composed codepoint, donated to the standard by different communities at different
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;times:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; - `¹²³` live in **Latin-1 Supplement** (U+00B9, U+00B2, U+00B3) — they've been there since the 1980s.
 - `⁰ ⁴-⁹ ⁺ ⁻ ⁽ ⁾` live in the **Superscripts and Subscripts** block (U+2070–U+209F).
 - Superscript *letters* are scattered across **Phonetic Extensions** (U+1D2C–U+1D7F), because linguists needed them for phonetic transcription: `ᵃ ᵇ ᶜ ᵈ ᵉ ᶠ ᵍ ʰ ...`
 - And a few stragglers like `ˣ` (U+02E3) hide in **Spacing Modifier Letters**.

 So "convert text to superscript" really means "look up each character in a hand-built mapping table someone assembled from four different corners of the standard." There's no
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;algorithm. It's a scavenger hunt.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest-output problem
&lt;/h2&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Here's where it gets philosophically interesting. The mapping has holes:

 - **No superscript `q`.** It's the famous gap. Proposals exist, but as of today there's no official codepoint.
 - **Uppercase letters are incomplete** — you get `ᴬ ᴮ ᴰ ᴱ ᴳ ᴴ ᴵ ᴶ ᴷ ᴸ ᴹ ᴺ ᴼ ᴾ ᴿ ᵀ ᵁ ⱽ ᵂ`, but try spelling a full sentence in superscript caps and you'll hit walls fast.
 - Subscripts are even sparser: `ₐ ₑ ₕ ᵢ ⱼ ₖ ₗ ₘ ₙ ₒ ₚ ᵣ ₛ ₜ ᵤ ᵥ ₓ` — that's most of what exists.

 Most converters on the web quietly patch the holes with **lookalike characters** — a Greek letter here, a math alphanumeric symbol there. The output *looks* fine until someone
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;copies it into a screen reader, a search box, or a font that renders the substitute as tofu (□).&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; I made a different call: when a character has no real superscript, the tool says so instead of faking it. It feels worse in the demo and better in real life — what you copy is
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;what you meant.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>unicode</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Styling plain text that survives copy-paste: a Unicode primer (and the tools I use)</title>
      <dc:creator>owen</dc:creator>
      <pubDate>Wed, 05 Aug 2026 02:00:34 +0000</pubDate>
      <link>https://dev.to/owen090/styling-plain-text-that-survives-copy-paste-a-unicode-primer-and-the-tools-i-use-220m</link>
      <guid>https://dev.to/owen090/styling-plain-text-that-survives-copy-paste-a-unicode-primer-and-the-tools-i-use-220m</guid>
      <description>&lt;h2&gt;
  
  
  The problem: there is no "bold" in a bio
&lt;/h2&gt;

&lt;p&gt;Here's a scenario you've probably hit: you want to emphasize something in an&lt;br&gt;
Instagram bio, a Discord nickname, a Slack message, or a plain-text README —&lt;br&gt;
and there's no formatting toolbar. No bold, no italic, no superscript button.&lt;/p&gt;

&lt;p&gt;The reason is simple: these fields store &lt;strong&gt;plain text&lt;/strong&gt;. Formatting isn't&lt;br&gt;
data; it's a rendering layer applied by a rich-text editor. Take the editor&lt;br&gt;
away and the formatting never existed.&lt;/p&gt;

&lt;p&gt;The workaround that &lt;em&gt;does&lt;/em&gt; travel with the text is Unicode.&lt;/p&gt;

&lt;h2&gt;
  
  
  Unicode already contains "styled" characters
&lt;/h2&gt;

&lt;p&gt;Unicode isn't just a bigger ASCII. It includes entire blocks of letter&lt;br&gt;
variants — most notably the &lt;strong&gt;Mathematical Alphanumeric Symbols&lt;/strong&gt; block&lt;br&gt;
(U+1D400–U+1D7FF), which encodes bold, italic, script, fraktur, double-struck,&lt;br&gt;
monospace, and more as &lt;em&gt;distinct characters&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;x&lt;/code&gt; → &lt;code&gt;𝐱&lt;/code&gt; (bold), &lt;code&gt;𝑥&lt;/code&gt; (italic), &lt;code&gt;𝚡&lt;/code&gt; (monospace)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;A&lt;/code&gt; → &lt;code&gt;𝔸&lt;/code&gt; (double-struck), &lt;code&gt;𝔄&lt;/code&gt; (fraktur)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;2&lt;/code&gt; → &lt;code&gt;²&lt;/code&gt; (superscript, from a much older block)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because these are real characters with their own codepoints, they survive&lt;br&gt;
copy-paste into any plain-text field. That's the whole trick behind "font&lt;br&gt;
generator" sites — no fonts are involved, just a codepoint lookup.&lt;/p&gt;

&lt;h2&gt;
  
  
  The catch: honest vs. fake conversion
&lt;/h2&gt;

&lt;p&gt;There's a subtle quality problem most converters have. Coverage is uneven:&lt;br&gt;
superscript has no lowercase &lt;code&gt;b&lt;/code&gt; (Unicode simply never encoded one), italic&lt;br&gt;
has no capital &lt;code&gt;Q&lt;/code&gt;, and so on.&lt;/p&gt;

&lt;p&gt;Many tools quietly paper over the gaps by borrowing &lt;em&gt;lookalike&lt;/em&gt; characters&lt;br&gt;
from unrelated scripts — a Greek letter here, a Cyrillic one there. It looks&lt;br&gt;
fine until screen readers mangle it, search can't find it, or a font renders&lt;br&gt;
it as tofu. A good converter instead:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;converts what has a real mapping,&lt;/li&gt;
&lt;li&gt;leaves the rest untouched,&lt;/li&gt;
&lt;li&gt;and &lt;strong&gt;tells you&lt;/strong&gt; which characters stayed unchanged.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The tools I settled on
&lt;/h2&gt;

&lt;p&gt;I got tired of hand-searching codepoint tables, so I now use&lt;br&gt;
&lt;a href="https://literalkit.com" rel="noopener noreferrer"&gt;LiteralKit&lt;/a&gt; — a free collection of 50+ focused&lt;br&gt;
Unicode tools. Three things make it work for me as a developer:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Everything runs locally in the browser.&lt;/strong&gt; No accounts, no uploads — your&lt;br&gt;
text never leaves the page. Paste, convert, copy, done in seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Honest output.&lt;/strong&gt; It never substitutes lookalike characters. Unsupported&lt;br&gt;
characters pass through unchanged, and the page discloses its coverage up&lt;br&gt;
front. For a notation tool, that honesty matters more than a big feature list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One tool per page, one job per tool.&lt;/strong&gt; No popups, no multi-step funnels.&lt;br&gt;
A few I keep bookmarked:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://literalkit.com/superscript-generator" rel="noopener noreferrer"&gt;Superscript Generator&lt;/a&gt; —
x², footnote-style notation for plain-text docs and READMEs&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://literalkit.com/fancy-text-generator" rel="noopener noreferrer"&gt;Fancy Text Generator&lt;/a&gt; —
italic/bold/script/monospace Unicode styles for bios and profiles&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://literalkit.com/case-converter" rel="noopener noreferrer"&gt;Case Converter&lt;/a&gt; — boring, but
I reach for it weekly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full set (Wingdings and runic translators, symbol pickers, a symbol&lt;br&gt;
identifier, and more) lives in the &lt;a href="https://literalkit.com/tools" rel="noopener noreferrer"&gt;tool directory&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Styled plain text is just Unicode arithmetic — but doing that arithmetic by&lt;br&gt;
hand is a waste of your time. Know the trick exists, know that coverage has&lt;br&gt;
honest gaps, and let a tool that respects those gaps do the mapping.&lt;/p&gt;

</description>
      <category>unicode</category>
      <category>webdev</category>
      <category>productivity</category>
      <category>nocode</category>
    </item>
  </channel>
</rss>
