익스클루시브
As Stored <mv-as-stored>
폼 뒤의 시스템이 값을 변형할 때, 값이 조용히 늘어나거나 잘리게 두는 대신 저장 전에 각 값이 정확히 어떻게 저장될지 보여 줍니다.
문화적 레퍼런스
프로크루스테스 신화, 플루타르코스(테세우스전)와 디오도로스 시켈로스가 전하는 그리스 신화 (기원전 1세기경, 신화). 프로크루스테스는 나그네에게 침대를 내준 뒤, 미리 알려 주지도 않고 키 작은 사람은 늘리고 키 큰 사람은 잘라 침대에 꼭 맞게 만들었습니다. UI에서는 시스템의 고정된 형태(바이트 제한, 문자 집합, 반올림, 시간대)가 저장 전에 드러납니다: 값은 저장될 모습 그대로 표시되고, 잘리거나 늘어난 부분은 모두 명시되며, 사용자는 직접 고칠지, 받아들일지, 다른 방식을 고를지 선택합니다.
작동 방식
폼 뒤의 시스템이 값을 변형할 때, 값이 조용히 늘어나거나 잘리게 두는 대신 저장 전에 각 값이 정확히 어떻게 저장될지 보여 줍니다. 폼을 감싸고 필드에 저장 규칙을 지정합니다(data-as-stored="trim nfc max-bytes:20"): 문자, UTF-8 바이트 또는 UTF-16 단위 기준의 길이 제한, 대체·삭제·음역 처리하는 문자 집합(ASCII, Latin-1, BMP, 이모지 없음), trim / collapse / 대소문자 / NFC/NFKC 정규화, 발음 구별 기호 제거(일반 또는 ICAO 여권 표기: ü → UE), 아포스트로피와 하이픈 제거, 숫자와 통화 반올림(십진 정확, half-up, half-even 또는 down), 전화번호의 E.164 변환(내선 번호 제거, 35개국), 시간대 변환과 일 단위 절삭, 그리고 사용자 정의 규칙. 저장될 값이 달라지면 필드 아래에 조용한 “Will be saved as” 미리 보기가 나타나 저장될 값을 보여 주고, 모든 변경을 제자리에 표시합니다(대체, 삭제, 점선 뒤에 잘린 끝부분). 각 변경은 말로 설명되고(“é” will become “e”, “2 emoji removed”, “Cut after “Kowalczyk-Brzęczysz”: “czykiewicz” will be lost. The limit is 20 bytes (UTF-8) and this is 30. ę counts as 2 bytes.”, “The day changes from Oct 15 to Oct 16, because the time is converted to UTC”), 바이트 또는 문자 미터와 세 가지 해결 방법이 제공됩니다: Fix it myself(필드에 포커스를 두고 정확한 부분을 선택), Accept(확인하거나, 저장될 값을 필드에 적용), 앱이 제공하는 대안. 외관상의 변경(공백, 보이지 않는 정규화)은 show="all"이 아니면 표시되지 않습니다. 제출 시에는 폼 수준 요약이 변형될 모든 필드를 나열하고 Accept all and save와 함께 저장을 한 번 보류합니다. submit-stored를 사용하면 폼이 표시된 값을 그대로 전송합니다. 엔진은 순수하며 DOM에 의존하지 않으므로(MvAsStored.transform(value, rules)) 같은 규칙을 서버와 테스트에서도 실행할 수 있습니다.
| 카테고리 | 폼 |
|---|---|
| 유형 | Web Component (<mv-as-stored>) |
| 상태 | 안정 |
| 키트 | 사용자를 놓치지 않는 폼 |
| 함께 설치되는 항목 | button |
| Keywords | exclusive, culture, form, validation, normalization, truncation, maxlength, bytes, utf-8, charset, encoding, latin-1, ascii, emoji, diacritics, transliteration, i18n, names, phone, e164, rounding, currency, timezone, utc, preview, data-integrity |
When to use
- A form feeds a legacy system or database column that truncates, re-encodes or rounds what people type
- People with international names (accents, apostrophes, CJK, long hyphenated names) must see how a system will spell them
- A value is converted before storage (UTC day, E.164 phone, cents) and a silent shift would cause real mistakes
- Client and server must agree on one normalization and the preview has to match what the backend really stores
Avoid when
- The input must be formatted while typing (a card number, a phone mask) rather than previewed as stored → use Input instead
- The value is invalid and must be fixed before anything is sent, not reshaped → use One at a Time instead
- The problem is invisible or look-alike characters pasted into a field, not the storage rules → use Invisibles instead
설치
node scripts/add.mjs as-stored --out ./src/marvelousMarvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["as-stored"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
복사되는 파일(의존성 포함): tokens/tokens.css, core/base.css, components/button/button.css, core/dom.js, core/element.js, core/motion.js, core/observe.js, components/as-stored/as-stored.js, components/as-stored/as-stored.css.
사용법
빠른 시작, 동작하는 가장 작은 마크업:
<mv-as-stored>
<form>
<label>First name <input name="first_name" value="Chloé" data-as-stored="charset:ascii:transliterate"></label>
<button>Save</button>
</form>
</mv-as-stored>기본 마크업입니다. 여기서 시작해 속성, data-*, CSS 변수로 커스터마이즈하세요:
<div id="as-demo" style="width:min(100%,50rem);margin-inline:auto">
<style>
#as-demo { display:grid; gap:1rem; align-content:start }
#as-demo .as-top { display:flex; align-items:center; justify-content:space-between; gap:.5rem 1rem; flex-wrap:wrap }
#as-demo .as-top p { margin:0; color:var(--mv-fg-muted); font-size:.8125rem; flex:1 1 20rem }
#as-demo .as-top strong { color:var(--mv-fg); font-weight:600 }
#as-demo .as-switches { display:flex; gap:.5rem 1.125rem; flex-wrap:wrap }
#as-demo .mv-choice { font-size:.8125rem }
#as-demo .as-row { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:1rem 1.25rem; align-items:start }
#as-demo .as-log { margin:0; min-height:1.25rem; color:var(--mv-fg-subtle); font:.75rem/1.45 var(--mv-font-mono); overflow-wrap:anywhere }
#as-demo .as-legend { display:flex; align-items:center; gap:.5rem }
#as-demo .mv-fieldset { margin:0 }
@media (max-width:40rem) { #as-demo .as-row { grid-template-columns:minmax(0,1fr) } }
</style>
<div class="as-top">
<p>This CRM syncs every customer to a <strong>legacy billing system</strong> with byte limits, an ASCII-only first name, cents and UTC days. Before saving, each field shows how it will really be stored.</p>
<span class="as-switches">
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" data-size="sm" id="as-all"> Show cosmetic changes</label>
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" data-size="sm" id="as-live"> Live summary</label>
</span>
</div>
<!-- Main: a customer form synced to a legacy billing system -->
<mv-as-stored id="as-main" time-zone="America/Los_Angeles" country="US" submit-stored>
<form id="as-form" autocomplete="off">
<fieldset class="mv-fieldset" data-variant="card">
<legend class="as-legend">New customer <span class="mv-badge" data-variant="secondary" data-shape="pill">Syncs to billing</span></legend>
<div class="mv-field-group">
<div class="as-row">
<div class="mv-field">
<label class="mv-label" for="as-first">First name</label>
<input class="mv-input" id="as-first" name="first_name" value="Chloé" data-as-stored="trim charset:ascii:transliterate">
</div>
<div class="mv-field">
<label class="mv-label" for="as-last">Last name</label>
<input class="mv-input" id="as-last" name="last_name" value="Kowalczyk-Brzęczyszczykiewicz" data-as-stored="trim nfc max-bytes:20">
</div>
</div>
<div class="as-row">
<div class="mv-field">
<label class="mv-label" for="as-email">Work email</label>
<input class="mv-input" id="as-email" name="email" type="email" value="[email protected]" data-as-stored="trim lowercase">
</div>
<div class="mv-field">
<label class="mv-label" for="as-phone">Phone</label>
<input class="mv-input" id="as-phone" name="phone" type="tel" value="(415) 555-0132 ext. 204" data-as-stored="phone:e164">
</div>
</div>
<div class="as-row">
<div class="mv-field">
<label class="mv-label" for="as-company">Company</label>
<input class="mv-input" id="as-company" name="company" value="Tarnwick Traders " data-as-stored="trim collapse max-bytes:40">
</div>
<div class="mv-field">
<label class="mv-label" for="as-limit">Credit limit</label>
<input class="mv-input" id="as-limit" name="credit_limit" inputmode="decimal" value="$2,499.995" data-as-stored="currency:USD">
</div>
</div>
<div class="as-row">
<div class="mv-field">
<label class="mv-label" for="as-start">Contract start (Pacific time)</label>
<input class="mv-input" id="as-start" name="contract_start" type="datetime-local" value="2026-10-15T20:30" data-as-stored="timezone:UTC date">
</div>
<div class="mv-field">
<label class="mv-label" for="as-note">Delivery note</label>
<textarea class="mv-textarea" id="as-note" name="delivery_note" rows="2" data-as-stored="charset:no-emoji collapse trim">Leave at the front desk 📦 thanks! 🙏</textarea>
</div>
</div>
</div>
<div class="mv-fieldset-footer">
<button type="reset" class="mv-button" data-variant="ghost">Reset</button>
<button type="submit" class="mv-button">Save customer</button>
</div>
</fieldset>
</form>
</mv-as-stored>
<p class="as-log" id="as-log" aria-live="polite">Stored values are sent as shown (submit-stored).</p>
<!-- Variant: airline passenger names, printed on a boarding pass -->
<mv-as-stored id="as-pax" summary="none" accept="apply">
<form onsubmit="event.preventDefault()">
<fieldset class="mv-fieldset" data-variant="card">
<legend>Passengers <span class="mv-badge" data-variant="secondary" data-shape="pill">As on the boarding pass</span></legend>
<div class="as-row">
<div class="mv-field">
<label class="mv-label" for="as-p1">Passenger 1 · full name</label>
<input class="mv-input" id="as-p1" name="pax1" value="Seán O’Brien-Müller" data-as-stored="strip-diacritics:icao uppercase strip:'’- charset:ascii max:30">
</div>
<div class="mv-field">
<label class="mv-label" for="as-p2">Passenger 2 · full name</label>
<input class="mv-input" id="as-p2" name="pax2" value="王小明" data-as-stored="strip-diacritics:icao uppercase strip:'’- charset:ascii max:30">
</div>
</div>
</fieldset>
</form>
</mv-as-stored>
<script type="module">
const main = document.getElementById("as-main");
const pax = document.getElementById("as-pax");
const form = document.getElementById("as-form");
const log = document.getElementById("as-log");
const say = (t) => { log.textContent = t; };
// App-provided alternatives: a shorter legal name that fits, a passport spelling on file.
main.alternatives = {
last_name: ({ input, transform }) => {
const first = input.split(/[-\s]/)[0];
return first && first !== input && !transform(first).changed ? [{ label: `Use “${first}”`, value: first, description: "Keep only the first part of the name" }] : [];
},
};
pax.alternatives = {
pax2: ({ value }) => (value.includes("?") ? [{ label: "Use passport spelling “WANG XIAOMING”", value: "WANG XIAOMING" }] : []),
};
main.addEventListener("mv-reshape-accept", (e) => say(`Accepted: ${e.detail.label} → “${e.detail.value}”`));
main.addEventListener("mv-reshape-summary", (e) => say(`Held the save: ${e.detail.fields.length} fields will be reshaped.`));
pax.addEventListener("mv-reshape-accept", (e) => say(`Applied to the field: ${e.detail.label} → “${e.detail.value}”`));
form.addEventListener("submit", (e) => {
e.preventDefault();
const data = Object.fromEntries(new FormData(form));
say(`Saved · ${JSON.stringify(data)}`);
});
document.getElementById("as-all").addEventListener("change", (e) => { main.show = e.target.checked ? "all" : "change"; pax.show = main.show; });
document.getElementById("as-live").addEventListener("change", (e) => { main.summary = e.target.checked ? "live" : "hold"; });
</script>
</div>API
Attributes
| Name | 유형 | Default | Description |
|---|---|---|---|
for | string (form id) | The form to watch when it is not a child of the element. | |
show | change | loss | all | change | Lowest severity that opens a preview: loss (value lost: cut, replaced, removed, rounded, time dropped), change (visible but lossless: case, phone format, time zone), all (also cosmetic: spaces, invisible normalization). Per field: data-as-stored-show. |
summary | hold | live | none | hold | hold: on submit, if any preview was not accepted, the submission is held once and a summary lists every reshaped field with Accept all and save. live: the summary is also shown permanently while anything will be reshaped. none: never hold, never summarize. |
accept | keep | apply | keep | keep: Accept acknowledges the reshape (the field keeps what was typed and the preview folds to one line). apply: Accept writes the stored value into text-like fields (input/change events dispatched). Per field: data-as-stored-accept. |
time-zone | IANA zone | (browser zone) | Zone in which wall-clock values (datetime-local) were typed, for the timezone rule. Per field: data-as-stored-zone. |
country | ISO 3166 alpha-2 | US | Default country for phone numbers typed without an international prefix. Per field: data-as-stored-country, or phone:e164:GB in the rules. |
submit-stored | boolean | On the form’s formdata event, every reshaped field is sent as its stored value, so the server receives exactly what the preview showed. | |
delay | number (ms) | 160 | Debounce between typing and updating the preview. |
data-as-stored (on fields) | rules | Space-separated rules applied in order: trim, collapse, lowercase, uppercase, nfc, nfd, nfkc, nfkd, strip-diacritics[:icao], strip:<chars|punctuation|symbols|spaces|digits>, charset:<ascii|latin1|bmp|no-emoji>[:replace|drop|transliterate[:char]], max:<n> (characters = code points), max-bytes:<n> (UTF-8), max-utf16:<n>, round:<decimals>[:half-up|half-even|down], currency:<ISO code>[:mode], phone[:e164][:<country>], timezone:<target zone, e.g. UTC>, date[:day|month]. Also data-as-stored-label, -show, -accept, -zone, -country and -anchor (selector of the element the preview is appended to). |
Properties
| Name | 유형 | Description |
|---|---|---|
rules | Record<fieldName, string | Array<string | { name, apply(value, ctx), message?, severity?, kind? }>> | Rules per field name, appended to data-as-stored. Custom rules return the new value (or { value, message, severity }); severity is cosmetic | change | loss. |
alternatives | (ctx) => Alternative[] | Record<fieldName, (ctx) => Alternative[]> | Extra choices under a preview (max 3). ctx: { field, name, label, input, value, severity, changes, transform(v) }. Alternative: { label, value?, description?, action?(field) }; a value is written into the field, action runs your own code. |
strings | Partial<Record<string, string>> | Overrides for every visible text and announcement (label, savedAs, accepted, fix, accept, apply, review, empty, more, meter, desc, descAccepted, announce, announceMore, announceAccepted, summaryTitle, summaryTitleOne, summaryText, summaryLive, summaryDone, summaryDoneText, acceptAll, saveNow, reviewFirst, summaryAnnounce). English defaults. |
form | HTMLFormElement | null | The watched form (read-only). |
MvAsStored.transform(value, rules, options?) | static | Pure, DOM-free engine (also exported as transform). options: { timeZone, country }. Returns { input, value, changed, severity (none | cosmetic | change | loss), changes: [{ rule, kind, severity, message, detail? }], segments: [{ type: same | replace | add | remove | cut, text, was, start, end }], display (diff | plain), meter: { size, limit, unit } | null }. Also static parseRules, byteLength, measure(value, unit). |
Methods
| Name | Description |
|---|---|
check() | Re-evaluates every field (after setting values from code) and returns reshaped(). |
reshaped() | Fields that will be reshaped: [{ field, name, label, input, value, severity, changes, accepted }]. |
stored() | { name: storedValue } for every tracked field, as the system will store them. |
acceptField(fieldOrName) | Accepts one reshape (emits mv-reshape-accept). Returns false if vetoed. |
acceptAll() | Accepts every pending reshape. Returns how many are still pending (vetoed). |
review(fieldOrName) | Focuses the field and selects the part that will be reshaped (Fix it myself). |
Events
| Name | Description |
|---|---|
mv-reshape | Cancelable. A field’s value will be stored differently (fired when the stored value or its changes differ from last time). detail: { field, name, label, input, value, severity, changes, accepted }. preventDefault() hides this preview so the app can present it its own way. |
mv-reshape-clear | A field that was reshaped will now be stored as typed. detail: { field, name, value }. |
mv-reshape-accept | Cancelable, before a reshape is accepted (button, acceptField, Accept all). detail: same as mv-reshape plus apply (boolean). |
mv-reshape-alternative | Cancelable, before an app-provided alternative is applied. detail: { field, name, alternative }. |
mv-reshape-summary | Cancelable, before a submission is held for the summary. detail: { fields, submitter }. preventDefault() lets the submission through. |
CSS classes
| Name | Description |
|---|---|
mv-as-stored-preview | Preview under a field (appended to its .mv-field, or after it): data-severity (cosmetic | change | loss), data-state (open | accepted). Parts: -head, -icon, -label, -meter (data-over), -value (data-display diff | plain), -changes, -change (data-kind, data-severity), -change-detail, -actions, -fix, -accept, -alt, -compact, -review. |
mv-as-stored-mark / mv-as-stored-cut | Marks inside the stored value: <mark data-kind="replace|add"> (underlined), <del data-kind="remove"> (struck, whitespace shown as ␣), the cut tail after a dashed line. |
mv-as-stored-summary | Form-level summary inserted before the submit row (or inside [data-as-stored-summary]): data-severity (change | loss | done), data-held. Parts: -summary-head, -summary-title, -summary-text, -summary-list, -summary-item (data-accepted), -summary-field, -summary-value, -summary-reason, -summary-go, -summary-actions. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-as-stored-loss | var(--mv-warning) | Tone of previews where information is lost (cut, replaced, removed, rounded). |
--mv-as-stored-change | var(--mv-info) | Tone of previews where the value changes without loss (case, phone format, time zone). |
Accessibility
Each preview is linked to its field with aria-describedby (added only while it is visible) through a visually hidden sentence that says everything in words: “Will be saved as “Kowalczyk-Brzęczysz”. Cut after …: “czykiewicz” will be lost.”; the visual stored value with its marks is aria-hidden so it is never read twice, and every change is also listed as real text under it, never conveyed by color alone (marks use underline, strike-through and a dashed cut line, which survive forced colors). While typing, announcements are debounced (1.1 s after the last change, only for the focused field, only when the stored result actually changed) in a polite live region: “Last name will be saved as “…”. Cut after … 1 more change is listed under the field.”. Fix it myself focuses the field and selects the exact characters that will be reshaped (the overflow, the replaced letter); Accept returns focus to the field and announces the result; the accepted line keeps a Review button (labelled with the field name) that reopens the preview. Held submissions move focus to the summary (a labelled region, tabindex -1) and announce it; each summary item has a Review button that goes to its field. All controls are native buttons in document order right after their field, so Tab reaches them naturally. Appear and fold animations are opacity-only and skipped under reduced motion (OS or data-motion="reduce"). Known limit: the change explanations (message, detail) come from the engine in English; strings translates the component's own labels, buttons and announcements, and a custom rule can supply its own message.